diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2ca20..b58ab02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,45 @@ All notable changes to SurvivorCore are recorded here. The format follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). At release time, `## Unreleased` is promoted to the new version and `main` is tagged `vX.Y.Z`. +## Unreleased + +### Added +- **Quests** (#10) — the goals & progression foundation. A `Quests` registry + server runtime: + quests carry **objectives** (`gather` / `craft` / `kill` / `use` a target × count; blank target = + any) and **rewards**, with `autoStart`, a `requires` prerequisite (completing a quest + **auto-starts its chain**), and optional `turnIn` at a **quest giver** — tag any part/model + `QuestGiver` + set `Quest = ""` and the engine attaches the accept/turn-in prompt. Progress is + driven by the events players already generate — no wiring per quest — and **rewards are never + lost**: a full inventory parks the quest as *ready* and the grant retries until it fits. A + **Quests menu tab** (key `L`) renders Active (per-objective progress bars) / Ready / Completed. + New hooks + bus events: `quest:started/progress/completed/blocked`. Session-scoped (persistence is + a future system). See [docs/quests.md](docs/quests.md). +- **Achievements** — an always-on runtime for the existing registry, ported architecturally from The + Counter Earth's proven service: the new shared **Progression** layer translates gameplay events + into **auto-derived counters** (`gathers_reed`, `crafts_total`, `kills_husk`, `uses_berry`, + `quests_total`, …) so an achievement def is just `{ key, name, counter, threshold }` — the same + flat shape in code and no-code. Threshold crossings **unlock once**, fire `achievement:unlocked`, + and show a **toast**; an **Achievements menu tab** (key `J`) tracks progress bars (gold when + unlocked). Custom events/counters via `SurvivorCore.Progression.map` / `Achievements.addCount` / + `Achievements.award`. See [docs/achievements.md](docs/achievements.md). +- **Toasts** — a small themed top-right notification queue (`Notify` remote + `Toasts.show`), + used by quest completions and achievement unlocks (config-gated per system). +- **No-code quests & achievements** — the admin plugin's Content widget gains **Quests** + (single-objective: objective type/target/count, reward, auto-start, requires, turn-in; **+ Quest + giver** drops a tagged giver post) and **Achievements** (counter + threshold) editors; the engine + loads both from `SurvivorCoreContent` at start. +- **EventBridge parity** — harvesting, crafting and item-use lifecycle events (`gather:*`, + `craft:*`, `item:use`) now also cross the EventBridge bus (combat/mobs already did), so + quests/achievements/analytics can consume every gameplay event uniformly. + +### Fixed +- `SurvivorCore.UI.registerPanel` now **adopts** a menu tab the authored template already scaffolds + (hiding its placeholder and running the panel's `build` into it) instead of silently doing + nothing — this is what lets the Quests/Achievements tabs replace their "coming soon" placeholders. + +### Changed +- Toolchain: **rojo 7.6.1 → 7.7.0** (pin in `rokit.toml`; sandbox-verified against the full CI gate). + ## 0.5.0 — 2026-06-25 ### Added diff --git a/README.md b/README.md index 0cd746b..d6b6d05 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ authoring tools. If you know Roblox Studio, you can build a survival game. weight. One server-validated `combat:hit` / `combat:kill` schema. - **Mobs & AI** — a shared FSM substrate: hostile mobs chase & attack, passive animals flee. Behavior is data — a mob's `faction` picks the profile. +- **Quests & achievements** — event-driven goals: quest chains with objectives, rewards and + quest-giver NPCs, plus milestone achievements with toasts — both tracked automatically from + what players already do (gather, craft, fight). - **No-code admin plugin** — create items, weapons, ammo and mobs from a Studio form (damage, range, arrow curve, weight, aggro, leash) and drop them into the world. No scripting. @@ -89,7 +92,8 @@ The **admin plugin** turns all of this into Studio forms — see [Architecture](docs/architecture.md) · [Survival stats + HUD](docs/survival-stats.md) · [Inventory](docs/inventory.md) · [Harvesting](docs/harvesting.md) · [Crafting](docs/crafting.md) · -[Combat](docs/combat.md) · [Mobs & AI](docs/mobs.md) · [No-code content](docs/content-authoring.md) · +[Combat](docs/combat.md) · [Mobs & AI](docs/mobs.md) · [Quests](docs/quests.md) · +[Achievements](docs/achievements.md) · [No-code content](docs/content-authoring.md) · [Admin plugin](docs/admin-plugin.md) · [Design language](docs/design-language.md) · [Extending](docs/extending.md) diff --git a/demo/server/Boot.server.luau b/demo/server/Boot.server.luau index c3b75a8..c611729 100644 --- a/demo/server/Boot.server.luau +++ b/demo/server/Boot.server.luau @@ -212,6 +212,65 @@ SurvivorCore.Mobs.register({ runSpeed = 24, aggroRange = 28, -- bolts when you get within ~28 studs }) + +-- Quests: a small chain through the demo loop — gather → craft → fight. `gather_reeds` starts +-- automatically; finishing it auto-starts `weave_basket` (requires + autoStart); `slay_husk` is +-- offered at the quest-giver post near spawn and must be turned in there (turnIn). +SurvivorCore.Quests.register({ + id = "gather_reeds", + name = "Gather Reeds", + description = "Pull 3 reeds from the bushes by the river.", + objectives = { { type = "gather", target = "reed", count = 3 } }, + rewards = { { item = "berry", count = 2 } }, + autoStart = true, +}) +SurvivorCore.Quests.register({ + id = "weave_basket", + name = "Weave a Basket", + description = "Craft a reed basket from your gathered reeds.", + objectives = { { type = "craft", target = "reed_basket", count = 1 } }, + rewards = { { item = "arrow", count = 5 } }, + autoStart = true, + requires = "gather_reeds", +}) +SurvivorCore.Quests.register({ + id = "slay_husk", + name = "Slay a Husk", + description = "A husk stalks the field. Put it down, then report back.", + objectives = { { type = "kill", target = "husk", count = 1 } }, + rewards = { { item = "heavy_arrow", count = 4 } }, + turnIn = true, -- return to the giver post to claim +}) + +-- Achievements: flat counter + threshold against the engine's auto-derived counters. +SurvivorCore.Achievements.register({ + key = "first_reed", + name = "First Harvest", + description = "Gather your first reed.", + counter = "gathers_reed", + threshold = 1, +}) +SurvivorCore.Achievements.register({ + key = "reed_hoarder", + name = "Reed Hoarder", + description = "Gather 25 reeds.", + counter = "gathers_reed", + threshold = 25, +}) +SurvivorCore.Achievements.register({ + key = "handy", + name = "Handy", + description = "Craft your first item.", + counter = "crafts_total", + threshold = 1, +}) +SurvivorCore.Achievements.register({ + key = "husk_slayer", + name = "Husk Slayer", + description = "Slay 3 husks.", + counter = "kills_husk", + threshold = 3, +}) SurvivorCore.Items.register({ id = "straw_hat", name = "Straw Hat", @@ -459,6 +518,38 @@ SurvivorCore.Mobs.spawn("husk", CFrame.new(40, 5, 20), { respawn = true }) SurvivorCore.Mobs.spawn("husk", CFrame.new(48, 5, 32), { respawn = true }) SurvivorCore.Mobs.spawn("boar", CFrame.new(-30, 5, 18)) +-- The quest-giver post: offers `slay_husk` (hold E to accept; return to turn in). Any mesh works — +-- tag it "QuestGiver" + set Quest — this demo just uses a marked wooden post. +local function buildQuestPost(position: Vector3) + local post = Instance.new("Part") + post.Name = "QuestPost" + post.Size = Vector3.new(1, 5, 1) + post.Anchored = true + post.Color = Color3.fromRGB(150, 110, 70) + post.Material = Enum.Material.Wood + post.Position = position + Vector3.new(0, 2.5, 0) + local sign = Instance.new("Part") + sign.Name = "Sign" + sign.Size = Vector3.new(2.4, 1.4, 0.3) + sign.Color = Color3.fromRGB(120, 170, 255) + sign.Material = Enum.Material.SmoothPlastic + sign.Anchored = true + sign.CFrame = post.CFrame * CFrame.new(0, 1.6, 0) + sign.Parent = post + post:SetAttribute("Quest", "slay_husk") + CollectionService:AddTag(post, "QuestGiver") + post.Parent = Workspace +end +buildQuestPost(Vector3.new(8, 0, 8)) + +-- Flourish hooks: print quest + achievement milestones to the output. +SurvivorCore.Hooks.on("quest:completed", function(ctx) + print(("[demo] %s completed quest '%s'"):format(ctx.player.Name, tostring(ctx.questId))) +end) +SurvivorCore.Hooks.on("achievement:unlocked", function(ctx) + print(("[demo] %s unlocked achievement '%s'"):format(ctx.player.Name, tostring(ctx.key))) +end) + -- Death juice (per mob type): fade the husk's body out over its corpse time. Pure creator content -- via the reaction API — the engine just fires "died"; the game decides what a corpse looks like. local FADE = TweenInfo.new(4, Enum.EasingStyle.Linear) diff --git a/docs/achievements.md b/docs/achievements.md new file mode 100644 index 0000000..3492960 --- /dev/null +++ b/docs/achievements.md @@ -0,0 +1,79 @@ +# Achievements + +The achievement system ([`src/systems/Achievements.luau`](../src/systems/Achievements.luau)) tracks +**milestones**: always-on counters that unlock a badge (once) when they cross a threshold — with a +toast and an Achievements menu tab. The architecture is ported from The Counter Earth's proven +achievement service, made content-free. + +> Progress is **session-scoped** (Player attributes) — persistence (DataStore) is a future system. + +## Defining an achievement + +Defs are **flat** — the same shape in code and [no-code](content-authoring.md): + +```lua +SurvivorCore.Achievements.register({ + key = "husk_slayer", + name = "Husk Slayer", + description = "Slay 3 husks.", + counter = "kills_husk", -- which counter unlocks it (see the catalogue below) + threshold = 3, + -- icon = "rbxassetid://…", +}) +``` + +## The Progression stream + +One shared layer ([`src/systems/Progression.luau`](../src/systems/Progression.luau)) subscribes to +the EventBridge and translates gameplay events into `(player, kind, target, amount)` progress — +consumed by **both** achievements (counters) and [quests](quests.md) (objectives). Built-in: + +| Event | kind | target | amount | +|---|---|---|---| +| `gather:hit` | `gather` | the item id | amount granted | +| `craft:end` | `craft` | the output item id | output count | +| `mob:died` (with a killer) | `kill` | the mob type | 1 | +| `item:use` | `use` | the item id | 1 | +| `quest:completed` | `quest` | the quest id | 1 | + +### Counter catalogue (the naming rule) + +Every progress tick bumps `"s_total"` and `"s_"`: + +`gathers_total` · `gathers_reed` · `crafts_total` · `crafts_reed_basket` · `kills_total` · +`kills_husk` · `uses_total` · `uses_berry` · `quests_total` · `quests_` — author any +achievement against any of these, no code. + +### Custom events & counters (code) + +```lua +-- Teach the stream a game event (then author achievements against boss counters): +SurvivorCore.Progression.map("myGame:bossDown", function(player, data) + return "kill", data.bossId, 1 +end) + +-- Or bump a bespoke counter / award directly: +SurvivorCore.Achievements.addCount(player, "shrines_visited", 1) +SurvivorCore.Achievements.award(player, "secret_cave") +``` + +## Runtime API + +`SurvivorCore.Achievements` (registry always; runtime ops after `start()`): `award(player, key)`, +`addCount(player, counterId, n?)`, `isUnlocked(player, key)`, `getState(player)`. Unlocks fire the +`achievement:unlocked` hook + EventBridge event and a toast (config-gated). + +## The Achievements tab & data + +The menu's **Achievements** tab (key **J**) lists every def with a progress bar toward its +threshold; unlocked rows go gold. Defs replicate via `SurvivorCoreAchievementData`; per-player state +is the `AchievementState` JSON attribute (`{ c = { [counter] = n }, u = { [key] = true } }`) — both +readable via [`src/shared/AchievementData.luau`](../src/shared/AchievementData.luau). + +## Tuning + +`Config.override("Achievements", { Toasts = true })`. + +--- + +See also: [Quests](quests.md) · [No-code content](content-authoring.md) · [Extending](extending.md). diff --git a/docs/admin-plugin.md b/docs/admin-plugin.md index 5e6c94b..3506b56 100644 --- a/docs/admin-plugin.md +++ b/docs/admin-plugin.md @@ -5,11 +5,13 @@ Attributes in the Explorer. It adds two toolbar buttons under **SurvivorCore**: - **Survival Stats** — tune the survival-stat rates/thresholds/HUD on the `SurvivalStatsConfig` instance (the deltas-only, locked model below). -- **Content** — create/edit/delete **items**, **weapons**, **gatherable resources** and **mobs** with - no code (the Builder slice). It writes `SurvivorCoreContent` instances the engine loads at `start()` - — see [content-authoring.md](content-authoring.md). Gatherables and mobs each get a **+ Add to - World** button that drops the tagged instance in front of the camera. Unlike the stats editor, - content is full owner-authored defs (not deltas). Every edit is one Studio undo step. +- **Content** — create/edit/delete **items**, **weapons**, **arrows**, **gatherable resources**, + **mobs**, **quests** and **achievements** with no code (the Builder slice). It writes + `SurvivorCoreContent` instances the engine loads at `start()` — see + [content-authoring.md](content-authoring.md). Gatherables/mobs get **+ Add to World**, weapons + **+ Tool model**, quests **+ Quest giver** — each drops the tagged instance in front of the + camera. Unlike the stats editor, content is full owner-authored defs (not deltas). Every edit is + one Studio undo step. The rest of this page covers the Survival Stats editor; both install the same way. It's the [Builder / Admin plugin](https://github.com/TemujinCalidius/SurvivorCore/issues/11). diff --git a/docs/content-authoring.md b/docs/content-authoring.md index ceddb05..f00fc60 100644 --- a/docs/content-authoring.md +++ b/docs/content-authoring.md @@ -47,6 +47,20 @@ ReplicatedStorage │ • faction = "hostile" ("hostile" | "passive" | "neutral") │ • health = 60 │ • aggroRange = 40 + ├─ Quests (Folder) ← flat single-objective quests (normalized at load) + │ └─ gather_reeds (Configuration) + │ • name = "Gather Reeds" + │ • objectiveType = "gather" ("gather" | "craft" | "kill" | "use") + │ • objectiveTarget = "reed" + │ • objectiveCount = 3 + │ • rewardItem = "berry" + │ • rewardCount = 2 + │ • autoStart = true + ├─ Achievements (Folder) ← flat counter + threshold defs + │ └─ husk_slayer (Configuration) + │ • name = "Husk Slayer" + │ • counter = "kills_husk" (see docs/achievements.md for the counter catalogue) + │ • threshold = 3 ├─ Tools (Folder) ← Tool templates the hotbar equips (named by item id) └─ MobModels (Folder) ← rigged mob templates Mobs.spawn clones (named by mob id) ``` @@ -57,7 +71,7 @@ same instance-config pattern the survival stats use. ## The admin plugin Content widget -Open Studio → the **SurvivorCore** toolbar → **Content**. Five builders: +Open Studio → the **SurvivorCore** toolbar → **Content**. Seven builders: - **Items** — create an item by id, then set Name / Max stack / Weight / Category / Tool type / Icon / Description. @@ -74,6 +88,13 @@ Open Studio → the **SurvivorCore** toolbar → **Content**. Five builders: Yield min / Yield max. **+ Add to World** drops a tagged `Gatherable` node. - **Mobs** — create a mob type by id, then set Faction / Health / Speeds / Aggro / Leash / Attack. **+ Add to World** drops a tagged `Mob` placeholder rig (swap in your own model later). +- **Quests** — create a quest by id, then set Name / Description / Objective (type, target, count) / + Reward (item, count) / Auto-start / Requires / Turn in. **+ Quest giver** drops a tagged + `QuestGiver` post offering it (see [quests.md](quests.md)). Multi-objective chains stay + code-authored, like recipes. +- **Achievements** — create an achievement by key, then set Name / Description / Counter / + Threshold / Icon — counters are the engine's auto-derived progression counters + (see [achievements.md](achievements.md)). Each edit is one Studio **undo** step. Behind the scenes it creates/edits the `SurvivorCoreContent` instances above, so pressing Play registers your content with no code. diff --git a/docs/extending.md b/docs/extending.md index 27f04a3..dd5a0e4 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -46,10 +46,12 @@ SurvivorCore.Recipes.register({ | Registry | Key field | What it holds | |---|---|---| -| `Items` | `id` | Item definitions (name, stack size, …). | +| `Items` | `id` | Item definitions (name, stack size, …) — incl. weapons + ammo. | | `Recipes` | `id` | Crafting **and** cooking recipes — one registry, routed by `station`. | +| `Resources` | `id` | Gatherable-resource defs (what a tagged node *is*). | | `Stats` | `name` | Survival/status stat models. | -| `Achievements` | `key` | Achievement definitions. | +| `Achievements` | `key` | Achievement defs (counter + threshold — [docs](achievements.md)). | +| `Quests` | `id` | Quest defs (objectives + rewards — [docs](quests.md)). | | `Codex` | `id` | Discoverable lore / collectible entries. | | `Appearance` | `id` | Character appearance options. | | `Mobs` | `id` | Creature / hostile-mob definitions. | @@ -142,15 +144,22 @@ end) -- later: disconnect() ``` -Engine systems fire hooks with `Hooks.run("name", ctx)`. Today the `Gatherable` component -fires: +Engine systems fire hooks with `Hooks.run("name", ctx)`. The full catalogue lives in the header of +[`src/foundation/Hooks.luau`](../src/foundation/Hooks.luau); highlights: -| Hook | Context | +| Hook family | Fired by | |---|---| -| `gather:hit` | `{ instance, player, values, hpLeft }` — each interaction. | -| `gather:depleted` | `{ instance, player, values }` — final hit, before the instance is destroyed. | +| `gather:hit` / `gather:depleted` / `gather:blocked` | harvesting ([docs](harvesting.md)) | +| `craft:start` / `craft:end` / `craft:blocked` | crafting ([docs](crafting.md)) | +| `item:use` · `inventory:changed` | inventory ([docs](inventory.md)) | +| `mob:spawned` / `mob:hit` / `mob:attack` / `mob:died` | mobs & AI ([docs](mobs.md)) | +| `combat:hit` / `combat:kill` | combat ([docs](combat.md)) | +| `quest:started` / `quest:progress` / `quest:completed` / `quest:blocked` | quests ([docs](quests.md)) | +| `achievement:unlocked` | achievements ([docs](achievements.md)) | -More hooks land as systems are extracted (`craft:start`/`craft:end`, mob lifecycle, …). +These gameplay events ALSO cross the **EventBridge** with the same names — that bus is what quests, +achievements, and analytics consume (via the `Progression` translation layer, +[docs](achievements.md#the-progression-stream)). ### Hooks vs. EventBridge diff --git a/docs/quests.md b/docs/quests.md new file mode 100644 index 0000000..e389853 --- /dev/null +++ b/docs/quests.md @@ -0,0 +1,78 @@ +# Quests + +The quest system ([`src/systems/Quests.luau`](../src/systems/Quests.luau)) gives players **goals**: +accept a quest, work its objectives, claim the reward. Progress is driven entirely by the events +the other systems already fire (gather / craft / kill / use), so a quest needs **zero wiring** — +register a def and the engine tracks it. + +> Progress is **session-scoped** (Player attributes) — persistence (DataStore) is a future system. + +## Defining a quest + +In code (canonical, supports multiple objectives) or [no-code via the admin +plugin](content-authoring.md) (single-objective): + +```lua +SurvivorCore.Quests.register({ + id = "gather_reeds", + name = "Gather Reeds", + description = "Pull 3 reeds from the bushes by the river.", + objectives = { + { type = "gather", target = "reed", count = 3 }, -- type: "gather"|"craft"|"kill"|"use" + }, + rewards = { { item = "berry", count = 2 } }, + autoStart = true, -- accepted automatically on join (or when `requires` completes) + -- requires = "id", -- single prerequisite (chains) + -- turnIn = true, -- must return to a QuestGiver to claim the reward +}) +``` + +An objective's `target` is an item id (gather/craft/use) or a mob type (kill); **blank = any** +("slay 3 of anything"). The flat no-code shape (`objectiveType`/`objectiveTarget`/`objectiveCount`/ +`rewardItem`/`rewardCount` attributes) is normalized to this at load — both behave identically. + +## Lifecycle + +**active** → objectives met → (**ready**, if `turnIn` or the inventory was full) → **done**. + +- Progress comes from the [Progression](achievements.md#the-progression-stream) event stream. +- **Rewards are never lost:** if the inventory can't fit the reward, the quest parks in *ready* and + the grant retries automatically whenever the inventory changes. +- Completing a quest fires the `quest:completed` hook + EventBridge event, shows a toast + (config-gated), and **auto-starts** any `autoStart` quest that `requires` it — chains flow. + +## Quest givers (no-code) + +Tag any part/model **`QuestGiver`** (CollectionService) and set `Quest = ""` — the engine +attaches a hold-**E** prompt that *accepts* the quest (or *turns it in* when its objectives are met +and it's a `turnIn` quest). The admin plugin's Quests editor drops one via **+ Quest giver**. + +## Runtime API + +`SurvivorCore.Quests` (registry always; runtime ops after `start()`): + +| Function | Behavior | +|---|---| +| `accept(player, id) -> (ok, reason?)` | gates: unknown / done / active / `requires` / `MaxActive` | +| `complete(player, id) -> (ok, reason?)` | turn-in (or claim a met quest); `"full"` = no reward room | +| `abandon(player, id) -> ok` | drop an active quest (progress lost) | +| `getLog(player)` / `isActive` / `isCompleted` | read state | + +Hooks (also on EventBridge): `quest:started` / `quest:progress` / `quest:completed` / +`quest:blocked` — ctx `{ player, questId, def?, index?, count?, reason? }`. + +## The Quests tab & data + +The menu's **Quests** tab (key **L**) renders Active (per-objective progress bars) / Ready / +Completed. Defs replicate via a JSON `SurvivorCoreQuestData` StringValue; per-player progress is the +`QuestLog` JSON Player attribute (`{ active = { [id] = { p = {…} } }, ready = {}, done = {} }`) — +both readable via [`src/shared/QuestData.luau`](../src/shared/QuestData.luau). + +## Tuning + +`Config.override("Quests", { MaxActive = 0 --[[0 = unlimited]], Toasts = true })`. + +--- + +See also: [Achievements](achievements.md) · [No-code content](content-authoring.md) · +[Extending](extending.md). diff --git a/plugin/ContentAdmin.luau b/plugin/ContentAdmin.luau index 1ef04e3..2452ac4 100644 --- a/plugin/ContentAdmin.luau +++ b/plugin/ContentAdmin.luau @@ -149,6 +149,62 @@ ContentAdmin.CATEGORIES = { { 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", @@ -174,7 +230,7 @@ ContentAdmin.CATEGORIES = { } :: { [string]: Category } -- Render order for the UI. -ContentAdmin.ORDER = { "Items", "Weapons", "Arrows", "Resources", "Mobs" } +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 diff --git a/plugin/ContentAdminUi.luau b/plugin/ContentAdminUi.luau index ef7a1f4..20438c5 100644 --- a/plugin/ContentAdminUi.luau +++ b/plugin/ContentAdminUi.luau @@ -110,17 +110,20 @@ local function buildEntry(catKey: string, id: string, ContentAdmin: any, applySe applyDelete(catKey, id) end) - -- Gatherables + Mobs drop a tagged, def-linked instance; Weapons drop a starter Tool (Handle + - -- the weapon's ToolType/WeaponKind) you can model the look on — all in front of the camera, the - -- bridge between the editor and the world. + -- Gatherables + Mobs + Quests drop a tagged, def-linked instance; Weapons drop a starter Tool + -- (Handle + the weapon's ToolType/WeaponKind) you can model the look on — all in front of the + -- camera, the bridge between the editor and the world. local labelRight = 64 - if (catKey == "Resources" or catKey == "Mobs" or catKey == "Weapons") and applySpawn then + if (catKey == "Resources" or catKey == "Mobs" or catKey == "Weapons" or catKey == "Quests") and applySpawn then local add = make("TextButton", { Size = UDim2.fromOffset(104, 20), Position = UDim2.new(1, -168, 0.5, -10), BackgroundColor3 = COL_FIELD, AutoButtonColor = true, - Text = if catKey == "Weapons" then "+ Tool model" else "+ Add to World", + Text = if catKey == "Weapons" + then "+ Tool model" + elseif catKey == "Quests" then "+ Quest giver" + else "+ Add to World", TextColor3 = COL_ACCENT, TextSize = 11, Font = FONT, diff --git a/plugin/init.server.luau b/plugin/init.server.luau index 860b0b4..387ecae 100644 --- a/plugin/init.server.luau +++ b/plugin/init.server.luau @@ -190,6 +190,22 @@ local function applySpawn(catKey: string, id: string): any 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 diff --git a/rokit.toml b/rokit.toml index 1a33a9f..dd39c82 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,7 +1,7 @@ # Toolchain pins (https://github.com/rojo-rbx/rokit). Run `rokit install`. # CI installs this exact set; pin versions so local dev and CI stay identical. [tools] -rojo = "rojo-rbx/rojo@7.6.1" +rojo = "rojo-rbx/rojo@7.7.0" wally = "UpliftGames/wally@0.3.2" stylua = "JohnnyMorganz/stylua@2.5.2" selene = "Kampfkarren/selene@0.31.0" diff --git a/site/index.html b/site/index.html index b778ed2..94fded1 100644 --- a/site/index.html +++ b/site/index.html @@ -134,11 +134,18 @@

