feat(plugin): SurvivorCore Studio — floating window, sidebar nav, search, Engine Config editor, roster overrides (#11, #21, #40)

New shell: Theme (shared visual system), Nav (rail + lazy router + badges),
OverviewPage, SearchPage (global editable results), SpawnHelpers; init.server
restructured around data-driven pages with last-page persistence and
undo/redo-aware refresh. Widget: floating 660×580 (min 480×380), new ID
SurvivorCoreStudio. ConfigAdmin/-Ui: deltas-only editor pages for all 11
engine Config sections incl. Theme colors (R,G,B + swatch) and fonts.
ContentAdmin/-Ui: per-category pages over the merged roster; + Override
entries (blank = inherit) tuning code-registered defs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Samuel Lison
2026-07-07 12:07:48 +10:00
co-authored by Claude Opus 4.8
parent c661832485
commit 91c7a87f89
12 changed files with 1978 additions and 489 deletions
+298
View File
@@ -0,0 +1,298 @@
--!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 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
+338
View File
@@ -0,0 +1,338 @@
--!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
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
local panel = Theme.panel()
panel.Size = UDim2.new(1, 0, 0, 22 + ROW_H * #group.fields + 8)
panel.AutomaticSize = Enum.AutomaticSize.None
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, applyEdit, 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.
-- Callbacks are the plugin main's record() wrappers.
function ConfigAdminUi.pages(ConfigAdmin: any, applyEdit, applyReset, applyResetSection): { 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, close and reopen this window 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 then
message.Text = "Engine found — close and reopen the SurvivorCore Studio window"
.. " to load the Engine Config sections."
end
end
header.refreshBtn.MouseButton1Click:Connect(refresh)
return { refresh = refresh }
end,
})
end
return pages
end
return ConfigAdminUi
+127
View File
@@ -377,4 +377,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
+198 -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,149 +168,33 @@ 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
local function refresh()
@@ -307,14 +203,72 @@ function ContentAdminUi.mount(
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()
actions.create(catKey, idBox.Text)
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()
actions.createOverride(catKey, idBox.Text)
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 = 2,
}).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 = 3,
}).Parent =
scroll
else
for i, entry in roster do
local panel = ContentAdminUi.buildRosterEntry(catKey, entry, ContentAdmin, actions)
panel.LayoutOrder = 3 + 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 then
continue
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
+12 -24
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
+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
+197 -221
View File
@@ -1,107 +1,78 @@
--!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 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 +94,183 @@ 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()
return ContentAdmin.create(catKey, rawId)
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()
return ContentAdmin.createOverride(catKey, rawId)
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) ─────────────
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,
})
for _, page in ConfigAdminUi.pages(ConfigAdmin, applyConfigEdit, applyConfigReset, applyConfigResetSection) 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
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)
end
showTab("stats")
nav = Nav.mount(root, pages, {
initialId = restoredId,
onNavigate = function(id: string)
lastNonSearchId = id
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,
})
-- An undo/redo can revert any edit the pages show — refresh so they never go stale.
ChangeHistoryService.OnUndo:Connect(function()
nav.refreshAll()
end)
ChangeHistoryService.OnRedo:Connect(function()
nav.refreshAll()
end)
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"]}]}