mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
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:
co-authored by
Claude Opus 4.8
parent
0892bf948e
commit
9caa888369
@@ -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 = "<id>"` 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+8
-1
@@ -134,11 +134,18 @@
|
||||
<p>A shared FSM substrate: hostile mobs chase & attack, passive animals flee. Behavior is data — faction picks the profile.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<span class="ico ico-green" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3 8-8"/><path d="M20 12v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h9"/></svg>
|
||||
</span>
|
||||
<h3>Quests & achievements</h3>
|
||||
<p>Event-driven goals: quest chains with objectives, rewards and quest-giver NPCs, plus milestone achievements with toasts — tracked automatically from what players already do.</p>
|
||||
</article>
|
||||
<article class="card card-wide">
|
||||
<span class="ico ico-green" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4l-6 6 2 2 6-6a4 4 0 0 0 5.4-5.4l-2.3 2.3-2-2 2.3-2.3z"/></svg>
|
||||
</span>
|
||||
<h3>No-code admin plugin</h3>
|
||||
<p>Create items, weapons, ammo and mobs from a Studio form — damage, range, arrow curve, weight, aggro, leash — and drop them into the world.</p>
|
||||
<p>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.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+4
-1
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = "<quest id>"`. 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,
|
||||
})
|
||||
@@ -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 = {}
|
||||
|
||||
+46
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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_<item>`, `crafts_total`,
|
||||
`crafts_<item>`, `kills_<mobType>`, `uses_<item>`, `quests_completed`, … — so an achievement
|
||||
def is FLAT and no-code-authorable:
|
||||
|
||||
SurvivorCore.Achievements.register({
|
||||
key = "husk_slayer", name = "Husk Slayer",
|
||||
counter = "kills_husk", threshold = 3,
|
||||
})
|
||||
|
||||
Games add custom mappings via `SurvivorCore.Progression.map`, bump bespoke counters with
|
||||
`Achievements.addCount`, or unlock directly with `Achievements.award`. State replicates as ONE
|
||||
JSON Player attribute (`AchievementData.STATE_ATTR`); unlocks fire `achievement:unlocked`
|
||||
(Hooks + EventBridge) and a toast. Tuning: the "Achievements" Config section. Session-scoped
|
||||
(persistence is a future system). Started by SurvivorCore.start().
|
||||
]]
|
||||
|
||||
local Players = game:GetService("Players")
|
||||
local HttpService = game:GetService("HttpService")
|
||||
local RunService = game:GetService("RunService")
|
||||
|
||||
assert(RunService:IsServer(), "SurvivorCore.Achievements is server-only — booted by SurvivorCore.start()")
|
||||
|
||||
local Registries = require(script.Parent.Parent.registries)
|
||||
local Progression = require(script.Parent.Progression)
|
||||
local Hooks = require(script.Parent.Parent.foundation.Hooks)
|
||||
local EventBridge = require(script.Parent.Parent.foundation.EventBridge)
|
||||
local Remotes = require(script.Parent.Parent.shared.Remotes)
|
||||
local AchievementData = require(script.Parent.Parent.shared.AchievementData)
|
||||
local AchievementsConfig = require(script.Parent.Parent.shared.AchievementsConfig)
|
||||
|
||||
local Achievements = {}
|
||||
|
||||
local started = false
|
||||
|
||||
-- Validated display defs (key → def) and, per counter, the defs it can unlock.
|
||||
local defs: { [string]: AchievementData.Achievement } = {}
|
||||
local byCounter: { [string]: { AchievementData.Achievement } } = {}
|
||||
|
||||
type PlayerState = { c: { [string]: number }, u: { [string]: boolean } }
|
||||
local states: { [Player]: PlayerState } = {}
|
||||
|
||||
local function getState(player: Player): PlayerState
|
||||
local s = states[player]
|
||||
if not s then
|
||||
s = { c = {}, u = {} }
|
||||
states[player] = s
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
local function writeState(player: Player)
|
||||
local s = getState(player)
|
||||
player:SetAttribute(AchievementData.STATE_ATTR, HttpService:JSONEncode({ v = 1, c = s.c, u = s.u }))
|
||||
end
|
||||
|
||||
local function unlock(player: Player, def: AchievementData.Achievement)
|
||||
local s = getState(player)
|
||||
if s.u[def.key] then
|
||||
return
|
||||
end
|
||||
s.u[def.key] = true
|
||||
local ctx = { player = player, key = def.key, def = def }
|
||||
Hooks.run("achievement:unlocked", ctx)
|
||||
EventBridge.fire("achievement:unlocked", player, { key = def.key, def = def })
|
||||
if AchievementsConfig.get().Toasts ~= false then
|
||||
Remotes.event("Notify"):FireClient(player, {
|
||||
kind = "achievement",
|
||||
title = "Achievement unlocked",
|
||||
body = def.name,
|
||||
icon = def.icon,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Bump a counter and unlock anything it satisfies. The single write path for all progress.
|
||||
local function bump(player: Player, counterId: string, amount: number)
|
||||
local s = getState(player)
|
||||
s.c[counterId] = (s.c[counterId] or 0) + amount
|
||||
local watchers = byCounter[counterId]
|
||||
if watchers then
|
||||
for _, def in watchers do
|
||||
if not s.u[def.key] and s.c[counterId] >= def.threshold then
|
||||
unlock(player, def)
|
||||
end
|
||||
end
|
||||
end
|
||||
writeState(player)
|
||||
end
|
||||
|
||||
-- ── Public API (attached to SurvivorCore.Achievements after start) ──────────
|
||||
|
||||
-- Manually unlock an achievement (story beats, secrets — things counters can't express).
|
||||
function Achievements.award(player: Player, key: string): boolean
|
||||
local def = defs[key]
|
||||
if not def then
|
||||
warn(`[SurvivorCore.Achievements] award: unknown key '{tostring(key)}'`)
|
||||
return false
|
||||
end
|
||||
if getState(player).u[key] then
|
||||
return false
|
||||
end
|
||||
unlock(player, def)
|
||||
writeState(player)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Bump a bespoke counter from game code (pairs with defs authored against that counter id).
|
||||
function Achievements.addCount(player: Player, counterId: string, amount: number?)
|
||||
bump(player, tostring(counterId), math.max(1, math.floor(tonumber(amount) or 1)))
|
||||
end
|
||||
|
||||
function Achievements.isUnlocked(player: Player, key: string): boolean
|
||||
return getState(player).u[key] == true
|
||||
end
|
||||
|
||||
function Achievements.getState(player: Player)
|
||||
return AchievementData.decodeState(player)
|
||||
end
|
||||
|
||||
-- ── Boot ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
local function addPlayer(player: Player)
|
||||
getState(player)
|
||||
writeState(player)
|
||||
end
|
||||
|
||||
function Achievements.start(_options: { [string]: any }?)
|
||||
if started then
|
||||
return
|
||||
end
|
||||
started = true
|
||||
|
||||
-- Snapshot + validate the registered defs, index by counter, replicate for the tab.
|
||||
for _, raw in Registries.Achievements.getAll() do
|
||||
local def = AchievementData.toDisplay(raw)
|
||||
if def then
|
||||
defs[def.key] = def
|
||||
local list = byCounter[def.counter]
|
||||
if not list then
|
||||
list = {}
|
||||
byCounter[def.counter] = list
|
||||
end
|
||||
table.insert(list, def)
|
||||
end
|
||||
end
|
||||
AchievementData.publish(Registries.Achievements.getAll())
|
||||
|
||||
Remotes.event("Notify") -- eager (shared with Quests; idempotent)
|
||||
|
||||
-- Generic counters from the shared progress stream: kind totals + per-target.
|
||||
Progression.onProgress(function(player, kind, target, amount)
|
||||
for _, counterId in Progression.counterIds(kind, target) do
|
||||
bump(player, counterId, amount)
|
||||
end
|
||||
end)
|
||||
|
||||
for _, player in Players:GetPlayers() do
|
||||
task.defer(addPlayer, player)
|
||||
end
|
||||
Players.PlayerAdded:Connect(addPlayer)
|
||||
Players.PlayerRemoving:Connect(function(player)
|
||||
states[player] = nil
|
||||
end)
|
||||
end
|
||||
|
||||
return Achievements
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)` →
|
||||
{ "<kind>s_total", "<kind>s_<target>" } — 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user