mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 09:02:29 +00:00
Engine: quest overrides with objective*/reward* fields on nested code quests now WARN at boot (they merge nothing — QuestData prefers nested tables); EngineConfig + ConfigAdmin reject non-finite numbers (inf/nan). Plugin: Config group panels auto-size (fixed-height math clipped the last row of big groups — Theme group lost its Bold font row); Stats panels get the gap math right; the Engine Config explainer page now REBUILDS the window when the engine appears (the old hint was impossible — pages were assembled once at plugin load); create() refuses ids that already have an override (mirror guard); failed Create/Override reasons render under the create row; rejected config edits report in the footer; explicit navigation cancels a pending debounced search jump; the mount-time page restore no longer clobbers the saved last-page setting during an engine-less session; undo/redo refresh skips while typing in one of the plugin's own text boxes; search results past the 50-cap no longer render empty category headers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
318 lines
11 KiB
Luau
318 lines
11 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 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
|
|
|
|
-- ── 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,
|
|
})
|
|
|
|
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)
|