Merge pull request #85 from TemujinCalidius/feat/admin-studio

feat: SurvivorCore Studio — Engine Config editor, content overrides, window/sidebar rework
This commit is contained in:
Samuel Lison
2026-07-07 12:41:01 +10:00
committed by GitHub
20 changed files with 2861 additions and 542 deletions
+28
View File
@@ -5,6 +5,34 @@ All notable changes to SurvivorCore are recorded here. The format follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). At release time, `## Unreleased`
is promoted to the new version and `main` is tagged `vX.Y.Z`.
## Unreleased
### Added
- **Engine Config, no-code** (#21) — a new persisted instance, **`SurvivorCoreEngineConfig`**,
layers owner overrides over EVERY engine Config section (Movement incl. feedback asset ids,
Combat + Bow, Mobs, Harvesting, Crafting, Inventory scalars, Consequences, LootBags, Quests,
Achievements, and UI incl. the full **Theme** — colors and fonts). Same locked, **deltas-only**
model as `SurvivalStatsConfig`: an attribute exists only where the owner diverged from the
engine default, so untouched fields keep following engine updates. Applied as the FIRST step of
`start()`/`startClient()` (edits take effect on the next Play); invalid saved values warn and
are skipped, never fatal. The schema (`src/shared/EngineConfig.luau`, ~94 typed fields across 11
sections) is the single source of truth the plugin renders from.
- **Content overrides** (#40) — `SurvivorCoreContent/Overrides/<Category>` entries **field-merge
onto defs registered from code** (previously untouchable no-code: the loader skips registered
ids). Deltas-only: blank = inherit; deleting an override restores the code def; an id matching
no def **warns at boot** (typo backstop). Applied before components bind and before the client
data caches publish, so tooltips and gatherables see merged values. New
`Registry.applyOverrides(folder)`.
- **SurvivorCore Studio** — the admin plugin rebuilt as its own **floating window** (drag-dockable,
660×580 default) with a **sidebar**: Overview (engine status + content counts), Survival Stats,
**Engine Config** (one page per section; color fields edited as `R, G, B` with a live swatch,
fonts by name with a cycle button; per-section reset), and Content (one page per category with
count badges, **+ Override** authoring, blue override pills). A rail **search box** finds any
content entry by id/name across categories with editable results in place. Last-open page is
remembered; undo/redo now refreshes the forms. New shared plugin visual system
(`plugin/Theme.luau`) replaces the copy-pasted styling. *(Window identity changed — your old
dock position resets once.)*
## 0.7.4 — 2026-07-06
### Fixed
+5 -2
View File
@@ -40,8 +40,11 @@ authoring tools. If you know Roblox Studio, you can build a survival game.
- **Hunting & loot bags** — slain animals leave butcherable carcasses (knife required, real
yields); player death drops everything into a lootable bag with a countdown — get back to it
before it's gone.
- **No-code admin plugin** — create items, weapons, ammo and mobs from a Studio form (damage,
range, arrow curve, weight, aggro, leash) and drop them into the world. No scripting.
- **SurvivorCore Studio (no-code admin plugin)** — one floating window with sidebar + search:
tune **every engine config section** (movement, combat, mobs, loot bags, UI theme colors &
fonts, …) as locked deltas that survive engine updates; create items, weapons, ammo, mobs,
quests and achievements from forms; **override code-registered content** field-by-field; drop
any of it into the world. No scripting.
Everything is **content-free by design**, **server-authoritative**, and **restyleable** — the UI
is real Instances driven by attributes, and every default is overridable.
+104 -51
View File
@@ -1,20 +1,32 @@
# SurvivorCore admin plugin
# SurvivorCore Studio (the admin plugin)
A small **Studio plugin** that gives the experience owner friendly forms instead of hand-editing
Attributes in the Explorer. It adds two toolbar buttons under **SurvivorCore**:
A **Studio plugin** that gives the experience owner friendly forms instead of hand-editing
Attributes in the Explorer. One **SurvivorCore Studio** toolbar button opens one **floating
window** (drag-dock it anywhere) with a sidebar of editors:
- **Overview** — is the engine synced, what content exists (click through), how the model works.
- **Survival Stats** — tune the survival-stat rates/thresholds/HUD on the `SurvivalStatsConfig`
instance (the deltas-only, locked model below).
instance (the deltas-only, locked model below), with an Edit-mode **HUD preview**.
- **Engine Config** — every engine Config section (issue #21): Movement (speeds, energy, audio,
feedback **asset ids**), Combat + Bow, Mobs & AI, Harvesting, Crafting, Inventory, Consequences,
Loot bags, Quests, Achievements, and **UI & theme** (keybinds, chat placement, layout — and the
full **Theme**: colors edited as `R, G, B` with a live swatch, fonts by name). Same deltas-only
model, persisted on **`SurvivorCoreEngineConfig`**; edits apply on the **next Play**.
- **Content** — create/edit/delete **items**, **weapons**, **arrows**, **gatherable resources**,
**mobs**, **quests** and **achievements** with no code (the Builder slice). It writes
`SurvivorCoreContent` instances the engine loads at `start()` — see
[content-authoring.md](content-authoring.md). Gatherables/mobs get **+ Add to World**, weapons
**+ Tool model**, quests **+ Quest giver** — each drops the tagged instance in front of the
camera. Unlike the stats editor, content is full owner-authored defs (not deltas). Every edit is
one Studio undo step.
**mobs**, **quests** and **achievements** with no code, one page per category with entry counts —
plus **Overrides** that tune defs registered *from code* (issue #40, below). Gatherables/mobs get
**+ Add to World**, weapons **+ Tool model**, quests **+ Quest giver**. See
[content-authoring.md](content-authoring.md).
The rest of this page covers the Survival Stats editor; both install the same way. It's the
[Builder / Admin plugin](https://github.com/TemujinCalidius/SurvivorCore/issues/11).
The **search box** at the top of the sidebar finds any content entry by id or name across every
category and shows fully **editable** results in place. The window remembers your last-open page.
Every edit is one Studio **undo** step (and undo/redo refreshes the forms).
This is the [Builder / Admin plugin](https://github.com/TemujinCalidius/SurvivorCore/issues/11).
> **Upgrading from the tabbed panel?** The window identity changed (`SurvivorCore Studio`), so
> Studio forgets the old panel's dock position **once** — the new window opens floating; dock it
> wherever you like and Studio remembers from then on.
> 📹 **Demos:** [HUD, survival stats & the admin plugin](https://makertube.net/w/xqX7wfRpTqd9L9BkozCS1P) · [no-code item & gatherable creation](https://makertube.net/w/mCneurjoY3Av6yi48VsGQE) · [no-code weapon, ammo & mob creation](https://makertube.net/w/tyn8JEMG3CaMbTXid8osdU) · [no-code quest & achievement creation](https://makertube.net/w/uSGJ2MHEFjSSKxMiJBJ6Y5)
@@ -44,16 +56,17 @@ SurvivorCoreStatAdmin.rbxm` — and drop it into that folder, or open it from St
Then **restart Studio** — a brand-new plugin's toolbar only registers when Studio loads it. (After
this first install, re-running the `--plugin` build hot-reloads the plugin live, no restart needed.)
A **SurvivorCore Survival Stats** button then appears on the **Plugins** ribbon tab; click it to
toggle the dock widget.
A **SurvivorCore Studio** button then appears on the **Plugins** ribbon tab; click it to toggle
the window.
> **Open a place that has the engine in it.** The plugin tunes the stats of whatever place is open,
> reading the live roster from `ReplicatedStorage.SurvivorCore` — so sync the engine (or drop in
> `SurvivorCore.rbxm`) first. With no engine present it opens to an instructional empty state and
> writes nothing. Note that restarting Studio drops an *unsaved* Rojo-synced place, so reconnect
> Rojo and re-sync (or save the place before restarting) to bring the engine back.
> **Open a place that has the engine in it.** The plugin edits whatever place is open, reading the
> stat roster and the Engine Config schema live from `ReplicatedStorage.SurvivorCore` — so sync the
> engine (or drop in `SurvivorCore.rbxm`) first. With no engine present it opens to an
> instructional empty state and writes nothing. Note that restarting Studio drops an *unsaved*
> Rojo-synced place, so reconnect Rojo and re-sync (or save the place before restarting) to bring
> the engine back.
## Using it
## Survival Stats
Each stat shows the seven tunable fields, each displaying its **effective** value (your override if
you've set one, otherwise the live engine default):
@@ -64,53 +77,93 @@ you've set one, otherwise the live engine default):
- **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.
Edit a field (type a number / click to toggle or cycle) and it's applied immediately — stats apply
**live**, no restart. 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.
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.
The plugin can tune **only** the seven owner-facing fields above. It deliberately **cannot** touch
`Invert` or `DangerHigh` — those are engine-owned stat *semantics*; a guardrail makes them
impossible to write.
### Preview the HUD in Edit mode
Roblox doesn't run the HUD's client binder in Studio's **Edit** view, so an authored `SurvivalHud`
normally shows its static template there — full bars, blank readouts, and only the shipped *default*
icons (an icon override you set above isn't visible until you press Play). The footer's **Preview
HUD** button paints, in Edit, what the running game *would* render: each bar's resolved icon
(including your overrides), a sample partial fill, and a sample value readout — so you can tune and
**see the result without pressing Play**. **Clear** restores the HUD; both are a single **undo**
step. (Preview resolves icons exactly like the engine — per-bar `Icon` attribute the stat's
effective `icon` — and edits the `SurvivalHud` in StarterGui, so on the Rojo-mounted demo a re-sync
also resets it.)
normally shows its static template there. The footer's **Preview HUD** button paints, in Edit, what
the running game *would* render: each bar's resolved icon (including your overrides), a sample
partial fill, and a sample value readout — so you can tune and **see the result without pressing
Play**. **Clear** restores the HUD; both are a single **undo** step.
## Engine Config
Every page under **Engine Config** edits one engine Config section with the exact same row model as
the stats editor (override dot, engine-default placeholder, blue = overridden). Differences worth
knowing:
- **Persisted on `SurvivorCoreEngineConfig`** (a Configuration in ReplicatedStorage, one child per
section, one grandchild per group) — created on your first real override, deltas-only, never
seeded, never overwritten by the engine.
- **Applies on the next Play / server start.** The engine layers the instance over its defaults as
the very first step of `start()`/`startClient()` — before any system boots — so live-reading and
boot-caching systems all agree. (Survival Stats stay live-applied; everything else is
boot-applied. The footer reminds you.)
- **Theme colors** are edited as `R, G, B` (0255) with a live **swatch**; **fonts** by
`Enum.Font` name (the ↻ button cycles common ones; any valid name can be typed). Invalid values
are rejected in the form, and a hand-authored bad attribute is **warned about and skipped at
boot** — never fatal.
- **Reset section** (footer) removes every override on that page in one undo step.
- The two Inventory **lists** (equip slots, auto-hotbar categories) are code-managed — set them
via `Config.override("Inventory", …)`.
- **After re-syncing an UPDATED engine** (one whose config fields/defaults changed), restart
Studio — Studio caches required modules per session, so the plugin keeps seeing the old schema
until it reloads.
Resolution order everywhere: **engine default → `Config.override(...)` in your game code → the
instance (highest)**. Fields you never touched keep following engine defaults across updates —
that's the locked model.
## Content: authored entries & overrides
Each category page lists the **merged roster**:
- **Authored entries** — full defs this plugin created under `SurvivorCoreContent/<Category>`;
every field editable, **Delete** removes the def.
- **Override entries** (blue *override* pill) — **deltas over a def registered from code** (e.g. a
starter kit's `SurvivorCore.Items.register{...}`). Code-registered defs can't be listed here in
Edit mode (registries fill at runtime), so: type the id, click **+ Override**, and set **only the
fields you want to change** — **blank = inherit** the code value (booleans cycle
`(inherit) → true → false`). **Remove** deletes the override and the pristine code def returns on
the next Play. A typo'd id can only be caught at runtime: the engine **warns in Output on Play**
(`override 'x' matches no registered def`).
Overrides live under `SurvivorCoreContent/Overrides/<Category>` and are field-merged onto the
registered defs at `start()` — before anything caches or replicates, so tooltips, recipes and
gatherables all see the merged values. One caveat: on a **code-registered quest** with nested
objective/reward tables, the flat `objective*`/`reward*` override fields are ignored (name/
description/autoStart/requires/turnIn still merge); overrides of no-code quests merge fully.
## 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:
The engine resolves configuration as **engine default → `Config.override` your instance
(highest priority)**, and it never overwrites your instances. The plugin builds on that with the
**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.
- **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
1. **Your explicit overrides are never lost** — they live on your instances, 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.
default 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.
> **Caveat — the engine's own demo.** In this repo's demo, config instances are *Rojo-mounted*, so
> a `rojo serve` re-sync reverts plugin edits there. That's a demo artifact: a **real** game keeps
> its instances in the place file, so plugin edits persist across engine updates — which is the
> whole point.
---
See also: [Survival Stats + HUD](survival-stats.md) · [Design Language](design-language.md).
See also: [Survival Stats + HUD](survival-stats.md) · [Content authoring](content-authoring.md) ·
[Design Language](design-language.md).
+29
View File
@@ -103,6 +103,35 @@ Open Studio → the **SurvivorCore** toolbar → **Content**. Seven builders:
Each edit is one Studio **undo** step. Behind the scenes it creates/edits the
`SurvivorCoreContent` instances above, so pressing Play registers your content with no code.
## Overrides — tune code-registered defs without code
The authoring folders above can't touch a def that was registered **from code** (the engine's
loader skips already-registered ids — code wins). The **`Overrides`** folder can:
```text
SurvivorCoreContent/
└─ Overrides (Folder) ← mirrors the category folders above
└─ Items (Folder)
└─ berry (Configuration) ← name of a CODE-registered def
• name = "Sweet Berries" (ONLY the fields you change)
• stack = 99
```
At `start()` the engine **field-merges** each override's attributes onto the registered def —
before components bind and before the client data caches publish, so tooltips/recipes/gatherables
all see the merged values. Rules:
- **Deltas-only**: an override carries only the attributes it changes; anything absent inherits
the code value. Deleting the override restores the pristine code def on the next Play.
- The **id can't be renamed** by an override, and an id matching **no registered def warns in
Output on Play** (`override 'x' matches no registered def`) — your typo backstop.
- **Quest caveat**: a code-registered quest with nested objective/reward tables ignores the flat
`objective*`/`reward*` override fields (name/description/autoStart/requires/turnIn still merge).
Overrides of no-code quests merge fully.
The admin plugin's category pages author these for you (**+ Override**, blue pill, blank = inherit)
— see [admin-plugin.md](admin-plugin.md).
## Binding a world object
A creator builds any mesh, tags it **`Gatherable`** (CollectionService), and sets a `Resource`
+9
View File
@@ -192,6 +192,15 @@ SurvivorCore.Config.override("Energy", { DrainPerSecond = 10 }) -- your tweak
SurvivorCore.Config.get("Energy.DrainPerSecond") -- 10
```
Full resolution order (each layer wins over the previous):
1. **Engine defaults**`defineSection` at require time.
2. **Your game's `Config.override(...)`** — code-time tuning, any time before `start()`.
3. **The `SurvivorCoreEngineConfig` instance** — the no-code layer the
[admin plugin's Engine Config editor](admin-plugin.md) writes (deltas-only). Layered over
everything as the first step of `start()`/`startClient()`, so it applies on the next Play.
(`SurvivalStatsConfig` is its live-applied sibling for survival stats.)
## `Assets` — keep IDs out of code
The engine **never** hardcodes asset IDs — your game registers them and the engine reads them
+301
View File
@@ -0,0 +1,301 @@
--!nonstrict
--[[
ConfigAdmin headless logic for the Engine Config editor (issue #21). The generic sibling of
StatAdmin: it edits the persisted `ReplicatedStorage.SurvivorCoreEngineConfig` instance the
engine layers over every Config section at start()/startClient().
The SCHEMA (sections groups typed fields) comes from the ENGINE readSchema() requires
ReplicatedStorage.SurvivorCore.shared.EngineConfig live, so plugin and engine can never
disagree about fields, kinds or defaults. Writes are DELTAS-ONLY, same rule as StatAdmin:
an attribute exists IFF the owner's value differs from the engine default
• typing the default back (or blanking the box) REMOVES the attribute
• unset fields keep following engine defaults across engine updates
Instance shape: <instance>/<Section>/(attributes | <Group>/attributes). Field kinds:
number | boolean | string | enum | color3 (native Color3 attribute) | font (attribute is an
Enum.Font NAME string; the engine resolves it at apply time).
]]
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ConfigAdmin = {}
ConfigAdmin.INSTANCE_NAME = "SurvivorCoreEngineConfig"
-- Relative float tolerance: |vd| ≤ EPS·max(|v|,|d|,1e-6). StatAdmin's absolute 1e-4 would
-- swallow genuine overrides of tiny rates (Consequences drains ≈ 0.0035/s); the relative form
-- still collapses display round-trip noise.
local FLOAT_EPSILON = 1e-4
-- ── Schema (live from the engine) ────────────────────────────────────────────
local function findEngineConfigModule(): ModuleScript?
local engine = ReplicatedStorage:FindFirstChild("SurvivorCore")
local shared = engine and engine:FindFirstChild("shared")
local module = shared and shared:FindFirstChild("EngineConfig")
if module and module:IsA("ModuleScript") then
return module
end
return nil
end
-- Read the schema + per-section defaults from the live engine. Never errors:
-- { ok = true, source, sections, defaults } | { ok = false, reason, sections = {} }
function ConfigAdmin.readSchema(): any
local module = findEngineConfigModule()
if not module then
return {
ok = false,
reason = "SurvivorCore engine not found in this place (expected"
.. " ReplicatedStorage.SurvivorCore.shared.EngineConfig — sync/insert the engine, then Refresh).",
sections = {},
}
end
local ok, engineConfig = pcall(require, module)
if not ok or typeof(engineConfig) ~= "table" or typeof(engineConfig.SECTIONS) ~= "table" then
return {
ok = false,
reason = "Found the engine, but its EngineConfig module failed to load — is the engine"
.. " up to date? (Engine Config needs SurvivorCore ≥ 0.8.)",
sections = {},
}
end
local defaults: { [string]: any } = {}
for _, section in engineConfig.SECTIONS do
defaults[section.id] = engineConfig.getDefaults(section.id)
end
return {
ok = true,
source = module:GetFullName(),
sections = engineConfig.SECTIONS,
defaults = defaults,
}
end
-- ── Instance access (read-only vs ensure) ────────────────────────────────────
function ConfigAdmin.getNode(sectionId: string, groupName: string?): Instance?
local instance = ReplicatedStorage:FindFirstChild(ConfigAdmin.INSTANCE_NAME)
local section = instance and instance:FindFirstChild(sectionId)
if groupName then
return section and section:FindFirstChild(groupName)
end
return section
end
local function ensureConfiguration(parent: Instance, name: string): Instance
local existing = parent:FindFirstChild(name)
if existing then
return existing
end
local node = Instance.new("Configuration")
node.Name = name
node.Parent = parent
return node
end
function ConfigAdmin.ensureNode(sectionId: string, groupName: string?): Instance
local instance = ReplicatedStorage:FindFirstChild(ConfigAdmin.INSTANCE_NAME)
if not instance then
instance = Instance.new("Configuration")
instance.Name = ConfigAdmin.INSTANCE_NAME
instance.Parent = ReplicatedStorage
end
local section = ensureConfiguration(instance, sectionId)
if groupName then
return ensureConfiguration(section, groupName)
end
return section
end
-- ── Effective values ─────────────────────────────────────────────────────────
-- The value the engine will use for a field: the instance override when present, else `default`.
function ConfigAdmin.readEffective(sectionId: string, groupName: string?, attr: string, default: any): any
local node = ConfigAdmin.getNode(sectionId, groupName)
local override = node and node:GetAttribute(attr)
if override ~= nil then
return { value = override, default = default, hasOverride = true }
end
return { value = default, default = default, hasOverride = false }
end
-- ── Coercion & default equality ──────────────────────────────────────────────
-- Parse a raw edit (usually TextBox text) per the field spec. Returns { ok, value } or
-- { ok = false, error }. A blank string is handled by setOverride (it means "reset").
function ConfigAdmin.coerce(field: any, raw: any): any
if field.kind == "number" then
local n = if typeof(raw) == "number" then raw else tonumber(tostring(raw))
if n == nil then
return { ok = false, error = "expected a number" }
end
if n ~= n or n == math.huge or n == -math.huge then
return { ok = false, error = "expected a finite number" }
end
if field.min then
n = math.max(n, field.min)
end
if field.max then
n = math.min(n, field.max)
end
if field.integer then
n = math.floor(n + 0.5)
end
return { ok = true, value = n }
elseif field.kind == "boolean" then
if typeof(raw) == "boolean" then
return { ok = true, value = raw }
end
local s = string.lower(tostring(raw))
if s == "true" then
return { ok = true, value = true }
elseif s == "false" then
return { ok = true, value = false }
end
return { ok = false, error = "expected true or false" }
elseif field.kind == "enum" then
local s = tostring(raw)
for _, choice in field.choices or {} :: { string } do
if choice == s then
return { ok = true, value = s }
end
end
return { ok = false, error = `must be one of: {table.concat(field.choices or {}, ", ")}` }
elseif field.kind == "color3" then
if typeof(raw) == "Color3" then
return { ok = true, value = raw }
end
local r, g, b = string.match(tostring(raw), "^%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)%s*$")
if not r then
return { ok = false, error = "expected R, G, B (0-255 each)" }
end
local rn, gn, bn = tonumber(r), tonumber(g), tonumber(b)
if rn > 255 or gn > 255 or bn > 255 then
return { ok = false, error = "channels are 0-255" }
end
return { ok = true, value = Color3.fromRGB(rn, gn, bn) }
elseif field.kind == "font" then
local s = tostring(raw)
local ok, font = pcall(function()
return (Enum.Font :: any)[s]
end)
if not ok or typeof(font) ~= "EnumItem" then
return { ok = false, error = `'{s}' is not an Enum.Font name` }
end
return { ok = true, value = s } -- stored as the NAME string; the engine resolves it
else -- "string" (incl. check = keycode/assetId)
local s = tostring(raw)
if field.check == "keycode" then
local ok, keyCode = pcall(function()
return (Enum.KeyCode :: any)[s]
end)
if not ok or typeof(keyCode) ~= "EnumItem" then
return { ok = false, error = `'{s}' is not an Enum.KeyCode name` }
end
end
return { ok = true, value = s }
end
end
-- Does a coerced value equal the engine default (→ store nothing)? Kind-aware: fonts compare
-- the stored NAME against the default Enum.Font; Color3 compares exactly (both sides come from
-- fromRGB construction); numbers use the relative epsilon.
function ConfigAdmin.equalsDefault(field: any, value: any, default: any): boolean
if field.kind == "number" then
if typeof(value) ~= "number" or typeof(default) ~= "number" then
return value == default
end
local scale = math.max(math.abs(value), math.abs(default), 1e-6)
return math.abs(value - default) <= FLOAT_EPSILON * scale
elseif field.kind == "font" then
local defaultName = if typeof(default) == "EnumItem" then default.Name else tostring(default)
return value == defaultName
end
return value == default
end
-- Display formatting for effective values/defaults (what the TextBox shows).
function ConfigAdmin.formatValue(field: any, value: any): string
if value == nil then
return ""
end
if field.kind == "color3" and typeof(value) == "Color3" then
return string.format(
"%d, %d, %d",
math.round(value.R * 255),
math.round(value.G * 255),
math.round(value.B * 255)
)
end
if field.kind == "font" and typeof(value) == "EnumItem" then
return value.Name
end
return tostring(value)
end
-- ── Writes (the deltas-only decision point) ──────────────────────────────────
-- Set (or clear) one field. Blank input = reset to default. Writes ONLY when the value differs
-- from the engine default; removes the attribute when it matches.
-- Returns { ok, action = "write"|"remove"|"noop", error? }.
function ConfigAdmin.setOverride(sectionId: string, groupName: string?, field: any, raw: any, default: any): any
if typeof(raw) == "string" and string.match(raw, "^%s*$") then
return ConfigAdmin.resetOverride(sectionId, groupName, field.attr)
end
local coerced = ConfigAdmin.coerce(field, raw)
if not coerced.ok then
return { ok = false, action = "noop", error = coerced.error }
end
if ConfigAdmin.equalsDefault(field, coerced.value, default) then
return ConfigAdmin.resetOverride(sectionId, groupName, field.attr)
end
local node = ConfigAdmin.ensureNode(sectionId, groupName)
node:SetAttribute(field.attr, coerced.value)
return { ok = true, action = "write" }
end
function ConfigAdmin.resetOverride(sectionId: string, groupName: string?, attr: string): any
local node = ConfigAdmin.getNode(sectionId, groupName)
if node and node:GetAttribute(attr) ~= nil then
node:SetAttribute(attr, nil)
return { ok = true, action = "remove" }
end
return { ok = true, action = "noop" }
end
-- Remove every override in one section (all groups). Returns the number removed.
function ConfigAdmin.resetSection(section: any): number
local removed = 0
for _, group in section.groups do
local node = ConfigAdmin.getNode(section.id, group.name)
if node then
for _, field in group.fields do
if node:GetAttribute(field.attr) ~= nil then
node:SetAttribute(field.attr, nil)
removed += 1
end
end
end
end
return removed
end
-- Count the overrides currently set in one section (drives the "n overridden" footer).
function ConfigAdmin.countOverrides(section: any): number
local count = 0
for _, group in section.groups do
local node = ConfigAdmin.getNode(section.id, group.name)
if node then
for _, field in group.fields do
if node:GetAttribute(field.attr) ~= nil then
count += 1
end
end
end
end
return count
end
return ConfigAdmin
+347
View File
@@ -0,0 +1,347 @@
--!nonstrict
--[[
ConfigAdminUi the Engine Config editor pages (issue #21). One sidebar page per Config
section; each page renders its groups as headed panels of typed field rows (the StatAdminUi
row pattern, generalized): reset dot /, label, and a control per kind number/string
TextBox (placeholder = engine default), boolean/enum click-cycle, color3 TextBox "R, G, B"
with a live swatch, font TextBox with a cycle button over common fonts. All writes route
through the plugin main's record() wrappers (one undo step each), deltas-only via ConfigAdmin.
]]
local Theme = require(script.Parent.Theme)
local ConfigAdminUi = {}
local ROW_H = Theme.ROW_H
local FOOTER_H = 36
-- The click-cycle list for font fields (any valid Enum.Font name can still be typed).
local COMMON_FONTS = {
"Gotham",
"GothamMedium",
"GothamBold",
"GothamBlack",
"SourceSans",
"SourceSansBold",
"Arial",
"ArialBold",
"Merriweather",
"Bangers",
"PermanentMarker",
}
-- One field row. `groupName` may be nil (root attributes).
local function buildRow(ConfigAdmin, sectionId, groupName, field, eff, applyEdit, applyReset): Instance
local row = Theme.make("Frame", {
Size = UDim2.new(1, 0, 0, ROW_H),
BackgroundTransparency = 1,
})
local reset = Theme.button({
Size = UDim2.fromOffset(18, 18),
Position = UDim2.new(0, 0, 0.5, -9),
Text = eff.hasOverride and "●" or "○",
TextColor3 = eff.hasOverride and Theme.COLOR.ACCENT or Theme.COLOR.DIM,
})
reset.Parent = row
reset.MouseButton1Click:Connect(function()
applyReset(sectionId, groupName, field.attr)
end)
Theme.label({
Size = UDim2.new(0.42, -28, 1, 0),
Position = UDim2.fromOffset(26, 0),
Text = field.label,
TextColor3 = eff.hasOverride and Theme.COLOR.TEXT or Theme.COLOR.DIM,
TextTruncate = Enum.TextTruncate.AtEnd,
}).Parent =
row
local controlPos = UDim2.new(0.42, 4, 0, 2)
local controlSize = UDim2.new(0.58, -4, 0, ROW_H - 4)
if field.kind == "boolean" or field.kind == "enum" then
local control = Theme.button({
Size = controlSize,
Position = controlPos,
Text = ConfigAdmin.formatValue(field, eff.value),
TextSize = 13,
})
control.Parent = row
control.MouseButton1Click:Connect(function()
local nextValue: any
if field.kind == "boolean" then
nextValue = not (eff.value == true)
else
local choices = field.choices or {}
local i = table.find(choices, tostring(eff.value)) or 0
nextValue = choices[(i % #choices) + 1]
end
applyEdit(sectionId, groupName, field, nextValue, eff.default)
end)
elseif field.kind == "color3" then
-- [ R, G, B text box ][ swatch ] — the swatch previews the effective color live.
local box = Theme.textBox({
Size = UDim2.new(0.58, -28, 0, ROW_H - 4),
Position = controlPos,
Text = ConfigAdmin.formatValue(field, eff.value),
PlaceholderText = ConfigAdmin.formatValue(field, eff.default),
})
box.Parent = row
local swatch = Theme.make("Frame", {
Size = UDim2.fromOffset(18, 18),
Position = UDim2.new(1, -18, 0.5, -9),
BackgroundColor3 = if typeof(eff.value) == "Color3" then eff.value else Color3.new(0, 0, 0),
BorderSizePixel = 0,
}, { Theme.corner(4), Theme.stroke(0.7) })
swatch.Parent = row
box.FocusLost:Connect(function()
applyEdit(sectionId, groupName, field, box.Text, eff.default)
end)
elseif field.kind == "font" then
-- [ font name box ][ ↻ cycle ] — cycles common fonts; any Enum.Font name can be typed.
local box = Theme.textBox({
Size = UDim2.new(0.58, -28, 0, ROW_H - 4),
Position = controlPos,
Text = ConfigAdmin.formatValue(field, eff.value),
PlaceholderText = ConfigAdmin.formatValue(field, eff.default),
})
box.Parent = row
box.FocusLost:Connect(function()
applyEdit(sectionId, groupName, field, box.Text, eff.default)
end)
local cycle = Theme.button({
Size = UDim2.fromOffset(22, ROW_H - 4),
Position = UDim2.new(1, -22, 0, 2),
Text = "↻",
TextColor3 = Theme.COLOR.DIM,
})
cycle.Parent = row
cycle.MouseButton1Click:Connect(function()
local current = ConfigAdmin.formatValue(field, eff.value)
local i = table.find(COMMON_FONTS, current) or 0
applyEdit(sectionId, groupName, field, COMMON_FONTS[(i % #COMMON_FONTS) + 1], eff.default)
end)
else
local box = Theme.textBox({
Size = controlSize,
Position = controlPos,
Text = ConfigAdmin.formatValue(field, eff.value),
PlaceholderText = ConfigAdmin.formatValue(field, eff.default),
})
box.Parent = row
box.FocusLost:Connect(function()
applyEdit(sectionId, groupName, field, box.Text, eff.default)
end)
end
return row
end
-- Mount one section's page. Re-reads the schema on every refresh (self-heals after re-syncs).
local function mountSection(
container: Frame,
ConfigAdmin: any,
sectionId: string,
applyEdit,
applyReset,
applyResetSection
): { refresh: () -> () }
local header = Theme.header(container, "Engine Config")
local scroll = Theme.scroll(32, FOOTER_H, 6)
scroll.Parent = container
local footer = Theme.make("Frame", {
Size = UDim2.new(1, 0, 0, FOOTER_H),
Position = UDim2.new(0, 0, 1, -FOOTER_H),
BackgroundColor3 = Theme.COLOR.PANEL,
BorderSizePixel = 0,
})
footer.Parent = container
local resetBtn = Theme.button({
Size = UDim2.fromOffset(96, 22),
Position = UDim2.new(0, 10, 0.5, -11),
Text = "Reset section",
TextColor3 = Theme.COLOR.DANGER,
})
resetBtn.Parent = footer
local status = Theme.label({
Size = UDim2.new(1, -120, 1, 0),
Position = UDim2.fromOffset(112, 0),
Text = "",
TextColor3 = Theme.COLOR.DIM,
TextXAlignment = Enum.TextXAlignment.Right,
TextTruncate = Enum.TextTruncate.AtEnd,
TextSize = 11,
})
status.Parent = footer
local currentSection: any = nil
-- Route a rejected edit's reason to the footer (set AFTER record()'s refresh, so it sticks).
local function editField(editSectionId, groupName, field, raw, default)
local res = applyEdit(editSectionId, groupName, field, raw, default)
if typeof(res) == "table" and res.ok == false and res.error then
status.Text = `{field.label}: {res.error}`
end
return res
end
local function refresh()
for _, child in scroll:GetChildren() do
if not child:IsA("UIBase") then
child:Destroy()
end
end
local schema = ConfigAdmin.readSchema()
if not schema.ok then
currentSection = nil
status.Text = ""
Theme.label({
Size = UDim2.new(1, 0, 0, 80),
Text = schema.reason or "Engine not found.",
TextColor3 = Theme.COLOR.DIM,
TextWrapped = true,
}).Parent =
scroll
return
end
local section: any = nil
for _, s in schema.sections do
if s.id == sectionId then
section = s
break
end
end
if not section then
status.Text = ""
Theme.label({
Size = UDim2.new(1, 0, 0, 60),
Text = `Section '{sectionId}' is not in this engine's schema (older engine?).`,
TextColor3 = Theme.COLOR.DIM,
TextWrapped = true,
}).Parent =
scroll
return
end
currentSection = section
local defaults = schema.defaults[sectionId] or {}
local order = 0
Theme.label({
Size = UDim2.new(1, 0, 0, 20),
Text = section.title,
Font = Theme.FONT_BOLD,
TextSize = 15,
LayoutOrder = order,
}).Parent =
scroll
if section.note then
order += 1
Theme.label({
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
Text = section.note,
TextColor3 = Theme.COLOR.DIM,
TextWrapped = true,
TextSize = 12,
LayoutOrder = order,
}).Parent =
scroll
end
for _, group in section.groups do
order += 1
-- Theme.panel() auto-sizes (AutomaticSize.Y) — a fixed-height formula here would
-- miss the panel's own padding + per-row list gaps and clip the last row.
local panel = Theme.panel()
panel.LayoutOrder = order
panel.Parent = scroll
Theme.label({
Size = UDim2.new(1, 0, 0, 22),
Text = group.label,
Font = Theme.FONT_BOLD,
TextSize = 14,
LayoutOrder = 0,
}).Parent =
panel
local groupDefaults = if group.name then defaults[group.name] else defaults
for i, field in group.fields do
local default = if typeof(groupDefaults) == "table" then groupDefaults[field.attr] else nil
local eff = ConfigAdmin.readEffective(section.id, group.name, field.attr, default)
local row = buildRow(ConfigAdmin, section.id, group.name, field, eff, editField, applyReset)
row.LayoutOrder = i
row.Parent = panel
end
end
local overridden = ConfigAdmin.countOverrides(section)
status.Text = if overridden == 0
then "no overrides — engine defaults"
else `{overridden} field(s) overridden · applies on next Play`
end
resetBtn.MouseButton1Click:Connect(function()
if currentSection then
applyResetSection(currentSection)
end
end)
header.refreshBtn.MouseButton1Click:Connect(refresh)
refresh()
return { refresh = refresh }
end
-- Build the Nav pages: one per section when the engine is present, else a single explainer page
-- whose Refresh triggers `onEngineFound` (the plugin main rebuilds the whole window) once the
-- engine appears — the page list is otherwise fixed for the life of the mount.
function ConfigAdminUi.pages(ConfigAdmin: any, applyEdit, applyReset, applyResetSection, onEngineFound): { any }
local pages = {}
local schema = ConfigAdmin.readSchema()
if schema.ok then
for _, section in schema.sections do
table.insert(pages, {
id = "config/" .. section.id,
label = section.title,
section = "Engine Config",
mount = function(frame)
return mountSection(frame, ConfigAdmin, section.id, applyEdit, applyReset, applyResetSection)
end,
})
end
else
table.insert(pages, {
id = "config/unavailable",
label = "Engine Config",
section = "Engine Config",
mount = function(frame)
local header = Theme.header(frame, "Engine Config")
local message = Theme.label({
Size = UDim2.new(1, -24, 0, 100),
Position = UDim2.fromOffset(12, 40),
Text = (schema.reason or "Engine not found.")
.. " Once the engine is in the place, click Refresh to load the sections.",
TextColor3 = Theme.COLOR.DIM,
TextWrapped = true,
TextYAlignment = Enum.TextYAlignment.Top,
})
message.Parent = frame
local function refresh()
local retry = ConfigAdmin.readSchema()
if retry.ok and onEngineFound then
onEngineFound() -- the plugin main remounts the window with the full page list
end
end
header.refreshBtn.MouseButton1Click:Connect(refresh)
return { refresh = refresh }
end,
})
end
return pages
end
return ConfigAdminUi
+131
View File
@@ -337,6 +337,10 @@ function ContentAdmin.create(catKey: string, rawId: any): (boolean, string)
if folder:FindFirstChild(id) then
return false, `'{id}' already exists.`
end
if ContentAdmin.getOverrideNode(catKey, id) then
-- Authoring over an override would leave ONE roster entry masking the other.
return false, `'{id}' has an override — remove the override first (or keep editing it).`
end
local node = Instance.new("Configuration")
node.Name = id
for _, f in cat.fields do
@@ -377,4 +381,131 @@ function ContentAdmin.set(catKey: string, id: string, field: FieldSpec, raw: any
return true, nil
end
-- ── Overrides (issue #40) ─────────────────────────────────────────────────────
--
-- SurvivorCoreContent/Overrides/<folder>/<id> tunes a def registered FROM CODE (which the
-- authoring folders can't touch — the engine's loadFromFolder skips already-registered keys).
-- The engine field-merges these at start(), so an override child carries ONLY the attributes it
-- changes: BLANK = INHERIT the code value. The plugin can't display code defaults in Edit mode
-- (registries fill at runtime), which is exactly why these are deltas with empty placeholders —
-- and why an unknown id can only be caught at Play (the engine warns in Output).
ContentAdmin.OVERRIDES_FOLDER = "Overrides"
function ContentAdmin.getOverrideFolder(catKey: string): Instance?
local cat = ContentAdmin.CATEGORIES[catKey]
local root = ContentAdmin.getRoot()
local overrides = root and root:FindFirstChild(ContentAdmin.OVERRIDES_FOLDER)
return overrides and cat and overrides:FindFirstChild(cat.folder) or nil
end
-- Find-or-create SurvivorCoreContent/Overrides/<folder>. Only called from the write path.
function ContentAdmin.ensureOverrideFolder(catKey: string): Instance
local cat = ContentAdmin.CATEGORIES[catKey]
ContentAdmin.ensureFolder(catKey) -- guarantees the root exists
local root = ContentAdmin.getRoot() :: Instance
local overrides = root:FindFirstChild(ContentAdmin.OVERRIDES_FOLDER)
if not overrides then
overrides = Instance.new("Folder")
overrides.Name = ContentAdmin.OVERRIDES_FOLDER
overrides.Parent = root
end
local folder = overrides:FindFirstChild(cat.folder)
if not folder then
folder = Instance.new("Folder")
folder.Name = cat.folder
folder.Parent = overrides
end
return folder
end
function ContentAdmin.getOverrideNode(catKey: string, id: string): Instance?
local folder = ContentAdmin.getOverrideFolder(catKey)
return folder and folder:FindFirstChild(id) or nil
end
export type RosterEntry = { id: string, authored: boolean, override: boolean }
-- The merged category roster: authored entries + override entries, sorted by id.
function ContentAdmin.listRoster(catKey: string): { RosterEntry }
local byId: { [string]: RosterEntry } = {}
for _, id in ContentAdmin.list(catKey) do
byId[id] = { id = id, authored = true, override = false }
end
local overrideFolder = ContentAdmin.getOverrideFolder(catKey)
if overrideFolder then
for _, child in overrideFolder:GetChildren() do
local entry = byId[child.Name]
if entry then
entry.override = true -- authored + override shouldn't happen (createOverride refuses)
else
byId[child.Name] = { id = child.Name, authored = false, override = true }
end
end
end
local out = {}
for _, entry in byId do
table.insert(out, entry)
end
table.sort(out, function(a, b)
return a.id < b.id
end)
return out
end
-- Create an EMPTY override (no attributes — deltas-only; NOT create()'s seed-all-defaults).
function ContentAdmin.createOverride(catKey: string, rawId: any): (boolean, string)
local cat = ContentAdmin.CATEGORIES[catKey]
if not cat then
return false, "unknown category"
end
local id = ContentAdmin.sanitizeId(rawId)
if id == "" then
return false, "Enter a valid id (letters, numbers, _)."
end
if ContentAdmin.getNode(catKey, id) then
return false, `'{id}' is authored here — edit the entry itself.`
end
local folder = ContentAdmin.ensureOverrideFolder(catKey)
if folder:FindFirstChild(id) then
return false, `'{id}' already has an override.`
end
local node = Instance.new("Configuration")
node.Name = id
node.Parent = folder
return true, id
end
function ContentAdmin.deleteOverride(catKey: string, id: string)
local node = ContentAdmin.getOverrideNode(catKey, id)
if node then
node:Destroy()
end
end
-- Read one override field: { value?, hasOverride } — value is nil when inheriting.
function ContentAdmin.readOverrideField(catKey: string, id: string, field: FieldSpec): any
local node = ContentAdmin.getOverrideNode(catKey, id)
local v = node and node:GetAttribute(field.attr)
return { value = v, hasOverride = v ~= nil }
end
-- Write one override field. BLANK (or nil) = remove the attribute → inherit the code value.
function ContentAdmin.setOverrideField(catKey: string, id: string, field: FieldSpec, raw: any): (boolean, string?)
local node = ContentAdmin.getOverrideNode(catKey, id)
if not node then
return false, "missing override"
end
if raw == nil or (typeof(raw) == "string" and string.match(raw, "^%s*$")) then
node:SetAttribute(field.attr, nil)
return true, nil
end
local ok, value, err = coerce(field.kind, raw)
if not ok then
return false, err
end
node:SetAttribute(field.attr, value)
return true, nil
end
return ContentAdmin
+221 -244
View File
@@ -1,154 +1,166 @@
--!nonstrict
--[[
ContentAdminUi the dock-widget UI for the no-code content builder. A thin layer over
ContentAdmin (the logic): it renders an Items section and a Gatherables section, each with a
"create" row and one editable panel per entry (field rows + Delete). Edits route through the
applySet / applyCreate / applyDelete callbacks the plugin main supplies (which wrap the
ContentAdmin call in a ChangeHistoryService recording, then refresh). Styling matches
StatAdminUi / docs/design-language.md.
ContentAdminUi the no-code content editor pages. One sidebar page per category
(mountCategory), each rendering the MERGED roster (issue #40):
authored entries Configurations under SurvivorCoreContent/<folder>: the full editor
(every field seeded, Delete destroys the def), exactly as before.
override entries Configurations under SurvivorCoreContent/Overrides/<folder>: DELTAS
over a def registered from code. Blank = inherit the code value (the plugin can't show
code defaults in Edit mode — registries fill at runtime); Remove restores the pristine
code def on the next Play.
buildRosterEntry/matchEntry are exported so the global search page renders the same editable
panels. All writes route through the `actions` table of record()-wrapped callbacks the plugin
main supplies. Styling comes from Theme.
]]
local Theme = require(script.Parent.Theme)
local ContentAdminUi = {}
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_FIELD = Color3.fromRGB(38, 43, 56)
local COL_ACCENT = Color3.fromRGB(120, 170, 255)
local COL_DANGER = Color3.fromRGB(210, 90, 90)
local FONT = Enum.Font.GothamMedium
local ROW_H = 26
local ROW_H = Theme.ROW_H
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: label .... control (TextBox committed on focus loss).
local function buildField(catKey: string, id: string, field: any, value: any, applySet): Instance
local row = make("Frame", { Size = UDim2.new(1, 0, 0, ROW_H), BackgroundTransparency = 1 })
make("TextLabel", {
-- A single authored-field row: label .... TextBox (committed on focus loss).
local function buildField(catKey: string, id: string, field: any, value: any, actions): Instance
local row = Theme.make("Frame", { Size = UDim2.new(1, 0, 0, ROW_H), BackgroundTransparency = 1 })
Theme.label({
Size = UDim2.fromScale(0.4, 1),
BackgroundTransparency = 1,
Text = field.label,
TextColor3 = COL_DIM,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 13,
Font = FONT,
TextColor3 = Theme.COLOR.DIM,
TextTruncate = Enum.TextTruncate.AtEnd,
}).Parent =
row
local box = make("TextBox", {
local box = Theme.textBox({
Size = UDim2.new(0.6, -4, 0, ROW_H - 4),
Position = UDim2.new(0.4, 4, 0, 2),
BackgroundColor3 = COL_FIELD,
Text = tostring(value),
PlaceholderText = field.placeholder or tostring(field.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()
applySet(catKey, id, field, box.Text)
actions.set(catKey, id, field, box.Text)
end)
return row
end
-- One entry panel: a title row (id + Delete) + a field row per field.
local function buildEntry(catKey: string, id: string, ContentAdmin: any, applySet, applyDelete, applySpawn): Instance
local cat = ContentAdmin.CATEGORIES[catKey]
local panel = make("Frame", {
BackgroundColor3 = COL_PANEL,
BorderSizePixel = 0,
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
}, {
corner(8),
make("UIPadding", {
PaddingLeft = UDim.new(0, 8),
PaddingRight = UDim.new(0, 8),
PaddingTop = UDim.new(0, 6),
PaddingBottom = UDim.new(0, 8),
}),
make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
})
-- A single override-field row: blank = inherit. Booleans cycle (inherit) → true → false.
local function buildOverrideField(catKey: string, id: string, field: any, state: any, actions): Instance
local row = Theme.make("Frame", { Size = UDim2.new(1, 0, 0, ROW_H), BackgroundTransparency = 1 })
Theme.label({
Size = UDim2.fromScale(0.4, 1),
Text = field.label,
TextColor3 = if state.hasOverride then Theme.COLOR.TEXT else Theme.COLOR.DIM,
TextTruncate = Enum.TextTruncate.AtEnd,
}).Parent =
row
local title = make("Frame", { Size = UDim2.new(1, 0, 0, 24), BackgroundTransparency = 1, LayoutOrder = 0 })
if field.kind == "boolean" then
local control = Theme.button({
Size = UDim2.new(0.6, -4, 0, ROW_H - 4),
Position = UDim2.new(0.4, 4, 0, 2),
Text = if state.hasOverride then tostring(state.value) else "(inherit)",
TextColor3 = if state.hasOverride then Theme.COLOR.TEXT else Theme.COLOR.DIM,
TextSize = 13,
})
control.Parent = row
control.MouseButton1Click:Connect(function()
-- (inherit) → true → false → (inherit)
local nextRaw: any
if not state.hasOverride then
nextRaw = true
elseif state.value == true then
nextRaw = false
else
nextRaw = "" -- blank = remove the attribute = inherit
end
actions.setOverride(catKey, id, field, nextRaw)
end)
else
local box = Theme.textBox({
Size = UDim2.new(0.6, -4, 0, ROW_H - 4),
Position = UDim2.new(0.4, 4, 0, 2),
Text = if state.hasOverride then tostring(state.value) else "",
PlaceholderText = "(code default)",
})
box.Parent = row
box.FocusLost:Connect(function()
actions.setOverride(catKey, id, field, box.Text)
end)
end
return row
end
-- One roster entry panel — branches authored vs override.
function ContentAdminUi.buildRosterEntry(catKey: string, entry: any, ContentAdmin: any, actions): Instance
local cat = ContentAdmin.CATEGORIES[catKey]
local panel = Theme.panel()
local title = Theme.make("Frame", { Size = UDim2.new(1, 0, 0, 24), BackgroundTransparency = 1, LayoutOrder = 0 })
title.Parent = panel
local del = make("TextButton", {
local del = Theme.button({
Size = UDim2.fromOffset(56, 20),
Position = UDim2.new(1, -56, 0.5, -10),
BackgroundColor3 = COL_FIELD,
AutoButtonColor = true,
Text = "Delete",
TextColor3 = COL_DANGER,
TextSize = 12,
Font = FONT,
BorderSizePixel = 0,
}, { corner(4) })
Text = if entry.override then "Remove" else "Delete",
TextColor3 = Theme.COLOR.DANGER,
})
del.Parent = title
del.MouseButton1Click:Connect(function()
applyDelete(catKey, id)
if entry.override then
actions.deleteOverride(catKey, entry.id)
else
actions.delete(catKey, entry.id)
end
end)
-- Gatherables + Mobs + Quests drop a tagged, def-linked instance; Weapons drop a starter Tool
-- (Handle + the weapon's ToolType/WeaponKind) you can model the look on — all in front of the
-- camera, the bridge between the editor and the world.
local labelRight = 64
if (catKey == "Resources" or catKey == "Mobs" or catKey == "Weapons" or catKey == "Quests") and applySpawn then
local add = make("TextButton", {
if entry.override then
-- Accent pill marking this as a delta over a code-registered def.
local pill = Theme.badge("override", Theme.COLOR.ACCENT)
pill.AnchorPoint = Vector2.new(1, 0.5)
pill.Position = UDim2.new(1, -64, 0.5, 0)
pill.Parent = title
labelRight = 128
elseif
(catKey == "Resources" or catKey == "Mobs" or catKey == "Weapons" or catKey == "Quests") and actions.spawn
then
-- Gatherables + Mobs + Quests drop a tagged, def-linked instance; Weapons drop a starter
-- Tool you can model the look on — all in front of the camera.
local add = Theme.button({
Size = UDim2.fromOffset(104, 20),
Position = UDim2.new(1, -168, 0.5, -10),
BackgroundColor3 = COL_FIELD,
AutoButtonColor = true,
Text = if catKey == "Weapons"
then "+ Tool model"
elseif catKey == "Quests" then "+ Quest giver"
else "+ Add to World",
TextColor3 = COL_ACCENT,
TextColor3 = Theme.COLOR.ACCENT,
TextSize = 11,
Font = FONT,
BorderSizePixel = 0,
}, { corner(4) })
})
add.Parent = title
add.MouseButton1Click:Connect(function()
applySpawn(catKey, id)
actions.spawn(catKey, entry.id)
end)
labelRight = 176
end
make("TextLabel", {
Theme.label({
Size = UDim2.new(1, -labelRight, 1, 0),
BackgroundTransparency = 1,
Text = id,
TextColor3 = COL_TEXT,
TextXAlignment = Enum.TextXAlignment.Left,
Text = entry.id,
TextSize = 14,
Font = Enum.Font.GothamBold,
Font = Theme.FONT_BOLD,
TextTruncate = Enum.TextTruncate.AtEnd,
}).Parent =
title
for i, field in cat.fields do
local row = buildField(catKey, id, field, ContentAdmin.read(catKey, id, field), applySet)
local row: any
if entry.override then
local state = ContentAdmin.readOverrideField(catKey, entry.id, field)
row = buildOverrideField(catKey, entry.id, field, state, actions)
else
row = buildField(catKey, entry.id, field, ContentAdmin.read(catKey, entry.id, field), actions)
end
row.LayoutOrder = i
row.Parent = panel
end
@@ -156,165 +168,130 @@ local function buildEntry(catKey: string, id: string, ContentAdmin: any, applySe
return panel
end
-- Build one category block: bold header, a create row, then an entry panel per id.
local function buildCategory(
catKey: string,
ContentAdmin: any,
applySet,
applyCreate,
applyDelete,
applySpawn
): Instance
local cat = ContentAdmin.CATEGORIES[catKey]
local block = make("Frame", {
BackgroundTransparency = 1,
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
}, {
make("UIListLayout", { Padding = UDim.new(0, 6), SortOrder = Enum.SortOrder.LayoutOrder }),
})
make("TextLabel", {
Size = UDim2.new(1, 0, 0, 24),
BackgroundTransparency = 1,
Text = cat.title,
TextColor3 = COL_ACCENT,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 15,
Font = Enum.Font.GothamBold,
LayoutOrder = 0,
}).Parent =
block
-- Create row: [ new id ............ ] [ + Create ]
local createRow = make("Frame", { Size = UDim2.new(1, 0, 0, 28), BackgroundTransparency = 1, LayoutOrder = 1 })
createRow.Parent = block
local idBox = make("TextBox", {
Size = UDim2.new(1, -92, 1, 0),
BackgroundColor3 = COL_FIELD,
Text = "",
PlaceholderText = cat.keyLabel,
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) }) })
idBox.Parent = createRow
local createBtn = make("TextButton", {
Size = UDim2.fromOffset(84, 28),
Position = UDim2.new(1, -84, 0, 0),
BackgroundColor3 = COL_FIELD,
AutoButtonColor = true,
Text = "+ Create",
TextColor3 = COL_TEXT,
TextSize = 13,
Font = FONT,
BorderSizePixel = 0,
}, { corner(4) })
createBtn.Parent = createRow
createBtn.MouseButton1Click:Connect(function()
applyCreate(catKey, idBox.Text)
end)
local ids = ContentAdmin.list(catKey)
if #ids == 0 then
make("TextLabel", {
Size = UDim2.new(1, 0, 0, 22),
BackgroundTransparency = 1,
Text = `No {string.lower(cat.title)} yet — create one above.`,
TextColor3 = COL_DIM,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 12,
Font = FONT,
LayoutOrder = 2,
}).Parent =
block
else
for i, id in ids do
local entry = buildEntry(catKey, id, ContentAdmin, applySet, applyDelete, applySpawn)
entry.LayoutOrder = 2 + i
entry.Parent = block
end
-- Case-insensitive substring match on the entry id and its `name` attribute (when the category
-- has one). Shared by the category pages and the global search page.
function ContentAdminUi.matchEntry(ContentAdmin: any, catKey: string, entry: any, loweredQuery: string): boolean
if string.find(string.lower(entry.id), loweredQuery, 1, true) then
return true
end
return block
local node = if entry.override
then ContentAdmin.getOverrideNode(catKey, entry.id)
else ContentAdmin.getNode(catKey, entry.id)
local name = node and node:GetAttribute("name")
if typeof(name) == "string" and string.find(string.lower(name), loweredQuery, 1, true) then
return true
end
return false
end
function ContentAdminUi.mount(
container: Instance,
-- Mount one category's page. `actions` = { set, create, delete, spawn, createOverride,
-- setOverride, deleteOverride } — all record()-wrapped by the plugin main.
function ContentAdminUi.mountCategory(
container: Frame,
catKey: string,
ContentAdmin: any,
applySet,
applyCreate,
applyDelete,
applySpawn
actions
): { 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 = "Content",
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, 12), 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),
}),
})
local cat = ContentAdmin.CATEGORIES[catKey]
local header = Theme.header(container, cat.title)
local scroll = Theme.scroll(32, 0, 8)
scroll.Parent = container
-- A failed Create/Override's reason, rendered by refresh() under the create row (the row
-- itself is destroyed and rebuilt by record()'s refresh, so the message must live here).
local lastError: string? = nil
local function refresh()
for _, child in scroll:GetChildren() do
if not child:IsA("UIBase") then
child:Destroy()
end
end
for i, catKey in ContentAdmin.ORDER do
local block = buildCategory(catKey, ContentAdmin, applySet, applyCreate, applyDelete, applySpawn)
block.LayoutOrder = i
block.Parent = scroll
-- Create row: [ new id ............ ] [ + Create ] [ + Override ]
local createRow =
Theme.make("Frame", { Size = UDim2.new(1, 0, 0, 28), BackgroundTransparency = 1, LayoutOrder = 1 })
createRow.Parent = scroll
local idBox = Theme.textBox({
Size = UDim2.new(1, -172, 1, 0),
Text = "",
PlaceholderText = cat.keyLabel,
})
idBox.Parent = createRow
local createBtn = Theme.button({
Size = UDim2.fromOffset(76, 28),
Position = UDim2.new(1, -164, 0, 0),
Text = "+ Create",
TextSize = 13,
})
createBtn.Parent = createRow
createBtn.MouseButton1Click:Connect(function()
local res = actions.create(catKey, idBox.Text)
lastError = if typeof(res) == "table" and res.ok == false then res.message else nil
refresh()
end)
local overrideBtn = Theme.button({
Size = UDim2.fromOffset(84, 28),
Position = UDim2.new(1, -84, 0, 0),
Text = "+ Override",
TextColor3 = Theme.COLOR.ACCENT,
TextSize = 13,
})
overrideBtn.Parent = createRow
overrideBtn.MouseButton1Click:Connect(function()
local res = actions.createOverride(catKey, idBox.Text)
lastError = if typeof(res) == "table" and res.ok == false then res.message else nil
refresh()
end)
if lastError then
local message = lastError :: string
lastError = nil -- shown once; the next refresh clears it
Theme.label({
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
Text = `{message}`,
TextColor3 = Theme.COLOR.DANGER,
TextWrapped = true,
TextSize = 12,
LayoutOrder = 2,
}).Parent =
scroll
end
Theme.label({
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
Text = "Create authors a full def here. Override tunes a def registered from code"
.. " (invisible in Edit — registries fill at runtime): type its id, set ONLY the"
.. " fields to change, blank = inherit. Typos warn in Output on Play.",
TextColor3 = Theme.COLOR.DIM,
TextWrapped = true,
TextSize = 11,
LayoutOrder = 3,
}).Parent =
scroll
local roster = ContentAdmin.listRoster(catKey)
if #roster == 0 then
Theme.label({
Size = UDim2.new(1, 0, 0, 22),
Text = `No {string.lower(cat.title)} yet — create one above.`,
TextColor3 = Theme.COLOR.DIM,
TextSize = 12,
LayoutOrder = 4,
}).Parent =
scroll
else
for i, entry in roster do
local panel = ContentAdminUi.buildRosterEntry(catKey, entry, ContentAdmin, actions)
panel.LayoutOrder = 4 + i
panel.Parent = scroll
end
end
end
refreshBtn.MouseButton1Click:Connect(refresh)
header.refreshBtn.MouseButton1Click:Connect(refresh)
refresh()
return { refresh = refresh }
end
+261
View File
@@ -0,0 +1,261 @@
--!nonstrict
--[[
Nav the plugin's sidebar rail + page router. Pages are data (id/label/section/mount);
Nav renders the grouped rail (uppercase section headers, count badges), lazy-mounts each
page into its own detail frame on first selection, and toggles visibility after that —
so per-page state (scroll position, footer status) survives switching.
refreshAll() refreshes the ACTIVE page immediately and dirty-flags the other mounted pages,
which re-render the next time they're shown an edit on one page never rebuilds a form the
user might be mid-thought on elsewhere, but every page is fresh by the time it's seen.
]]
local Theme = require(script.Parent.Theme)
local Nav = {}
export type PageHandle = { refresh: () -> () }
export type Page = {
id: string,
label: string,
section: string, -- rail group header ("" = top-level, no header)
badge: (() -> number?)?,
hidden: boolean?, -- routable but not listed (e.g. search results)
mount: (frame: Frame) -> PageHandle,
}
export type Options = {
railWidth: number?,
initialId: string?,
onNavigate: ((id: string) -> ())?,
onSearch: ((query: string) -> ())?,
}
local ITEM_H = 24
local SEARCH_H = 26
function Nav.mount(root: Frame, pages: { Page }, opts: Options?)
local options: Options = opts or {}
local railWidth = options.railWidth or 156
local byId: { [string]: Page } = {}
for _, page in pages do
byId[page.id] = page
end
-- ── Layout: rail (left) + detail (right) ────────────────────────────────
local rail = Theme.make("Frame", {
Size = UDim2.new(0, railWidth, 1, 0),
BackgroundColor3 = Theme.COLOR.RAIL,
BorderSizePixel = 0,
}) :: Frame
rail.Parent = root
local detail = Theme.make("Frame", {
Position = UDim2.fromOffset(railWidth, 0),
Size = UDim2.new(1, -railWidth, 1, 0),
BackgroundTransparency = 1,
}) :: Frame
detail.Parent = root
-- Search box pinned to the rail top.
local searchBox = Theme.textBox({
Size = UDim2.new(1, -12, 0, SEARCH_H),
Position = UDim2.fromOffset(6, 6),
Text = "",
PlaceholderText = "Search content…",
TextSize = 12,
})
searchBox.Parent = rail
if options.onSearch then
searchBox:GetPropertyChangedSignal("Text"):Connect(function()
options.onSearch(searchBox.Text)
end)
end
local railScroll = Theme.make("ScrollingFrame", {
Position = UDim2.fromOffset(0, SEARCH_H + 12),
Size = UDim2.new(1, 0, 1, -(SEARCH_H + 12)),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ScrollBarThickness = 4,
CanvasSize = UDim2.new(),
AutomaticCanvasSize = Enum.AutomaticSize.Y,
}, {
Theme.make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
Theme.pad(6, 6, 0, 10),
}) :: ScrollingFrame
railScroll.Parent = rail
-- ── Router state ─────────────────────────────────────────────────────────
local frames: { [string]: Frame } = {}
local handles: { [string]: PageHandle } = {}
local dirty: { [string]: boolean } = {}
local activeId: string? = nil
local items: { [string]: { button: TextButton, label: TextLabel, bar: Frame, badge: TextLabel? } } = {}
local function paintItem(id: string)
local item = items[id]
if not item then
return
end
local selected = id == activeId
item.button.BackgroundColor3 = if selected then Theme.COLOR.PANEL else Theme.COLOR.RAIL
item.button.BackgroundTransparency = if selected then 0 else 1
item.label.TextColor3 = if selected then Theme.COLOR.TEXT else Theme.COLOR.DIM
item.bar.Visible = selected
end
local function refreshBadges()
for id, item in items do
local page = byId[id]
if item.badge and page and page.badge then
local count = page.badge()
item.badge.Text = if count ~= nil then tostring(count) else ""
item.badge.Visible = count ~= nil
end
end
end
local api = {}
function api.selectPage(id: string)
local page = byId[id]
if not page then
return
end
if not frames[id] then
local frame = Theme.make("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundColor3 = Theme.COLOR.BG,
BorderSizePixel = 0,
Visible = false,
}) :: Frame
frame.Parent = detail
frames[id] = frame
handles[id] = page.mount(frame)
dirty[id] = nil -- mount() renders fresh
end
if activeId ~= id then
local previous = activeId and frames[activeId :: string]
if previous then
previous.Visible = false
end
local old = activeId
activeId = id
frames[id].Visible = true
if old then
paintItem(old)
end
paintItem(id)
end
if dirty[id] then
dirty[id] = nil
handles[id].refresh()
end
if options.onNavigate and not page.hidden then
options.onNavigate(id)
end
end
-- Refresh the active page now; mark every other mounted page to refresh on next show.
function api.refreshAll()
for id in handles do
if id == activeId then
handles[id].refresh()
else
dirty[id] = true
end
end
refreshBadges()
end
function api.getActiveId(): string?
return activeId
end
-- ── Rail rendering (grouped, ordered as given) ───────────────────────────
local order = 0
local seenSections: { [string]: boolean } = {}
for _, page in pages do
if page.hidden then
continue
end
if page.section ~= "" and not seenSections[page.section] then
seenSections[page.section] = true
order += 1
Theme.label({
Size = UDim2.new(1, 0, 0, 20),
Text = string.upper(page.section),
TextColor3 = Theme.COLOR.DIM,
TextSize = 10,
LayoutOrder = order,
}).Parent =
railScroll
end
order += 1
local button = Theme.make("TextButton", {
Size = UDim2.new(1, 0, 0, ITEM_H),
BackgroundColor3 = Theme.COLOR.RAIL,
BackgroundTransparency = 1,
AutoButtonColor = false,
Text = "",
BorderSizePixel = 0,
LayoutOrder = order,
}, { Theme.corner(4) }) :: TextButton
button.Parent = railScroll
local label = Theme.make("TextLabel", {
Size = UDim2.new(1, -34, 1, 0),
Position = UDim2.fromOffset(10, 0),
BackgroundTransparency = 1,
Text = page.label,
TextColor3 = Theme.COLOR.DIM,
TextXAlignment = Enum.TextXAlignment.Left,
TextTruncate = Enum.TextTruncate.AtEnd,
TextSize = 12,
Font = Theme.FONT,
}) :: TextLabel
label.Parent = button
local bar = Theme.make("Frame", {
Size = UDim2.new(0, 2, 1, -6),
Position = UDim2.fromOffset(0, 3),
BackgroundColor3 = Theme.COLOR.ACCENT,
BorderSizePixel = 0,
Visible = false,
}) :: Frame
bar.Parent = button
local badgeLabel: TextLabel? = nil
if page.badge then
badgeLabel = Theme.badge("")
local badge = badgeLabel :: TextLabel
badge.AnchorPoint = Vector2.new(1, 0.5)
badge.Position = UDim2.new(1, -4, 0.5, 0)
badge.Parent = button
end
items[page.id] = { button = button, label = label, bar = bar, badge = badgeLabel }
button.MouseEnter:Connect(function()
if page.id ~= activeId then
button.BackgroundTransparency = 0
button.BackgroundColor3 = Theme.COLOR.PANEL_HOVER
end
end)
button.MouseLeave:Connect(function()
paintItem(page.id)
end)
button.MouseButton1Click:Connect(function()
api.selectPage(page.id)
end)
end
refreshBadges()
api.selectPage(options.initialId or pages[1].id)
return api
end
return Nav
+124
View File
@@ -0,0 +1,124 @@
--!nonstrict
--[[
OverviewPage the Studio window's home page: is the engine synced, what content exists
(click a row to jump to its editor), and where the docs live.
]]
local Theme = require(script.Parent.Theme)
local OverviewPage = {}
-- deps = { StatAdmin, ContentAdmin, selectPage(id) }
function OverviewPage.mount(container: Frame, deps: any): { refresh: () -> () }
local header = Theme.header(container, "SurvivorCore Studio")
local scroll = Theme.scroll(32, 0, 6)
scroll.Parent = container
local function refresh()
for _, child in scroll:GetChildren() do
if not child:IsA("UIBase") then
child:Destroy()
end
end
-- Engine status.
local roster = deps.StatAdmin.readRoster()
local status = Theme.panel()
status.LayoutOrder = 1
status.Parent = scroll
Theme.label({
Size = UDim2.new(1, 0, 0, 20),
Text = "Engine",
Font = Theme.FONT_BOLD,
TextSize = 14,
LayoutOrder = 0,
}).Parent =
status
Theme.label({
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
Text = if roster.ok
then `✓ Synced — {roster.source or "ReplicatedStorage.SurvivorCore"}. Stats, engine config and content editors are live.`
else `✗ {roster.reason or "Engine not found in this place."}`,
TextColor3 = if roster.ok then Theme.COLOR.DIM else Theme.COLOR.DANGER,
TextWrapped = true,
TextSize = 12,
LayoutOrder = 1,
}).Parent =
status
-- Content counts (click-through).
local content = Theme.panel()
content.LayoutOrder = 2
content.Parent = scroll
Theme.label({
Size = UDim2.new(1, 0, 0, 20),
Text = "Content",
Font = Theme.FONT_BOLD,
TextSize = 14,
LayoutOrder = 0,
}).Parent =
content
for i, catKey in deps.ContentAdmin.ORDER do
local cat = deps.ContentAdmin.CATEGORIES[catKey]
local count = #deps.ContentAdmin.listRoster(catKey)
local row = Theme.make("TextButton", {
Size = UDim2.new(1, 0, 0, 22),
BackgroundColor3 = Theme.COLOR.PANEL,
AutoButtonColor = false,
Text = "",
BorderSizePixel = 0,
LayoutOrder = i,
}, { Theme.corner(4) }) :: TextButton
row.Parent = content
Theme.label({
Size = UDim2.new(1, -40, 1, 0),
Position = UDim2.fromOffset(6, 0),
Text = cat.title,
TextColor3 = Theme.COLOR.DIM,
TextSize = 12,
}).Parent =
row
local badge = Theme.badge(tostring(count), Theme.COLOR.TEXT)
badge.AnchorPoint = Vector2.new(1, 0.5)
badge.Position = UDim2.new(1, -4, 0.5, 0)
badge.Parent = row
Theme.hover(row, Theme.COLOR.PANEL, Theme.COLOR.PANEL_HOVER)
row.MouseButton1Click:Connect(function()
deps.selectPage("content/" .. catKey)
end)
end
-- Pointers.
local help = Theme.panel()
help.LayoutOrder = 3
help.Parent = scroll
Theme.label({
Size = UDim2.new(1, 0, 0, 20),
Text = "How it works",
Font = Theme.FONT_BOLD,
TextSize = 14,
LayoutOrder = 0,
}).Parent =
help
Theme.label({
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
Text = "Everything here is no-code and deltas-only: a field is written ONLY when it"
.. " differs from the engine default, so unset fields keep following engine updates."
.. " Stats apply live; Engine Config and content edits apply on the next Play."
.. " Docs: docs/admin-plugin.md in the SurvivorCore repo.",
TextColor3 = Theme.COLOR.DIM,
TextWrapped = true,
TextSize = 12,
LayoutOrder = 1,
}).Parent =
help
end
header.refreshBtn.MouseButton1Click:Connect(refresh)
refresh()
return { refresh = refresh }
end
return OverviewPage
+104
View File
@@ -0,0 +1,104 @@
--!nonstrict
--[[
SearchPage global content search results. A hidden (rail-less) page the plugin main routes
to while the rail search box has text. Matches ids + `name` attributes across every category
(ContentAdminUi.matchEntry) and renders full EDITABLE entry panels grouped by category you
fix the thing right where you found it.
]]
local Theme = require(script.Parent.Theme)
local MAX_RESULTS = 50
local SearchPage = {}
-- deps = { ContentAdmin, ContentAdminUi, getQuery(), actions }
function SearchPage.mount(container: Frame, deps: any): { refresh: () -> () }
local header = Theme.header(container, "Search")
local scroll = Theme.scroll(32, 0, 8)
scroll.Parent = container
local function refresh()
for _, child in scroll:GetChildren() do
if not child:IsA("UIBase") then
child:Destroy()
end
end
local query = tostring(deps.getQuery() or "")
if string.match(query, "^%s*$") then
Theme.label({
Size = UDim2.new(1, 0, 0, 40),
Text = "Type in the sidebar box to search content by id or name.",
TextColor3 = Theme.COLOR.DIM,
TextWrapped = true,
}).Parent =
scroll
return
end
local lowered = string.lower(query)
local order = 0
local shown = 0
local total = 0
for _, catKey in deps.ContentAdmin.ORDER do
local cat = deps.ContentAdmin.CATEGORIES[catKey]
local matches = {}
for _, entry in deps.ContentAdmin.listRoster(catKey) do
if deps.ContentAdminUi.matchEntry(deps.ContentAdmin, catKey, entry, lowered) then
table.insert(matches, entry)
end
end
total += #matches
if #matches == 0 or shown >= MAX_RESULTS then
continue -- past the cap, count the matches but don't render an empty header
end
order += 1
Theme.label({
Size = UDim2.new(1, 0, 0, 20),
Text = `{cat.title}{#matches} match{if #matches == 1 then "" else "es"}`,
TextColor3 = Theme.COLOR.ACCENT,
TextSize = 13,
Font = Theme.FONT_BOLD,
LayoutOrder = order,
}).Parent =
scroll
for _, entry in matches do
if shown >= MAX_RESULTS then
break
end
shown += 1
order += 1
local panel = deps.ContentAdminUi.buildRosterEntry(catKey, entry, deps.ContentAdmin, deps.actions)
panel.LayoutOrder = order
panel.Parent = scroll
end
end
if total == 0 then
Theme.label({
Size = UDim2.new(1, 0, 0, 40),
Text = `No content matches '{query}'.`,
TextColor3 = Theme.COLOR.DIM,
TextWrapped = true,
}).Parent =
scroll
elseif total > shown then
order += 1
Theme.label({
Size = UDim2.new(1, 0, 0, 22),
Text = `Showing {shown} of {total} — refine your search.`,
TextColor3 = Theme.COLOR.DIM,
TextSize = 12,
LayoutOrder = order,
}).Parent =
scroll
end
end
header.refreshBtn.MouseButton1Click:Connect(refresh)
refresh()
return { refresh = refresh }
end
return SearchPage
+117
View File
@@ -0,0 +1,117 @@
--!nonstrict
--[[
SpawnHelpers the "Add to World" branch logic, moved verbatim out of the plugin main. Drops a
tagged, def-linked instance in front of the camera and selects it the no-code bridge between
an editor entry and the world:
Resources a Part tagged "Gatherable" with Resource = id.
Mobs a placeholder rig Model tagged "Mob" with MobType = id.
Quests a quest-giver post tagged "QuestGiver" with Quest = id.
Weapons a starter Tool (Handle + ToolType/WeaponKind) to model the equipped look on.
]]
local CollectionService = game:GetService("CollectionService")
local Selection = game:GetService("Selection")
local Workspace = game:GetService("Workspace")
local SpawnHelpers = {}
local function spawnPosition(): Vector3
local cam = Workspace.CurrentCamera
return if cam then cam.CFrame.Position + cam.CFrame.LookVector * 16 else Vector3.new(0, 5, 0)
end
-- A minimal placeholder mob rig (Model + HumanoidRootPart + Humanoid + head), tagged "Mob" with the
-- MobType set — exactly what the engine adopts at play. The creator swaps in their own rig later.
local function buildMobRig(id: string): Model
local model = Instance.new("Model")
model.Name = id
local rootPart = Instance.new("Part")
rootPart.Name = "HumanoidRootPart"
rootPart.Size = Vector3.new(2, 3, 1)
rootPart.Color = Color3.fromRGB(150, 60, 60)
rootPart.Anchored = false
rootPart.Parent = model
local head = Instance.new("Part")
head.Name = "Head"
head.Shape = Enum.PartType.Ball
head.Size = Vector3.new(1.4, 1.4, 1.4)
head.Color = rootPart.Color
head.CanCollide = false
head.Massless = true
head.CFrame = rootPart.CFrame * CFrame.new(0, 2, 0)
head.Parent = model
local weld = Instance.new("WeldConstraint")
weld.Part0 = rootPart
weld.Part1 = head
weld.Parent = rootPart
local hum = Instance.new("Humanoid")
hum.Parent = model
model.PrimaryPart = rootPart
return model
end
function SpawnHelpers.spawn(ContentAdmin: any, catKey: string, id: string): Instance
if catKey == "Mobs" then
local model = buildMobRig(id)
model:SetAttribute("MobType", id)
model:PivotTo(CFrame.new(spawnPosition()))
CollectionService:AddTag(model, "Mob")
model.Parent = Workspace
Selection:Set({ model })
return model
end
if catKey == "Quests" then
-- Drop a quest-giver post: tag + Quest attribute is exactly what the engine reads. The
-- creator swaps in their own NPC/notice-board model later (tag it and set Quest).
local post = Instance.new("Part")
post.Name = id .. "_giver"
post.Size = Vector3.new(1, 5, 1)
post.Anchored = true
post.Color = Color3.fromRGB(120, 170, 255)
post.Material = Enum.Material.Wood
post.Position = spawnPosition()
post:SetAttribute("Quest", id)
CollectionService:AddTag(post, "QuestGiver")
post.Parent = Workspace
Selection:Set({ post })
return post
end
if catKey == "Weapons" then
-- Drop a starter Tool you can model the look on. It carries the weapon's ToolType /
-- WeaponKind so it's recognisable; move it under ReplicatedStorage.SurvivorCoreContent.Tools
-- (named by this id) to make it the equipped look the hotbar clones.
local node = ContentAdmin.getNode("Weapons", id)
local tool = Instance.new("Tool")
tool.Name = id
tool.RequiresHandle = true
tool.CanBeDropped = false
local handle = Instance.new("Part")
handle.Name = "Handle"
handle.Size = Vector3.new(0.5, 3, 0.5)
handle.Color = Color3.fromRGB(120, 90, 60)
handle.Anchored = true
handle.Position = spawnPosition()
handle.Parent = tool
for _, attr in { "toolType", "weaponKind" } do
local v = node and node:GetAttribute(attr)
if typeof(v) == "string" and v ~= "" then
tool:SetAttribute(attr == "toolType" and "ToolType" or "WeaponKind", v)
end
end
tool.Parent = Workspace
Selection:Set({ tool })
return tool
end
local part = Instance.new("Part")
part.Name = id
part.Size = Vector3.new(4, 4, 4)
part.Anchored = true
part.Position = spawnPosition()
part:SetAttribute("Resource", id)
CollectionService:AddTag(part, "Gatherable")
part.Parent = Workspace
Selection:Set({ part })
return part
end
return SpawnHelpers
+15 -25
View File
@@ -6,34 +6,22 @@
ChangeHistoryService recording, then refresh). Styling follows docs/design-language.md.
]]
local Theme = require(script.Parent.Theme)
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 COL_BG = Theme.COLOR.BG
local COL_PANEL = Theme.COLOR.PANEL
local COL_TEXT = Theme.COLOR.TEXT
local COL_DIM = Theme.COLOR.DIM
local COL_ACCENT = Theme.COLOR.ACCENT -- marks an overridden field
local COL_FIELD = Theme.COLOR.FIELD
local FONT = Theme.FONT
local ROW_H = Theme.ROW_H
local FOOTER_H = 36 -- the Preview / Clear control bar at the bottom
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
local make = Theme.make
local corner = Theme.corner
-- A single field row: [reset] label .... control. `applyEdit/applyReset` take (stat, attr, …).
local function buildRow(stat: string, spec: any, eff: any, applyEdit, applyReset): Instance
@@ -276,7 +264,9 @@ function StatAdminUi.mount(
BackgroundColor3 = COL_PANEL,
BorderSizePixel = 0,
LayoutOrder = order,
Size = UDim2.new(1, 0, 0, 22 + ROW_H * #StatAdmin.TUNABLE + 8),
-- Content height = padding (4+4) + header (22) + rows (ROW_H each) + 2px list
-- gaps between the header and every row — the +2 per row below covers the gaps.
Size = UDim2.new(1, 0, 0, 22 + (ROW_H + 2) * #StatAdmin.TUNABLE + 10),
AutomaticSize = Enum.AutomaticSize.None,
}, {
corner(8),
+201
View File
@@ -0,0 +1,201 @@
--!nonstrict
--[[
Theme the plugin's shared visual system. One home for the style constants that used to be
copy-pasted across every UI file, plus the small builders (instance factory, header, scroll,
panel, styled controls) every page composes from. The palette is the dense/dark editor variant
of docs/design-language.md.
]]
local Theme = {}
Theme.COLOR = {
BG = Color3.fromRGB(18, 21, 28),
RAIL = Color3.fromRGB(14, 17, 23), -- sidebar, a step darker than the page
PANEL = Color3.fromRGB(28, 32, 42),
PANEL_HOVER = Color3.fromRGB(34, 39, 52),
FIELD = Color3.fromRGB(38, 43, 56),
TEXT = Color3.fromRGB(235, 238, 245),
DIM = Color3.fromRGB(150, 160, 180),
ACCENT = Color3.fromRGB(120, 170, 255),
DANGER = Color3.fromRGB(210, 90, 90),
STROKE = Color3.fromRGB(210, 220, 245),
}
Theme.FONT = Enum.Font.GothamMedium
Theme.FONT_BOLD = Enum.Font.GothamBold
Theme.ROW_H = 26
-- Generic instance factory: props dict + optional children (reparented in order).
function Theme.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
function Theme.corner(radius: number): Instance
return Theme.make("UICorner", { CornerRadius = UDim.new(0, radius) })
end
function Theme.stroke(transparency: number?): Instance
return Theme.make("UIStroke", {
Color = Theme.COLOR.STROKE,
Transparency = transparency or 0.9,
ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
})
end
function Theme.pad(left: number, right: number, top: number, bottom: number): Instance
return Theme.make("UIPadding", {
PaddingLeft = UDim.new(0, left),
PaddingRight = UDim.new(0, right),
PaddingTop = UDim.new(0, top),
PaddingBottom = UDim.new(0, bottom),
})
end
-- Styled primitives: sensible defaults, overridable via props.
function Theme.label(props: { [string]: any }): TextLabel
local merged: { [string]: any } = {
BackgroundTransparency = 1,
TextColor3 = Theme.COLOR.TEXT,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 13,
Font = Theme.FONT,
}
for k, v in props do
merged[k] = v
end
return Theme.make("TextLabel", merged) :: TextLabel
end
function Theme.button(props: { [string]: any }): TextButton
local merged: { [string]: any } = {
BackgroundColor3 = Theme.COLOR.FIELD,
AutoButtonColor = true,
TextColor3 = Theme.COLOR.TEXT,
TextSize = 12,
Font = Theme.FONT,
BorderSizePixel = 0,
}
for k, v in props do
merged[k] = v
end
return Theme.make("TextButton", merged, { Theme.corner(4) }) :: TextButton
end
function Theme.textBox(props: { [string]: any }): TextBox
local merged: { [string]: any } = {
BackgroundColor3 = Theme.COLOR.FIELD,
TextColor3 = Theme.COLOR.TEXT,
TextSize = 13,
Font = Theme.FONT,
ClearTextOnFocus = false,
BorderSizePixel = 0,
}
for k, v in props do
merged[k] = v
end
return Theme.make("TextBox", merged, {
Theme.corner(4),
Theme.make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }),
}) :: TextBox
end
-- The standard 32px page header: bold title left, Refresh button right.
function Theme.header(container: Instance, title: string): { frame: Frame, refreshBtn: TextButton }
local frame = Theme.make("Frame", {
Size = UDim2.new(1, 0, 0, 32),
BackgroundTransparency = 1,
}) :: Frame
frame.Parent = container
Theme.label({
Size = UDim2.new(1, -84, 1, 0),
Position = UDim2.fromOffset(12, 0),
Text = title,
TextSize = 15,
Font = Theme.FONT_BOLD,
TextTruncate = Enum.TextTruncate.AtEnd,
}).Parent =
frame
local refreshBtn = Theme.button({
Size = UDim2.fromOffset(72, 22),
Position = UDim2.new(1, -80, 0.5, -11),
BackgroundColor3 = Theme.COLOR.PANEL,
Text = "Refresh",
})
refreshBtn.Parent = frame
return { frame = frame, refreshBtn = refreshBtn }
end
-- The standard page scroll: CanvasSize reset + AutomaticCanvasSize.Y + list layout. Keep this
-- exact recipe — the explicit `CanvasSize = UDim2.new()` matters (the default canvas adds
-- phantom scroll).
function Theme.scroll(offsetTop: number, offsetBottom: number, listPadding: number?): ScrollingFrame
return Theme.make("ScrollingFrame", {
Size = UDim2.new(1, 0, 1, -(offsetTop + offsetBottom)),
Position = UDim2.fromOffset(0, offsetTop),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ScrollBarThickness = 6,
CanvasSize = UDim2.new(),
AutomaticCanvasSize = Enum.AutomaticSize.Y,
}, {
Theme.make("UIListLayout", {
Padding = UDim.new(0, listPadding or 4),
SortOrder = Enum.SortOrder.LayoutOrder,
}),
Theme.pad(10, 10, 6, 12),
}) :: ScrollingFrame
end
-- The standard content panel: PANEL background, rounded, subtle stroke, padded list layout.
-- Grows with content by default (AutomaticSize.Y); override Size/AutomaticSize for fixed panels.
function Theme.panel(): Frame
return Theme.make("Frame", {
BackgroundColor3 = Theme.COLOR.PANEL,
BorderSizePixel = 0,
Size = UDim2.fromScale(1, 0),
AutomaticSize = Enum.AutomaticSize.Y,
}, {
Theme.corner(8),
Theme.stroke(),
Theme.pad(8, 8, 6, 8),
Theme.make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
}) :: Frame
end
-- A small count/status pill (rail badges, entry badges).
function Theme.badge(text: string, textColor: Color3?): TextLabel
return Theme.make("TextLabel", {
AutomaticSize = Enum.AutomaticSize.X,
Size = UDim2.fromOffset(0, 14),
BackgroundColor3 = Theme.COLOR.FIELD,
BorderSizePixel = 0,
Text = text,
TextColor3 = textColor or Theme.COLOR.DIM,
TextSize = 10,
Font = Theme.FONT,
}, {
Theme.corner(7),
Theme.make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }),
}) :: TextLabel
end
-- MouseEnter/Leave background tint for custom (non-AutoButtonColor) elements.
function Theme.hover(gui: GuiObject, base: Color3, hoverColor: Color3)
gui.MouseEnter:Connect(function()
gui.BackgroundColor3 = hoverColor
end)
gui.MouseLeave:Connect(function()
gui.BackgroundColor3 = base
end)
end
return Theme
+235 -220
View File
@@ -1,107 +1,79 @@
--!nonstrict
--[[
SurvivorCore admin plugin (main).
SurvivorCore Studio admin plugin (main).
ONE dock widget, a tab bar across the top, one feature per tab:
Stats tune the survival stats (deltas-only, locked) on SurvivalStatsConfig + HUD preview.
Content create/edit/delete Items + Gatherable resources (no-code) as SurvivorCoreContent
instances the engine loads at start().
Every mutation is wrapped in a single ChangeHistoryService recording (one undo step) and then
both editors refresh. All real logic lives in the requirable modules (StatAdmin / ContentAdmin);
the forms in StatAdminUi / ContentAdminUi. This is the Builder / Admin plugin (issue #11), and
new sections (Movement / Consequences, #21) slot in as more tabs.
ONE floating (dockable) window with a sidebar rail and a detail pane, one page per editor:
Overview engine status + content counts (click-through) + how-it-works.
Survival Stats tune stats (deltas-only, locked) on SurvivalStatsConfig + HUD preview.
Engine Config every engine Config section (Movement/Combat/Mobs//UI theme) as
deltas-only overrides on SurvivorCoreEngineConfig (issue #21).
Content create/edit/delete Items/Weapons/Arrows/Resources/Mobs/Quests/Achievements
as SurvivorCoreContent instances, plus OVERRIDES that tune code-registered
defs (issue #40). A rail search box finds any entry by id/name.
Every mutation is wrapped in a single ChangeHistoryService recording (one undo step), then the
active page refreshes (others refresh on next show). All real logic lives in the requirable
modules (StatAdmin / ConfigAdmin / ContentAdmin); the forms in the *Ui modules; the shared look
in Theme. This is the Builder / Admin plugin (issue #11).
]]
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local CollectionService = game:GetService("CollectionService")
local Selection = game:GetService("Selection")
local Workspace = game:GetService("Workspace")
local UserInputService = game:GetService("UserInputService")
local Theme = require(script.Theme)
local Nav = require(script.Nav)
local SpawnHelpers = require(script.SpawnHelpers)
local OverviewPage = require(script.OverviewPage)
local SearchPage = require(script.SearchPage)
local StatAdmin = require(script.StatAdmin)
local StatAdminUi = require(script.StatAdminUi)
local HudPreview = require(script.HudPreview)
local ContentAdmin = require(script.ContentAdmin)
local ContentAdminUi = require(script.ContentAdminUi)
local ConfigAdmin = require(script.ConfigAdmin)
local ConfigAdminUi = require(script.ConfigAdminUi)
local COL_BG = Color3.fromRGB(18, 21, 28)
local COL_TAB = Color3.fromRGB(28, 32, 42)
local COL_TAB_ON = Color3.fromRGB(52, 58, 74)
local COL_TEXT = Color3.fromRGB(235, 238, 245)
local TAB_H = 30
local SETTING_LAST_PAGE = "SurvivorCoreStudio.lastPage"
local toolbar = plugin:CreateToolbar("SurvivorCore")
local button = toolbar:CreateButton("Admin Panel", "Tune stats + create items/weapons/gatherables/mobs (no-code)", "")
local button = toolbar:CreateButton("Studio", "Tune the engine + create items/weapons/gatherables/mobs (no-code)", "")
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.
-- a dockable plugin widget (there is no replacement) — so allow it here. NOTE: the widget ID is
-- "SurvivorCoreStudio" (was "SurvivorCoreAdmin") — Studio persists dock state PER ID and ignores
-- new initial values for known IDs, so the rename is what makes the floating default reach
-- existing users (their old dock position resets once).
-- selene: allow(deprecated)
local widget = plugin:CreateDockWidgetPluginGui(
"SurvivorCoreAdmin",
DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 340, 560, 320, 400)
"SurvivorCoreStudio",
DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, false, false, 660, 580, 480, 380)
)
widget.Title = "SurvivorCore Admin"
widget.Name = "SurvivorCoreAdmin"
widget.Title = "SurvivorCore Studio"
widget.Name = "SurvivorCoreStudio"
local root = Instance.new("Frame")
root.Size = UDim2.fromScale(1, 1)
root.BackgroundColor3 = COL_BG
root.BackgroundColor3 = Theme.COLOR.BG
root.BorderSizePixel = 0
root.Parent = widget
-- Tab bar + content region.
local tabBar = Instance.new("Frame")
tabBar.Size = UDim2.new(1, 0, 0, TAB_H)
tabBar.BackgroundColor3 = COL_BG
tabBar.BorderSizePixel = 0
tabBar.Parent = root
local tabLayout = Instance.new("UIListLayout")
tabLayout.FillDirection = Enum.FillDirection.Horizontal
tabLayout.Padding = UDim.new(0, 4)
tabLayout.Parent = tabBar
local tabPad = Instance.new("UIPadding")
tabPad.PaddingLeft = UDim.new(0, 8)
tabPad.PaddingTop = UDim.new(0, 4)
tabPad.Parent = tabBar
local nav -- assigned after the pages are assembled; closures below capture it
local region = Instance.new("Frame")
region.Position = UDim2.fromOffset(0, TAB_H)
region.Size = UDim2.new(1, 0, 1, -TAB_H)
region.BackgroundTransparency = 1
region.BorderSizePixel = 0
region.Parent = root
local statFrame = Instance.new("Frame")
statFrame.Size = UDim2.fromScale(1, 1)
statFrame.BackgroundTransparency = 1
statFrame.Parent = region
local contentFrame = Instance.new("Frame")
contentFrame.Size = UDim2.fromScale(1, 1)
contentFrame.BackgroundTransparency = 1
contentFrame.Visible = false
contentFrame.Parent = region
local statUi: { refresh: () -> () }? = nil
local contentUi: { refresh: () -> () }? = nil
-- Wrap a mutation in one ChangeHistory recording (one undo step), then refresh both editors.
-- Wrap a mutation in one ChangeHistory recording (one undo step), then refresh the UI.
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 statUi then
statUi.refresh()
end
if contentUi then
contentUi.refresh()
if nav then
nav.refreshAll()
end
return result
end
-- Stats callbacks.
-- ── Stats callbacks ───────────────────────────────────────────────────────────
local function applyEdit(statName: string, attr: string, raw: any, default: any): any
return record(`Stats: {statName}.{attr}`, function()
return StatAdmin.setOverride(statName, attr, raw, default)
@@ -123,178 +95,221 @@ local function applyClear(): any
end)
end
-- Content callbacks.
local function applySet(catKey: string, id: string, field: any, raw: any): any
return record(`Content: {catKey} {id}.{field.attr}`, function()
return ContentAdmin.set(catKey, id, field, raw)
-- ── Engine Config callbacks ───────────────────────────────────────────────────
local function applyConfigEdit(sectionId: string, groupName: string?, field: any, raw: any, default: any): any
return record(`Config: {sectionId}.{field.attr}`, function()
return ConfigAdmin.setOverride(sectionId, groupName, field, raw, default)
end)
end
local function applyCreate(catKey: string, rawId: any): any
return record(`Content: create {catKey}`, function()
return ContentAdmin.create(catKey, rawId)
local function applyConfigReset(sectionId: string, groupName: string?, attr: string): any
return record(`Config: reset {sectionId}.{attr}`, function()
return ConfigAdmin.resetOverride(sectionId, groupName, attr)
end)
end
local function applyDelete(catKey: string, id: string): any
return record(`Content: delete {catKey} {id}`, function()
ContentAdmin.delete(catKey, id)
local function applyConfigResetSection(section: any): any
return record(`Config: reset section {section.id}`, function()
return ConfigAdmin.resetSection(section)
end)
end
local function spawnPosition(): Vector3
local cam = Workspace.CurrentCamera
return if cam then cam.CFrame.Position + cam.CFrame.LookVector * 16 else Vector3.new(0, 5, 0)
end
-- A minimal placeholder mob rig (Model + HumanoidRootPart + Humanoid + head), tagged "Mob" with the
-- MobType set — exactly what the engine adopts at play. The creator swaps in their own rig later.
local function buildMobRig(id: string): Model
local model = Instance.new("Model")
model.Name = id
local rootPart = Instance.new("Part")
rootPart.Name = "HumanoidRootPart"
rootPart.Size = Vector3.new(2, 3, 1)
rootPart.Color = Color3.fromRGB(150, 60, 60)
rootPart.Anchored = false
rootPart.Parent = model
local head = Instance.new("Part")
head.Name = "Head"
head.Shape = Enum.PartType.Ball
head.Size = Vector3.new(1.4, 1.4, 1.4)
head.Color = rootPart.Color
head.CanCollide = false
head.Massless = true
head.CFrame = rootPart.CFrame * CFrame.new(0, 2, 0)
head.Parent = model
local weld = Instance.new("WeldConstraint")
weld.Part0 = rootPart
weld.Part1 = head
weld.Parent = rootPart
local hum = Instance.new("Humanoid")
hum.Parent = model
model.PrimaryPart = rootPart
return model
end
-- "Add to World": drop a tagged, def-linked instance in front of the camera, then select it. The
-- no-code way to place one — tag + def-id attribute is exactly what the engine reads at play.
-- Resources → a Part tagged "Gatherable" with Resource = id.
-- Mobs → a placeholder rig Model tagged "Mob" with MobType = id.
local function applySpawn(catKey: string, id: string): any
return record(`Content: add {catKey} '{id}' to world`, function()
if catKey == "Mobs" then
local model = buildMobRig(id)
model:SetAttribute("MobType", id)
model:PivotTo(CFrame.new(spawnPosition()))
CollectionService:AddTag(model, "Mob")
model.Parent = Workspace
Selection:Set({ model })
return model
end
if catKey == "Quests" then
-- Drop a quest-giver post: tag + Quest attribute is exactly what the engine reads. The
-- creator swaps in their own NPC/notice-board model later (tag it and set Quest).
local post = Instance.new("Part")
post.Name = id .. "_giver"
post.Size = Vector3.new(1, 5, 1)
post.Anchored = true
post.Color = Color3.fromRGB(120, 170, 255)
post.Material = Enum.Material.Wood
post.Position = spawnPosition()
post:SetAttribute("Quest", id)
CollectionService:AddTag(post, "QuestGiver")
post.Parent = Workspace
Selection:Set({ post })
return post
end
if catKey == "Weapons" then
-- Drop a starter Tool you can model the look on. It carries the weapon's ToolType /
-- WeaponKind so it's recognisable; move it under ReplicatedStorage.SurvivorCoreContent.Tools
-- (named by this id) to make it the equipped look the hotbar clones.
local node = ContentAdmin.getNode("Weapons", id)
local tool = Instance.new("Tool")
tool.Name = id
tool.RequiresHandle = true
tool.CanBeDropped = false
local handle = Instance.new("Part")
handle.Name = "Handle"
handle.Size = Vector3.new(0.5, 3, 0.5)
handle.Color = Color3.fromRGB(120, 90, 60)
handle.Anchored = true
handle.Position = spawnPosition()
handle.Parent = tool
for _, attr in { "toolType", "weaponKind" } do
local v = node and node:GetAttribute(attr)
if typeof(v) == "string" and v ~= "" then
tool:SetAttribute(attr == "toolType" and "ToolType" or "WeaponKind", v)
end
end
tool.Parent = Workspace
Selection:Set({ tool })
return tool
end
local part = Instance.new("Part")
part.Name = id
part.Size = Vector3.new(4, 4, 4)
part.Anchored = true
part.Position = spawnPosition()
part:SetAttribute("Resource", id)
CollectionService:AddTag(part, "Gatherable")
part.Parent = Workspace
Selection:Set({ part })
return part
end)
end
statUi = StatAdminUi.mount(statFrame, StatAdmin, applyEdit, applyReset, applyPreview, applyClear)
contentUi = ContentAdminUi.mount(contentFrame, ContentAdmin, applySet, applyCreate, applyDelete, applySpawn)
-- Tabs.
local TABS = {
{ id = "stats", label = "Stats", frame = statFrame },
{ id = "content", label = "Content", frame = contentFrame },
-- ── Content callbacks (authored + overrides), bundled for the pages ──────────
local actions = {
set = function(catKey: string, id: string, field: any, raw: any): any
return record(`Content: {catKey} {id}.{field.attr}`, function()
return ContentAdmin.set(catKey, id, field, raw)
end)
end,
create = function(catKey: string, rawId: any): any
return record(`Content: create {catKey}`, function()
local ok, message = ContentAdmin.create(catKey, rawId)
return { ok = ok, message = message }
end)
end,
delete = function(catKey: string, id: string): any
return record(`Content: delete {catKey} {id}`, function()
ContentAdmin.delete(catKey, id)
end)
end,
spawn = function(catKey: string, id: string): any
return record(`Content: add {catKey} '{id}' to world`, function()
return SpawnHelpers.spawn(ContentAdmin, catKey, id)
end)
end,
createOverride = function(catKey: string, rawId: any): any
return record(`Content: override {catKey}`, function()
local ok, message = ContentAdmin.createOverride(catKey, rawId)
return { ok = ok, message = message }
end)
end,
setOverride = function(catKey: string, id: string, field: any, raw: any): any
return record(`Content: override {catKey} {id}.{field.attr}`, function()
return ContentAdmin.setOverrideField(catKey, id, field, raw)
end)
end,
deleteOverride = function(catKey: string, id: string): any
return record(`Content: remove override {catKey} {id}`, function()
ContentAdmin.deleteOverride(catKey, id)
end)
end,
}
local tabButtons: { [string]: TextButton } = {}
local function showTab(id: string)
for _, t in TABS do
t.frame.Visible = t.id == id
local b = tabButtons[t.id]
if b then
b.BackgroundColor3 = if t.id == id then COL_TAB_ON else COL_TAB
-- ── Search state (rail box → hidden results page) ─────────────────────────────
local currentQuery = ""
local searchGeneration = 0
local lastNonSearchId = "overview"
-- ── NAV assembly (data-driven; new editors slot in as more pages) ─────────────
-- The whole window is built by mountUi() so it can REBUILD: opening an engine-less place yields
-- a single Engine Config explainer page whose Refresh remounts everything once the engine syncs.
local restoring = false
local function mountUi()
for _, child in root:GetChildren() do
child:Destroy()
end
local pages = {}
table.insert(pages, {
id = "overview",
label = "Overview",
section = "",
mount = function(frame)
return OverviewPage.mount(frame, {
StatAdmin = StatAdmin,
ContentAdmin = ContentAdmin,
selectPage = function(id: string)
nav.selectPage(id)
end,
})
end,
})
table.insert(pages, {
id = "stats",
label = "Survival Stats",
section = "Stats",
mount = function(frame)
return StatAdminUi.mount(frame, StatAdmin, applyEdit, applyReset, applyPreview, applyClear)
end,
})
local configPages = ConfigAdminUi.pages(
ConfigAdmin,
applyConfigEdit,
applyConfigReset,
applyConfigResetSection,
function() -- onEngineFound: remount with the full section list
task.defer(mountUi)
end
)
for _, page in configPages do
table.insert(pages, page)
end
for _, catKey in ContentAdmin.ORDER do
local cat = ContentAdmin.CATEGORIES[catKey]
table.insert(pages, {
id = "content/" .. catKey,
label = cat.title,
section = "Content",
badge = function()
return #ContentAdmin.listRoster(catKey)
end,
mount = function(frame)
return ContentAdminUi.mountCategory(frame, catKey, ContentAdmin, actions)
end,
})
end
table.insert(pages, {
id = "search",
label = "Search",
section = "",
hidden = true,
mount = function(frame)
return SearchPage.mount(frame, {
ContentAdmin = ContentAdmin,
ContentAdminUi = ContentAdminUi,
actions = actions,
getQuery = function()
return currentQuery
end,
})
end,
})
-- Restore the last-open page (validated against the live page list — ids may change across
-- plugin versions or when the engine's schema grows/shrinks).
local restoredId = "overview"
local saved = plugin:GetSetting(SETTING_LAST_PAGE)
if typeof(saved) == "string" then
for _, page in pages do
if page.id == saved and not page.hidden then
restoredId = saved
break
end
end
end
-- The mount-time selectPage must NOT write the setting: an engine-less session falls back to
-- "overview", and persisting that would clobber a saved config page the user will want back.
restoring = true
nav = Nav.mount(root, pages, {
initialId = restoredId,
onNavigate = function(id: string)
lastNonSearchId = id
searchGeneration += 1 -- explicit navigation cancels any pending search-page jump
if restoring then
return
end
if plugin:GetSetting(SETTING_LAST_PAGE) ~= id then
plugin:SetSetting(SETTING_LAST_PAGE, id)
end
end,
onSearch = function(text: string)
currentQuery = text
searchGeneration += 1
local generation = searchGeneration
task.delay(0.25, function()
if generation ~= searchGeneration or not widget.Enabled then
return
end
if string.match(currentQuery, "^%s*$") then
if nav.getActiveId() == "search" then
nav.selectPage(lastNonSearchId)
end
else
nav.selectPage("search")
nav.refreshAll() -- re-render the results with the new query
end
end)
end,
})
restoring = false
end
for _, t in TABS do
local b = Instance.new("TextButton")
b.Size = UDim2.fromOffset(92, TAB_H - 6)
b.BackgroundColor3 = COL_TAB
b.AutoButtonColor = true
b.Text = t.label
b.TextColor3 = COL_TEXT
b.TextSize = 13
b.Font = Enum.Font.GothamMedium
b.BorderSizePixel = 0
b.Parent = tabBar
local c = Instance.new("UICorner")
c.CornerRadius = UDim.new(0, 4)
c.Parent = b
tabButtons[t.id] = b
b.MouseButton1Click:Connect(function()
showTab(t.id)
end)
mountUi()
-- An undo/redo can revert any edit the pages show — refresh so they never go stale. Skip while
-- the user is mid-edit in one of OUR text boxes (the rebuild would destroy the focused box and
-- eat the typed text); the per-page Refresh button covers that rare overlap.
local function onHistoryChanged()
local focused = UserInputService:GetFocusedTextBox()
if focused and focused:IsDescendantOf(widget) then
return
end
if nav then
nav.refreshAll()
end
end
showTab("stats")
ChangeHistoryService.OnUndo:Connect(onHistoryChanged)
ChangeHistoryService.OnRedo:Connect(onHistoryChanged)
button.Click:Connect(function()
widget.Enabled = not widget.Enabled
if widget.Enabled then
if statUi then
statUi.refresh()
end
if contentUi then
contentUi.refresh()
end
nav.refreshAll()
end
end)
widget:GetPropertyChangedSignal("Enabled"):Connect(function()
+1
View File
@@ -0,0 +1 @@
{"name":"SurvivorCoreStatAdmin","className":"Script","filePaths":["plugin/init.server.luau","plugin.project.json"],"children":[{"name":"ConfigAdmin","className":"ModuleScript","filePaths":["plugin/ConfigAdmin.luau"]},{"name":"ConfigAdminUi","className":"ModuleScript","filePaths":["plugin/ConfigAdminUi.luau"]},{"name":"ContentAdmin","className":"ModuleScript","filePaths":["plugin/ContentAdmin.luau"]},{"name":"ContentAdminUi","className":"ModuleScript","filePaths":["plugin/ContentAdminUi.luau"]},{"name":"HudPreview","className":"ModuleScript","filePaths":["plugin/HudPreview.luau"]},{"name":"Nav","className":"ModuleScript","filePaths":["plugin/Nav.luau"]},{"name":"OverviewPage","className":"ModuleScript","filePaths":["plugin/OverviewPage.luau"]},{"name":"SearchPage","className":"ModuleScript","filePaths":["plugin/SearchPage.luau"]},{"name":"SpawnHelpers","className":"ModuleScript","filePaths":["plugin/SpawnHelpers.luau"]},{"name":"StatAdmin","className":"ModuleScript","filePaths":["plugin/StatAdmin.luau"]},{"name":"StatAdminUi","className":"ModuleScript","filePaths":["plugin/StatAdminUi.luau"]},{"name":"Theme","className":"ModuleScript","filePaths":["plugin/Theme.luau"]}]}
+26
View File
@@ -69,6 +69,32 @@ function Registry.new(name: string, options: Options?)
end
end
-- Owner override path (issue #40): field-merge each child Configuration's attributes onto the
-- ALREADY-registered def with the same key (code- or folder-registered — the def table is
-- shared by reference, so live .get()/.getAll() consumers see the merged fields). Deltas-only:
-- an override child carries ONLY the attributes it changes. Unknown keys WARN (louder than
-- loadFromFolder's silent skip — an override that matches nothing is a typo, and this is the
-- only place it can be surfaced). The key field itself can't be overridden. Boot-time only:
-- callers (SurvivorCore.start) run this before systems cache or replicate defs.
function self.applyOverrides(folder: Instance?)
if folder == nil then
return
end
for _, child in folder:GetChildren() do
local key = child.Name
local def = byId[key]
if def == nil then
warn(`[{name}] override '{tostring(key)}' matches no registered def — skipped (typo?)`)
continue
end
for attrName, value in child:GetAttributes() do
if attrName ~= keyField then
def[attrName] = value
end
end
end
end
function self.get(key: any): any
return byId[key]
end
+53
View File
@@ -63,6 +63,11 @@ require(script.shared.AchievementsConfig)
-- before start().
require(script.shared.LootBagsConfig)
-- The no-code layer over ALL of the sections above: the persisted SurvivorCoreEngineConfig
-- instance (written by the admin plugin, deltas-only). apply() runs as the first step of
-- start()/startClient(), AFTER game-code Config.override calls — the instance wins.
local EngineConfig = require(script.shared.EngineConfig)
local SurvivorCore = {}
SurvivorCore.VERSION = "0.7.4"
@@ -105,6 +110,11 @@ function SurvivorCore.start(_options: { [string]: any }?)
assert(not started, "SurvivorCore.start() called twice")
started = true
-- No-code engine tuning: layer the persisted SurvivorCoreEngineConfig instance's deltas onto
-- every Config section BEFORE anything boots, so systems that cache config at start (and the
-- ones that read live) all see the same resolved values. Edits apply on the next server start.
EngineConfig.apply()
-- No-code content: register item/resource defs authored as instances (the admin plugin writes
-- these under ReplicatedStorage.SurvivorCoreContent), complementing code register(). Done first,
-- so Gatherable nodes can resolve their Resource def and systems see the full roster. Recipes
@@ -122,6 +132,45 @@ function SurvivorCore.start(_options: { [string]: any }?)
-- achievement defs are flat by design, so they load verbatim.
Registries.Quests.loadFromFolder(content:FindFirstChild("Quests"))
Registries.Achievements.loadFromFolder(content:FindFirstChild("Achievements"))
-- Owner overrides (issue #40): SurvivorCoreContent/Overrides mirrors the folders above and
-- field-merges deltas onto ALREADY-registered defs — including code-registered ones, which
-- the folders themselves can't touch (loadFromFolder skips existing keys). Applied here,
-- before Components.scan and system starts, so bind-time stamps and the replicated data
-- caches (ItemData/RecipeData/QuestData/AchievementData) all serialize the merged defs.
local overrides = content:FindFirstChild("Overrides")
if overrides then
Registries.Items.applyOverrides(overrides:FindFirstChild("Items"))
Registries.Items.applyOverrides(overrides:FindFirstChild("Weapons"))
Registries.Items.applyOverrides(overrides:FindFirstChild("Arrows"))
Registries.Resources.applyOverrides(overrides:FindFirstChild("Resources"))
Registries.Mobs.applyOverrides(overrides:FindFirstChild("Mobs"))
Registries.Quests.applyOverrides(overrides:FindFirstChild("Quests"))
Registries.Achievements.applyOverrides(overrides:FindFirstChild("Achievements"))
-- Quest-override caveat, made LOUD: QuestData.normalize prefers a def's nested
-- objectives/rewards tables, so flat objective*/reward* override fields silently do
-- nothing on a code-registered quest that uses the canonical nested shape. The id
-- matched, so applyOverrides' typo warning didn't fire — surface it here instead.
local questOverrides = overrides:FindFirstChild("Quests")
if questOverrides then
for _, child in questOverrides:GetChildren() do
local def = Registries.Quests.get(child.Name)
if def and typeof(def.objectives) == "table" then
for attr in child:GetAttributes() do
if string.match(attr, "^objective") or string.match(attr, "^reward") then
warn(
`[SurvivorCore] quest override '{child.Name}': objective*/reward* fields are`
.. " ignored for code quests with nested objectives — only name/description/"
.. "autoStart/requires/turnIn merge"
)
break
end
end
end
end
end
end
end
-- Load built-in components so their tags are recognised.
@@ -242,6 +291,10 @@ function SurvivorCore.startClient(_options: { [string]: any }?)
end
clientStarted = true
-- Same no-code tuning layer as the server (idempotent per VM; the client cares about the UI
-- section — theme, keybinds, layout — which the client-side builders read at start).
EngineConfig.apply()
require(script.client.Hud).start(_options)
-- Sprint input + low-stat vignette/breathing/heartbeat feedback.
+549
View File
@@ -0,0 +1,549 @@
--!nonstrict
--[[
EngineConfig the no-code layer over EVERY engine Config section (issue #21). SHARED.
Owners tune the engine without code via ONE persisted instance:
ReplicatedStorage.SurvivorCoreEngineConfig (Configuration)
Movement (Configuration)
Energy / Movement / Health / Audio / Assets (Configurations; attributes = fields)
Combat (Configuration; root attributes) Bow (Configuration)
one child per section, one grandchild per nested group
The instance holds ONLY overridden fields (deltas the admin plugin removes an attribute the
moment it equals the engine default), so unset fields keep following engine defaults across
updates. Resolution order: engine defaults (defineSection at require) game
`Config.override(...)` this instance (HIGHEST priority, applied by `EngineConfig.apply()` as
the first step of `SurvivorCore.start()` / `startClient()`). Apply-once at boot: edits take
effect on the NEXT Play/server start (several systems cache their section at start, so a live
watch would only half-apply this contract is honest).
`EngineConfig.SECTIONS` is the schema the admin plugin renders (sections groups typed
fields) and the allow-list `apply()` walks plugin and engine can never disagree. Field kinds:
number | boolean | string | enum | color3 (native Color3 attribute) | font (attribute holds an
Enum.Font NAME, e.g. "GothamMedium"). Invalid saved values are warned about and SKIPPED a bad
attribute must never break boot. SurvivalStats is deliberately absent (it has its own locked
instance + editor; see StatConfig).
]]
local Config = require(script.Parent.Parent.foundation.Config)
local MovementConfig = require(script.Parent.MovementConfig)
local ConsequenceConfig = require(script.Parent.ConsequenceConfig)
local InventoryConfig = require(script.Parent.InventoryConfig)
local UiConfig = require(script.Parent.UiConfig)
local HarvestingConfig = require(script.Parent.HarvestingConfig)
local CraftingConfig = require(script.Parent.CraftingConfig)
local MobsConfig = require(script.Parent.MobsConfig)
local CombatConfig = require(script.Parent.CombatConfig)
local QuestsConfig = require(script.Parent.QuestsConfig)
local AchievementsConfig = require(script.Parent.AchievementsConfig)
local LootBagsConfig = require(script.Parent.LootBagsConfig)
local EngineConfig = {}
EngineConfig.INSTANCE_NAME = "SurvivorCoreEngineConfig"
export type Field = {
attr: string,
kind: string, -- "number" | "boolean" | "string" | "enum" | "color3" | "font"
label: string,
min: number?,
max: number?,
integer: boolean?,
choices: { string }?,
check: string?, -- "keycode" | "assetId"
note: string?,
}
export type Group = {
name: string?, -- nil = attributes live on the section child itself
label: string,
fields: { Field },
}
export type Section = {
id: string, -- Config section name AND instance child name
title: string,
note: string?, -- rendered by the plugin as a dim note row (e.g. code-managed fields)
groups: { Group },
}
local function num(attr: string, label: string, min: number?, max: number?, integer: boolean?): Field
return { attr = attr, kind = "number", label = label, min = min, max = max, integer = integer }
end
local function boolean(attr: string, label: string): Field
return { attr = attr, kind = "boolean", label = label }
end
local function color(attr: string, label: string): Field
return { attr = attr, kind = "color3", label = label }
end
EngineConfig.SECTIONS = {
{
id = "Movement",
title = "Movement",
groups = {
{
name = "Movement",
label = "Speeds",
fields = {
num("WalkSpeed", "Walk speed", 0),
num("SprintSpeed", "Sprint speed", 0),
num("ExhaustedSpeed", "Exhausted speed", 0),
num("JumpPower", "Jump power", 0),
},
},
{
name = "Energy",
label = "Energy",
fields = {
num("Max", "Max energy", 1),
num("SprintDrainPerSecond", "Sprint drain / s", 0),
num("JumpCost", "Jump cost", 0),
num("RegenPerSecond", "Regen / s", 0),
num("RegenDelaySeconds", "Regen delay (s)", 0),
num("MinToJump", "Min energy to jump", 0),
},
},
{
name = "Health",
label = "Health feedback",
fields = {
num("HeartbeatStartRatio", "Heartbeat starts at (0-1)", 0, 1),
},
},
{
name = "Audio",
label = "Audio feedback",
fields = {
num("BreathingMaxVolume", "Breathing max volume", 0, 10),
num("BreathingMinSpeed", "Breathing min speed", 0.05),
num("BreathingMaxSpeed", "Breathing max speed", 0.05),
num("HeartbeatMaxVolume", "Heartbeat max volume", 0, 10),
num("HeartbeatMinSpeed", "Heartbeat min speed", 0.05),
num("HeartbeatMaxSpeed", "Heartbeat max speed", 0.05),
},
},
{
name = "Assets",
label = "Assets (rbxassetid://…)",
fields = {
{ attr = "Vignette", kind = "string", label = "Low-stat vignette", check = "assetId" },
{ attr = "Breathing", kind = "string", label = "Breathing sound", check = "assetId" },
{ attr = "Heartbeat", kind = "string", label = "Heartbeat sound", check = "assetId" },
},
},
},
},
{
id = "Combat",
title = "Combat",
groups = {
{
label = "Melee",
fields = {
num("MeleeRange", "Melee range (studs)", 0.5),
num("MeleeCooldown", "Melee cooldown (s)", 0),
boolean("RequireLineOfSight", "Require line of sight"),
boolean("FriendlyFire", "Friendly fire"),
},
},
{
name = "Bow",
label = "Bow & arrows",
fields = {
num("Gravity", "Arrow gravity", 0),
num("ProjectileSpeed", "Projectile speed", 1),
num("MaxRange", "Max range (studs)", 1),
num("MinDrawDamageMult", "Min-draw damage mult (0-1)", 0, 1),
num("StepSize", "Sim step size (studs)", 1),
},
},
},
},
{
id = "Mobs",
title = "Mobs & AI",
groups = {
{
label = "AI defaults",
fields = {
num("TickRate", "AI tick rate (s)", 0.05),
num("DefaultAggroRange", "Aggro range", 0),
num("DefaultLeashRange", "Leash range", 0),
num("DefaultAttackRange", "Attack range", 0),
num("DefaultAttackDamage", "Attack damage", 0),
num("DefaultAttackCooldown", "Attack cooldown (s)", 0),
num("DefaultWanderRadius", "Wander radius", 0),
boolean("RequireLineOfSight", "Require line of sight"),
num("RespawnSeconds", "Respawn after (s)", 0),
num("CorpseSeconds", "Corpse lingers (s)", 0),
num("DefaultCarcassSeconds", "Carcass lingers (s)", 0),
},
},
},
},
{
id = "Harvesting",
title = "Harvesting",
groups = {
{
label = "Tool swings",
fields = {
num("SwingRange", "Swing range (studs)", 0.5),
num("SwingCooldown", "Swing cooldown (s)", 0),
boolean("RequireLineOfSight", "Require line of sight"),
boolean("ShowHealthBar", "Show node health bar"),
},
},
},
},
{
id = "Crafting",
title = "Crafting",
groups = {
{
label = "Craft times",
fields = {
num("BaseCraftTime", "Base craft time (s)", 0),
num("TimePerIngredient", "Time per ingredient (s)", 0),
num("MaxCraftTime", "Max craft time (s)", 0.1),
},
},
},
},
{
id = "Inventory",
title = "Inventory",
note = 'Equip slots & auto-hotbar categories are lists — set them from code via Config.override("Inventory", …).',
groups = {
{
label = "Capacity & hotbar",
fields = {
num("BasePocketSlots", "Base pocket slots", 1, nil, true),
num("BasePocketWeight", "Base carry weight", 0),
num("HotbarSize", "Hotbar slots", 1, 9, true),
num("UseCooldownSeconds", "Item-use cooldown (s)", 0),
},
},
},
},
{
id = "Consequences",
title = "Consequences",
groups = {
{
name = "HealthDrainPerSecond",
label = "Health drain / s",
fields = {
num("Starving", "While starving", 0),
num("Dehydrated", "While dehydrated", 0),
num("PoisonAtMax", "At max poison", 0),
},
},
{
name = "Affliction",
label = "Affliction build-up / s",
fields = {
num("BleedRatePerSecond", "Bleed rate", 0),
num("PoisonRatePerSecond", "Poison rate", 0),
},
},
},
},
{
id = "LootBags",
title = "Loot bags",
groups = {
{
label = "Death drops",
fields = {
boolean("Enabled", "Drop a bag on death"),
boolean("DropEquipment", "Drop worn equipment too"),
num("LifetimeSeconds", "Bag lifetime (s)", 1),
num("InteractRange", "Loot prompt range (studs)", 1),
boolean("ShowCountdown", "Show countdown"),
boolean("OwnerBeacon", "Owner beacon"),
},
},
},
},
{
id = "Quests",
title = "Quests",
groups = {
{
label = "Quests",
fields = {
num("MaxActive", "Max active (0 = unlimited)", 0, nil, true),
boolean("Toasts", "Completion toasts"),
},
},
},
},
{
id = "Achievements",
title = "Achievements",
groups = {
{
label = "Achievements",
fields = {
boolean("Toasts", "Unlock toasts"),
},
},
},
},
{
id = "UI",
title = "UI & theme",
groups = {
{
label = "General",
fields = {
boolean("ReclaimCoreKeys", "Reclaim core Roblox keys"),
num("DragThreshold", "Drag threshold (px)", 0),
},
},
{
name = "Keybinds",
label = "Keybinds (Enum.KeyCode names)",
fields = {
{ attr = "Menu", kind = "string", label = "Menu", check = "keycode" },
{ attr = "Character", kind = "string", label = "Character", check = "keycode" },
{ attr = "Codex", kind = "string", label = "Codex", check = "keycode" },
{ attr = "Achievements", kind = "string", label = "Achievements", check = "keycode" },
{ attr = "Quests", kind = "string", label = "Quests", check = "keycode" },
},
},
{
name = "Chat",
label = "Chat placement",
fields = {
boolean("Reposition", "Reposition chat"),
{
attr = "Horizontal",
kind = "enum",
label = "Horizontal",
choices = { "Left", "Center", "Right" },
},
{ attr = "Vertical", kind = "enum", label = "Vertical", choices = { "Top", "Center", "Bottom" } },
},
},
{
name = "Theme",
label = "Theme",
fields = {
color("PanelColor", "Panel color"),
num("PanelTransparency", "Panel transparency (0-1)", 0, 1),
color("SlotColor", "Slot color"),
num("CornerRadius", "Corner radius", 0, nil, true),
color("StrokeColor", "Stroke color"),
num("StrokeTransparency", "Stroke transparency (0-1)", 0, 1),
color("Text", "Text color"),
color("TextSecondary", "Secondary text color"),
color("Accent", "Accent color"),
color("Bad", "Bad/danger color"),
color("Ok", "Ok/good color"),
{ attr = "Font", kind = "font", label = "Font" },
{ attr = "FontBold", kind = "font", label = "Bold font" },
},
},
{
name = "Inventory",
label = "Inventory grid",
fields = {
num("SlotSize", "Slot size (px)", 8),
num("SlotPadding", "Slot padding (px)", 0),
num("Columns", "Columns", 1, nil, true),
},
},
{
name = "Hotbar",
label = "Hotbar",
fields = {
num("SlotSize", "Slot size (px)", 8),
num("SlotPadding", "Slot padding (px)", 0),
},
},
{
name = "DisplayOrder",
label = "Display order (z-layers)",
fields = {
num("Menu", "Menu", nil, nil, true),
num("Hotbar", "Hotbar", nil, nil, true),
num("ClickOutside", "Click-outside shield", nil, nil, true),
num("DragGhost", "Drag ghost", nil, nil, true),
},
},
},
},
}
local DEFAULTS_BY_SECTION: { [string]: any } = {
Movement = MovementConfig.DEFAULTS,
Combat = CombatConfig.DEFAULTS,
Mobs = MobsConfig.DEFAULTS,
Harvesting = HarvestingConfig.DEFAULTS,
Crafting = CraftingConfig.DEFAULTS,
Inventory = InventoryConfig.DEFAULTS,
Consequences = ConsequenceConfig.DEFAULTS,
LootBags = LootBagsConfig.DEFAULTS,
Quests = QuestsConfig.DEFAULTS,
Achievements = AchievementsConfig.DEFAULTS,
UI = UiConfig.DEFAULTS,
}
local function deepCopy(t: any): any
if typeof(t) ~= "table" then
return t
end
local c = {}
for k, v in t do
c[k] = deepCopy(v)
end
return c
end
-- The engine defaults for one section (deep copy — safe for the plugin to display/compare).
function EngineConfig.getDefaults(sectionId: string): any
local defaults = DEFAULTS_BY_SECTION[sectionId]
return if defaults then deepCopy(defaults) else nil
end
-- Validate + convert one saved attribute value per its field spec. Returns (ok, value?, err?).
local function resolveValue(field: Field, raw: any): (boolean, any, string?)
if field.kind == "number" then
if typeof(raw) ~= "number" then
return false, nil, "expected a number"
end
if raw ~= raw or raw == math.huge or raw == -math.huge then
return false, nil, "expected a finite number"
end
local v = raw
if field.min then
v = math.max(v, field.min)
end
if field.max then
v = math.min(v, field.max)
end
if field.integer then
v = math.floor(v + 0.5)
end
return true, v
elseif field.kind == "boolean" then
if typeof(raw) ~= "boolean" then
return false, nil, "expected a boolean"
end
return true, raw
elseif field.kind == "string" then
if typeof(raw) ~= "string" then
return false, nil, "expected a string"
end
if field.check == "keycode" then
local ok, keyCode = pcall(function()
return (Enum.KeyCode :: any)[raw]
end)
if not ok or typeof(keyCode) ~= "EnumItem" then
return false, nil, `'{raw}' is not an Enum.KeyCode name`
end
elseif field.check == "assetId" then
if raw ~= "" and string.match(raw, "^rbxassetid://%d+$") == nil then
-- Accept but call it out — creators sometimes paste other valid content URIs.
warn(`[SurvivorCore.EngineConfig] '{field.attr}' = '{raw}' doesn't look like rbxassetid://<id>`)
end
end
return true, raw
elseif field.kind == "enum" then
if typeof(raw) ~= "string" then
return false, nil, "expected a string"
end
local choices: { string } = field.choices or {}
for _, choice in choices do
if choice == raw then
return true, raw
end
end
return false, nil, `'{raw}' is not one of: {table.concat(choices, ", ")}`
elseif field.kind == "color3" then
if typeof(raw) ~= "Color3" then
return false, nil, "expected a Color3"
end
return true, raw
elseif field.kind == "font" then
if typeof(raw) ~= "string" then
return false, nil, "expected an Enum.Font name"
end
local ok, font = pcall(function()
return (Enum.Font :: any)[raw]
end)
if not ok or typeof(font) ~= "EnumItem" then
return false, nil, `'{raw}' is not an Enum.Font name`
end
return true, font
end
return false, nil, `unknown field kind '{field.kind}'`
end
-- Read the persisted instance (if any) and layer its deltas onto every section via
-- Config.override. Call once, before systems boot, on BOTH server and client. Invalid values
-- warn and are skipped — never fatal. Returns counts for testing.
function EngineConfig.apply(): { sections: number, fields: number, warnings: { string } }
local result = { sections = 0, fields = 0, warnings = {} }
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local instance = ReplicatedStorage:FindFirstChild(EngineConfig.INSTANCE_NAME)
if not instance then
return result
end
for _, section in EngineConfig.SECTIONS do
local sectionChild = instance:FindFirstChild(section.id)
if not sectionChild then
continue
end
local overrides: { [string]: any } = {}
local sectionFields = 0
for _, group in section.groups do
local node: Instance? = if group.name then sectionChild:FindFirstChild(group.name) else sectionChild
if not node then
continue
end
for _, field in group.fields do
local raw = node:GetAttribute(field.attr)
if raw == nil then
continue
end
local ok, value, err = resolveValue(field, raw)
if not ok then
local path = `{section.id}{if group.name then "." .. group.name else ""}.{field.attr}`
local message = `[SurvivorCore.EngineConfig] ignoring '{path}': {err}`
warn(message)
table.insert(result.warnings, message)
continue
end
if group.name then
local sub = overrides[group.name]
if sub == nil then
sub = {}
overrides[group.name] = sub
end
sub[field.attr] = value
else
overrides[field.attr] = value
end
sectionFields += 1
end
end
if sectionFields > 0 then
Config.override(section.id, overrides)
result.sections += 1
result.fields += sectionFields
end
end
return result
end
return EngineConfig