Files
SurvivorCore/plugin/init.server.luau
T
Samuel LisonandClaude Opus 4.8 bbfefbec38 fix(builder): chooser cards were unclickable and misrouted picks
Found by an adversarial review of the Build page before it was ever run — all
three reviewers independently flagged the first one.

1. The "what is this object?" cards were dead. chooserCard parented a
   Size=fromScale(1,1) TextButton into Theme.panel() intending an overlay, but
   Theme.panel() contains a UIListLayout, which lays out EVERY GuiObject child —
   there is no opt-out, and ZIndex does not affect layout. So the button became
   another list row: clicking a card's title/summary did nothing, and the
   oversized button spilled past the card and took the click for the card BELOW,
   applying the WRONG component (which also strips the previous component's
   attributes). This was the page's primary interaction.
   Fixed with Theme.panelButton() — the card itself is the button, matching the
   pattern OverviewPage already uses.

2. Clear did nothing on an object whose only tag was unknown to this engine,
   yet reported success. The page renders exactly that branch with a Clear
   button. BuildAdmin.clear now takes the tags to remove explicitly, and the page
   passes the unknown tags it just displayed — never removing an unlisted tag
   speculatively, since it may belong to another plugin. It also reports
   "nothing to clear" instead of a false success.

3. FieldRow's help text set Position inside a UIListLayout parent, so its indent
   was silently dropped and every help line rendered flush left. Uses padding now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:54:21 +10:00

357 lines
12 KiB
Luau

--!nonstrict
--[[
SurvivorCore Studio admin plugin (main).
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 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 BuildAdmin = require(script.BuildAdmin)
local BuildAdminUi = require(script.BuildAdminUi)
local SETTING_LAST_PAGE = "SurvivorCoreStudio.lastPage"
local toolbar = plugin:CreateToolbar("SurvivorCore")
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. 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(
"SurvivorCoreStudio",
DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, false, false, 660, 580, 480, 380)
)
widget.Title = "SurvivorCore Studio"
widget.Name = "SurvivorCoreStudio"
local root = Instance.new("Frame")
root.Size = UDim2.fromScale(1, 1)
root.BackgroundColor3 = Theme.COLOR.BG
root.BorderSizePixel = 0
root.Parent = widget
local nav -- assigned after the pages are assembled; closures below capture it
-- 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 nav then
nav.refreshAll()
end
return result
end
-- ── 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)
end)
end
local function applyReset(statName: string, attr: string): any
return record(`Stats: reset {statName}.{attr}`, function()
return StatAdmin.resetOverride(statName, attr)
end)
end
local function applyPreview(): any
return record("Stats: preview HUD", function()
return HudPreview.apply(StatAdmin)
end)
end
local function applyClear(): any
return record("Stats: clear HUD preview", function()
return HudPreview.clear()
end)
end
-- ── 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 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 applyConfigResetSection(section: any): any
return record(`Config: reset section {section.id}`, function()
return ConfigAdmin.resetSection(section)
end)
end
-- ── Build callbacks (world objects: tag + attributes) ────────────────────────
-- One ChangeHistory recording per call, kept strictly at this boundary: BuildAdmin functions must
-- never call each other through here, since a nested TryBeginRecording returns nil.
local buildActions = {
applyType = function(instances: { Instance }, schema: any, all: { any }): any
return record(`Build: {BuildAdmin.displayOf(schema).title}`, function()
return BuildAdmin.applyType(instances, schema, all)
end)
end,
clear = function(instances: { Instance }, all: { any }, extraTags: { string }?): any
return record("Build: clear component", function()
return BuildAdmin.clear(instances, all, extraTags)
end)
end,
setField = function(instance: Instance, spec: any, raw: any): any
return record(`Build: {spec.attr}`, function()
return BuildAdmin.setField(instance, spec, raw)
end)
end,
resetField = function(instance: Instance, spec: any): any
return record(`Build: reset {spec.attr}`, function()
return BuildAdmin.resetField(instance, spec)
end)
end,
}
-- ── 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,
}
-- ── 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,
})
-- Build: select a world object → "what is this?" → a schema-rendered setup form. Registered
-- unconditionally; it renders its own explainer when the engine (or its schemas) is missing.
table.insert(pages, {
id = "build",
label = "Build",
section = "",
mount = function(frame)
return BuildAdminUi.mount(frame, BuildAdmin, ContentAdmin, buildActions)
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
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
ChangeHistoryService.OnUndo:Connect(onHistoryChanged)
ChangeHistoryService.OnRedo:Connect(onHistoryChanged)
button.Click:Connect(function()
widget.Enabled = not widget.Enabled
if widget.Enabled then
nav.refreshAll()
end
end)
widget:GetPropertyChangedSignal("Enabled"):Connect(function()
button:SetActive(widget.Enabled)
end)