mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
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>
357 lines
12 KiB
Luau
357 lines
12 KiB
Luau
--!nonstrict
|
||
--[[
|
||
ContentAdmin — the pure LOGIC layer of the no-code content builder (Items + Gatherable
|
||
resources). NO Studio widget / `plugin` global / ChangeHistoryService, so it's requirable and
|
||
testable headlessly. The UI (ContentAdminUi) + plugin main are thin layers over this.
|
||
|
||
Unlike the stat editor (which writes DELTAS over engine defaults), content is entirely
|
||
owner-created: each item/resource is a `Configuration` child under
|
||
`ReplicatedStorage.SurvivorCoreContent.{Items,Resources}`, named by its id, with its fields as
|
||
attributes. The engine's `Registry.loadFromFolder` reads exactly this at start() — so what the
|
||
plugin writes here, the runtime registers, with no code. Field attribute names are the def field
|
||
names (name/stack/…) the loader copies verbatim.
|
||
]]
|
||
|
||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||
|
||
local ContentAdmin = {}
|
||
|
||
ContentAdmin.ROOT_NAME = "SurvivorCoreContent"
|
||
|
||
export type FieldSpec = { attr: string, kind: string, label: string, default: any, placeholder: string? }
|
||
export type Category = { folder: string, keyLabel: string, title: string, fields: { FieldSpec } }
|
||
|
||
-- The two builders. `attr` are the lowercase def field names the engine loader reads.
|
||
ContentAdmin.CATEGORIES = {
|
||
Items = {
|
||
folder = "Items",
|
||
title = "Items",
|
||
keyLabel = "New item id",
|
||
fields = {
|
||
{ attr = "name", kind = "string", label = "Name", default = "" },
|
||
{ attr = "stack", kind = "number", label = "Max stack", default = 1 },
|
||
{ attr = "weight", kind = "number", label = "Weight", default = 0 },
|
||
{
|
||
attr = "category",
|
||
kind = "string",
|
||
label = "Category",
|
||
default = "",
|
||
placeholder = "material / tool / consumable",
|
||
},
|
||
{ attr = "toolType", kind = "string", label = "Tool type", default = "", placeholder = "axe (tools only)" },
|
||
{ attr = "icon", kind = "string", label = "Icon", default = "", placeholder = "rbxassetid://…" },
|
||
{ attr = "description", kind = "string", label = "Description", default = "" },
|
||
},
|
||
},
|
||
Weapons = {
|
||
-- Weapons ARE items (category = "weapon"), but live in their own SurvivorCoreContent.Weapons
|
||
-- folder so this editor never collides with the Items editor; the engine loads it into the
|
||
-- Items registry at start(). `toolType` lets the hotbar equip it; the `weapon*` fields are the
|
||
-- flat stats the Combat system reads.
|
||
folder = "Weapons",
|
||
title = "Weapons",
|
||
keyLabel = "New weapon id",
|
||
fields = {
|
||
{ attr = "name", kind = "string", label = "Name", default = "" },
|
||
{ attr = "category", kind = "string", label = "Category", default = "weapon" },
|
||
{
|
||
attr = "toolType",
|
||
kind = "string",
|
||
label = "Tool type",
|
||
default = "",
|
||
placeholder = "sword / bow (required to equip)",
|
||
},
|
||
{ attr = "weaponKind", kind = "string", label = "Kind", default = "melee", placeholder = "melee / bow" },
|
||
{ attr = "weaponDamage", kind = "number", label = "Damage", default = 10 },
|
||
{ attr = "weaponRange", kind = "number", label = "Range (melee)", default = 8 },
|
||
{ attr = "weaponCooldown", kind = "number", label = "Cooldown", default = 0.6 },
|
||
{ attr = "weaponDrawTime", kind = "number", label = "Draw time (bow)", default = 1 },
|
||
{ attr = "weaponProjectileSpeed", kind = "number", label = "Arrow speed (bow)", default = 180 },
|
||
{
|
||
attr = "weaponMaxRange",
|
||
kind = "number",
|
||
label = "Max range (bow)",
|
||
default = 0,
|
||
placeholder = "0 = engine default",
|
||
},
|
||
{
|
||
attr = "weaponAmmo",
|
||
kind = "string",
|
||
label = "Ammo item (bow)",
|
||
default = "",
|
||
placeholder = "an arrow id; blank = none",
|
||
},
|
||
{ attr = "stack", kind = "number", label = "Max stack", default = 1 },
|
||
{ attr = "weight", kind = "number", label = "Weight", default = 1 },
|
||
{ attr = "icon", kind = "string", label = "Icon", default = "", placeholder = "rbxassetid://…" },
|
||
},
|
||
},
|
||
Arrows = {
|
||
-- Arrows ARE items (category = "ammo"), in their own SurvivorCoreContent.Arrows folder (loaded
|
||
-- into the Items registry). A bow's `weaponAmmo` points at one of these ids; the shot combines
|
||
-- the bow's pullback with the arrow's weight/damage/range. Different arrow types = different
|
||
-- ballistics.
|
||
folder = "Arrows",
|
||
title = "Arrows / Ammo",
|
||
keyLabel = "New arrow id",
|
||
fields = {
|
||
{ attr = "name", kind = "string", label = "Name", default = "" },
|
||
{ attr = "category", kind = "string", label = "Category", default = "ammo" },
|
||
{
|
||
attr = "ammoDamage",
|
||
kind = "number",
|
||
label = "Damage ×",
|
||
default = 1,
|
||
placeholder = "1.0 = bow base; 1.2 = +20%",
|
||
},
|
||
{
|
||
attr = "ammoDrop",
|
||
kind = "number",
|
||
label = "Drop / curve ×",
|
||
default = 1,
|
||
placeholder = "1.0 = normal; heavier = more arc",
|
||
},
|
||
{
|
||
attr = "ammoRange",
|
||
kind = "number",
|
||
label = "Max range",
|
||
default = 0,
|
||
placeholder = "studs; 0 = bow default",
|
||
},
|
||
{
|
||
attr = "ammoSpeed",
|
||
kind = "number",
|
||
label = "Speed ×",
|
||
default = 0,
|
||
placeholder = "0 = bow speed; 1.2 = faster",
|
||
},
|
||
{ attr = "weight", kind = "number", label = "Carry weight", default = 0.05 },
|
||
{ attr = "stack", kind = "number", label = "Max stack", default = 32 },
|
||
{ attr = "icon", kind = "string", label = "Icon", default = "", placeholder = "rbxassetid://…" },
|
||
{ attr = "description", kind = "string", label = "Description", default = "" },
|
||
},
|
||
},
|
||
Resources = {
|
||
folder = "Resources",
|
||
title = "Gatherables",
|
||
keyLabel = "New resource id",
|
||
fields = {
|
||
{ attr = "item", kind = "string", label = "Yields item", default = "", placeholder = "an item id" },
|
||
{ attr = "hp", kind = "number", label = "HP / gathers", default = 3 },
|
||
{
|
||
attr = "requireTool",
|
||
kind = "string",
|
||
label = "Tool required",
|
||
default = "",
|
||
placeholder = "axe (blank = bare hand)",
|
||
},
|
||
{ attr = "yieldMin", kind = "number", label = "Yield min", default = 1 },
|
||
{ attr = "yieldMax", kind = "number", label = "Yield max", default = 1 },
|
||
},
|
||
},
|
||
Quests = {
|
||
-- Flat single-objective quests (multi-objective chains stay code-authored, like recipes).
|
||
-- The engine's QuestData.normalize turns these attributes into the canonical def at load.
|
||
folder = "Quests",
|
||
title = "Quests",
|
||
keyLabel = "New quest id",
|
||
fields = {
|
||
{ attr = "name", kind = "string", label = "Quest name", default = "" },
|
||
{ attr = "description", kind = "string", label = "Description", default = "" },
|
||
{
|
||
attr = "objectiveType",
|
||
kind = "string",
|
||
label = "Objective",
|
||
default = "gather",
|
||
placeholder = "gather / craft / kill / use",
|
||
},
|
||
{
|
||
attr = "objectiveTarget",
|
||
kind = "string",
|
||
label = "Target id",
|
||
default = "",
|
||
placeholder = "item / mob id; blank = any",
|
||
},
|
||
{ attr = "objectiveCount", kind = "number", label = "Count needed", default = 1 },
|
||
{ attr = "rewardItem", kind = "string", label = "Reward item", default = "", placeholder = "an item id" },
|
||
{ attr = "rewardCount", kind = "number", label = "Reward count", default = 1 },
|
||
{ attr = "autoStart", kind = "boolean", label = "Auto-start", default = false },
|
||
{
|
||
attr = "requires",
|
||
kind = "string",
|
||
label = "Requires quest",
|
||
default = "",
|
||
placeholder = "prerequisite quest id",
|
||
},
|
||
{ attr = "turnIn", kind = "boolean", label = "Turn in at giver", default = false },
|
||
},
|
||
},
|
||
Achievements = {
|
||
-- Flat counter + threshold defs against the engine's auto-derived progression counters.
|
||
folder = "Achievements",
|
||
title = "Achievements",
|
||
keyLabel = "New achievement key",
|
||
fields = {
|
||
{ attr = "name", kind = "string", label = "Name", default = "" },
|
||
{ attr = "description", kind = "string", label = "Description", default = "" },
|
||
{
|
||
attr = "counter",
|
||
kind = "string",
|
||
label = "Counter",
|
||
default = "",
|
||
placeholder = "gathers_reed / kills_husk / crafts_total",
|
||
},
|
||
{ attr = "threshold", kind = "number", label = "Threshold", default = 1 },
|
||
{ attr = "icon", kind = "string", label = "Icon", default = "", placeholder = "rbxassetid://…" },
|
||
},
|
||
},
|
||
Mobs = {
|
||
folder = "Mobs",
|
||
title = "Mobs",
|
||
keyLabel = "New mob id",
|
||
fields = {
|
||
{
|
||
attr = "faction",
|
||
kind = "string",
|
||
label = "Faction",
|
||
default = "hostile",
|
||
placeholder = "hostile / passive / neutral",
|
||
},
|
||
{ attr = "health", kind = "number", label = "Health", default = 50 },
|
||
{ attr = "walkSpeed", kind = "number", label = "Walk speed", default = 6 },
|
||
{ attr = "runSpeed", kind = "number", label = "Run speed", default = 14 },
|
||
{ attr = "aggroRange", kind = "number", label = "Aggro range", default = 40 },
|
||
{ attr = "leashRange", kind = "number", label = "Leash range", default = 60 },
|
||
{ attr = "attackRange", kind = "number", label = "Attack range", default = 6 },
|
||
{ attr = "attackDamage", kind = "number", label = "Attack damage", default = 8 },
|
||
{ attr = "attackCooldown", kind = "number", label = "Attack cooldown", default = 1.5 },
|
||
},
|
||
},
|
||
} :: { [string]: Category }
|
||
|
||
-- Render order for the UI.
|
||
ContentAdmin.ORDER = { "Items", "Weapons", "Arrows", "Resources", "Mobs", "Quests", "Achievements" }
|
||
|
||
-- ids are lowercase alphanumeric + underscore (matches how content is referenced everywhere).
|
||
function ContentAdmin.sanitizeId(raw: any): string
|
||
return (string.gsub(string.lower(tostring(raw or "")), "[^%w_]", ""))
|
||
end
|
||
|
||
function ContentAdmin.getRoot(): Instance?
|
||
return ReplicatedStorage:FindFirstChild(ContentAdmin.ROOT_NAME)
|
||
end
|
||
|
||
function ContentAdmin.getCategoryFolder(catKey: string): Instance?
|
||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||
local root = ContentAdmin.getRoot()
|
||
return root and cat and root:FindFirstChild(cat.folder) or nil
|
||
end
|
||
|
||
-- Find-or-create the SurvivorCoreContent/<folder> path. Only called from the write path.
|
||
function ContentAdmin.ensureFolder(catKey: string): Instance
|
||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||
local root = ContentAdmin.getRoot()
|
||
if not root then
|
||
root = Instance.new("Folder")
|
||
root.Name = ContentAdmin.ROOT_NAME
|
||
root.Parent = ReplicatedStorage
|
||
end
|
||
local folder = root:FindFirstChild(cat.folder)
|
||
if not folder then
|
||
folder = Instance.new("Folder")
|
||
folder.Name = cat.folder
|
||
folder.Parent = root
|
||
end
|
||
return folder
|
||
end
|
||
|
||
function ContentAdmin.list(catKey: string): { string }
|
||
local out = {}
|
||
local folder = ContentAdmin.getCategoryFolder(catKey)
|
||
if folder then
|
||
for _, child in folder:GetChildren() do
|
||
table.insert(out, child.Name)
|
||
end
|
||
table.sort(out)
|
||
end
|
||
return out
|
||
end
|
||
|
||
function ContentAdmin.getNode(catKey: string, id: string): Instance?
|
||
local folder = ContentAdmin.getCategoryFolder(catKey)
|
||
return folder and folder:FindFirstChild(id) or nil
|
||
end
|
||
|
||
local function coerce(kind: string, raw: any): (boolean, any, string?)
|
||
if kind == "number" then
|
||
local n = tonumber(raw)
|
||
if n == nil then
|
||
return false, nil, "not a number"
|
||
end
|
||
return true, n, nil
|
||
elseif kind == "boolean" then
|
||
if typeof(raw) == "boolean" then
|
||
return true, raw, nil
|
||
end
|
||
return true, raw == "true", nil
|
||
end
|
||
return true, tostring(raw), nil
|
||
end
|
||
ContentAdmin.coerce = coerce
|
||
|
||
-- Create a new entry, seeding every field to its default so the loader sees a complete def.
|
||
function ContentAdmin.create(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
|
||
local folder = ContentAdmin.ensureFolder(catKey)
|
||
if folder:FindFirstChild(id) then
|
||
return false, `'{id}' already exists.`
|
||
end
|
||
local node = Instance.new("Configuration")
|
||
node.Name = id
|
||
for _, f in cat.fields do
|
||
node:SetAttribute(f.attr, f.default)
|
||
end
|
||
node.Parent = folder
|
||
return true, id
|
||
end
|
||
|
||
function ContentAdmin.delete(catKey: string, id: string)
|
||
local node = ContentAdmin.getNode(catKey, id)
|
||
if node then
|
||
node:Destroy()
|
||
end
|
||
end
|
||
|
||
-- The attribute value for a field (or its default if unset/no node).
|
||
function ContentAdmin.read(catKey: string, id: string, field: FieldSpec): any
|
||
local node = ContentAdmin.getNode(catKey, id)
|
||
local v = node and node:GetAttribute(field.attr)
|
||
if v == nil then
|
||
return field.default
|
||
end
|
||
return v
|
||
end
|
||
|
||
-- Write a field (full value — content is owner-owned, not a delta over an engine default).
|
||
function ContentAdmin.set(catKey: string, id: string, field: FieldSpec, raw: any): (boolean, string?)
|
||
local node = ContentAdmin.getNode(catKey, id)
|
||
if not node then
|
||
return false, "missing entry"
|
||
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
|