Merge pull request #53 from TemujinCalidius/dev

release: v0.6.0 → main
This commit is contained in:
Samuel Lison
2026-07-03 15:29:47 +10:00
committed by GitHub
33 changed files with 2289 additions and 36 deletions
+39
View File
@@ -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`.
## 0.6.0 — 2026-07-03
### 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
+5 -1
View File
@@ -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)
+91
View File
@@ -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)
+79
View File
@@ -0,0 +1,79 @@
# Achievements
The achievement system ([`src/systems/Achievements.luau`](../src/systems/Achievements.luau)) tracks
**milestones**: always-on counters that unlock a badge (once) when they cross a threshold — with a
toast and an Achievements menu tab. The architecture is ported from The Counter Earth's proven
achievement service, made content-free.
> Progress is **session-scoped** (Player attributes) — persistence (DataStore) is a future system.
## Defining an achievement
Defs are **flat** — the same shape in code and [no-code](content-authoring.md):
```lua
SurvivorCore.Achievements.register({
key = "husk_slayer",
name = "Husk Slayer",
description = "Slay 3 husks.",
counter = "kills_husk", -- which counter unlocks it (see the catalogue below)
threshold = 3,
-- icon = "rbxassetid://…",
})
```
## The Progression stream
One shared layer ([`src/systems/Progression.luau`](../src/systems/Progression.luau)) subscribes to
the EventBridge and translates gameplay events into `(player, kind, target, amount)` progress —
consumed by **both** achievements (counters) and [quests](quests.md) (objectives). Built-in:
| Event | kind | target | amount |
|---|---|---|---|
| `gather:hit` | `gather` | the item id | amount granted |
| `craft:end` | `craft` | the output item id | output count |
| `mob:died` (with a killer) | `kill` | the mob type | 1 |
| `item:use` | `use` | the item id | 1 |
| `quest:completed` | `quest` | the quest id | 1 |
### Counter catalogue (the naming rule)
Every progress tick bumps `"<kind>s_total"` and `"<kind>s_<target>"`:
`gathers_total` · `gathers_reed` · `crafts_total` · `crafts_reed_basket` · `kills_total` ·
`kills_husk` · `uses_total` · `uses_berry` · `quests_total` · `quests_<questId>` — author any
achievement against any of these, no code.
### Custom events & counters (code)
```lua
-- Teach the stream a game event (then author achievements against boss counters):
SurvivorCore.Progression.map("myGame:bossDown", function(player, data)
return "kill", data.bossId, 1
end)
-- Or bump a bespoke counter / award directly:
SurvivorCore.Achievements.addCount(player, "shrines_visited", 1)
SurvivorCore.Achievements.award(player, "secret_cave")
```
## Runtime API
`SurvivorCore.Achievements` (registry always; runtime ops after `start()`): `award(player, key)`,
`addCount(player, counterId, n?)`, `isUnlocked(player, key)`, `getState(player)`. Unlocks fire the
`achievement:unlocked` hook + EventBridge event and a toast (config-gated).
## The Achievements tab & data
The menu's **Achievements** tab (key **J**) lists every def with a progress bar toward its
threshold; unlocked rows go gold. Defs replicate via `SurvivorCoreAchievementData`; per-player state
is the `AchievementState` JSON attribute (`{ c = { [counter] = n }, u = { [key] = true } }`) — both
readable via [`src/shared/AchievementData.luau`](../src/shared/AchievementData.luau).
## Tuning
`Config.override("Achievements", { Toasts = true })`.
---
See also: [Quests](quests.md) · [No-code content](content-authoring.md) · [Extending](extending.md).
+7 -5
View File
@@ -5,11 +5,13 @@ Attributes in the Explorer. It adds two toolbar buttons under **SurvivorCore**:
- **Survival Stats** — tune the survival-stat rates/thresholds/HUD on the `SurvivalStatsConfig`
instance (the deltas-only, locked model below).
- **Content** — create/edit/delete **items**, **weapons**, **gatherable resources** and **mobs** with
no code (the Builder slice). It writes `SurvivorCoreContent` instances the engine loads at `start()`
— see [content-authoring.md](content-authoring.md). Gatherables and mobs each get a **+ Add to
World** button that drops the tagged instance in front of the camera. Unlike the stats editor,
content is full owner-authored defs (not deltas). Every edit is one Studio undo step.
- **Content** — create/edit/delete **items**, **weapons**, **arrows**, **gatherable resources**,
**mobs**, **quests** and **achievements** with no code (the Builder slice). It writes
`SurvivorCoreContent` instances the engine loads at `start()` — see
[content-authoring.md](content-authoring.md). Gatherables/mobs get **+ Add to World**, weapons
**+ Tool model**, quests **+ Quest giver** — each drops the tagged instance in front of the
camera. Unlike the stats editor, content is full owner-authored defs (not deltas). Every edit is
one Studio undo step.
The rest of this page covers the Survival Stats editor; both install the same way. It's the
[Builder / Admin plugin](https://github.com/TemujinCalidius/SurvivorCore/issues/11).
+22 -1
View File
@@ -47,6 +47,20 @@ ReplicatedStorage
│ • faction = "hostile" ("hostile" | "passive" | "neutral")
│ • health = 60
│ • aggroRange = 40
├─ Quests (Folder) ← flat single-objective quests (normalized at load)
│ └─ gather_reeds (Configuration)
│ • name = "Gather Reeds"
│ • objectiveType = "gather" ("gather" | "craft" | "kill" | "use")
│ • objectiveTarget = "reed"
│ • objectiveCount = 3
│ • rewardItem = "berry"
│ • rewardCount = 2
│ • autoStart = true
├─ Achievements (Folder) ← flat counter + threshold defs
│ └─ husk_slayer (Configuration)
│ • name = "Husk Slayer"
│ • counter = "kills_husk" (see docs/achievements.md for the counter catalogue)
│ • threshold = 3
├─ Tools (Folder) ← Tool templates the hotbar equips (named by item id)
└─ MobModels (Folder) ← rigged mob templates Mobs.spawn clones (named by mob id)
```
@@ -57,7 +71,7 @@ same instance-config pattern the survival stats use.
## The admin plugin Content widget
Open Studio → the **SurvivorCore** toolbar → **Content**. Five builders:
Open Studio → the **SurvivorCore** toolbar → **Content**. Seven builders:
- **Items** — create an item by id, then set Name / Max stack / Weight / Category / Tool type /
Icon / Description.
@@ -74,6 +88,13 @@ Open Studio → the **SurvivorCore** toolbar → **Content**. Five builders:
Yield min / Yield max. **+ Add to World** drops a tagged `Gatherable` node.
- **Mobs** — create a mob type by id, then set Faction / Health / Speeds / Aggro / Leash / Attack.
**+ Add to World** drops a tagged `Mob` placeholder rig (swap in your own model later).
- **Quests** — create a quest by id, then set Name / Description / Objective (type, target, count) /
Reward (item, count) / Auto-start / Requires / Turn in. **+ Quest giver** drops a tagged
`QuestGiver` post offering it (see [quests.md](quests.md)). Multi-objective chains stay
code-authored, like recipes.
- **Achievements** — create an achievement by key, then set Name / Description / Counter /
Threshold / Icon — counters are the engine's auto-derived progression counters
(see [achievements.md](achievements.md)).
Each edit is one Studio **undo** step. Behind the scenes it creates/edits the
`SurvivorCoreContent` instances above, so pressing Play registers your content with no code.
+17 -8
View File
@@ -46,10 +46,12 @@ SurvivorCore.Recipes.register({
| Registry | Key field | What it holds |
|---|---|---|
| `Items` | `id` | Item definitions (name, stack size, …). |
| `Items` | `id` | Item definitions (name, stack size, …) — incl. weapons + ammo. |
| `Recipes` | `id` | Crafting **and** cooking recipes — one registry, routed by `station`. |
| `Resources` | `id` | Gatherable-resource defs (what a tagged node *is*). |
| `Stats` | `name` | Survival/status stat models. |
| `Achievements` | `key` | Achievement definitions. |
| `Achievements` | `key` | Achievement defs (counter + threshold — [docs](achievements.md)). |
| `Quests` | `id` | Quest defs (objectives + rewards — [docs](quests.md)). |
| `Codex` | `id` | Discoverable lore / collectible entries. |
| `Appearance` | `id` | Character appearance options. |
| `Mobs` | `id` | Creature / hostile-mob definitions. |
@@ -142,15 +144,22 @@ end)
-- later: disconnect()
```
Engine systems fire hooks with `Hooks.run("name", ctx)`. Today the `Gatherable` component
fires:
Engine systems fire hooks with `Hooks.run("name", ctx)`. The full catalogue lives in the header of
[`src/foundation/Hooks.luau`](../src/foundation/Hooks.luau); highlights:
| Hook | Context |
| Hook family | Fired by |
|---|---|
| `gather:hit` | `{ instance, player, values, hpLeft }` — each interaction. |
| `gather:depleted` | `{ instance, player, values }` — final hit, before the instance is destroyed. |
| `gather:hit` / `gather:depleted` / `gather:blocked` | harvesting ([docs](harvesting.md)) |
| `craft:start` / `craft:end` / `craft:blocked` | crafting ([docs](crafting.md)) |
| `item:use` · `inventory:changed` | inventory ([docs](inventory.md)) |
| `mob:spawned` / `mob:hit` / `mob:attack` / `mob:died` | mobs & AI ([docs](mobs.md)) |
| `combat:hit` / `combat:kill` | combat ([docs](combat.md)) |
| `quest:started` / `quest:progress` / `quest:completed` / `quest:blocked` | quests ([docs](quests.md)) |
| `achievement:unlocked` | achievements ([docs](achievements.md)) |
More hooks land as systems are extracted (`craft:start`/`craft:end`, mob lifecycle, …).
These gameplay events ALSO cross the **EventBridge** with the same names — that bus is what quests,
achievements, and analytics consume (via the `Progression` translation layer,
[docs](achievements.md#the-progression-stream)).
### Hooks vs. EventBridge
+78
View File
@@ -0,0 +1,78 @@
# Quests
The quest system ([`src/systems/Quests.luau`](../src/systems/Quests.luau)) gives players **goals**:
accept a quest, work its objectives, claim the reward. Progress is driven entirely by the events
the other systems already fire (gather / craft / kill / use), so a quest needs **zero wiring**
register a def and the engine tracks it.
> Progress is **session-scoped** (Player attributes) — persistence (DataStore) is a future system.
## Defining a quest
In code (canonical, supports multiple objectives) or [no-code via the admin
plugin](content-authoring.md) (single-objective):
```lua
SurvivorCore.Quests.register({
id = "gather_reeds",
name = "Gather Reeds",
description = "Pull 3 reeds from the bushes by the river.",
objectives = {
{ type = "gather", target = "reed", count = 3 }, -- type: "gather"|"craft"|"kill"|"use"
},
rewards = { { item = "berry", count = 2 } },
autoStart = true, -- accepted automatically on join (or when `requires` completes)
-- requires = "id", -- single prerequisite (chains)
-- turnIn = true, -- must return to a QuestGiver to claim the reward
})
```
An objective's `target` is an item id (gather/craft/use) or a mob type (kill); **blank = any**
("slay 3 of anything"). The flat no-code shape (`objectiveType`/`objectiveTarget`/`objectiveCount`/
`rewardItem`/`rewardCount` attributes) is normalized to this at load — both behave identically.
## Lifecycle
**active** → objectives met → (**ready**, if `turnIn` or the inventory was full) → **done**.
- Progress comes from the [Progression](achievements.md#the-progression-stream) event stream.
- **Rewards are never lost:** if the inventory can't fit the reward, the quest parks in *ready* and
the grant retries automatically whenever the inventory changes.
- Completing a quest fires the `quest:completed` hook + EventBridge event, shows a toast
(config-gated), and **auto-starts** any `autoStart` quest that `requires` it — chains flow.
## Quest givers (no-code)
Tag any part/model **`QuestGiver`** (CollectionService) and set `Quest = "<quest id>"` — the engine
attaches a hold-**E** prompt that *accepts* the quest (or *turns it in* when its objectives are met
and it's a `turnIn` quest). The admin plugin's Quests editor drops one via **+ Quest giver**.
## Runtime API
`SurvivorCore.Quests` (registry always; runtime ops after `start()`):
| Function | Behavior |
|---|---|
| `accept(player, id) -> (ok, reason?)` | gates: unknown / done / active / `requires` / `MaxActive` |
| `complete(player, id) -> (ok, reason?)` | turn-in (or claim a met quest); `"full"` = no reward room |
| `abandon(player, id) -> ok` | drop an active quest (progress lost) |
| `getLog(player)` / `isActive` / `isCompleted` | read state |
Hooks (also on EventBridge): `quest:started` / `quest:progress` / `quest:completed` /
`quest:blocked` — ctx `{ player, questId, def?, index?, count?, reason? }`.
## The Quests tab & data
The menu's **Quests** tab (key **L**) renders Active (per-objective progress bars) / Ready /
Completed. Defs replicate via a JSON `SurvivorCoreQuestData` StringValue; per-player progress is the
`QuestLog` JSON Player attribute (`{ active = { [id] = { p = {…} } }, ready = {}, done = {} }`) —
both readable via [`src/shared/QuestData.luau`](../src/shared/QuestData.luau).
## Tuning
`Config.override("Quests", { MaxActive = 0 --[[0 = unlimited]], Toasts = true })`.
---
See also: [Achievements](achievements.md) · [No-code content](content-authoring.md) ·
[Extending](extending.md).
+57 -1
View File
@@ -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
+8 -5
View File
@@ -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,
+16
View File
@@ -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
View File
@@ -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
View File
@@ -134,11 +134,18 @@
<p>A shared FSM substrate: hostile mobs chase &amp; 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 &amp; 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
View File
@@ -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; }
+189
View File
@@ -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
+29 -1
View File
@@ -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
+291
View File
@@ -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
+165
View File
@@ -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
+59
View File
@@ -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,
})
+7
View File
@@ -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
View File
@@ -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)
+8
View File
@@ -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" })
+124
View File
@@ -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
+24
View File
@@ -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
+192
View File
@@ -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
+25
View File
@@ -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
+172
View File
@@ -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
+18 -9
View File
@@ -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
+4
View File
@@ -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()
+2
View File
@@ -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
+142
View File
@@ -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
+359
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "temujincalidius/survivorcore"
description = "Batteries-included, creator-extensible survival game framework for Roblox."
version = "0.5.0"
version = "0.6.0"
license = "MIT"
authors = ["Samuel Lison"]
registry = "https://github.com/UpliftGames/wally-index"