Files
SurvivorCore/plugin/init.server.luau
T
Samuel LisonandClaude Opus 4.8 9caa888369 feat: quests + achievements — the goals & progression release (#10)
Quests (#10): a Quests registry + server runtime — objectives (gather/craft/
kill/use × count, blank target = any) and rewards, autoStart + requires chains
(completing a prerequisite auto-starts dependents), optional turn-in at a
tagged QuestGiver (component + prompt). Rewards are never lost: a full
inventory parks the quest as ready and the grant retries on inventory change.
Quests menu tab (L) with per-objective progress bars; QuestLog JSON attribute
replicates state; quest:started/progress/completed/blocked via Hooks + bus.

Achievements: an always-on runtime for the existing registry, ported
architecturally from TCE. A shared Progression layer translates bus events
into auto-derived counters (gathers_reed, crafts_total, kills_husk, …) so a
def is just { key, name, counter, threshold } — flat in code and no-code.
Unlock-once + toast + Achievements tab (J) with progress bars.

Toasts: themed top-right notification queue (Notify remote + Toasts.show).

No-code: admin plugin gains Quests (single-objective + "+ Quest giver" drop)
and Achievements (counter/threshold) editors; the engine loads both from
SurvivorCoreContent. Demo: a 3-quest chain + 4 achievements + a giver post.

Fixed: registerPanel now ADOPTS a template-scaffolded tab (hides the
placeholder, builds into the authored frame) instead of silently no-opping —
this replaces the Quests/Achievements "coming soon" placeholders.

EventBridge parity: gather:*, craft:* and item:use now also cross the bus.
Changed: rojo 7.6.1 → 7.7.0 (rokit pin; CI follows; gate verified).

Docs: quests.md + achievements.md (new), content-authoring/admin-plugin/
extending/README updated, CHANGELOG Unreleased, site feature card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:26:19 +10:00

303 lines
10 KiB
Luau

--!nonstrict
--[[
SurvivorCore 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.
]]
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local CollectionService = game:GetService("CollectionService")
local Selection = game:GetService("Selection")
local Workspace = game:GetService("Workspace")
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 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 toolbar = plugin:CreateToolbar("SurvivorCore")
local button = toolbar:CreateButton("Admin Panel", "Tune stats + 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.
-- selene: allow(deprecated)
local widget = plugin:CreateDockWidgetPluginGui(
"SurvivorCoreAdmin",
DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 340, 560, 320, 400)
)
widget.Title = "SurvivorCore Admin"
widget.Name = "SurvivorCoreAdmin"
local root = Instance.new("Frame")
root.Size = UDim2.fromScale(1, 1)
root.BackgroundColor3 = COL_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 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.
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()
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
-- 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)
end)
end
local function applyCreate(catKey: string, rawId: any): any
return record(`Content: create {catKey}`, function()
return ContentAdmin.create(catKey, rawId)
end)
end
local function applyDelete(catKey: string, id: string): any
return record(`Content: delete {catKey} {id}`, function()
ContentAdmin.delete(catKey, id)
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 },
}
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
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")
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
end
end)
widget:GetPropertyChangedSignal("Enabled"):Connect(function()
button:SetActive(widget.Enabled)
end)