feat: quests + achievements — the goals & progression release (#10)

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Samuel Lison
2026-07-03 15:26:19 +10:00
co-authored by Claude Opus 4.8
parent 0892bf948e
commit 9caa888369
32 changed files with 2288 additions and 35 deletions
+79
View File
@@ -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 `"<kind>s_total"` and `"<kind>s_<target>"`:
`gathers_total` · `gathers_reed` · `crafts_total` · `crafts_reed_basket` · `kills_total` ·
`kills_husk` · `uses_total` · `uses_berry` · `quests_total` · `quests_<questId>` — 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).
+7 -5
View File
@@ -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).
+22 -1
View File
@@ -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.
+17 -8
View File
@@ -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
+78
View File
@@ -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 = "<quest id>"` — 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).