mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 09:02:29 +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>
173 lines
5.8 KiB
Luau
173 lines
5.8 KiB
Luau
--!nonstrict
|
|
--[[
|
|
Achievements — server. The achievement runtime: always-on counters → threshold → unlock-once.
|
|
|
|
The architecture is ported from The Counter Earth's proven AchievementService, made
|
|
content-free: instead of hand-written event mappings, the shared `Progression` stream bumps
|
|
GENERIC counters for every (kind, target) — `gathers_total`, `gathers_<item>`, `crafts_total`,
|
|
`crafts_<item>`, `kills_<mobType>`, `uses_<item>`, `quests_completed`, … — so an achievement
|
|
def is FLAT and no-code-authorable:
|
|
|
|
SurvivorCore.Achievements.register({
|
|
key = "husk_slayer", name = "Husk Slayer",
|
|
counter = "kills_husk", threshold = 3,
|
|
})
|
|
|
|
Games add custom mappings via `SurvivorCore.Progression.map`, bump bespoke counters with
|
|
`Achievements.addCount`, or unlock directly with `Achievements.award`. State replicates as ONE
|
|
JSON Player attribute (`AchievementData.STATE_ATTR`); unlocks fire `achievement:unlocked`
|
|
(Hooks + EventBridge) and a toast. Tuning: the "Achievements" Config section. Session-scoped
|
|
(persistence is a future system). Started by SurvivorCore.start().
|
|
]]
|
|
|
|
local Players = game:GetService("Players")
|
|
local HttpService = game:GetService("HttpService")
|
|
local RunService = game:GetService("RunService")
|
|
|
|
assert(RunService:IsServer(), "SurvivorCore.Achievements is server-only — booted by SurvivorCore.start()")
|
|
|
|
local Registries = require(script.Parent.Parent.registries)
|
|
local Progression = require(script.Parent.Progression)
|
|
local Hooks = require(script.Parent.Parent.foundation.Hooks)
|
|
local EventBridge = require(script.Parent.Parent.foundation.EventBridge)
|
|
local Remotes = require(script.Parent.Parent.shared.Remotes)
|
|
local AchievementData = require(script.Parent.Parent.shared.AchievementData)
|
|
local AchievementsConfig = require(script.Parent.Parent.shared.AchievementsConfig)
|
|
|
|
local Achievements = {}
|
|
|
|
local started = false
|
|
|
|
-- Validated display defs (key → def) and, per counter, the defs it can unlock.
|
|
local defs: { [string]: AchievementData.Achievement } = {}
|
|
local byCounter: { [string]: { AchievementData.Achievement } } = {}
|
|
|
|
type PlayerState = { c: { [string]: number }, u: { [string]: boolean } }
|
|
local states: { [Player]: PlayerState } = {}
|
|
|
|
local function getState(player: Player): PlayerState
|
|
local s = states[player]
|
|
if not s then
|
|
s = { c = {}, u = {} }
|
|
states[player] = s
|
|
end
|
|
return s
|
|
end
|
|
|
|
local function writeState(player: Player)
|
|
local s = getState(player)
|
|
player:SetAttribute(AchievementData.STATE_ATTR, HttpService:JSONEncode({ v = 1, c = s.c, u = s.u }))
|
|
end
|
|
|
|
local function unlock(player: Player, def: AchievementData.Achievement)
|
|
local s = getState(player)
|
|
if s.u[def.key] then
|
|
return
|
|
end
|
|
s.u[def.key] = true
|
|
local ctx = { player = player, key = def.key, def = def }
|
|
Hooks.run("achievement:unlocked", ctx)
|
|
EventBridge.fire("achievement:unlocked", player, { key = def.key, def = def })
|
|
if AchievementsConfig.get().Toasts ~= false then
|
|
Remotes.event("Notify"):FireClient(player, {
|
|
kind = "achievement",
|
|
title = "Achievement unlocked",
|
|
body = def.name,
|
|
icon = def.icon,
|
|
})
|
|
end
|
|
end
|
|
|
|
-- Bump a counter and unlock anything it satisfies. The single write path for all progress.
|
|
local function bump(player: Player, counterId: string, amount: number)
|
|
local s = getState(player)
|
|
s.c[counterId] = (s.c[counterId] or 0) + amount
|
|
local watchers = byCounter[counterId]
|
|
if watchers then
|
|
for _, def in watchers do
|
|
if not s.u[def.key] and s.c[counterId] >= def.threshold then
|
|
unlock(player, def)
|
|
end
|
|
end
|
|
end
|
|
writeState(player)
|
|
end
|
|
|
|
-- ── Public API (attached to SurvivorCore.Achievements after start) ──────────
|
|
|
|
-- Manually unlock an achievement (story beats, secrets — things counters can't express).
|
|
function Achievements.award(player: Player, key: string): boolean
|
|
local def = defs[key]
|
|
if not def then
|
|
warn(`[SurvivorCore.Achievements] award: unknown key '{tostring(key)}'`)
|
|
return false
|
|
end
|
|
if getState(player).u[key] then
|
|
return false
|
|
end
|
|
unlock(player, def)
|
|
writeState(player)
|
|
return true
|
|
end
|
|
|
|
-- Bump a bespoke counter from game code (pairs with defs authored against that counter id).
|
|
function Achievements.addCount(player: Player, counterId: string, amount: number?)
|
|
bump(player, tostring(counterId), math.max(1, math.floor(tonumber(amount) or 1)))
|
|
end
|
|
|
|
function Achievements.isUnlocked(player: Player, key: string): boolean
|
|
return getState(player).u[key] == true
|
|
end
|
|
|
|
function Achievements.getState(player: Player)
|
|
return AchievementData.decodeState(player)
|
|
end
|
|
|
|
-- ── Boot ─────────────────────────────────────────────────────────────────────
|
|
|
|
local function addPlayer(player: Player)
|
|
getState(player)
|
|
writeState(player)
|
|
end
|
|
|
|
function Achievements.start(_options: { [string]: any }?)
|
|
if started then
|
|
return
|
|
end
|
|
started = true
|
|
|
|
-- Snapshot + validate the registered defs, index by counter, replicate for the tab.
|
|
for _, raw in Registries.Achievements.getAll() do
|
|
local def = AchievementData.toDisplay(raw)
|
|
if def then
|
|
defs[def.key] = def
|
|
local list = byCounter[def.counter]
|
|
if not list then
|
|
list = {}
|
|
byCounter[def.counter] = list
|
|
end
|
|
table.insert(list, def)
|
|
end
|
|
end
|
|
AchievementData.publish(Registries.Achievements.getAll())
|
|
|
|
Remotes.event("Notify") -- eager (shared with Quests; idempotent)
|
|
|
|
-- Generic counters from the shared progress stream: kind totals + per-target.
|
|
Progression.onProgress(function(player, kind, target, amount)
|
|
for _, counterId in Progression.counterIds(kind, target) do
|
|
bump(player, counterId, amount)
|
|
end
|
|
end)
|
|
|
|
for _, player in Players:GetPlayers() do
|
|
task.defer(addPlayer, player)
|
|
end
|
|
Players.PlayerAdded:Connect(addPlayer)
|
|
Players.PlayerRemoving:Connect(function(player)
|
|
states[player] = nil
|
|
end)
|
|
end
|
|
|
|
return Achievements
|