A shared FSM substrate: hostile mobs chase & attack, passive animals flee. Behavior is data — faction picks the profile.

+ +

Quests & achievements

+

Event-driven goals: quest chains with objectives, rewards and quest-giver NPCs, plus milestone achievements with toasts — tracked automatically from what players already do.

+
+

No-code admin plugin

-

Create items, weapons, ammo and mobs from a Studio form — damage, range, arrow curve, weight, aggro, leash — and drop them into the world.

+

Create items, weapons, ammo, mobs, quests and achievements from a Studio form — damage, range, arrow curve, aggro, objectives, rewards — and drop them straight into the world. No scripting.

diff --git a/site/styles.css b/site/styles.css index 449d531..7fab96f 100644 --- a/site/styles.css +++ b/site/styles.css @@ -210,6 +210,8 @@ h1 { .ico svg { width: 24px; height: 24px; } .ico-green { color: var(--green); background: rgba(90, 205, 120, 0.12); } .ico-ember { color: var(--ember); background: rgba(240, 136, 60, 0.13); } +/* The capstone card (the no-code admin plugin) spans the full grid row. */ +.card-wide { grid-column: 1 / -1; } /* ---- Showcase ---- */ .shot { @@ -310,7 +312,8 @@ pre code { color: inherit; } .start-grid { grid-template-columns: 1fr; } } @media (max-width: 680px) { - .nav-links { gap: 0.8rem; } + .nav { flex-wrap: wrap; justify-content: center; } + .nav-links { gap: 0.8rem; flex-wrap: wrap; justify-content: center; } .nav-links > a:not(.btn) { display: none; } .props { grid-template-columns: 1fr; } .grid { grid-template-columns: 1fr; } diff --git a/src/client/AchievementsUi.luau b/src/client/AchievementsUi.luau new file mode 100644 index 0000000..1d88b99 --- /dev/null +++ b/src/client/AchievementsUi.luau @@ -0,0 +1,189 @@ +--!nonstrict +--[[ + AchievementsUi — the Achievements tab. CLIENT-ONLY. + + Fills the menu's scaffolded "achievements" tab with one row per registered achievement, + rendered from the replicated defs (AchievementData) + the AchievementState Player attribute: + icon (if any) + name + description + a progress bar toward the counter threshold; unlocked + rows get a gold check. Re-renders whenever AchievementState changes. Themed from the "UI" + Config section. Booted by SurvivorCore.startClient() (after PanelManager). +]] + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +assert(RunService:IsClient(), "SurvivorCore.AchievementsUi is client-only") + +local AchievementData = require(script.Parent.Parent.shared.AchievementData) +local UiConfig = require(script.Parent.Parent.shared.UiConfig) +local PanelManager = require(script.Parent.PanelManager) + +local AchievementsUi = {} + +local started = false +local localPlayer = Players.LocalPlayer + +local GOLD = Color3.fromRGB(240, 190, 80) + +local function corner(parent: Instance, radius: number) + local c = Instance.new("UICorner") + c.CornerRadius = UDim.new(0, radius) + c.Parent = parent +end + +local function buildAchievements(content: Instance) + local theme = UiConfig.get().Theme + + local scroll = Instance.new("ScrollingFrame") + scroll.Name = "AchievementList" + scroll.Size = UDim2.fromScale(1, 1) + scroll.BackgroundTransparency = 1 + scroll.BorderSizePixel = 0 + scroll.ScrollBarThickness = 6 + scroll.CanvasSize = UDim2.new() + scroll.AutomaticCanvasSize = Enum.AutomaticSize.Y + scroll.Active = true -- sink clicks so the menu's click-outside overlay doesn't close the menu + scroll.Parent = content + + local layout = Instance.new("UIListLayout") + layout.Padding = UDim.new(0, 6) + layout.SortOrder = Enum.SortOrder.LayoutOrder + layout.Parent = scroll + + local function addRow(def: any, unlocked: boolean, count: number, orderNum: number) + local row = Instance.new("Frame") + row.Name = "Achievement_" .. def.key + row.Size = UDim2.new(1, -8, 0, 56) + row.BackgroundColor3 = theme.SlotColor or theme.PanelColor or Color3.fromRGB(30, 34, 44) + row.BackgroundTransparency = 0.15 + row.BorderSizePixel = 0 + row.LayoutOrder = orderNum + row.Parent = scroll + corner(row, theme.CornerRadius or 8) + + local hasIcon = typeof(def.icon) == "string" and def.icon ~= "" + if hasIcon then + local icon = Instance.new("ImageLabel") + icon.BackgroundTransparency = 1 + icon.Size = UDim2.fromOffset(36, 36) + icon.Position = UDim2.fromOffset(10, 10) + icon.Image = def.icon + icon.ImageTransparency = if unlocked then 0 else 0.35 + icon.Parent = row + end + local textX = if hasIcon then 56 else 12 + + local name = Instance.new("TextLabel") + name.BackgroundTransparency = 1 + name.Size = UDim2.new(1, -textX - 130, 0, 20) + name.Position = UDim2.fromOffset(textX, 8) + name.TextXAlignment = Enum.TextXAlignment.Left + name.Font = theme.FontBold or Enum.Font.GothamBold + name.TextSize = 14 + name.TextColor3 = if unlocked then GOLD else (theme.Text or Color3.fromRGB(245, 245, 245)) + name.Text = if unlocked then "✓ " .. def.name else def.name + name.Parent = row + + if def.description then + local desc = Instance.new("TextLabel") + desc.BackgroundTransparency = 1 + desc.Size = UDim2.new(1, -textX - 130, 0, 16) + desc.Position = UDim2.fromOffset(textX, 30) + desc.TextXAlignment = Enum.TextXAlignment.Left + desc.TextTruncate = Enum.TextTruncate.AtEnd + desc.Font = theme.Font or Enum.Font.Gotham + desc.TextSize = 12 + desc.TextColor3 = theme.TextSecondary or Color3.fromRGB(200, 205, 215) + desc.Text = def.description + desc.Parent = row + end + + -- Progress toward the threshold (a full gold bar once unlocked). + local shown = math.min(count, def.threshold) + local label = Instance.new("TextLabel") + label.BackgroundTransparency = 1 + label.Size = UDim2.fromOffset(110, 16) + label.Position = UDim2.new(1, -122, 0, 10) + label.TextXAlignment = Enum.TextXAlignment.Right + label.Font = theme.Font or Enum.Font.Gotham + label.TextSize = 12 + label.TextColor3 = if unlocked then GOLD else (theme.TextSecondary or Color3.fromRGB(200, 205, 215)) + label.Text = `{shown}/{def.threshold}` + label.Parent = row + + local track = Instance.new("Frame") + track.Size = UDim2.fromOffset(110, 6) + track.Position = UDim2.new(1, -122, 0, 32) + track.BackgroundColor3 = Color3.fromRGB(28, 32, 42) + track.BorderSizePixel = 0 + track.Parent = row + corner(track, 3) + local fill = Instance.new("Frame") + fill.Size = UDim2.fromScale(if unlocked then 1 else math.clamp(count / def.threshold, 0, 1), 1) + fill.BackgroundColor3 = if unlocked then GOLD else (theme.Accent or Color3.fromRGB(120, 170, 255)) + fill.BorderSizePixel = 0 + fill.Parent = track + corner(fill, 3) + end + + local function render() + for _, child in scroll:GetChildren() do + if child:IsA("GuiObject") then + child:Destroy() + end + end + + local defs = AchievementData.getAll() + local state = AchievementData.decodeState(localPlayer) + + if #defs == 0 then + local empty = Instance.new("TextLabel") + empty.BackgroundTransparency = 1 + empty.Size = UDim2.new(1, 0, 0, 40) + empty.Font = theme.Font or Enum.Font.Gotham + empty.TextSize = 14 + empty.TextColor3 = theme.TextSecondary or Color3.fromRGB(200, 205, 215) + empty.Text = "No achievements registered." + empty.Parent = scroll + return + end + + -- Unlocked first (most satisfying up top), then by progress toward threshold. + local sorted = table.clone(defs) + table.sort(sorted, function(a, b) + local ua, ub = state.u[a.key] == true, state.u[b.key] == true + if ua ~= ub then + return ua + end + local pa = (tonumber(state.c[a.counter]) or 0) / a.threshold + local pb = (tonumber(state.c[b.counter]) or 0) / b.threshold + if pa ~= pb then + return pa > pb + end + return a.name < b.name + end) + + for i, def in sorted do + addRow(def, state.u[def.key] == true, tonumber(state.c[def.counter]) or 0, i) + end + end + + render() + localPlayer:GetAttributeChangedSignal(AchievementData.STATE_ATTR):Connect(render) +end + +function AchievementsUi.start(_options: { [string]: any }?) + if started then + return + end + started = true + + PanelManager.registerPanel({ + id = "achievements", + title = "Achievements", + order = 4, + build = buildAchievements, + }) +end + +return AchievementsUi diff --git a/src/client/PanelManager.luau b/src/client/PanelManager.luau index a7befb0..1f4b8c7 100644 --- a/src/client/PanelManager.luau +++ b/src/client/PanelManager.luau @@ -250,10 +250,37 @@ local function bindContent(id: string, frame: GuiObject) frame.Visible = false end +-- Panels whose build function has already run (an id builds at most once per menu bind). +local built: { [string]: boolean } = {} + +-- A code-registered panel for a tab the TEMPLATE already scaffolds ("quests"/"achievements" +-- placeholders): adopt the authored content frame — hide its placeholder children and run the +-- panel's build into it — instead of silently doing nothing (registered[id] is already true). +local function adoptAuthoredPanel(spec: any): boolean + local content = contents[spec.id] + if not content or built[spec.id] then + return content ~= nil -- already built (or nothing to adopt) + end + built[spec.id] = true + for _, child in content:GetChildren() do + if child:IsA("GuiObject") then + child.Visible = false -- the authored "coming soon" placeholder steps aside + end + end + if typeof(spec.build) == "function" then + task.spawn(spec.build, content) + end + return true +end + -- Create a tab from code: clone an authored tab button for styling (or build a plain one), -- add an empty content frame, and let the caller fill it once. local function realizePanel(spec: any) - if not menuGui or registered[spec.id] then + if not menuGui then + return + end + if registered[spec.id] then + adoptAuthoredPanel(spec) return end local host = menuGui:FindFirstChild("TabContentHost", true) @@ -272,6 +299,7 @@ local function realizePanel(spec: any) content:SetAttribute("TabContent", spec.id) content.Parent = host bindContent(spec.id, content) + built[spec.id] = true if typeof(spec.build) == "function" then task.spawn(spec.build, content) end diff --git a/src/client/QuestsUi.luau b/src/client/QuestsUi.luau new file mode 100644 index 0000000..602e8b2 --- /dev/null +++ b/src/client/QuestsUi.luau @@ -0,0 +1,291 @@ +--!nonstrict +--[[ + QuestsUi — the Quests tab. CLIENT-ONLY. + + Fills the menu's scaffolded "quests" tab (PanelManager adopts the authored placeholder) with + the player's quest log, rendered from the replicated quest defs (QuestData) + the QuestLog + Player attribute the server runtime writes: + • Active — name, description, one line + progress bar per objective ("2/3 Reed"), + • Ready — objectives met, awaiting turn-in (or inventory room) — accented, + • Completed — dimmed history. + Re-renders whenever QuestLog changes. Themed from the "UI" Config section. + Booted by SurvivorCore.startClient() (after PanelManager). +]] + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +assert(RunService:IsClient(), "SurvivorCore.QuestsUi is client-only") + +local ItemData = require(script.Parent.Parent.shared.ItemData) +local QuestData = require(script.Parent.Parent.shared.QuestData) +local UiConfig = require(script.Parent.Parent.shared.UiConfig) +local PanelManager = require(script.Parent.PanelManager) + +local QuestsUi = {} + +local started = false +local localPlayer = Players.LocalPlayer + +local function itemName(itemId: string): string + local def = ItemData.get(itemId) + return (def and def.name) or itemId +end + +local function corner(parent: Instance, radius: number) + local c = Instance.new("UICorner") + c.CornerRadius = UDim.new(0, radius) + c.Parent = parent +end + +local function objectiveText(obj: any, have: number): string + local target = if obj.target ~= "" then itemName(obj.target) else "any" + local verb = ({ gather = "Gather", craft = "Craft", kill = "Slay", use = "Use" })[obj.type] or obj.type + return `{verb} {target} — {math.min(have, obj.count)}/{obj.count}` +end + +local function rewardsText(quest: any): string + if #quest.rewards == 0 then + return "" + end + local parts = {} + for _, r in quest.rewards do + table.insert(parts, `{r.count}× {itemName(r.item)}`) + end + return "Reward: " .. table.concat(parts, ", ") +end + +local function buildQuests(content: Instance) + local theme = UiConfig.get().Theme + + local scroll = Instance.new("ScrollingFrame") + scroll.Name = "QuestList" + scroll.Size = UDim2.fromScale(1, 1) + scroll.BackgroundTransparency = 1 + scroll.BorderSizePixel = 0 + scroll.ScrollBarThickness = 6 + scroll.CanvasSize = UDim2.new() + scroll.AutomaticCanvasSize = Enum.AutomaticSize.Y + scroll.Active = true -- sink clicks so the menu's click-outside overlay doesn't close the menu + scroll.Parent = content + + local layout = Instance.new("UIListLayout") + layout.Padding = UDim.new(0, 6) + layout.SortOrder = Enum.SortOrder.LayoutOrder + layout.Parent = scroll + + local order = 0 + local function nextOrder(): number + order += 1 + return order + end + + local function addHeader(text: string) + local h = Instance.new("TextLabel") + h.BackgroundTransparency = 1 + h.Size = UDim2.new(1, -8, 0, 24) + h.Font = theme.FontBold or Enum.Font.GothamBold + h.TextSize = 13 + h.TextXAlignment = Enum.TextXAlignment.Left + h.TextColor3 = theme.TextSecondary or Color3.fromRGB(200, 205, 215) + h.Text = text + h.LayoutOrder = nextOrder() + h.Parent = scroll + end + + -- One quest card. `state` = "active" | "ready" | "done"; progress only for active. + local function addCard(quest: any, state: string, progress: { number }?) + local objLines = if state == "active" then #quest.objectives else 0 + local rewardLine = if rewardsText(quest) ~= "" then 1 else 0 + local descLine = if quest.description then 1 else 0 + local height = 34 + descLine * 18 + objLines * 22 + rewardLine * 18 + + local card = Instance.new("Frame") + card.Name = "Quest_" .. quest.id + card.Size = UDim2.new(1, -8, 0, height) + card.BackgroundColor3 = theme.SlotColor or theme.PanelColor or Color3.fromRGB(30, 34, 44) + card.BackgroundTransparency = 0.15 + card.BorderSizePixel = 0 + card.LayoutOrder = nextOrder() + card.Parent = scroll + corner(card, theme.CornerRadius or 8) + + local dim = if state == "done" then 0.45 else 0 + + local name = Instance.new("TextLabel") + name.BackgroundTransparency = 1 + name.Size = UDim2.new(1, -110, 0, 22) + name.Position = UDim2.fromOffset(12, 7) + name.TextXAlignment = Enum.TextXAlignment.Left + name.Font = theme.FontBold or Enum.Font.GothamBold + name.TextSize = 15 + name.TextColor3 = theme.Text or Color3.fromRGB(245, 245, 245) + name.TextTransparency = dim + name.Text = quest.name + name.Parent = card + + local badgeText, badgeColor + if state == "ready" then + badgeText, badgeColor = + if quest.turnIn then "TURN IN" else "READY", theme.Accent or Color3.fromRGB(120, 170, 255) + elseif state == "done" then + badgeText, badgeColor = "DONE", Color3.fromRGB(110, 190, 130) + end + if badgeText then + local badge = Instance.new("TextLabel") + badge.BackgroundTransparency = 1 + badge.Size = UDim2.fromOffset(90, 22) + badge.Position = UDim2.new(1, -98, 0, 7) + badge.TextXAlignment = Enum.TextXAlignment.Right + badge.Font = theme.FontBold or Enum.Font.GothamBold + badge.TextSize = 12 + badge.TextColor3 = badgeColor + badge.Text = badgeText + badge.Parent = card + end + + local y = 29 + if quest.description then + local desc = Instance.new("TextLabel") + desc.BackgroundTransparency = 1 + desc.Size = UDim2.new(1, -24, 0, 16) + desc.Position = UDim2.fromOffset(12, y) + desc.TextXAlignment = Enum.TextXAlignment.Left + desc.TextTruncate = Enum.TextTruncate.AtEnd + desc.Font = theme.Font or Enum.Font.Gotham + desc.TextSize = 12 + desc.TextColor3 = theme.TextSecondary or Color3.fromRGB(200, 205, 215) + desc.TextTransparency = dim + desc.Text = quest.description + desc.Parent = card + y += 18 + end + + if state == "active" and progress then + for i, obj in quest.objectives do + local have = tonumber(progress[i]) or 0 + local line = Instance.new("TextLabel") + line.BackgroundTransparency = 1 + line.Size = UDim2.new(1, -140, 0, 18) + line.Position = UDim2.fromOffset(12, y) + line.TextXAlignment = Enum.TextXAlignment.Left + line.Font = theme.Font or Enum.Font.Gotham + line.TextSize = 13 + line.TextColor3 = if have >= obj.count + then Color3.fromRGB(110, 190, 130) + else (theme.Text or Color3.fromRGB(245, 245, 245)) + line.Text = objectiveText(obj, have) + line.Parent = card + + -- Thin progress bar to the right of the line. + local track = Instance.new("Frame") + track.Size = UDim2.fromOffset(110, 6) + track.Position = UDim2.new(1, -122, 0, y + 6) + track.BackgroundColor3 = Color3.fromRGB(28, 32, 42) + track.BorderSizePixel = 0 + track.Parent = card + corner(track, 3) + local fill = Instance.new("Frame") + fill.Size = UDim2.fromScale(math.clamp(have / obj.count, 0, 1), 1) + fill.BackgroundColor3 = if have >= obj.count + then Color3.fromRGB(110, 190, 130) + else (theme.Accent or Color3.fromRGB(120, 170, 255)) + fill.BorderSizePixel = 0 + fill.Parent = track + corner(fill, 3) + + y += 22 + end + end + + local rt = rewardsText(quest) + if rt ~= "" then + local reward = Instance.new("TextLabel") + reward.BackgroundTransparency = 1 + reward.Size = UDim2.new(1, -24, 0, 16) + reward.Position = UDim2.fromOffset(12, y) + reward.TextXAlignment = Enum.TextXAlignment.Left + reward.Font = theme.Font or Enum.Font.Gotham + reward.TextSize = 12 + reward.TextColor3 = Color3.fromRGB(240, 190, 80) + reward.TextTransparency = dim + reward.Text = rt + reward.Parent = card + end + end + + local function render() + order = 0 + for _, child in scroll:GetChildren() do + if child:IsA("GuiObject") then + child:Destroy() + end + end + + local quests = QuestData.getAll() + local log = QuestData.decodeLog(localPlayer) + + local activeList, readyList, doneList = {}, {}, {} + for _, q in quests do + if log.active[q.id] then + table.insert(activeList, q) + elseif log.ready[q.id] then + table.insert(readyList, q) + elseif log.done[q.id] then + table.insert(doneList, q) + end + end + + if #activeList + #readyList + #doneList == 0 then + local empty = Instance.new("TextLabel") + empty.BackgroundTransparency = 1 + empty.Size = UDim2.new(1, 0, 0, 40) + empty.Font = theme.Font or Enum.Font.Gotham + empty.TextSize = 14 + empty.TextColor3 = theme.TextSecondary or Color3.fromRGB(200, 205, 215) + empty.Text = "No quests yet — explore the world." + empty.LayoutOrder = 1 + empty.Parent = scroll + return + end + + if #readyList > 0 then + addHeader("READY") + for _, q in readyList do + addCard(q, "ready") + end + end + if #activeList > 0 then + addHeader("ACTIVE") + for _, q in activeList do + local entry = log.active[q.id] + addCard(q, "active", entry and entry.p) + end + end + if #doneList > 0 then + addHeader("COMPLETED") + for _, q in doneList do + addCard(q, "done") + end + end + end + + render() + localPlayer:GetAttributeChangedSignal(QuestData.LOG_ATTR):Connect(render) +end + +function QuestsUi.start(_options: { [string]: any }?) + if started then + return + end + started = true + + PanelManager.registerPanel({ + id = "quests", + title = "Quests", + order = 5, + build = buildQuests, + }) +end + +return QuestsUi diff --git a/src/client/Toasts.luau b/src/client/Toasts.luau new file mode 100644 index 0000000..5b75ec0 --- /dev/null +++ b/src/client/Toasts.luau @@ -0,0 +1,165 @@ +--!nonstrict +--[[ + Toasts — client. Small top-right notifications ("Quest complete", "Achievement unlocked"). + + The server fires the `Notify` RemoteEvent with { title, body?, icon?, kind? }; this queues and + slides them in (at most QUEUE_MAX on screen; extras wait). Games can also call + `Toasts.show(payload)` locally. Styled from the UI Config Theme like every other engine + surface — dark translucent panel, accent title. Booted by SurvivorCore.startClient(). +]] + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local TweenService = game:GetService("TweenService") + +assert(RunService:IsClient(), "SurvivorCore.Toasts is client-only — boot it via SurvivorCore.startClient()") + +local Remotes = require(script.Parent.Parent.shared.Remotes) +local UiConfig = require(script.Parent.Parent.shared.UiConfig) + +local Toasts = {} + +local started = false +local player = Players.LocalPlayer + +local QUEUE_MAX = 3 -- visible at once +local HOLD_SECONDS = 4 +local TOAST_W, TOAST_H = 260, 56 + +local list: Frame? = nil +local pending: { any } = {} +local showing = 0 + +local function ensureGui(): Frame? + if list and list.Parent then + return list + end + local playerGui = player:FindFirstChildOfClass("PlayerGui") + if not playerGui then + return nil + end + local g = Instance.new("ScreenGui") + g.Name = "SurvivorCoreToasts" + g.ResetOnSpawn = false + g.DisplayOrder = 80 + + local holder = Instance.new("Frame") + holder.Name = "List" + holder.AnchorPoint = Vector2.new(1, 0) + holder.Position = UDim2.new(1, -12, 0, 12) + holder.Size = UDim2.fromOffset(TOAST_W, (TOAST_H + 8) * QUEUE_MAX) + holder.BackgroundTransparency = 1 + local layout = Instance.new("UIListLayout") + layout.Padding = UDim.new(0, 8) + layout.SortOrder = Enum.SortOrder.LayoutOrder + layout.HorizontalAlignment = Enum.HorizontalAlignment.Right + layout.Parent = holder + holder.Parent = g + + g.Parent = playerGui + list = holder + return holder +end + +local function buildToast(payload: any): Frame + local theme = UiConfig.get().Theme or {} + local accent = if payload.kind == "achievement" + then Color3.fromRGB(240, 190, 80) -- gold for achievements + else (theme.Accent or Color3.fromRGB(120, 170, 255)) + + local frame = Instance.new("Frame") + frame.Size = UDim2.fromOffset(TOAST_W, TOAST_H) + frame.BackgroundColor3 = theme.PanelColor or Color3.fromRGB(20, 23, 30) + frame.BackgroundTransparency = 0.15 + frame.BorderSizePixel = 0 + local corner = Instance.new("UICorner") + corner.CornerRadius = UDim.new(0, tonumber(theme.CornerRadius) or 10) + corner.Parent = frame + + local bar = Instance.new("Frame") + bar.Size = UDim2.new(0, 4, 1, -12) + bar.Position = UDim2.fromOffset(6, 6) + bar.BackgroundColor3 = accent + bar.BorderSizePixel = 0 + local bc = Instance.new("UICorner") + bc.CornerRadius = UDim.new(1, 0) + bc.Parent = bar + bar.Parent = frame + + local title = Instance.new("TextLabel") + title.Size = UDim2.new(1, -24, 0, 22) + title.Position = UDim2.fromOffset(18, 7) + title.BackgroundTransparency = 1 + title.Text = tostring(payload.title or "") + title.TextColor3 = accent + title.TextXAlignment = Enum.TextXAlignment.Left + title.Font = theme.FontBold or Enum.Font.GothamBold + title.TextSize = 14 + title.Parent = frame + + local body = Instance.new("TextLabel") + body.Size = UDim2.new(1, -24, 0, 20) + body.Position = UDim2.fromOffset(18, 28) + body.BackgroundTransparency = 1 + body.Text = tostring(payload.body or "") + body.TextColor3 = theme.Text or Color3.fromRGB(235, 238, 245) + body.TextTruncate = Enum.TextTruncate.AtEnd + body.TextXAlignment = Enum.TextXAlignment.Left + body.Font = theme.Font or Enum.Font.GothamMedium + body.TextSize = 13 + body.Parent = frame + + return frame +end + +local function pump() + if showing >= QUEUE_MAX or #pending == 0 then + return + end + local holder = ensureGui() + if not holder then + return + end + local payload = table.remove(pending, 1) + showing += 1 + + local frame = buildToast(payload) + frame.Position = UDim2.fromOffset(TOAST_W + 20, 0) -- slid off-right; UIListLayout owns Y + frame.Parent = holder + TweenService:Create(frame, TweenInfo.new(0.25, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), { + Position = UDim2.fromOffset(0, 0), + }):Play() + + task.delay(HOLD_SECONDS, function() + local out = TweenService:Create(frame, TweenInfo.new(0.35, Enum.EasingStyle.Quad, Enum.EasingDirection.In), { + Position = UDim2.fromOffset(TOAST_W + 20, 0), + }) + out.Completed:Once(function() + frame:Destroy() + showing -= 1 + pump() + end) + out:Play() + end) +end + +-- Show a toast locally: { title, body?, icon?, kind? ("quest" | "achievement" | nil) }. +function Toasts.show(payload: any) + if typeof(payload) ~= "table" or payload.title == nil then + return + end + table.insert(pending, payload) + pump() +end + +function Toasts.start(_options: { [string]: any }?) + if started then + return + end + started = true + Remotes.event("Notify").OnClientEvent:Connect(function(payload) + Toasts.show(payload) + end) +end + +return Toasts diff --git a/src/components/QuestGiver.luau b/src/components/QuestGiver.luau new file mode 100644 index 0000000..1f5bd36 --- /dev/null +++ b/src/components/QuestGiver.luau @@ -0,0 +1,59 @@ +--[[ + QuestGiver — the no-code quest board/NPC component. + + A creator builds ANY part/model (a signpost, a campfire NPC, a notice board), tags it + "QuestGiver" (CollectionService), and sets `Quest = ""`. The engine attaches a + ProximityPrompt that routes through the server Quests runtime: + • quest not started → hold E to ACCEPT it, + • objectives met on a turn-in quest → hold E to TURN IT IN and claim the reward, + • otherwise the prompt is a no-op (the runtime fires `quest:blocked` with a reason). + + The admin plugin's Quests editor drops one of these via "+ Quest giver". Placement is the + creator's content; the engine ships no NPCs. +]] + +local Components = require(script.Parent) +local Registries = require(script.Parent.Parent.registries) +local Quests = require(script.Parent.Parent.systems.Quests) +local QuestData = require(script.Parent.Parent.shared.QuestData) + +return Components.define({ + name = "QuestGiver", + tag = "QuestGiver", + attributes = { + Quest = "", -- the quest id this giver offers / accepts turn-ins for + }, + onSetup = function(instance, values) + local questId = tostring(values.Quest or "") + if questId == "" then + warn(`[QuestGiver] '{instance:GetFullName()}' has no Quest attribute`) + return + end + + local host = if instance:IsA("BasePart") then instance else instance:FindFirstChildWhichIsA("BasePart") + if not host then + warn(`[QuestGiver] '{instance:GetFullName()}' has no BasePart to host a prompt`) + return + end + + -- Display name from the registered def (normalized), falling back to the id. + local raw = Registries.Quests.get(questId) + local quest = raw and QuestData.normalize(raw) + local questName = quest and quest.name or questId + + local prompt = Instance.new("ProximityPrompt") + prompt.ActionText = "Quest" + prompt.ObjectText = questName + prompt.HoldDuration = 0.4 + prompt.RequiresLineOfSight = false + prompt.Parent = host + + prompt.Triggered:Connect(function(player) + -- Turn-in first (objectives met), else accept; the runtime re-validates everything. + local ok = Quests.complete(player, questId) + if not ok then + Quests.accept(player, questId) + end + end) + end, +}) diff --git a/src/foundation/Hooks.luau b/src/foundation/Hooks.luau index 0c815bf..ba608f0 100644 --- a/src/foundation/Hooks.luau +++ b/src/foundation/Hooks.luau @@ -18,9 +18,16 @@ mob:attack { instance, mobType, player, damage, position } combat:hit { attacker, victim, weapon, source, damage, victimHpLeft } combat:kill { attacker, victim, weapon, source } -- source "melee"|"bow" + quest:started / quest:progress / quest:completed / quest:blocked + { player, questId, def?, index?, count?, reason? } + achievement:unlocked { player, key, def } Per-resource / per-mob-type variants of these dispatch through Reactions (see Reactions.luau): SurvivorCore.Gather.onReaction(resourceId, …) and SurvivorCore.Mobs.onReaction(mobType, …). + + Gameplay lifecycle events (gather:*, craft:*, item:use, mob:*, combat:*, quest:*, + achievement:unlocked) ALSO cross the EventBridge bus with the same names — that is what + quests/achievements/analytics subscribe to (see EventBridge.luau and systems/Progression.luau). ]] local Hooks = {} diff --git a/src/init.luau b/src/init.luau index 853fdf9..24bbf9a 100644 --- a/src/init.luau +++ b/src/init.luau @@ -54,9 +54,14 @@ require(script.shared.CraftingConfig) require(script.shared.MobsConfig) require(script.shared.CombatConfig) +-- Define the "Quests" (max active/toasts) and "Achievements" (toasts) Config sections, so +-- Config.override(...) works any time before start()/startClient(). +require(script.shared.QuestsConfig) +require(script.shared.AchievementsConfig) + local SurvivorCore = {} -SurvivorCore.VERSION = "0.5.0" +SurvivorCore.VERSION = "0.6.0" -- Foundation SurvivorCore.Config = Config @@ -74,6 +79,7 @@ SurvivorCore.Recipes = Registries.Recipes SurvivorCore.Resources = Registries.Resources SurvivorCore.Stats = Registries.Stats SurvivorCore.Achievements = Registries.Achievements +SurvivorCore.Quests = Registries.Quests SurvivorCore.Codex = Registries.Codex SurvivorCore.Appearance = Registries.Appearance SurvivorCore.Mobs = Registries.Mobs @@ -108,11 +114,16 @@ function SurvivorCore.start(_options: { [string]: any }?) Registries.Items.loadFromFolder(content:FindFirstChild("Arrows")) Registries.Resources.loadFromFolder(content:FindFirstChild("Resources")) Registries.Mobs.loadFromFolder(content:FindFirstChild("Mobs")) + -- Quests are flat single-objective defs no-code (QuestData.normalize reads them); + -- achievement defs are flat by design, so they load verbatim. + Registries.Quests.loadFromFolder(content:FindFirstChild("Quests")) + Registries.Achievements.loadFromFolder(content:FindFirstChild("Achievements")) end -- Load built-in components so their tags are recognised. require(script.components.Gatherable) require(script.components.Mob) + require(script.components.QuestGiver) -- TODO (extraction): boot order — Config merge → Assets → persistence → systems. Components.scan() @@ -181,6 +192,34 @@ function SurvivorCore.start(_options: { [string]: any }?) crafting.start(_options) SurvivorCore.Crafting = crafting + -- Progression: the ONE event → (kind, target, amount) translation layer quests and + -- achievements consume. Exposed so games can map their own events: + -- SurvivorCore.Progression.map("myEvent", function(player, data) return "kill", "boss", 1 end) + local progression = require(script.systems.Progression) + progression.start(_options) + SurvivorCore.Progression = progression + + -- Quests: accept → progress → complete → reward (issue #10). Runtime ops attach onto the + -- registry table (register/getAll were available before start; these act on live players): + -- SurvivorCore.Quests.accept(player, "gather_reeds") + local quests = require(script.systems.Quests) + quests.start(_options) + SurvivorCore.Quests.accept = quests.accept + SurvivorCore.Quests.complete = quests.complete + SurvivorCore.Quests.abandon = quests.abandon + SurvivorCore.Quests.getLog = quests.getLog + SurvivorCore.Quests.isActive = quests.isActive + SurvivorCore.Quests.isCompleted = quests.isCompleted + + -- Achievements: always-on counters → threshold → unlock-once (+ toast). Same attach pattern: + -- SurvivorCore.Achievements.award(player, "secret_cave") + local achievements = require(script.systems.Achievements) + achievements.start(_options) + SurvivorCore.Achievements.award = achievements.award + SurvivorCore.Achievements.addCount = achievements.addCount + SurvivorCore.Achievements.isUnlocked = achievements.isUnlocked + SurvivorCore.Achievements.getState = achievements.getState + return SurvivorCore end @@ -211,6 +250,12 @@ function SurvivorCore.startClient(_options: { [string]: any }?) require(script.client.CharacterSheet).start(_options) require(script.client.CraftingUi).start(_options) + -- Goals & progression: toast notifications + the Quests and Achievements tabs (they adopt the + -- menu template's scaffolded panels). + require(script.client.Toasts).start(_options) + require(script.client.QuestsUi).start(_options) + require(script.client.AchievementsUi).start(_options) + -- Tool-swing harvesting input (click an equipped tool at a gatherable node). require(script.client.ToolHarvest).start(_options) diff --git a/src/registries/init.luau b/src/registries/init.luau index 72cdbf5..c482659 100644 --- a/src/registries/init.luau +++ b/src/registries/init.luau @@ -16,7 +16,15 @@ Registries.Recipes = Registry.new("Recipes", { keyField = "id" }) -- item it yields. Fields: { id, item, hp, requireTool, yieldMin, yieldMax }. Registries.Resources = Registry.new("Resources", { keyField = "id" }) Registries.Stats = Registry.new("Stats", { keyField = "name" }) +-- An achievement def is FLAT (so no-code and code defs share one shape): +-- { key, name, description?, icon?, counter, threshold } — `counter` names an auto-derived +-- progression counter ("gathers_reed", "kills_husk", "crafts_total", …; see systems/Progression). Registries.Achievements = Registry.new("Achievements", { keyField = "key" }) +-- A quest def: objectives a player works through for rewards. Canonical nested shape +-- { id, name, description?, objectives = {{type, target, count}}, rewards = {{item, count}}, +-- autoStart?, requires?, turnIn? }; the flat no-code shape (objectiveType/objectiveTarget/…) +-- is normalized on read by shared/QuestData.normalize. +Registries.Quests = Registry.new("Quests", { keyField = "id" }) Registries.Codex = Registry.new("Codex", { keyField = "id" }) Registries.Appearance = Registry.new("Appearance", { keyField = "id" }) Registries.Mobs = Registry.new("Mobs", { keyField = "id" }) diff --git a/src/shared/AchievementData.luau b/src/shared/AchievementData.luau new file mode 100644 index 0000000..0e51b8a --- /dev/null +++ b/src/shared/AchievementData.luau @@ -0,0 +1,124 @@ +--!nonstrict +--[[ + AchievementData — achievement def replication + the AchievementState schema. SHARED. + + Achievement defs are FLAT — { key, name, description?, icon?, counter, threshold } — so the + no-code (attribute) and code shapes are identical and no normalization pass is needed; this + module just validates + replicates them (RecipeData pattern) and owns the per-player state + attribute schema the server runtime writes and the Achievements tab reads. + + -- server (engine, at start): AchievementData.publish(SurvivorCore.Achievements.getAll()) + -- client (Achievements tab): AchievementData.getAll(), AchievementData.decodeState(localPlayer) +]] + +local HttpService = game:GetService("HttpService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local AchievementData = {} + +local HOLDER_NAME = "SurvivorCoreAchievementData" + +-- The per-player state attribute (JSON): +-- { v = 1, c = { [counterId] = n }, u = { [key] = true } } (c = counters, u = unlocked) +AchievementData.STATE_ATTR = "AchievementState" + +export type Achievement = { + key: string, + name: string, + description: string?, + icon: string?, + counter: string, + threshold: number, +} + +local function toDisplay(def: any): Achievement? + if typeof(def) ~= "table" or typeof(def.key) ~= "string" or def.key == "" then + return nil + end + if typeof(def.counter) ~= "string" or def.counter == "" then + warn(`[SurvivorCore.Achievements] '{def.key}' has no counter — skipped`) + return nil + end + return { + key = def.key, + name = if typeof(def.name) == "string" and def.name ~= "" then def.name else def.key, + description = if typeof(def.description) == "string" and def.description ~= "" then def.description else nil, + icon = if typeof(def.icon) == "string" and def.icon ~= "" then def.icon else nil, + counter = def.counter, + threshold = math.max(1, math.floor(tonumber(def.threshold) or 1)), + } +end +AchievementData.toDisplay = toDisplay + +-- ── Server: serialise + replicate ────────────────────────────────────────── +function AchievementData.publish(defs: { any }) + local out = {} + for _, def in defs do + local d = toDisplay(def) + if d then + table.insert(out, d) + end + end + local holder = ReplicatedStorage:FindFirstChild(HOLDER_NAME) + if not holder then + holder = Instance.new("StringValue") + holder.Name = HOLDER_NAME + holder.Parent = ReplicatedStorage + end + holder.Value = HttpService:JSONEncode(out) +end + +-- ── Both sides: decode a player's AchievementState attribute ──────────────── +function AchievementData.decodeState(player: Player): any + local raw = player:GetAttribute(AchievementData.STATE_ATTR) + if typeof(raw) == "string" and raw ~= "" then + local ok, decoded = pcall(function() + return HttpService:JSONDecode(raw) + end) + if ok and typeof(decoded) == "table" then + decoded.c = if typeof(decoded.c) == "table" then decoded.c else {} + decoded.u = if typeof(decoded.u) == "table" then decoded.u else {} + return decoded + end + end + return { v = 1, c = {}, u = {} } +end + +-- ── Client: read + cache ──────────────────────────────────────────────────── +local cache: { Achievement }? = nil +local watching = false + +local function rebuild(): { Achievement } + local result: { Achievement } = {} + local holder = ReplicatedStorage:WaitForChild(HOLDER_NAME, 10) + if holder and holder:IsA("StringValue") and holder.Value ~= "" then + local ok, decoded = pcall(function() + return HttpService:JSONDecode(holder.Value) + end) + if ok and typeof(decoded) == "table" then + for _, d in decoded do + if typeof(d) == "table" and typeof(d.key) == "string" then + table.insert(result, d) + end + end + end + if not watching then + watching = true + holder:GetPropertyChangedSignal("Value"):Connect(function() + cache = nil + end) + end + end + return result +end + +function AchievementData.getAll(): { Achievement } + local c = cache + if not c then + c = rebuild() + cache = c + end + return c +end + +return AchievementData diff --git a/src/shared/AchievementsConfig.luau b/src/shared/AchievementsConfig.luau new file mode 100644 index 0000000..3d11a13 --- /dev/null +++ b/src/shared/AchievementsConfig.luau @@ -0,0 +1,24 @@ +--!nonstrict +--[[ + AchievementsConfig — tuning for the achievements system. SHARED. Defines the "Achievements" + Config section so games retune via `Config.override("Achievements", { ... })`. Read the merged + section with AchievementsConfig.get(). +]] + +local Config = require(script.Parent.Parent.foundation.Config) + +local AchievementsConfig = {} + +AchievementsConfig.SECTION = "Achievements" + +AchievementsConfig.DEFAULTS = { + Toasts = true, -- show a toast notification when an achievement unlocks +} + +Config.defineSection(AchievementsConfig.SECTION, AchievementsConfig.DEFAULTS) + +function AchievementsConfig.get(): any + return Config.get(AchievementsConfig.SECTION) or AchievementsConfig.DEFAULTS +end + +return AchievementsConfig diff --git a/src/shared/QuestData.luau b/src/shared/QuestData.luau new file mode 100644 index 0000000..6205cd2 --- /dev/null +++ b/src/shared/QuestData.luau @@ -0,0 +1,192 @@ +--!nonstrict +--[[ + QuestData — quest def replication + the QuestLog schema. SHARED. + + Two jobs, both sides of the wire: + 1. DEFS: quests are registered server-side; the client's registry copy is empty. Mirroring + RecipeData, the engine serialises the (normalized) defs into a replicated StringValue at + start(); the Quests tab reads them back. `QuestData.normalize` is the ONE place both the + canonical nested shape and the flat no-code shape (admin plugin / loadFromFolder attributes) + become the same def — the runtime and the UI never see a flat def. + 2. LOG: per-player progress replicates as ONE JSON Player attribute (`QuestLog`), written by the + server runtime after every mutation; the client decodes it with `QuestData.decodeLog`. + + -- server (engine, at start): QuestData.publish(SurvivorCore.Quests.getAll()) + -- client (Quests tab): QuestData.getAll(), QuestData.decodeLog(localPlayer) +]] + +local HttpService = game:GetService("HttpService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local QuestData = {} + +local HOLDER_NAME = "SurvivorCoreQuestData" + +-- The per-player progress attribute (JSON): +-- { v = 1, active = { [id] = { p = {n, …} } }, ready = { [id] = true }, done = { [id] = true } } +-- `p` is a progress array parallel to the quest's objectives. `ready` = objectives met but the +-- reward not yet granted (turn-in pending, or the inventory was full). +QuestData.LOG_ATTR = "QuestLog" + +export type Objective = { type: string, target: string, count: number } +export type Reward = { item: string, count: number } +export type Quest = { + id: string, + name: string, + description: string?, + objectives: { Objective }, + rewards: { Reward }, + autoStart: boolean, + requires: string?, + turnIn: boolean, +} + +local OBJECTIVE_TYPES = { gather = true, craft = true, kill = true, use = true } + +-- Normalize a registered def (nested OR flat no-code fields) into the canonical Quest shape. +-- Returns nil (with a warn) for defs missing the essentials, so a bad authored quest can't +-- break publish or the runtime. +function QuestData.normalize(def: any): Quest? + if typeof(def) ~= "table" or typeof(def.id) ~= "string" or def.id == "" then + return nil + end + + -- Objectives: canonical nested list, else the flat single-objective fields. + local objectives: { Objective } = {} + if typeof(def.objectives) == "table" then + for _, o in def.objectives do + if typeof(o) == "table" and OBJECTIVE_TYPES[o.type] and typeof(o.target) == "string" then + table.insert(objectives, { + type = o.type, + target = o.target, + count = math.max(1, math.floor(tonumber(o.count) or 1)), + }) + end + end + elseif typeof(def.objectiveType) == "string" and OBJECTIVE_TYPES[def.objectiveType] then + table.insert(objectives, { + type = def.objectiveType, + target = tostring(def.objectiveTarget or ""), + count = math.max(1, math.floor(tonumber(def.objectiveCount) or 1)), + }) + end + if #objectives == 0 then + warn(`[SurvivorCore.Quests] quest '{def.id}' has no valid objectives — skipped`) + return nil + end + + -- Rewards: nested list, else the flat pair; rewards are optional. + local rewards: { Reward } = {} + if typeof(def.rewards) == "table" then + for _, r in def.rewards do + if typeof(r) == "table" and typeof(r.item) == "string" and r.item ~= "" then + table.insert(rewards, { item = r.item, count = math.max(1, math.floor(tonumber(r.count) or 1)) }) + end + end + elseif typeof(def.rewardItem) == "string" and def.rewardItem ~= "" then + table.insert(rewards, { + item = def.rewardItem, + count = math.max(1, math.floor(tonumber(def.rewardCount) or 1)), + }) + end + + local requires = if typeof(def.requires) == "string" and def.requires ~= "" then def.requires else nil + + return { + id = def.id, + name = if typeof(def.name) == "string" and def.name ~= "" then def.name else def.id, + description = if typeof(def.description) == "string" and def.description ~= "" then def.description else nil, + objectives = objectives, + rewards = rewards, + autoStart = def.autoStart == true, + requires = requires, + turnIn = def.turnIn == true, + } +end + +-- ── Server: serialise + replicate ────────────────────────────────────────── +function QuestData.publish(defs: { any }) + local out = {} + for _, def in defs do + local q = QuestData.normalize(def) + if q then + table.insert(out, q) + end + end + local holder = ReplicatedStorage:FindFirstChild(HOLDER_NAME) + if not holder then + holder = Instance.new("StringValue") + holder.Name = HOLDER_NAME + holder.Parent = ReplicatedStorage + end + holder.Value = HttpService:JSONEncode(out) +end + +-- ── Both sides: decode a player's QuestLog attribute (always returns a full shape) ── +function QuestData.decodeLog(player: Player): any + local raw = player:GetAttribute(QuestData.LOG_ATTR) + if typeof(raw) == "string" and raw ~= "" then + local ok, decoded = pcall(function() + return HttpService:JSONDecode(raw) + end) + if ok and typeof(decoded) == "table" then + decoded.active = if typeof(decoded.active) == "table" then decoded.active else {} + decoded.ready = if typeof(decoded.ready) == "table" then decoded.ready else {} + decoded.done = if typeof(decoded.done) == "table" then decoded.done else {} + return decoded + end + end + return { v = 1, active = {}, ready = {}, done = {} } +end + +-- ── Client: read + cache ──────────────────────────────────────────────────── +local cache: { Quest }? = nil +local watching = false + +local function rebuild(): { Quest } + local result: { Quest } = {} + local holder = ReplicatedStorage:WaitForChild(HOLDER_NAME, 10) + if holder and holder:IsA("StringValue") and holder.Value ~= "" then + local ok, decoded = pcall(function() + return HttpService:JSONDecode(holder.Value) + end) + if ok and typeof(decoded) == "table" then + for _, q in decoded do + if typeof(q) == "table" and typeof(q.id) == "string" then + table.insert(result, q) + end + end + end + if not watching then + watching = true + holder:GetPropertyChangedSignal("Value"):Connect(function() + cache = nil + end) + end + end + return result +end + +local function ensureCache(): { Quest } + local c = cache + if not c then + c = rebuild() + cache = c + end + return c +end + +function QuestData.getAll(): { Quest } + return ensureCache() +end + +function QuestData.get(id: string): Quest? + for _, q in ensureCache() do + if q.id == id then + return q + end + end + return nil +end + +return QuestData diff --git a/src/shared/QuestsConfig.luau b/src/shared/QuestsConfig.luau new file mode 100644 index 0000000..a966df8 --- /dev/null +++ b/src/shared/QuestsConfig.luau @@ -0,0 +1,25 @@ +--!nonstrict +--[[ + QuestsConfig — tuning for the quest system (issue #10). SHARED (the server runtime enforces the + limits; the client tab only renders). Defines the "Quests" Config section so games retune via + `Config.override("Quests", { ... })`. Read the merged section with QuestsConfig.get(). +]] + +local Config = require(script.Parent.Parent.foundation.Config) + +local QuestsConfig = {} + +QuestsConfig.SECTION = "Quests" + +QuestsConfig.DEFAULTS = { + MaxActive = 0, -- max quests a player can have active at once; 0 = unlimited + Toasts = true, -- show a toast notification when a quest completes +} + +Config.defineSection(QuestsConfig.SECTION, QuestsConfig.DEFAULTS) + +function QuestsConfig.get(): any + return Config.get(QuestsConfig.SECTION) or QuestsConfig.DEFAULTS +end + +return QuestsConfig diff --git a/src/systems/Achievements.luau b/src/systems/Achievements.luau new file mode 100644 index 0000000..0a09ff4 --- /dev/null +++ b/src/systems/Achievements.luau @@ -0,0 +1,172 @@ +--!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_`, `crafts_total`, + `crafts_`, `kills_`, `uses_`, `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 diff --git a/src/systems/Crafting.luau b/src/systems/Crafting.luau index ea70bb0..f61127a 100644 --- a/src/systems/Crafting.luau +++ b/src/systems/Crafting.luau @@ -19,6 +19,7 @@ assert(RunService:IsServer(), "SurvivorCore.Crafting is server-only — require local Inventory = require(script.Parent.Inventory) local Registries = require(script.Parent.Parent.registries) 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 RecipeData = require(script.Parent.Parent.shared.RecipeData) local CraftingConfig = require(script.Parent.Parent.shared.CraftingConfig) @@ -30,6 +31,14 @@ local Crafting = {} local started = false local channeling: { [Player]: boolean } = {} -- players mid-craft (re-entry guard) +-- Craft lifecycle events go to BOTH extension surfaces: Hooks (engine extension) and the +-- EventBridge bus (quests/achievements/analytics). +local function emit(event: string, player: Player, ctx: { [string]: any }) + ctx.player = player + Hooks.run(event, ctx) + EventBridge.fire(event, player, ctx) +end + local function ingredientsOf(recipe: any): { { item: string, count: number } } local out = {} if typeof(recipe.ingredients) == "table" then @@ -105,15 +114,15 @@ function Crafting.craft(player: Player, recipeId: string): boolean local ok, reason = Crafting.canCraft(player, recipeId) local recipe = Recipes.get(recipeId) if not ok or not recipe then - Hooks.run("craft:blocked", { player = player, recipeId = recipeId, reason = reason }) + emit("craft:blocked", player, { recipeId = recipeId, reason = reason }) return false end - Hooks.run("craft:start", { player = player, recipeId = recipeId, recipe = recipe }) + emit("craft:start", player, { recipeId = recipeId, recipe = recipe }) local done, why = consumeProduce(player, recipe) if done then - Hooks.run("craft:end", { player = player, recipeId = recipeId, recipe = recipe }) + emit("craft:end", player, { recipeId = recipeId, recipe = recipe }) else - Hooks.run("craft:blocked", { player = player, recipeId = recipeId, reason = why }) + emit("craft:blocked", player, { recipeId = recipeId, reason = why }) end return done end @@ -179,12 +188,12 @@ local function channelCraft(player: Player, recipeId: string) local ok, reason = Crafting.canCraft(player, recipeId) local recipe = Recipes.get(recipeId) if not ok or not recipe then - Hooks.run("craft:blocked", { player = player, recipeId = recipeId, reason = reason }) + emit("craft:blocked", player, { recipeId = recipeId, reason = reason }) return end channeling[player] = true - Hooks.run("craft:start", { player = player, recipeId = recipeId, recipe = recipe }) + emit("craft:start", player, { recipeId = recipeId, recipe = recipe }) local outName = recipe.output and recipe.output.item or recipeId local bar = showCraftBar(player, Crafting.craftTime(recipe), tostring(outName)) @@ -200,14 +209,14 @@ local function channelCraft(player: Player, recipeId: string) -- Re-validate after the channel (items/inventory may have changed) before consuming. if not Crafting.canCraft(player, recipeId) then - Hooks.run("craft:blocked", { player = player, recipeId = recipeId, reason = "ingredients" }) + emit("craft:blocked", player, { recipeId = recipeId, reason = "ingredients" }) return end local done, why = consumeProduce(player, recipe) if done then - Hooks.run("craft:end", { player = player, recipeId = recipeId, recipe = recipe }) + emit("craft:end", player, { recipeId = recipeId, recipe = recipe }) else - Hooks.run("craft:blocked", { player = player, recipeId = recipeId, reason = why }) + emit("craft:blocked", player, { recipeId = recipeId, reason = why }) end end diff --git a/src/systems/Harvesting.luau b/src/systems/Harvesting.luau index ba64160..9b0a961 100644 --- a/src/systems/Harvesting.luau +++ b/src/systems/Harvesting.luau @@ -22,6 +22,7 @@ assert(RunService:IsServer(), "SurvivorCore.Harvesting is server-only — requir local Inventory = require(script.Parent.Inventory) local Reactions = require(script.Parent.Parent.foundation.Reactions) 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 HarvestingConfig = require(script.Parent.Parent.shared.HarvestingConfig) @@ -94,6 +95,7 @@ local function fireBlocked(player: Player, node: Instance, resource: any, reason local ctx = { instance = node, player = player, resource = resource, reason = reason } Hooks.run("gather:blocked", ctx) Reactions.run(resource, "blocked", ctx) + EventBridge.fire("gather:blocked", player, ctx) fireResult(player, { ok = false, reason = reason, resource = resource }) end @@ -263,6 +265,7 @@ function Harvesting.tryHarvest(node: Instance, player: Player, mode: string): bo } Hooks.run("gather:hit", ctx) Reactions.run(info.resource, "hit", ctx) + EventBridge.fire("gather:hit", player, ctx) -- quests/achievements/analytics consume the bus fireResult(player, { ok = true, resource = info.resource, granted = amount, hpLeft = hp }) if hp <= 0 then @@ -275,6 +278,7 @@ function Harvesting.tryHarvest(node: Instance, player: Player, mode: string): bo } Hooks.run("gather:depleted", dctx) Reactions.run(info.resource, "depleted", dctx) + EventBridge.fire("gather:depleted", player, dctx) removeHealthBar(node) if info.destroyOnDeplete then node:Destroy() diff --git a/src/systems/Inventory.luau b/src/systems/Inventory.luau index 7e892d9..9a4c323 100644 --- a/src/systems/Inventory.luau +++ b/src/systems/Inventory.luau @@ -36,6 +36,7 @@ local InventoryTypes = require(script.Parent.Parent.shared.InventoryTypes) local ItemData = require(script.Parent.Parent.shared.ItemData) local Remotes = require(script.Parent.Parent.shared.Remotes) local Hooks = require(script.Parent.Parent.foundation.Hooks) +local EventBridge = require(script.Parent.Parent.foundation.EventBridge) local Items = Registries.Items @@ -336,6 +337,7 @@ local function consume(player: Player, itemId: string, slot: number?): boolean -- Every successful consume fires item:use — the seam for eat animations, sounds, or -- modifier logic (e.g. Stats.removeModifier to stop an ongoing poison tick). Hooks.run("item:use", { player = player, itemId = itemId, def = def, slot = slot }) + EventBridge.fire("item:use", player, { itemId = itemId, slot = slot }) fireChanged(player, "use", { itemId = itemId }) return true end diff --git a/src/systems/Progression.luau b/src/systems/Progression.luau new file mode 100644 index 0000000..054ad81 --- /dev/null +++ b/src/systems/Progression.luau @@ -0,0 +1,142 @@ +--!nonstrict +--[[ + Progression — server. The ONE translation layer between raw gameplay events and the systems + that track player progress (Quests, Achievements — and anything a game adds). + + It subscribes to the EventBridge once and turns events into a semantic stream of + (player, kind, target?, amount) tuples, so target extraction (the item from a gather ctx, the + output item from a craft ctx, the mobType from a mob death) lives in exactly one place. + + Built-in mappings (content-free): + gather:hit → ("gather", item, granted) + craft:end → ("craft", output item or recipeId, output count) + mob:died → ("kill", mobType, 1) -- canonical kill source: fires ONCE per mob + -- death (combat:kill also fires for the same + -- death — counting both would double-count) + item:use → ("use", itemId, 1) + quest:completed → ("quest", questId, 1) + + Games add their own with `Progression.map(eventType, resolver)`. Counter naming (what + achievement defs author against): `Progression.counterIds(kind, target)` → + { "s_total", "s_" } — e.g. gathers_total / gathers_reed, kills_husk. +]] + +local RunService = game:GetService("RunService") + +assert(RunService:IsServer(), "SurvivorCore.Progression is server-only — booted by SurvivorCore.start()") + +local EventBridge = require(script.Parent.Parent.foundation.EventBridge) + +local Progression = {} + +local started = false + +type Resolver = (player: Player, data: { [string]: any }) -> (string?, string?, number?) +type ProgressCb = (player: Player, kind: string, target: string?, amount: number) -> () + +local resolvers: { [string]: Resolver } = {} +local listeners: { ProgressCb } = {} + +-- ── Built-in event → (kind, target, amount) mappings ──────────────────────── + +resolvers["gather:hit"] = function(_player, data) + local item = tostring(data.item or "") + if item == "" then + return nil + end + return "gather", item, math.max(1, math.floor(tonumber(data.granted) or 1)) +end + +resolvers["craft:end"] = function(_player, data) + local recipe = data.recipe + local output = recipe and recipe.output + local target = (typeof(output) == "table" and typeof(output.item) == "string" and output.item) + or tostring(data.recipeId or "") + if target == "" then + return nil + end + local count = (typeof(output) == "table" and math.max(1, math.floor(tonumber(output.count) or 1))) or 1 + return "craft", target, count +end + +resolvers["mob:died"] = function(_player, data) + -- player (the bridge's 2nd arg) is the killer; nil = environmental death (no progress). + local mobType = tostring(data.mobType or "") + if mobType == "" then + return nil + end + return "kill", mobType, 1 +end + +resolvers["item:use"] = function(_player, data) + local itemId = tostring(data.itemId or "") + if itemId == "" then + return nil + end + return "use", itemId, 1 +end + +resolvers["quest:completed"] = function(_player, data) + local questId = tostring(data.questId or "") + if questId == "" then + return nil + end + return "quest", questId, 1 +end + +-- ── Public API ─────────────────────────────────────────────────────────────── + +-- Subscribe to the semantic progress stream. Returns an unsubscribe function. +function Progression.onProgress(cb: ProgressCb): () -> () + table.insert(listeners, cb) + return function() + local i = table.find(listeners, cb) + if i then + table.remove(listeners, i) + end + end +end + +-- Register (or override) a custom event mapping — how a game teaches quests/achievements about +-- its own EventBridge events. resolver(player, data) -> (kind, target?, amount?) or nil to skip. +function Progression.map(eventType: string, resolver: Resolver) + assert(typeof(eventType) == "string" and eventType ~= "", "Progression.map: eventType required") + assert(typeof(resolver) == "function", "Progression.map: resolver must be a function") + resolvers[eventType] = resolver +end + +-- The counter ids a (kind, target) bump feeds — the naming rule achievement defs author against. +function Progression.counterIds(kind: string, target: string?): { string } + local ids = { kind .. "s_total" } + if target and target ~= "" then + table.insert(ids, kind .. "s_" .. target) + end + return ids +end + +function Progression.start(_options: { [string]: any }?) + if started then + return + end + started = true + + EventBridge.onFire(function(eventType, player, data) + if not player or typeof(player) ~= "Instance" or not player:IsA("Player") then + return + end + local resolver = resolvers[eventType] + if not resolver then + return + end + local kind, target, amount = resolver(player, data or {}) + if not kind then + return + end + local n = math.max(1, math.floor(tonumber(amount) or 1)) + for _, cb in listeners do + task.spawn(cb, player, kind, target, n) + end + end) +end + +return Progression diff --git a/src/systems/Quests.luau b/src/systems/Quests.luau new file mode 100644 index 0000000..fa2e3d9 --- /dev/null +++ b/src/systems/Quests.luau @@ -0,0 +1,359 @@ +--!nonstrict +--[[ + Quests — server. The quest runtime (issue #10): accept → progress → complete → reward. + + Defs come from the `Quests` registry (code-authored nested, or flat no-code via the admin + plugin / SurvivorCoreContent — `QuestData.normalize` makes them identical). Progress is driven + by the shared `Progression` stream (gather/craft/kill/use), so quests need zero wiring into the + systems that generate events. An objective with a blank target matches ANY target of its kind + ("kill 3 of anything"). + + Lifecycle per quest per player: `active` (progress array parallels the objectives) → + objectives met → `ready` (only if the quest needs a turn-in, or the reward didn't fit) → + `done`. Rewards are NEVER lost: a grant that doesn't fit (full inventory) parks the remainder + and retries on every `inventory:changed` until it lands. Auto-start quests begin on join, and + chain — completing a prerequisite auto-starts dependents marked autoStart. + + State replicates as ONE JSON Player attribute (`QuestData.LOG_ATTR`); the Quests tab re-renders + from it. Fires quest:started/progress/completed/blocked through Hooks AND EventBridge. + Tuning: the "Quests" Config section. Started by SurvivorCore.start(). +]] + +local Players = game:GetService("Players") +local HttpService = game:GetService("HttpService") +local RunService = game:GetService("RunService") + +assert(RunService:IsServer(), "SurvivorCore.Quests is server-only — booted by SurvivorCore.start()") + +local Registries = require(script.Parent.Parent.registries) +local Inventory = require(script.Parent.Inventory) +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 QuestData = require(script.Parent.Parent.shared.QuestData) +local QuestsConfig = require(script.Parent.Parent.shared.QuestsConfig) + +local Quests = {} + +local started = false + +-- Normalized defs, id → Quest (built once at start from the registry). +local defs: { [string]: QuestData.Quest } = {} + +-- Per-player runtime state. `active[id].p` parallels the def's objectives; `ready[id] = true` +-- awaits turn-in / reward room; `pendingRewards[id]` = rewards still owed and `needsGiver[id]` +-- marks a ready quest that must be turned in at a QuestGiver (both server-side only). +type PlayerState = { + active: { [string]: { p: { number } } }, + ready: { [string]: boolean }, + done: { [string]: boolean }, + pendingRewards: { [string]: { QuestData.Reward } }, + needsGiver: { [string]: boolean }, +} +local states: { [Player]: PlayerState } = {} + +local function getState(player: Player): PlayerState + local s = states[player] + if not s then + s = { active = {}, ready = {}, done = {}, pendingRewards = {}, needsGiver = {} } + states[player] = s + end + return s +end + +-- Serialize the client-facing slice of the state into the QuestLog attribute. +local function writeLog(player: Player) + local s = getState(player) + local log = { v = 1, active = s.active, ready = s.ready, done = s.done } + player:SetAttribute(QuestData.LOG_ATTR, HttpService:JSONEncode(log)) +end + +local function emit(event: string, player: Player, ctx: { [string]: any }) + ctx.player = player + Hooks.run(event, ctx) + EventBridge.fire(event, player, ctx) +end + +local function toast(player: Player, payload: { [string]: any }) + if QuestsConfig.get().Toasts == false then + return + end + Remotes.event("Notify"):FireClient(player, payload) +end + +local function activeCount(s: PlayerState): number + local n = 0 + for _ in s.active do + n += 1 + end + return n +end + +local function objectivesMet(quest: QuestData.Quest, progress: { number }): boolean + for i, obj in quest.objectives do + if (progress[i] or 0) < obj.count then + return false + end + end + return true +end + +-- Grant a quest's (remaining) rewards. Returns the rewards that still don't fit (empty = all in). +local function grantRewards(player: Player, rewards: { QuestData.Reward }): { QuestData.Reward } + local remaining = {} + for _, reward in rewards do + if not Inventory.add(player, reward.item, reward.count) then + table.insert(remaining, reward) + end + end + return remaining +end + +-- Re-entrancy guard: granting a reward fires inventory:changed, whose retry handler must not +-- re-enter tryFinish for the same quest mid-grant (that would double-grant). +local finishing: { [Player]: { [string]: boolean } } = {} + +-- Finish a quest whose objectives are met: grant rewards (parking any that don't fit), and if +-- everything landed, move it to done + fire completed + chain any autoStart dependents. +local function tryFinish(player: Player, questId: string) + local s = getState(player) + local quest = defs[questId] + if not quest then + return + end + local f = finishing[player] + if f and f[questId] then + return + end + f = f or {} + finishing[player] = f + f[questId] = true + + local owed = s.pendingRewards[questId] or quest.rewards + local remaining = grantRewards(player, owed) + if #remaining > 0 then + -- No room for (some of) the reward: park it in `ready` and retry when the inventory changes. + s.active[questId] = nil + s.ready[questId] = true + s.pendingRewards[questId] = remaining + s.needsGiver[questId] = nil -- past the giver gate (if any); now only waiting for room + writeLog(player) + f[questId] = nil + return + end + + s.active[questId] = nil + s.ready[questId] = nil + s.pendingRewards[questId] = nil + s.needsGiver[questId] = nil + s.done[questId] = true + writeLog(player) + f[questId] = nil + + emit("quest:completed", player, { questId = questId, def = quest }) + toast(player, { kind = "quest", title = "Quest complete", body = quest.name }) + + -- Chain: auto-start any autoStart quest this completion unlocks. + for id, def in defs do + if def.autoStart and def.requires == questId then + Quests.accept(player, id) + end + end +end + +-- ── Public API (attached to SurvivorCore.Quests after start) ───────────────── + +function Quests.accept(player: Player, questId: string): (boolean, string?) + local s = getState(player) + local quest = defs[questId] + local reason: string? = nil + if not quest then + reason = "unknown" + elseif s.done[questId] then + reason = "done" + elseif s.active[questId] or s.ready[questId] then + reason = "active" + elseif quest.requires and not s.done[quest.requires] then + reason = "requires" + else + local maxActive = tonumber(QuestsConfig.get().MaxActive) or 0 + if maxActive > 0 and activeCount(s) >= maxActive then + reason = "max" + end + end + if reason then + emit("quest:blocked", player, { questId = questId, reason = reason }) + return false, reason + end + + local progress = table.create(#quest.objectives, 0) + s.active[questId] = { p = progress } + writeLog(player) + emit("quest:started", player, { questId = questId, def = quest }) + return true +end + +-- Turn in a `ready` quest (the QuestGiver path) — also completes a met-but-unclaimed quest. +function Quests.complete(player: Player, questId: string): (boolean, string?) + local s = getState(player) + local quest = defs[questId] + if not quest then + return false, "unknown" + end + if s.done[questId] then + return false, "done" + end + if s.ready[questId] then + s.needsGiver[questId] = nil -- the giver gate is satisfied by this turn-in + tryFinish(player, questId) + return s.done[questId] == true, if s.done[questId] then nil else "full" + end + local entry = s.active[questId] + if entry and objectivesMet(quest, entry.p) then + tryFinish(player, questId) + return s.done[questId] == true, if s.done[questId] then nil else "full" + end + return false, "incomplete" +end + +function Quests.abandon(player: Player, questId: string): boolean + local s = getState(player) + if not s.active[questId] then + return false + end + s.active[questId] = nil + writeLog(player) + return true +end + +function Quests.isActive(player: Player, questId: string): boolean + local s = getState(player) + return s.active[questId] ~= nil or s.ready[questId] == true +end + +function Quests.isCompleted(player: Player, questId: string): boolean + return getState(player).done[questId] == true +end + +function Quests.getLog(player: Player) + return QuestData.decodeLog(player) +end + +-- ── Progress driver ────────────────────────────────────────────────────────── + +local function onProgress(player: Player, kind: string, target: string?, amount: number) + local s = states[player] + if not s then + return + end + for questId, entry in s.active do + local quest = defs[questId] + if quest then + local changed = false + for i, obj in quest.objectives do + local matches = obj.type == kind and (obj.target == "" or obj.target == (target or "")) + if matches and (entry.p[i] or 0) < obj.count then + entry.p[i] = math.min(obj.count, (entry.p[i] or 0) + amount) + changed = true + emit("quest:progress", player, { + questId = questId, + def = quest, + index = i, + count = entry.p[i], + }) + end + end + if changed then + writeLog(player) + if objectivesMet(quest, entry.p) then + if quest.turnIn then + s.active[questId] = nil + s.ready[questId] = true + s.pendingRewards[questId] = quest.rewards + s.needsGiver[questId] = true + writeLog(player) + toast(player, { + kind = "quest", + title = "Objectives complete", + body = quest.name .. " — turn it in!", + }) + else + tryFinish(player, questId) + end + end + end + end + end +end + +-- ── Boot ───────────────────────────────────────────────────────────────────── + +local function addPlayer(player: Player) + local s = getState(player) + writeLog(player) + for id, def in defs do + if def.autoStart and not def.requires and not s.done[id] and not s.active[id] then + Quests.accept(player, id) + end + end +end + +function Quests.start(_options: { [string]: any }?) + if started then + return + end + started = true + + -- Snapshot + normalize the registered defs, then replicate them for the Quests tab. + for _, raw in Registries.Quests.getAll() do + local q = QuestData.normalize(raw) + if q then + defs[q.id] = q + end + end + QuestData.publish(Registries.Quests.getAll()) + + -- Eager so clients can connect at startup. + Remotes.event("Notify") + + Progression.onProgress(onProgress) + + -- Client requests: accept via a giver prompt UI / turn-in. Server re-validates everything. + Remotes.event("QuestAccept").OnServerEvent:Connect(function(player, questId) + if typeof(questId) == "string" then + Quests.accept(player, questId) + end + end) + Remotes.event("QuestTurnIn").OnServerEvent:Connect(function(player, questId) + if typeof(questId) == "string" then + Quests.complete(player, questId) + end + end) + + -- A parked reward retries whenever the player's inventory changes (something freed up). + -- Quests still awaiting a giver turn-in (needsGiver) are NOT auto-delivered. + Hooks.on("inventory:changed", function(ctx) + local player = ctx and ctx.player + local s = player and states[player] + if not s then + return + end + for questId in pairs(s.ready) do + if not s.needsGiver[questId] then + tryFinish(player, questId) + end + 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 + finishing[player] = nil + end) +end + +return Quests