diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63fad92..22099e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,42 @@ 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.9.0 — 2026-07-30
+
+### Security
+- **Bow shots are now rate-limited server-side.** The bow release handler enforced no cooldown — the
+ one client-driven action in the engine that didn't — so a client could fire far faster than a bow's
+ design rate, and each release costs the server up to `MaxRange / Bow.StepSize` raycasts to simulate
+ the arc. The gate runs **before** the arrow is spent and before the simulation, honours a bow's own
+ **`weaponCooldown`** (which previously only melee read, despite being offered for every weapon), and
+ falls back to the new **`Combat.Bow.Cooldown`** (0.35s, tunable in SurvivorCore Studio). A release
+ with no matching draw is rejected, `BowDraw` now validates the sender is alive and holding a bow,
+ and aim points are checked for finiteness so a malformed one can't consume an arrow. Affects
+ v0.8.0 and earlier.
+
+### Added
+- **Player interact window** — walk up to another player and an **"[E] Interact"** badge appears
+ over *their* head; press **E** (or tap) to open a window with their name + survival stats and an
+ **action list** (Trade ships built-in). Targeting is a client-side nearest-*other*-player scan, so
+ it can never point at you — this **replaces** the earlier per-character server ProximityPrompt
+ (which wrongly showed on your own character). Games extend it with
+ `SurvivorCore.Interact.addAction{…}`; interact key is `UI.Keybinds.Interact` (default `E`). Ported
+ from The Counter Earth. See [docs/interact.md](docs/interact.md).
+- **Player trading** (#15) — secure, server-authoritative, **dupe-proof** face-to-face item swaps.
+ Open a player's interact window and choose **Trade**; the target Accepts/Declines. The trade
+ window is **self-contained** — your carried stacks are listed inside it (click to offer, "All" for
+ the whole stack), so trading never depends on the separate inventory menu being open — and its
+ header is a **drag handle** so it can be moved out of the way. Both stage loose backpack
+ stacks (drag from the inventory grid, with −/+ qty steppers) and must **Confirm** before anything
+ moves. The swap is one synchronous, no-yield commit — re-validate holds → pre-flight both
+ receivers have room (new **`Inventory.canAccept`**) → remove both → grant with `addUpTo` → refund
+ any residue — so item count is conserved on every path. Auto-cancels on death / leave / walking
+ out of range (`MaxDistance`) / request timeout; a staging change resets both confirms. New
+ `Trade` server system + `TradeUi` client window, the `"Trading"` Config section (tunable in
+ SurvivorCore Studio), hooks `trade:started` / `trade:completed`, and a `trades_total` progression
+ counter. v1 trades loose backpack stacks only (worn gear reserved behind `AllowEquippedItems`).
+ See [docs/trading.md](docs/trading.md).
+
## 0.8.0 — 2026-07-16
### Added
diff --git a/README.md b/README.md
index 16d329c..0a4a720 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-> **Status: v0.8.0 — pre-release.** The core survival loop is in and working; the engine is
+> **Status: v0.9.0 — pre-release.** The core survival loop is in and working; the engine is
> being grown toward v1.0 and APIs may still shift. Production-tested in
> [The Counter Earth](https://thecounterearth.com).
@@ -40,6 +40,8 @@ authoring tools. If you know Roblox Studio, you can build a survival game.
- **Hunting & loot bags** — slain animals leave butcherable carcasses (knife required, real
yields); player death drops everything into a lootable bag with a countdown — get back to it
before it's gone.
+- **Player trading** — walk up, offer items, both confirm: a server-authoritative, **dupe-proof**
+ face-to-face swap (nothing moves until both agree, then atomically).
- **SurvivorCore Studio (no-code admin plugin)** — one floating window with sidebar + search:
tune **every engine config section** (movement, combat, mobs, loot bags, UI theme colors &
fonts, …) as locked deltas that survive engine updates; create items, weapons, ammo, mobs,
@@ -69,7 +71,7 @@ into `ReplicatedStorage`, or add it via [Wally](https://wally.run):
```toml
# wally.toml
[dependencies]
-SurvivorCore = "temujincalidius/survivorcore@0.8.0"
+SurvivorCore = "temujincalidius/survivorcore@0.9.0"
```
Working from source? Clone and `rojo serve` the `demo.project.json` place.
@@ -100,6 +102,7 @@ The **admin plugin** turns all of this into Studio forms — see
[Inventory](docs/inventory.md) · [Harvesting](docs/harvesting.md) · [Crafting](docs/crafting.md) ·
[Combat](docs/combat.md) · [Mobs & AI](docs/mobs.md) · [Quests](docs/quests.md) ·
[Achievements](docs/achievements.md) · [Loot bags](docs/loot-bags.md) ·
+[Player trading](docs/trading.md) · [Interact window](docs/interact.md) ·
[No-code content](docs/content-authoring.md) ·
[Admin plugin](docs/admin-plugin.md) · [Design language](docs/design-language.md) ·
[Extending](docs/extending.md)
diff --git a/demo/server/TradeTestStation.server.luau b/demo/server/TradeTestStation.server.luau
new file mode 100644
index 0000000..38b1b7b
--- /dev/null
+++ b/demo/server/TradeTestStation.server.luau
@@ -0,0 +1,199 @@
+--!nonstrict
+--[[
+ TradeTestStation — DEMO harness for player trading (issue #15). Not engine code.
+
+ Trading needs two players, so it can't be driven from a single-client playtest. This script
+ drives the internal `SurvivorCore.Trade._*` API with two REAL players and asserts ITEM
+ CONSERVATION — merge(A,B) before == merge(A,B) after — across the tricky paths.
+
+ Run it in a 2-player local server (Studio → Test → Start, 2 players), then have either player
+ chat `/tradetest`. Results print to the Server output. It can also be invoked from a server
+ command bar / MCP as `_G.SurvivorCoreTradeTest()`.
+
+ It seeds each player's inventory before every scenario, so it overwrites their items — a test
+ tool, not something to leave enabled in a shipping game.
+]]
+
+local Players = game:GetService("Players")
+local ReplicatedStorage = game:GetService("ReplicatedStorage")
+
+local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
+
+-- ── inventory helpers (direct attribute seeding — deterministic setup) ─────────
+
+local function clearSlots(player: Player)
+ for n = 1, 40 do
+ player:SetAttribute("InvSlot_" .. n, nil)
+ player:SetAttribute("InvQty_" .. n, nil)
+ end
+end
+
+local function seed(player: Player, entries: { { itemId: string, slot: number, qty: number } })
+ clearSlots(player)
+ for _, e in entries do
+ player:SetAttribute("InvSlot_" .. e.slot, e.itemId)
+ player:SetAttribute("InvQty_" .. e.slot, e.qty)
+ end
+end
+
+-- itemId -> total qty across all of a player's slots.
+local function snap(player: Player): { [string]: number }
+ local out = {}
+ for _, s in SurvivorCore.Inventory.getSlots(player) do
+ out[s.itemId] = (out[s.itemId] or 0) + s.qty
+ end
+ return out
+end
+
+local function mergeCounts(a: { [string]: number }, b: { [string]: number }): { [string]: number }
+ local out = {}
+ for k, v in a do
+ out[k] = (out[k] or 0) + v
+ end
+ for k, v in b do
+ out[k] = (out[k] or 0) + v
+ end
+ return out
+end
+
+local function sameCounts(a: { [string]: number }, b: { [string]: number }): boolean
+ for k, v in a do
+ if (b[k] or 0) ~= v then
+ return false
+ end
+ end
+ for k, v in b do
+ if (a[k] or 0) ~= v then
+ return false
+ end
+ end
+ return true
+end
+
+local function slotOf(player: Player, itemId: string): number?
+ for _, s in SurvivorCore.Inventory.getSlots(player) do
+ if s.itemId == itemId then
+ return s.slot
+ end
+ end
+ return nil
+end
+
+-- ── scenario driver ────────────────────────────────────────────────────────────
+
+local passed, failed = 0, 0
+
+local function check(name: string, ok: boolean, detail: string?)
+ if ok then
+ passed += 1
+ print(` ✓ {name}`)
+ else
+ failed += 1
+ warn(` ✗ {name}{if detail then " — " .. detail else ""}`)
+ end
+end
+
+-- Open a trade, stage each side by itemId, both confirm. Returns after the (synchronous) commit.
+local function runTrade(A: Player, B: Player, aStage: { [string]: number }, bStage: { [string]: number })
+ SurvivorCore.Trade._startTrade(A, B)
+ SurvivorCore.Trade._respond(B, true)
+ for itemId, qty in aStage do
+ local slot = slotOf(A, itemId)
+ if slot then
+ SurvivorCore.Trade._stage(A, slot, qty)
+ end
+ end
+ for itemId, qty in bStage do
+ local slot = slotOf(B, itemId)
+ if slot then
+ SurvivorCore.Trade._stage(B, slot, qty)
+ end
+ end
+ SurvivorCore.Trade._confirm(A)
+ SurvivorCore.Trade._confirm(B)
+end
+
+local function runSuite()
+ local players = Players:GetPlayers()
+ if #players < 2 then
+ warn("[TradeTest] need two players in the server — Start a 2-player local server.")
+ return
+ end
+ local A, B = players[1], players[2]
+ passed, failed = 0, 0
+ print(`[TradeTest] A={A.Name} B={B.Name}`)
+
+ -- 1. Happy-path swap: A gives reed×10, B gives berry×5.
+ do
+ seed(A, { { itemId = "reed", slot = 1, qty = 10 } })
+ seed(B, { { itemId = "berry", slot = 1, qty = 5 } })
+ local before = mergeCounts(snap(A), snap(B))
+ runTrade(A, B, { reed = 10 }, { berry = 5 })
+ local a1, b1 = snap(A), snap(B)
+ check("happy swap: conserved", sameCounts(before, mergeCounts(a1, b1)))
+ check("happy swap: A got berries", (a1.berry or 0) == 5 and (a1.reed or 0) == 0)
+ check("happy swap: B got reeds", (b1.reed or 0) == 10 and (b1.berry or 0) == 0)
+ check("happy swap: session cleared", SurvivorCore.Trade._activeFor(A) == nil)
+ end
+
+ -- 2. Receiver-full refund: B has no free slots, so A's offer can't land. Nothing moves.
+ do
+ local full = {}
+ for n = 1, 5 do -- base pocket slots
+ table.insert(full, { itemId = "berry", slot = n, qty = 1 })
+ end
+ seed(A, { { itemId = "reed", slot = 1, qty = 3 } })
+ seed(B, full)
+ local a0, b0 = snap(A), snap(B)
+ runTrade(A, B, { reed = 3 }, {}) -- A offers, B offers nothing → B has no room
+ local a1, b1 = snap(A), snap(B)
+ check("receiver-full: A unchanged", sameCounts(a0, a1))
+ check("receiver-full: B unchanged", sameCounts(b0, b1))
+ SurvivorCore.Trade._cancel(A) -- trade reopened on failure; close it
+ end
+
+ -- 3. Stage-more-than-held clamps to what the player actually holds.
+ do
+ seed(A, { { itemId = "reed", slot = 1, qty = 4 } })
+ seed(B, { { itemId = "berry", slot = 1, qty = 2 } })
+ SurvivorCore.Trade._startTrade(A, B)
+ SurvivorCore.Trade._respond(B, true)
+ SurvivorCore.Trade._stage(A, 1, 999) -- ask for 999, hold 4
+ SurvivorCore.Trade._stage(B, 1, 2)
+ local before = mergeCounts(snap(A), snap(B))
+ SurvivorCore.Trade._confirm(A)
+ SurvivorCore.Trade._confirm(B)
+ local a1, b1 = snap(A), snap(B)
+ check("stage clamp: conserved", sameCounts(before, mergeCounts(a1, b1)))
+ check("stage clamp: only 4 reeds moved", (b1.reed or 0) == 4)
+ end
+
+ -- 4. Cancel mid-trade leaves both inventories untouched (staging never escrows).
+ do
+ seed(A, { { itemId = "reed", slot = 1, qty = 6 } })
+ seed(B, { { itemId = "berry", slot = 1, qty = 6 } })
+ local a0, b0 = snap(A), snap(B)
+ SurvivorCore.Trade._startTrade(A, B)
+ SurvivorCore.Trade._respond(B, true)
+ SurvivorCore.Trade._stage(A, 1, 6)
+ SurvivorCore.Trade._stage(B, 1, 6)
+ SurvivorCore.Trade._cancel(A)
+ check("cancel mid-trade: A unchanged", sameCounts(a0, snap(A)))
+ check("cancel mid-trade: B unchanged", sameCounts(b0, snap(B)))
+ check("cancel mid-trade: session gone", SurvivorCore.Trade._activeFor(A) == nil)
+ end
+
+ print(`[TradeTest] done — {passed} passed, {failed} failed.`)
+end
+
+-- selene: allow(global_usage)
+_G.SurvivorCoreTradeTest = runSuite -- so a command bar / MCP can invoke the suite directly
+
+Players.PlayerAdded:Connect(function(player)
+ player.Chatted:Connect(function(message)
+ local trimmed = string.gsub(message, "%s+", "")
+ if string.lower(trimmed) == "/tradetest" then
+ runSuite()
+ end
+ end)
+end)
diff --git a/docs/combat.md b/docs/combat.md
index 758cc80..046a3b0 100644
--- a/docs/combat.md
+++ b/docs/combat.md
@@ -127,9 +127,14 @@ end)
```lua
Config.override("Combat", {
MeleeRange = 8, MeleeCooldown = 0.6, RequireLineOfSight = true, FriendlyFire = false,
- Bow = { Gravity = 80, ProjectileSpeed = 180, MaxRange = 300, MinDrawDamageMult = 0.3, StepSize = 4 },
+ Bow = {
+ Gravity = 80, ProjectileSpeed = 180, MaxRange = 300, MinDrawDamageMult = 0.3, StepSize = 4,
+ Cooldown = 0.35, -- seconds between accepted shots (fallback when a bow sets no weaponCooldown)
+ },
})
```
-Per-weapon `weapon*` values override these fallbacks. **Out of scope:** durability, blocking/parrying,
+Per-weapon `weapon*` values override these fallbacks — including **`weaponCooldown`, which governs
+both melee swings and bow shots**. Every shot is rate-limited server-side before any arrow is spent
+or any arc is simulated, so a client can't out-run its own fire rate. **Out of scope:** durability, blocking/parrying,
and AoE are creator content via the hooks above; mob AI lives in [mobs.md](mobs.md).
diff --git a/docs/extending.md b/docs/extending.md
index af89923..c8f1fc8 100644
--- a/docs/extending.md
+++ b/docs/extending.md
@@ -157,6 +157,7 @@ Engine systems fire hooks with `Hooks.run("name", ctx)`. The full catalogue live
| `quest:started` / `quest:progress` / `quest:completed` / `quest:blocked` | quests ([docs](quests.md)) |
| `achievement:unlocked` | achievements ([docs](achievements.md)) |
| `player:died` · `lootbag:dropped` / `lootbag:collected` | death & loot bags ([docs](loot-bags.md)) |
+| `trade:started` / `trade:completed` | player trading ([docs](trading.md)) |
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,
diff --git a/docs/getting-started.md b/docs/getting-started.md
index f2b3b98..65a4aec 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -34,7 +34,7 @@ Once published, add it to your game's `wally.toml`:
```toml
[dependencies]
-SurvivorCore = "temujincalidius/survivorcore@0.8.0"
+SurvivorCore = "temujincalidius/survivorcore@0.9.0"
```
Then:
diff --git a/docs/interact.md b/docs/interact.md
new file mode 100644
index 0000000..c971392
--- /dev/null
+++ b/docs/interact.md
@@ -0,0 +1,57 @@
+# Player interact window
+
+> 📹 **Demo:** [the interact window + player trading](https://makertube.net/w/sJmS6L15jRmwxhQCE4Zgmi)
+
+Walk up to another player and a **"[E] Interact"** badge appears over *their* head. Press **E** (or
+tap the badge) to open an **interact window** showing that player's name and survival stats, plus a
+list of **actions** — **Trade** ships built-in; games add their own
+([`src/client/PlayerInteract.luau`](../src/client/PlayerInteract.luau), ported from The Counter
+Earth).
+
+This is the front door for player-to-player interaction. It replaced an earlier per-character
+proximity prompt that (wrongly) showed on your *own* character — the interact target is chosen by a
+client-side **nearest-other-player** scan, so the affordance can never point at you.
+
+## Targeting
+
+- A throttled scan picks the **nearest other player within range** (`Trading.MaxDistance`, default 16
+ studs) and floats the badge over their head. It never considers the local player.
+- Light hysteresis keeps the badge from flickering between two players who are the same distance away.
+- The badge (and any open window) clears when you walk away, when the target leaves or dies, or while
+ you're already in a trade.
+
+## Actions API
+
+Actions are a small **client-side registry**, so a game (or a future engine system) can add its own
+interactions. Trade is registered by the engine as the first entry.
+
+```lua
+local SurvivorCore = require(ReplicatedStorage.SurvivorCore)
+
+SurvivorCore.Interact.addAction({
+ id = "wave", -- unique; re-adding the same id replaces it
+ label = "Wave",
+ order = 50, -- sort key (lower = earlier); default 100
+ enabled = function(ctx) -- optional; return false to hide the button
+ return true
+ end,
+ onActivate = function(ctx)
+ -- ctx = { target: Player, targetUserId: number, distance: number, close: () -> () }
+ Remotes.event("Wave"):FireServer(ctx.targetUserId)
+ ctx.close() -- hide the interact window
+ end,
+})
+```
+
+The built-in **Trade** action simply fires the `TradeRequest` remote with the target's `UserId`; the
+server (`Trade._startTrade`) validates everything and the [trade flow](trading.md) takes over.
+
+## Config
+
+The scan reuses the **`Trading.MaxDistance`** setting, and the interact key is
+**`UI.Keybinds.Interact`** (default `"E"`) — both editable no-code in SurvivorCore Studio (Engine
+Config → *Trading* / *UI & theme*) or via `Config.override`.
+
+---
+
+See also: [Trading](trading.md) · [Survival stats](survival-stats.md) · [Extending](extending.md).
diff --git a/docs/trading.md b/docs/trading.md
new file mode 100644
index 0000000..4b40f81
--- /dev/null
+++ b/docs/trading.md
@@ -0,0 +1,76 @@
+# Player trading
+
+> 📹 **Demo:** [walk up, offer, confirm — a dupe-proof player trade](https://makertube.net/w/sJmS6L15jRmwxhQCE4Zgmi)
+
+Two survivors standing near each other can **trade items** face-to-face
+([`src/systems/Trade.luau`](../src/systems/Trade.luau), issue #15). The swap is fully
+server-authoritative and **dupe-proof**: nothing moves until both players confirm, and even then it
+moves in one atomic step that can never create or destroy an item.
+
+## How a trade goes
+
+1. **Start it.** Walk up to another player — an **"[E] Interact"** badge appears over *their* head.
+ Press **E** (or tap it) to open the [interact window](interact.md), then choose **Trade**. They
+ get an **Accept / Decline** request; the requester waits.
+2. **Stage your offer.** Once open, both players see the trade window: *your offer* and *their
+ offer* side by side, with **your backpack listed underneath** — click a row to offer one, or
+ **All** for the whole stack. (You can also drag straight from the inventory grid if you have the
+ menu open.) The **−/+** steppers on a staged row set the quantity and **✕** removes it. Changing
+ either offer **clears both confirms** (so nobody can confirm and then swap the goods out from
+ under you). Drag the window by its **header** to move it out of the way.
+3. **Confirm.** Both players press **Confirm**. The instant both are confirmed, the server runs the
+ atomic swap and the items change hands.
+
+Either side can **Cancel** at any time. A trade also auto-cancels if a trader **dies**, **leaves**,
+or **walks out of range** (see `MaxDistance`), and a pending request expires after
+`RequestTimeoutSeconds`.
+
+## Why it can't dupe
+
+Staging is **by reference, not escrow** — while the window is open your items stay in your
+inventory; the "offer" is just a list of intentions. Real inventory changes happen only in the
+commit, in one synchronous step:
+
+1. Re-check both players still **hold** everything they offered.
+2. Pre-check both players have **room** for what they're about to receive
+ (`Inventory.canAccept`, weight + free slots, accounting for what each is giving away).
+3. Remove both offers, grant them to the other side with the exact-count primitive
+ (`Inventory.addUpTo`), and refund anything that somehow doesn't fit.
+
+Because the whole commit runs without yielding, nothing else can slip in between the steps — the
+item count is conserved on every path. If a receiver turns out to be full, the trade simply reopens
+with a "not enough room" notice and nothing is lost.
+
+## What can be traded
+
+**v1: loose backpack stacks only.** Worn equipment and satchels aren't tradeable yet — they change
+carry capacity, which needs extra care. Flip `AllowEquippedItems` on when that lands.
+
+## Configuration
+
+```lua
+Config.override("Trading", {
+ Enabled = true, -- false = trading off (the prompt never appears)
+ MaxDistance = 16, -- studs; how close to open AND keep a trade
+ RequestTimeoutSeconds = 20,
+ ResetConfirmOnChange = true, -- a staging change clears both confirms
+ AllowEquippedItems = false, -- reserved: trade worn gear/satchels too
+})
+```
+
+All of these are also editable no-code in **SurvivorCore Studio** (Engine Config → *Trading*).
+
+## Hooks & events
+
+| Event | Payload |
+|---|---|
+| `trade:started` | `{ player, partner }` — fired once per player when both accept |
+| `trade:completed` | `{ player, partner, gave, got }` — fired once per player on a successful swap |
+
+Both also cross the EventBridge, and `trade:completed` feeds the Progression stream as a **`trade`**
+counter (`trades_total`), so quests and achievements can reward trading out of the box. Progress is
+**session-scoped** — persistence (DataStore) is a future system.
+
+---
+
+See also: [Inventory](inventory.md) · [Loot bags](loot-bags.md) · [Extending](extending.md).
diff --git a/plugin/ContentAdmin.luau b/plugin/ContentAdmin.luau
index a548d4d..ad0b300 100644
--- a/plugin/ContentAdmin.luau
+++ b/plugin/ContentAdmin.luau
@@ -64,7 +64,13 @@ ContentAdmin.CATEGORIES = {
{ attr = "weaponKind", kind = "string", label = "Kind", default = "melee", placeholder = "melee / bow" },
{ attr = "weaponDamage", kind = "number", label = "Damage", default = 10 },
{ attr = "weaponRange", kind = "number", label = "Range (melee)", default = 8 },
- { attr = "weaponCooldown", kind = "number", label = "Cooldown", default = 0.6 },
+ {
+ attr = "weaponCooldown",
+ kind = "number",
+ label = "Cooldown (s)",
+ default = 0.6,
+ placeholder = "melee swings AND bow shots",
+ },
{ attr = "weaponDrawTime", kind = "number", label = "Draw time (bow)", default = 1 },
{ attr = "weaponProjectileSpeed", kind = "number", label = "Arrow speed (bow)", default = 180 },
{
diff --git a/site/index.html b/site/index.html
index b055f44..d899c71 100644
--- a/site/index.html
+++ b/site/index.html
@@ -55,7 +55,7 @@
Event-driven goals: quest chains with objectives, rewards and quest-giver NPCs, plus milestone achievements with toasts — tracked automatically from what players already do.
+
+
+
+
+
Player trading
+
Walk up to another survivor and an interact window opens — their name, their condition, and what you can do. Choose Trade and both of you stage items, see each other's offer live, and confirm. The swap is dupe-proof by construction: nothing leaves an inventory until both sides agree, and then it moves in one atomic step that can't create or destroy an item.
+
@@ -172,6 +179,7 @@
Quests & achievements
Hunting & loot bags
SurvivorCore Studio
+
Player trading
@@ -187,7 +195,7 @@
Grab the drop-in model from the latest release, or add it with Wally:
…or drop SurvivorCore.rbxm into ReplicatedStorage.
Latest release ↗
diff --git a/src/client/PlayerInteract.luau b/src/client/PlayerInteract.luau
new file mode 100644
index 0000000..06bcdc7
--- /dev/null
+++ b/src/client/PlayerInteract.luau
@@ -0,0 +1,547 @@
+--!nonstrict
+--[[
+ PlayerInteract — client. Walk-up player interaction (ported from The Counter Earth's inspect
+ window). CLIENT-ONLY.
+
+ Get close to another player and a "[E] Interact" billboard appears over THEIR head (never your
+ own). Pressing the interact key (or tapping the billboard) opens an interact window showing that
+ player's name + survival stats and a list of ACTIONS. "Trade" is the first built-in action; games
+ add more with `SurvivorCore.Interact.addAction{…}`.
+
+ This replaces the old per-character server ProximityPrompt (which wrongly showed on your own
+ character). Targeting is a throttled nearest-other-in-range scan — self is skipped in the loop,
+ so the affordance can never point at you. The "Trade" action just fires the existing
+ `TradeRequest` remote; the server (`Trade._startTrade`) does all the validation, and the existing
+ invite/Accept flow (TradeUi) takes over. Booted by SurvivorCore.startClient().
+]]
+
+local Players = game:GetService("Players")
+local RunService = game:GetService("RunService")
+local UserInputService = game:GetService("UserInputService")
+
+assert(RunService:IsClient(), "SurvivorCore.PlayerInteract is client-only — boot via SurvivorCore.startClient()")
+
+local Remotes = require(script.Parent.Parent.shared.Remotes)
+local UiConfig = require(script.Parent.Parent.shared.UiConfig)
+local TradingConfig = require(script.Parent.Parent.shared.TradingConfig)
+local StatConfig = require(script.Parent.Parent.stats.StatConfig)
+
+local PlayerInteract = {}
+
+local started = false
+local localPlayer = Players.LocalPlayer
+
+local SCAN_INTERVAL = 0.2
+local SWITCH_MARGIN = 1.5 -- hysteresis: a new target must be this much closer to steal focus
+
+local currentTarget: Player? = nil
+local tradeActive = false -- suppress interaction while a trade window is up
+
+-- ── action registry (buffered; addAction works before or after start) ─────────
+local actions: { any } = {}
+
+-- spec = { id, label, order?, icon?, enabled?(ctx)->bool, onActivate(ctx) }
+-- ctx = { target, targetUserId, distance, close }
+function PlayerInteract.addAction(spec: any)
+ assert(
+ type(spec) == "table" and type(spec.id) == "string" and type(spec.onActivate) == "function",
+ "Interact.addAction: spec needs { id: string, onActivate: function, … }"
+ )
+ for i, a in actions do
+ if a.id == spec.id then
+ actions[i] = spec -- replace-by-id, so a game can override a built-in
+ return
+ end
+ end
+ table.insert(actions, spec)
+end
+
+-- ── theme helpers (same idiom as TradeUi) ──────────────────────────────────────
+
+local function theme(): any
+ return UiConfig.get().Theme or {}
+end
+
+local function corner(inst: Instance, radius: number)
+ local c = Instance.new("UICorner")
+ c.CornerRadius = UDim.new(0, radius)
+ c.Parent = inst
+end
+
+local function button(text: string, bg: Color3): TextButton
+ local t = theme()
+ local b = Instance.new("TextButton")
+ b.AutoButtonColor = true
+ b.Text = text
+ b.Font = t.FontBold or Enum.Font.GothamBold
+ b.TextSize = 14
+ b.TextColor3 = t.Text or Color3.fromRGB(245, 245, 245)
+ b.BackgroundColor3 = bg
+ b.BorderSizePixel = 0
+ corner(b, tonumber(t.CornerRadius) or 8)
+ return b
+end
+
+-- ── the interact window ────────────────────────────────────────────────────────
+
+local windowPanel: Frame? = nil
+local windowBody: Frame? = nil
+local savedMouseBehavior: Enum.MouseBehavior? = nil
+
+local function ensureWindow(): Frame?
+ if windowPanel and windowPanel.Parent then
+ return windowPanel
+ end
+ local playerGui = localPlayer:FindFirstChildOfClass("PlayerGui")
+ if not playerGui then
+ return nil
+ end
+ local t = theme()
+
+ local gui = Instance.new("ScreenGui")
+ gui.Name = "SurvivorCoreInteract"
+ gui.ResetOnSpawn = false
+ gui.DisplayOrder = 70 -- below TradeUi (90), above menu/HUD
+ gui.Enabled = true
+
+ local panel = Instance.new("Frame")
+ panel.Name = "Panel"
+ panel.AnchorPoint = Vector2.new(0.5, 0.5)
+ panel.Position = UDim2.fromScale(0.5, 0.5)
+ panel.Size = UDim2.fromOffset(320, 300)
+ panel.BackgroundColor3 = t.PanelColor or Color3.fromRGB(20, 23, 30)
+ panel.BackgroundTransparency = 0.1
+ panel.BorderSizePixel = 0
+ panel.Active = true
+ panel.Visible = false
+ corner(panel, tonumber(t.CornerRadius) or 10)
+
+ local pad = Instance.new("UIPadding")
+ pad.PaddingTop = UDim.new(0, 12)
+ pad.PaddingBottom = UDim.new(0, 12)
+ pad.PaddingLeft = UDim.new(0, 14)
+ pad.PaddingRight = UDim.new(0, 14)
+ pad.Parent = panel
+
+ local close = button("✕", Color3.fromRGB(90, 60, 60))
+ close.Size = UDim2.fromOffset(24, 24)
+ close.AnchorPoint = Vector2.new(1, 0)
+ close.Position = UDim2.fromScale(1, 0)
+ close.Parent = panel
+ close.MouseButton1Click:Connect(function()
+ PlayerInteract.closeWindow()
+ end)
+
+ local content = Instance.new("Frame")
+ content.Name = "Body"
+ content.BackgroundTransparency = 1
+ content.Position = UDim2.fromOffset(0, 28)
+ content.Size = UDim2.new(1, 0, 1, -28)
+ content.Parent = panel
+
+ panel.Parent = gui
+ gui.Parent = playerGui
+ windowPanel = panel
+ windowBody = content
+ return panel
+end
+
+-- One stat bar row for the target (read from replicated Player attributes).
+local function statRow(def: any, target: Player, order: number): Frame
+ local t = theme()
+ local attr = def.attribute or def.name
+ local value = tonumber(target:GetAttribute(attr))
+ local max = tonumber(def.max) or 100
+ local ratio = if value and max > 0 then math.clamp(value / max, 0, 1) else 0
+ local fillRatio = if def.invert then 1 - ratio else ratio
+
+ local row = Instance.new("Frame")
+ row.Size = UDim2.new(1, 0, 0, 22)
+ row.BackgroundTransparency = 1
+ row.LayoutOrder = order
+
+ local name = Instance.new("TextLabel")
+ name.Size = UDim2.fromScale(0.34, 1)
+ name.BackgroundTransparency = 1
+ name.Text = def.name
+ name.TextColor3 = t.TextSecondary or Color3.fromRGB(200, 205, 215)
+ name.Font = t.Font or Enum.Font.GothamMedium
+ name.TextSize = 12
+ name.TextXAlignment = Enum.TextXAlignment.Left
+ name.Parent = row
+
+ local track = Instance.new("Frame")
+ track.Size = UDim2.new(0.66, -40, 0, 8)
+ track.Position = UDim2.new(0.34, 0, 0.5, -4)
+ track.BackgroundColor3 = t.SlotColor or Color3.fromRGB(28, 32, 42)
+ track.BorderSizePixel = 0
+ corner(track, 4)
+ local fill = Instance.new("Frame")
+ fill.Size = UDim2.fromScale(fillRatio, 1)
+ fill.BackgroundColor3 = if def.dangerHigh
+ then Color3.fromRGB(200, 90, 80)
+ else (t.Ok or Color3.fromRGB(120, 190, 120))
+ fill.BorderSizePixel = 0
+ corner(fill, 4)
+ fill.Parent = track
+ track.Parent = row
+
+ local val = Instance.new("TextLabel")
+ val.Size = UDim2.fromOffset(38, 22)
+ val.Position = UDim2.new(1, -38, 0, 0)
+ val.BackgroundTransparency = 1
+ val.Text = if value then tostring(math.floor(value)) else "—"
+ val.TextColor3 = t.Text or Color3.fromRGB(235, 238, 245)
+ val.Font = t.Font or Enum.Font.GothamMedium
+ val.TextSize = 12
+ val.TextXAlignment = Enum.TextXAlignment.Right
+ val.Parent = row
+
+ return row
+end
+
+local function renderWindow(target: Player)
+ local panel = ensureWindow()
+ if not panel or not windowBody then
+ return
+ end
+ for _, c in windowBody:GetChildren() do
+ c:Destroy()
+ end
+ local t = theme()
+
+ local header = Instance.new("TextLabel")
+ header.Size = UDim2.new(1, -28, 0, 24)
+ header.BackgroundTransparency = 1
+ header.Text = target.DisplayName
+ header.TextColor3 = t.Accent or Color3.fromRGB(204, 166, 102)
+ header.Font = t.FontBold or Enum.Font.GothamBold
+ header.TextSize = 18
+ header.TextXAlignment = Enum.TextXAlignment.Left
+ header.TextTruncate = Enum.TextTruncate.AtEnd
+ header.Parent = windowBody
+
+ -- Stat bars (only stats flagged for display; new stats appear automatically).
+ local statsHolder = Instance.new("Frame")
+ statsHolder.BackgroundTransparency = 1
+ statsHolder.Position = UDim2.fromOffset(0, 30)
+ statsHolder.Size = UDim2.new(1, 0, 1, -74)
+ local sl = Instance.new("UIListLayout")
+ sl.Padding = UDim.new(0, 3)
+ sl.SortOrder = Enum.SortOrder.LayoutOrder
+ sl.Parent = statsHolder
+ local order = 0
+ for _, def in StatConfig.resolve().stats do
+ if def.display ~= false then
+ order += 1
+ statRow(def, target, order).Parent = statsHolder
+ end
+ end
+ statsHolder.Parent = windowBody
+
+ -- Action buttons (registry, sorted, enable-filtered).
+ local ctx = {
+ target = target,
+ targetUserId = target.UserId,
+ distance = 0,
+ close = function()
+ PlayerInteract.closeWindow()
+ end,
+ }
+ local hrp = target.Character and target.Character:FindFirstChild("HumanoidRootPart")
+ local lhrp = localPlayer.Character and localPlayer.Character:FindFirstChild("HumanoidRootPart")
+ if hrp and lhrp then
+ ctx.distance = (hrp.Position - lhrp.Position).Magnitude
+ end
+
+ local shown = {}
+ for _, a in actions do
+ if not a.enabled or a.enabled(ctx) then
+ table.insert(shown, a)
+ end
+ end
+ table.sort(shown, function(a, b)
+ return (tonumber(a.order) or 100) < (tonumber(b.order) or 100)
+ end)
+
+ local actionRow = Instance.new("Frame")
+ actionRow.BackgroundTransparency = 1
+ actionRow.Position = UDim2.new(0, 0, 1, -34)
+ actionRow.Size = UDim2.new(1, 0, 0, 34)
+ local al = Instance.new("UIListLayout")
+ al.FillDirection = Enum.FillDirection.Horizontal
+ al.Padding = UDim.new(0, 6)
+ al.Parent = actionRow
+ if #shown == 0 then
+ local none = Instance.new("TextLabel")
+ none.Size = UDim2.fromScale(1, 1)
+ none.BackgroundTransparency = 1
+ none.Text = "No actions available"
+ none.TextColor3 = t.TextSecondary or Color3.fromRGB(150, 160, 180)
+ none.Font = t.Font or Enum.Font.GothamMedium
+ none.TextSize = 13
+ none.Parent = actionRow
+ else
+ local w = 1 / #shown
+ for i, a in shown do
+ local b = button(a.label or a.id, t.Accent or Color3.fromRGB(120, 170, 90))
+ b.Size = UDim2.new(w, -6, 1, 0)
+ b.LayoutOrder = i
+ b.Parent = actionRow
+ b.MouseButton1Click:Connect(function()
+ local ok, err = pcall(a.onActivate, ctx)
+ if not ok then
+ warn(`[SurvivorCore.Interact] action '{a.id}' errored: {tostring(err)}`)
+ end
+ end)
+ end
+ end
+ actionRow.Parent = windowBody
+end
+
+function PlayerInteract.openWindow(target: Player)
+ if not target or target == localPlayer or not target.Parent then
+ return
+ end
+ local panel = ensureWindow()
+ if not panel then
+ return
+ end
+ renderWindow(target)
+ panel.Visible = true
+ -- Free the cursor so the buttons are clickable (first person locks it centred).
+ if savedMouseBehavior == nil then
+ savedMouseBehavior = UserInputService.MouseBehavior
+ end
+ UserInputService.MouseBehavior = Enum.MouseBehavior.Default
+end
+
+function PlayerInteract.closeWindow()
+ if windowPanel then
+ windowPanel.Visible = false
+ end
+ if savedMouseBehavior ~= nil then
+ UserInputService.MouseBehavior = savedMouseBehavior
+ savedMouseBehavior = nil
+ end
+end
+
+local function windowOpen(): boolean
+ return windowPanel ~= nil and windowPanel.Visible
+end
+
+-- ── the billboard affordance ────────────────────────────────────────────────────
+
+local billboard: BillboardGui? = nil
+
+local function ensureBillboard(): BillboardGui?
+ if billboard and billboard.Parent then
+ return billboard
+ end
+ local playerGui = localPlayer:FindFirstChildOfClass("PlayerGui")
+ if not playerGui then
+ return nil
+ end
+ local t = theme()
+ local bb = Instance.new("BillboardGui")
+ bb.Name = "SurvivorCoreInteractPrompt"
+ bb.Size = UDim2.fromOffset(150, 30)
+ bb.StudsOffset = Vector3.new(0, 3, 0)
+ bb.AlwaysOnTop = true
+ bb.MaxDistance = 60
+ bb.Enabled = false
+
+ local btn = Instance.new("TextButton")
+ btn.Name = "Btn"
+ btn.Size = UDim2.fromScale(1, 1)
+ btn.BackgroundColor3 = t.PanelColor or Color3.fromRGB(20, 23, 30)
+ btn.BackgroundTransparency = 0.2
+ btn.AutoButtonColor = true
+ btn.Text = ""
+ btn.Font = t.FontBold or Enum.Font.GothamBold
+ btn.TextSize = 13
+ btn.TextColor3 = t.Text or Color3.fromRGB(235, 238, 245)
+ corner(btn, tonumber(t.CornerRadius) or 8)
+ btn.Parent = bb
+ btn.MouseButton1Click:Connect(function()
+ if currentTarget then
+ PlayerInteract.openWindow(currentTarget)
+ end
+ end)
+
+ bb.Parent = playerGui
+ billboard = bb
+ return bb
+end
+
+local function setTarget(target: Player?)
+ currentTarget = target
+ local bb = ensureBillboard()
+ if not bb then
+ return
+ end
+ if target and target.Character then
+ local head = target.Character:FindFirstChild("Head") or target.Character:FindFirstChild("HumanoidRootPart")
+ bb.Adornee = head
+ local btn = bb:FindFirstChild("Btn")
+ if btn and btn:IsA("TextButton") then
+ btn.Text = `[E] Interact — {target.DisplayName}`
+ end
+ bb.Enabled = head ~= nil and not tradeActive
+ else
+ bb.Adornee = nil
+ bb.Enabled = false
+ end
+end
+
+-- ── scan ─────────────────────────────────────────────────────────────────────
+
+local function localHRP(): BasePart?
+ local char = localPlayer.Character
+ local hrp = char and char:FindFirstChild("HumanoidRootPart")
+ return if hrp and hrp:IsA("BasePart") then hrp else nil
+end
+
+local function targetValid(target: Player?, myPos: Vector3, maxDist: number): boolean
+ if not target or target == localPlayer or not target.Parent then
+ return false
+ end
+ local char = target.Character
+ local hrp = char and char:FindFirstChild("HumanoidRootPart")
+ local hum = char and char:FindFirstChildOfClass("Humanoid")
+ if not hrp or not hum or hum.Health <= 0 then
+ return false
+ end
+ return (hrp.Position - myPos).Magnitude <= maxDist
+end
+
+local function scan()
+ if tradeActive then
+ if currentTarget then
+ setTarget(nil)
+ end
+ if windowOpen() then
+ PlayerInteract.closeWindow()
+ end
+ return
+ end
+ local myHRP = localHRP()
+ if not myHRP then
+ if currentTarget then
+ setTarget(nil)
+ PlayerInteract.closeWindow()
+ end
+ return
+ end
+ local maxDist = tonumber(TradingConfig.get().MaxDistance) or 16
+
+ -- Nearest OTHER player in range (self skipped → never targets you).
+ local bestPlayer, bestDist = nil, math.huge
+ for _, plr in Players:GetPlayers() do
+ if plr ~= localPlayer and plr.Character then
+ local hrp = plr.Character:FindFirstChild("HumanoidRootPart")
+ local hum = plr.Character:FindFirstChildOfClass("Humanoid")
+ if hrp and hum and hum.Health > 0 then
+ local d = (hrp.Position - myHRP.Position).Magnitude
+ if d <= maxDist and d < bestDist then
+ bestPlayer, bestDist = plr, d
+ end
+ end
+ end
+ end
+
+ -- Keep the current target unless it's invalid or a new one is meaningfully closer (hysteresis).
+ local cur = currentTarget
+ if cur and targetValid(cur, myHRP.Position, maxDist) then
+ local curChar = cur.Character
+ local curHRP = curChar and curChar:FindFirstChild("HumanoidRootPart")
+ local curDist = if curHRP then (curHRP.Position - myHRP.Position).Magnitude else math.huge
+ if bestPlayer and bestPlayer ~= cur and bestDist < curDist - SWITCH_MARGIN then
+ setTarget(bestPlayer)
+ end
+ else
+ setTarget(bestPlayer)
+ -- If the open window's subject vanished/left range, close it.
+ if windowOpen() and not (currentTarget and targetValid(currentTarget, myHRP.Position, maxDist)) then
+ PlayerInteract.closeWindow()
+ end
+ end
+end
+
+-- ── input ───────────────────────────────────────────────────────────────────────
+
+local function interactKeyCode(): Enum.KeyCode
+ local name = (UiConfig.get().Keybinds or {}).Interact or "E"
+ local ok, kc = pcall(function()
+ return (Enum.KeyCode :: any)[name]
+ end)
+ if ok and typeof(kc) == "EnumItem" then
+ return kc :: Enum.KeyCode
+ end
+ return Enum.KeyCode.E
+end
+
+function PlayerInteract.start(_options: { [string]: any }?)
+ if started then
+ return
+ end
+ started = true
+
+ -- Built-in Trade action: fire the existing request remote; the server validates and the
+ -- invite/Accept flow (TradeUi) takes over.
+ PlayerInteract.addAction({
+ id = "trade",
+ label = "Trade",
+ order = 10,
+ enabled = function(ctx)
+ return TradingConfig.get().Enabled ~= false
+ and ctx.distance <= (tonumber(TradingConfig.get().MaxDistance) or 16)
+ end,
+ onActivate = function(ctx)
+ Remotes.event("TradeRequest"):FireServer(ctx.targetUserId)
+ ctx.close()
+ end,
+ })
+
+ -- Suppress interaction while a trade window is up; reopen scanning when it closes.
+ Remotes.event("TradeState").OnClientEvent:Connect(function(state)
+ local status = state and state.status
+ tradeActive = status ~= nil and status ~= "cancelled" and status ~= "done"
+ if tradeActive then
+ PlayerInteract.closeWindow()
+ setTarget(nil)
+ end
+ end)
+
+ UserInputService.InputBegan:Connect(function(input, gameProcessed)
+ if gameProcessed or tradeActive then
+ return
+ end
+ if input.KeyCode == interactKeyCode() and currentTarget then
+ if windowOpen() then
+ PlayerInteract.closeWindow()
+ else
+ PlayerInteract.openWindow(currentTarget)
+ end
+ end
+ end)
+
+ Players.PlayerRemoving:Connect(function(plr)
+ if plr == currentTarget then
+ setTarget(nil)
+ PlayerInteract.closeWindow()
+ end
+ end)
+
+ local accum = 0
+ RunService.Heartbeat:Connect(function(dt)
+ accum += dt
+ if accum < SCAN_INTERVAL then
+ return
+ end
+ accum = 0
+ scan()
+ end)
+end
+
+return PlayerInteract
diff --git a/src/client/TradeUi.luau b/src/client/TradeUi.luau
new file mode 100644
index 0000000..81cc55c
--- /dev/null
+++ b/src/client/TradeUi.luau
@@ -0,0 +1,678 @@
+--!nonstrict
+--[[
+ TradeUi — client. The player-to-player trade window (issue #15).
+
+ NOT a menu tab: a transient, server-driven surface with its own ScreenGui (built on demand, the
+ Toasts idiom). The server pushes a plain-data `TradeState` and this renders it:
+ • "invite" → an incoming request with Accept / Decline (the invitee).
+ • "waiting" → "waiting for …" + Cancel (the requester).
+ • "open" → two columns (your offer / their offer), each side's confirm state, Confirm/Cancel.
+ • "cancelled" / "done" → the window closes (a toast explains why).
+
+ The window is SELF-CONTAINED: your carried stacks are listed inside it (read from the replicated
+ inventory attributes), so trading never depends on the separate inventory menu being open — click
+ a backpack row to offer one, "All" for the stack. Dragging a slot from the inventory grid onto
+ the "Your offer" column still works when that menu happens to be open (cross-ScreenGui DragDrop).
+ Each staged row has −/+ steppers and a remove button. The header is a DRAG HANDLE, so the window
+ can be moved out of the way. Both sides must Confirm; the server does the atomic swap. `mine`
+ renders from the pushed list; `theirs` is built manually (SlotGrid reads only the local player).
+ Styled from the UI Config Theme. Booted by SurvivorCore.startClient().
+]]
+
+local Players = game:GetService("Players")
+local RunService = game:GetService("RunService")
+local UserInputService = game:GetService("UserInputService")
+
+assert(RunService:IsClient(), "SurvivorCore.TradeUi 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 InventoryTypes = require(script.Parent.Parent.shared.InventoryTypes)
+local ItemData = require(script.Parent.Parent.shared.ItemData)
+local SlotGrid = require(script.Parent.SlotGrid)
+local DragDrop = require(script.Parent.DragDrop)
+
+local TradeUi = {}
+
+local started = false
+local localPlayer = Players.LocalPlayer
+
+local root: Frame? = nil
+local body: Frame? = nil
+local offerZone: Frame? = nil -- the "Your offer" column; the drag drop-zone
+local lastStatus: string? = nil
+
+-- ── Theme helpers ──────────────────────────────────────────────────────────────
+
+local function theme(): any
+ return UiConfig.get().Theme or {}
+end
+
+local function corner(inst: Instance, radius: number)
+ local c = Instance.new("UICorner")
+ c.CornerRadius = UDim.new(0, radius)
+ c.Parent = inst
+end
+
+local function label(props: { [string]: any }): TextLabel
+ local t = theme()
+ local l = Instance.new("TextLabel")
+ l.BackgroundTransparency = 1
+ l.TextColor3 = props.color or t.Text or Color3.fromRGB(235, 238, 245)
+ l.Font = props.font or t.Font or Enum.Font.GothamMedium
+ l.TextSize = props.size or 13
+ l.TextXAlignment = props.align or Enum.TextXAlignment.Left
+ l.Text = props.text or ""
+ l.Size = props.size2 or UDim2.new(1, 0, 0, 20)
+ if props.pos then
+ l.Position = props.pos
+ end
+ l.TextTruncate = Enum.TextTruncate.AtEnd
+ return l
+end
+
+local function button(text: string, bg: Color3): TextButton
+ local t = theme()
+ local b = Instance.new("TextButton")
+ b.AutoButtonColor = true
+ b.Text = text
+ b.Font = t.FontBold or Enum.Font.GothamBold
+ b.TextSize = 14
+ b.TextColor3 = t.Text or Color3.fromRGB(245, 245, 245)
+ b.BackgroundColor3 = bg
+ b.BorderSizePixel = 0
+ corner(b, tonumber(t.CornerRadius) or 8)
+ return b
+end
+
+-- ── Inventory lookups (for staging qty from local attributes) ────────────────
+
+local function slotHolding(itemId: string): number?
+ local maxN = math.floor(tonumber(localPlayer:GetAttribute(InventoryTypes.MAX_SLOTS_ATTR)) or 0)
+ for n = 1, maxN do
+ if localPlayer:GetAttribute(InventoryTypes.invSlotAttr(n)) == itemId then
+ return n
+ end
+ end
+ return nil
+end
+
+-- The local player's carried stacks, read straight off the replicated attributes — so the trade
+-- window can show your backpack WITHOUT the separate inventory menu being open.
+local function carriedStacks(): { { slot: number, itemId: string, qty: number } }
+ local out = {}
+ local maxN = math.floor(tonumber(localPlayer:GetAttribute(InventoryTypes.MAX_SLOTS_ATTR)) or 0)
+ for n = 1, maxN do
+ local id = localPlayer:GetAttribute(InventoryTypes.invSlotAttr(n))
+ local qty = tonumber(localPlayer:GetAttribute(InventoryTypes.invQtyAttr(n))) or 0
+ if typeof(id) == "string" and id ~= "" and qty > 0 then
+ table.insert(out, { slot = n, itemId = id, qty = math.floor(qty) })
+ end
+ end
+ return out
+end
+
+-- Drag the window by its header, so it never sits on top of something you need to see.
+local function makeDraggable(panel: Frame, handle: GuiObject)
+ local dragging = false
+ local dragStart = Vector3.zero
+ local startPos = panel.Position
+
+ handle.InputBegan:Connect(function(input)
+ if
+ input.UserInputType == Enum.UserInputType.MouseButton1
+ or input.UserInputType == Enum.UserInputType.Touch
+ then
+ dragging = true
+ dragStart = input.Position
+ startPos = panel.Position
+ input.Changed:Connect(function()
+ if input.UserInputState == Enum.UserInputState.End then
+ dragging = false
+ end
+ end)
+ end
+ end)
+
+ UserInputService.InputChanged:Connect(function(input)
+ if
+ dragging
+ and (
+ input.UserInputType == Enum.UserInputType.MouseMovement
+ or input.UserInputType == Enum.UserInputType.Touch
+ )
+ then
+ local delta = input.Position - dragStart
+ panel.Position =
+ UDim2.new(startPos.X.Scale, startPos.X.Offset + delta.X, startPos.Y.Scale, startPos.Y.Offset + delta.Y)
+ end
+ end)
+end
+
+-- ── GUI scaffold ────────────────────────────────────────────────────────────
+
+local function ensureGui(): Frame?
+ if root and root.Parent then
+ return root
+ end
+ local playerGui = localPlayer:FindFirstChildOfClass("PlayerGui")
+ if not playerGui then
+ return nil
+ end
+ local t = theme()
+
+ local gui = Instance.new("ScreenGui")
+ gui.Name = "SurvivorCoreTrade"
+ gui.ResetOnSpawn = false
+ gui.DisplayOrder = 90 -- above the menu + toasts
+ gui.Enabled = true
+
+ local panel = Instance.new("Frame")
+ panel.Name = "Panel"
+ panel.AnchorPoint = Vector2.new(1, 0.5)
+ panel.Position = UDim2.new(1, -20, 0.5, 0) -- right side by default; the header drags it anywhere
+ panel.Size = UDim2.fromOffset(420, 476)
+ panel.BackgroundColor3 = t.PanelColor or Color3.fromRGB(20, 23, 30)
+ panel.BackgroundTransparency = 0.1
+ panel.BorderSizePixel = 0
+ panel.Active = true -- sink input
+ panel.Visible = false
+ corner(panel, tonumber(t.CornerRadius) or 10)
+
+ local pad = Instance.new("UIPadding")
+ pad.PaddingTop = UDim.new(0, 12)
+ pad.PaddingBottom = UDim.new(0, 12)
+ pad.PaddingLeft = UDim.new(0, 14)
+ pad.PaddingRight = UDim.new(0, 14)
+ pad.Parent = panel
+
+ -- Header doubles as the drag handle (Active so it receives input).
+ local head = Instance.new("Frame")
+ head.Name = "Head"
+ head.BackgroundTransparency = 1
+ head.Active = true
+ head.Size = UDim2.new(1, 0, 0, 22)
+ head.Parent = panel
+
+ local headText = label({
+ text = "Trade",
+ font = t.FontBold or Enum.Font.GothamBold,
+ size = 16,
+ color = t.Accent or Color3.fromRGB(204, 166, 102),
+ size2 = UDim2.fromScale(1, 1),
+ })
+ headText.Name = "Title"
+ headText.Parent = head
+
+ local dragHint = label({
+ text = "⠿ drag",
+ size = 11,
+ color = t.TextSecondary or Color3.fromRGB(150, 160, 180),
+ align = Enum.TextXAlignment.Right,
+ size2 = UDim2.fromScale(1, 1),
+ })
+ dragHint.Parent = head
+
+ makeDraggable(panel, head)
+
+ local content = Instance.new("Frame")
+ content.Name = "Body"
+ content.BackgroundTransparency = 1
+ content.Position = UDim2.fromOffset(0, 28)
+ content.Size = UDim2.new(1, 0, 1, -28)
+ content.Parent = panel
+
+ panel.Parent = gui
+ gui.Parent = playerGui
+ root = panel
+ body = content
+ return panel
+end
+
+local function clearBody()
+ if not body then
+ return
+ end
+ for _, c in body:GetChildren() do
+ c:Destroy()
+ end
+ offerZone = nil
+end
+
+-- ── Item rows ─────────────────────────────────────────────────────────────────
+
+-- One offer row. `mineControls` adds −/+/✕ (for the local player's editable side).
+local function offerRow(entry: any, mineControls: boolean): Frame
+ local t = theme()
+ local row = Instance.new("Frame")
+ row.Size = UDim2.new(1, 0, 0, 28)
+ row.BackgroundColor3 = t.SlotColor or Color3.fromRGB(30, 34, 44)
+ row.BackgroundTransparency = 0.25
+ row.BorderSizePixel = 0
+ corner(row, 6)
+
+ local icon = Instance.new("ImageLabel")
+ icon.Size = UDim2.fromOffset(20, 20)
+ icon.Position = UDim2.fromOffset(4, 4)
+ icon.BackgroundTransparency = 1
+ icon.Image = SlotGrid.resolveItemIcon(entry.itemId)
+ icon.Parent = row
+
+ local def = ItemData.get(entry.itemId)
+ local name = (def and def.name) or entry.itemId
+ local nameLabel = label({
+ text = name,
+ size2 = UDim2.new(1, if mineControls then -140 else -70, 1, 0),
+ pos = UDim2.fromOffset(30, 0),
+ })
+ nameLabel.Parent = row
+
+ local qtyLabel = label({
+ text = `×{entry.qty}`,
+ align = Enum.TextXAlignment.Right,
+ color = t.TextSecondary or Color3.fromRGB(200, 205, 215),
+ size2 = UDim2.fromOffset(40, 28),
+ pos = UDim2.new(1, if mineControls then -108 else -44, 0, 0),
+ })
+ qtyLabel.Parent = row
+
+ if mineControls then
+ local minus = button("−", t.SlotColor or Color3.fromRGB(48, 54, 68))
+ minus.Size = UDim2.fromOffset(24, 20)
+ minus.Position = UDim2.new(1, -66, 0.5, -10)
+ minus.Parent = row
+ minus.MouseButton1Click:Connect(function()
+ local slot = slotHolding(entry.itemId)
+ if entry.qty <= 1 or not slot then
+ Remotes.event("TradeUnstage"):FireServer(entry.itemId)
+ else
+ Remotes.event("TradeStage"):FireServer(slot, entry.qty - 1)
+ end
+ end)
+
+ local plus = button("+", t.SlotColor or Color3.fromRGB(48, 54, 68))
+ plus.Size = UDim2.fromOffset(24, 20)
+ plus.Position = UDim2.new(1, -38, 0.5, -10)
+ plus.Parent = row
+ plus.MouseButton1Click:Connect(function()
+ local slot = slotHolding(entry.itemId)
+ if slot then
+ Remotes.event("TradeStage"):FireServer(slot, entry.qty + 1)
+ end
+ end)
+
+ local remove = button("✕", Color3.fromRGB(120, 60, 60))
+ remove.Size = UDim2.fromOffset(20, 20)
+ remove.Position = UDim2.new(1, -12, 0.5, -10)
+ remove.AnchorPoint = Vector2.new(1, 0.5)
+ remove.Parent = row
+ remove.MouseButton1Click:Connect(function()
+ Remotes.event("TradeUnstage"):FireServer(entry.itemId)
+ end)
+ end
+
+ return row
+end
+
+-- A scrolling column of offer rows.
+local function offerColumn(title: string, entries: { any }, mineControls: boolean, confirmed: boolean): Frame
+ local t = theme()
+ local col = Instance.new("Frame")
+ col.BackgroundTransparency = 1
+
+ local heading = label({
+ text = title,
+ font = t.FontBold or Enum.Font.GothamBold,
+ size = 13,
+ color = if confirmed
+ then (t.Ok or Color3.fromRGB(120, 200, 120))
+ else (t.TextSecondary or Color3.fromRGB(200, 205, 215)),
+ size2 = UDim2.new(1, 0, 0, 18),
+ })
+ heading.Parent = col
+
+ local list = Instance.new("ScrollingFrame")
+ list.Position = UDim2.fromOffset(0, 22)
+ list.Size = UDim2.new(1, 0, 1, -22)
+ list.BackgroundColor3 = t.PanelColor or Color3.fromRGB(20, 23, 30)
+ list.BackgroundTransparency = 0.5
+ list.BorderSizePixel = 0
+ list.ScrollBarThickness = 5
+ list.CanvasSize = UDim2.new()
+ list.AutomaticCanvasSize = Enum.AutomaticSize.Y
+ list.Active = true
+ corner(list, 6)
+ local ll = Instance.new("UIListLayout")
+ ll.Padding = UDim.new(0, 4)
+ ll.SortOrder = Enum.SortOrder.LayoutOrder
+ ll.Parent = list
+ local lp = Instance.new("UIPadding")
+ lp.PaddingTop = UDim.new(0, 4)
+ lp.PaddingBottom = UDim.new(0, 4)
+ lp.PaddingLeft = UDim.new(0, 4)
+ lp.PaddingRight = UDim.new(0, 4)
+ lp.Parent = list
+
+ if #entries == 0 then
+ local hint = label({
+ text = if mineControls then "Offer items below" else "Nothing yet",
+ color = t.TextSecondary or Color3.fromRGB(150, 160, 180),
+ size = 12,
+ align = Enum.TextXAlignment.Center,
+ size2 = UDim2.new(1, 0, 0, 24),
+ })
+ hint.Parent = list
+ else
+ for i, entry in entries do
+ local r = offerRow(entry, mineControls)
+ r.LayoutOrder = i
+ r.Parent = list
+ end
+ end
+
+ list.Parent = col
+ return col
+end
+
+-- Your carried stacks, INSIDE the trade window — click to offer one more, "All" for the stack.
+-- Self-contained on purpose: the trade no longer depends on the separate inventory menu being open.
+local function backpackSection(state: any): Frame
+ local t = theme()
+
+ local staged: { [string]: number } = {}
+ for _, e in state.mine or {} do
+ staged[e.itemId] = e.qty
+ end
+
+ local holder = Instance.new("Frame")
+ holder.BackgroundTransparency = 1
+
+ local heading = label({
+ text = "Your backpack — click to offer",
+ font = t.FontBold or Enum.Font.GothamBold,
+ size = 13,
+ color = t.TextSecondary or Color3.fromRGB(200, 205, 215),
+ size2 = UDim2.new(1, 0, 0, 18),
+ })
+ heading.Parent = holder
+
+ local list = Instance.new("ScrollingFrame")
+ list.Position = UDim2.fromOffset(0, 22)
+ list.Size = UDim2.new(1, 0, 1, -22)
+ list.BackgroundColor3 = t.PanelColor or Color3.fromRGB(20, 23, 30)
+ list.BackgroundTransparency = 0.5
+ list.BorderSizePixel = 0
+ list.ScrollBarThickness = 5
+ list.CanvasSize = UDim2.new()
+ list.AutomaticCanvasSize = Enum.AutomaticSize.Y
+ list.Active = true
+ corner(list, 6)
+ local ll = Instance.new("UIListLayout")
+ ll.Padding = UDim.new(0, 4)
+ ll.SortOrder = Enum.SortOrder.LayoutOrder
+ ll.Parent = list
+ local lp = Instance.new("UIPadding")
+ lp.PaddingTop = UDim.new(0, 4)
+ lp.PaddingBottom = UDim.new(0, 4)
+ lp.PaddingLeft = UDim.new(0, 4)
+ lp.PaddingRight = UDim.new(0, 4)
+ lp.Parent = list
+
+ local stacks = carriedStacks()
+ if #stacks == 0 then
+ local hint = label({
+ text = "Your backpack is empty",
+ color = t.TextSecondary or Color3.fromRGB(150, 160, 180),
+ size = 12,
+ align = Enum.TextXAlignment.Center,
+ size2 = UDim2.new(1, 0, 0, 24),
+ })
+ hint.Parent = list
+ else
+ for i, s in stacks do
+ local row = Instance.new("TextButton")
+ row.Size = UDim2.new(1, 0, 0, 28)
+ row.BackgroundColor3 = t.SlotColor or Color3.fromRGB(30, 34, 44)
+ row.BackgroundTransparency = 0.25
+ row.BorderSizePixel = 0
+ row.AutoButtonColor = true
+ row.Text = ""
+ row.LayoutOrder = i
+ corner(row, 6)
+
+ local icon = Instance.new("ImageLabel")
+ icon.Size = UDim2.fromOffset(20, 20)
+ icon.Position = UDim2.fromOffset(4, 4)
+ icon.BackgroundTransparency = 1
+ icon.Image = SlotGrid.resolveItemIcon(s.itemId)
+ icon.Parent = row
+
+ local def = ItemData.get(s.itemId)
+ local nameLabel = label({
+ text = (def and def.name) or s.itemId,
+ size2 = UDim2.new(1, -150, 1, 0),
+ pos = UDim2.fromOffset(30, 0),
+ })
+ nameLabel.Parent = row
+
+ local offered = staged[s.itemId] or 0
+ local qtyLabel = label({
+ text = if offered > 0 then `{offered}/{s.qty} offered` else `×{s.qty}`,
+ align = Enum.TextXAlignment.Right,
+ color = if offered > 0
+ then (t.Accent or Color3.fromRGB(204, 166, 102))
+ else (t.TextSecondary or Color3.fromRGB(200, 205, 215)),
+ size = 12,
+ size2 = UDim2.fromOffset(90, 28),
+ pos = UDim2.new(1, -140, 0, 0),
+ })
+ qtyLabel.Parent = row
+
+ local all = button("All", t.SlotColor or Color3.fromRGB(48, 54, 68))
+ all.Size = UDim2.fromOffset(40, 20)
+ all.Position = UDim2.new(1, -46, 0.5, -10)
+ all.TextSize = 12
+ all.Parent = row
+ all.MouseButton1Click:Connect(function()
+ Remotes.event("TradeStage"):FireServer(s.slot, s.qty)
+ end)
+
+ -- Clicking the row itself offers one more.
+ row.MouseButton1Click:Connect(function()
+ local next = math.min(offered + 1, s.qty)
+ if next > offered then
+ Remotes.event("TradeStage"):FireServer(s.slot, next)
+ end
+ end)
+
+ row.Parent = list
+ end
+ end
+
+ list.Parent = holder
+ return holder
+end
+
+-- ── State views ────────────────────────────────────────────────────────────────
+
+local function buildInvite(state: any)
+ local t = theme()
+ local msg = label({
+ text = `{state.partner} wants to trade with you.`,
+ size2 = UDim2.new(1, 0, 0, 40),
+ size = 14,
+ })
+ msg.TextWrapped = true
+ msg.Parent = body
+
+ local accept = button("Accept", t.Accent or Color3.fromRGB(120, 170, 90))
+ accept.Size = UDim2.new(0.5, -6, 0, 34)
+ accept.Position = UDim2.fromOffset(0, 56)
+ accept.Parent = body
+ accept.MouseButton1Click:Connect(function()
+ Remotes.event("TradeRespond"):FireServer(true)
+ end)
+
+ local decline = button("Decline", Color3.fromRGB(90, 60, 60))
+ decline.Size = UDim2.new(0.5, -6, 0, 34)
+ decline.Position = UDim2.new(0.5, 6, 0, 56)
+ decline.Parent = body
+ decline.MouseButton1Click:Connect(function()
+ Remotes.event("TradeRespond"):FireServer(false)
+ end)
+end
+
+local function buildWaiting(state: any)
+ local msg = label({
+ text = `Waiting for {state.partner} to accept…`,
+ size2 = UDim2.new(1, 0, 0, 40),
+ size = 14,
+ })
+ msg.TextWrapped = true
+ msg.Parent = body
+
+ local cancel = button("Cancel", Color3.fromRGB(90, 60, 60))
+ cancel.Size = UDim2.new(1, 0, 0, 34)
+ cancel.Position = UDim2.fromOffset(0, 56)
+ cancel.Parent = body
+ cancel.MouseButton1Click:Connect(function()
+ Remotes.event("TradeCancel"):FireServer()
+ end)
+end
+
+local function buildOpen(state: any)
+ local t = theme()
+
+ -- Top half: the two offers side by side. Bottom half: your backpack (so the window is
+ -- self-contained — no dependency on the separate inventory menu).
+ local OFFERS_H = 168
+
+ local mineCol = offerColumn("You offer", state.mine or {}, true, state.myConfirm == true)
+ mineCol.Position = UDim2.fromOffset(0, 0)
+ mineCol.Size = UDim2.new(0.5, -6, 0, OFFERS_H)
+ mineCol.Parent = body
+ offerZone = mineCol
+
+ local theirsTitle = if state.theirConfirm then `{state.partner} ✓` else `{state.partner} offers`
+ local theirsCol = offerColumn(theirsTitle, state.theirs or {}, false, state.theirConfirm == true)
+ theirsCol.Position = UDim2.new(0.5, 6, 0, 0)
+ theirsCol.Size = UDim2.new(0.5, -6, 0, OFFERS_H)
+ theirsCol.Parent = body
+
+ local pack = backpackSection(state)
+ pack.Position = UDim2.fromOffset(0, OFFERS_H + 10)
+ pack.Size = UDim2.new(1, 0, 1, -(OFFERS_H + 10) - 80)
+ pack.Parent = body
+
+ -- Footer: status line + Confirm + Cancel.
+ local statusLabel = label({
+ text = if state.myConfirm
+ then "You confirmed — waiting for your partner…"
+ else "Stage items, then Confirm. Both must confirm.",
+ color = t.TextSecondary or Color3.fromRGB(200, 205, 215),
+ size = 12,
+ size2 = UDim2.new(1, 0, 0, 18),
+ pos = UDim2.new(0, 0, 1, -76),
+ })
+ statusLabel.Parent = body
+
+ local confirm = button(
+ if state.myConfirm then "✓ Confirmed" else "Confirm",
+ if state.myConfirm
+ then (t.SlotColor or Color3.fromRGB(60, 66, 80))
+ else (t.Accent or Color3.fromRGB(120, 170, 90))
+ )
+ confirm.Size = UDim2.new(0.5, -6, 0, 34)
+ confirm.Position = UDim2.new(0, 0, 1, -34)
+ confirm.Parent = body
+ confirm.MouseButton1Click:Connect(function()
+ if not state.myConfirm then
+ Remotes.event("TradeConfirm"):FireServer()
+ end
+ end)
+
+ local cancel = button("Cancel", Color3.fromRGB(90, 60, 60))
+ cancel.Size = UDim2.new(0.5, -6, 0, 34)
+ cancel.Position = UDim2.new(0.5, 6, 1, -34)
+ cancel.Parent = body
+ cancel.MouseButton1Click:Connect(function()
+ Remotes.event("TradeCancel"):FireServer()
+ end)
+end
+
+-- ── Render ─────────────────────────────────────────────────────────────────────
+
+local lastState: any = nil
+
+local function render(state: any)
+ local panel = ensureGui()
+ if not panel then
+ return
+ end
+ lastState = state
+
+ local status = state and state.status
+ if not status or status == "cancelled" or status == "done" then
+ panel.Visible = false
+ clearBody()
+ lastStatus = status
+ return
+ end
+
+ panel.Visible = true
+ clearBody()
+ if status == "invite" then
+ buildInvite(state)
+ elseif status == "waiting" then
+ buildWaiting(state)
+ elseif status == "open" then
+ buildOpen(state)
+ end
+ lastStatus = status
+end
+
+function TradeUi.start(_options: { [string]: any }?)
+ if started then
+ return
+ end
+ started = true
+
+ -- Drag-to-stage: dropping an inventory slot onto the "Your offer" column stages that item.
+ -- Registered once; hit-tests the live offer zone. Stages the whole held amount (clamped by the
+ -- server); the row steppers then trim it.
+ DragDrop.addTarget({
+ hitTest = function(pos)
+ return root ~= nil and root.Visible and offerZone ~= nil and DragDrop.hitTestGui(offerZone, pos)
+ end,
+ onDrop = function(payload, _pos)
+ if payload and payload.kind == "invSlot" and offerZone then
+ Remotes.event("TradeStage"):FireServer(payload.slot, 9999) -- server clamps to held
+ end
+ end,
+ })
+
+ Remotes.event("TradeState").OnClientEvent:Connect(function(state)
+ render(state)
+ end)
+
+ -- Keep the in-window backpack honest if the inventory changes mid-trade (a pickup, a craft…).
+ -- Coalesced to one re-render per frame; only while an open trade is on screen.
+ local refreshQueued = false
+ localPlayer.AttributeChanged:Connect(function(name: string)
+ if lastStatus ~= "open" or refreshQueued then
+ return
+ end
+ if not (string.match(name, "^InvSlot_%d+$") or string.match(name, "^InvQty_%d+$")) then
+ return
+ end
+ refreshQueued = true
+ task.defer(function()
+ refreshQueued = false
+ if lastStatus == "open" and lastState then
+ render(lastState)
+ end
+ end)
+ end)
+end
+
+return TradeUi
diff --git a/src/foundation/Hooks.luau b/src/foundation/Hooks.luau
index b175a6e..600542c 100644
--- a/src/foundation/Hooks.luau
+++ b/src/foundation/Hooks.luau
@@ -23,6 +23,7 @@
achievement:unlocked { player, key, def }
player:died { player, position } -- after any death-drop
lootbag:dropped / lootbag:collected { player, bag, position?, items? / emptied }
+ trade:started / trade:completed { player, partner, gave?, got? } -- fired per player
Per-resource / per-mob-type variants of these dispatch through Reactions (see Reactions.luau):
SurvivorCore.Gather.onReaction(resourceId, …) and SurvivorCore.Mobs.onReaction(mobType, …).
diff --git a/src/init.luau b/src/init.luau
index c658341..c02caa9 100644
--- a/src/init.luau
+++ b/src/init.luau
@@ -63,6 +63,10 @@ require(script.shared.AchievementsConfig)
-- before start().
require(script.shared.LootBagsConfig)
+-- Define the "Trading" (player-to-player trade) Config section, so Config.override(...) works any
+-- time before start().
+require(script.shared.TradingConfig)
+
-- The no-code layer over ALL of the sections above: the persisted SurvivorCoreEngineConfig
-- instance (written by the admin plugin, deltas-only). apply() runs as the first step of
-- start()/startClient(), AFTER game-code Config.override calls — the instance wins.
@@ -70,7 +74,7 @@ local EngineConfig = require(script.shared.EngineConfig)
local SurvivorCore = {}
-SurvivorCore.VERSION = "0.8.0"
+SurvivorCore.VERSION = "0.9.0"
-- Foundation
SurvivorCore.Config = Config
@@ -279,6 +283,12 @@ function SurvivorCore.start(_options: { [string]: any }?)
lootBags.start(_options)
SurvivorCore.LootBags = lootBags
+ -- Trade: secure player-to-player item swaps. Booted after Inventory (add/remove/canAccept) and
+ -- Progression (trades_total map).
+ local trade = require(script.systems.Trade)
+ trade.start(_options)
+ SurvivorCore.Trade = trade
+
return SurvivorCore
end
@@ -323,6 +333,15 @@ function SurvivorCore.startClient(_options: { [string]: any }?)
require(script.client.RespawnCamera).start(_options)
require(script.client.LootBagBeacon).start(_options)
+ -- Player-to-player trading window (server-driven; drag items from the inventory grid to offer).
+ require(script.client.TradeUi).start(_options)
+
+ -- Walk-up interact window (nearest other player → actions like Trade). Exposes an action
+ -- registry so games add their own entries.
+ local interact = require(script.client.PlayerInteract)
+ interact.start(_options)
+ SurvivorCore.Interact = interact
+
-- Tool-swing harvesting input (click an equipped tool at a gatherable node).
require(script.client.ToolHarvest).start(_options)
diff --git a/src/shared/CombatConfig.luau b/src/shared/CombatConfig.luau
index f4bc00a..8c1eca5 100644
--- a/src/shared/CombatConfig.luau
+++ b/src/shared/CombatConfig.luau
@@ -26,6 +26,8 @@ CombatConfig.DEFAULTS = {
MaxRange = 300, -- studs the server simulates a projectile before giving up
MinDrawDamageMult = 0.3, -- damage multiplier at zero draw; scales up to 1.0 at full draw
StepSize = 4, -- studs per raycast step when simulating the arc (smaller = more precise, costlier)
+ Cooldown = 0.35, -- seconds between accepted shots per player (fallback when a bow def
+ -- sets no weaponCooldown). Rate-limits BOTH the damage and the arc simulation's raycasts.
},
}
diff --git a/src/shared/EngineConfig.luau b/src/shared/EngineConfig.luau
index 400676d..a4c3e1b 100644
--- a/src/shared/EngineConfig.luau
+++ b/src/shared/EngineConfig.luau
@@ -39,6 +39,7 @@ local CombatConfig = require(script.Parent.CombatConfig)
local QuestsConfig = require(script.Parent.QuestsConfig)
local AchievementsConfig = require(script.Parent.AchievementsConfig)
local LootBagsConfig = require(script.Parent.LootBagsConfig)
+local TradingConfig = require(script.Parent.TradingConfig)
local EngineConfig = {}
@@ -160,6 +161,7 @@ EngineConfig.SECTIONS = {
num("MaxRange", "Max range (studs)", 1),
num("MinDrawDamageMult", "Min-draw damage mult (0-1)", 0, 1),
num("StepSize", "Sim step size (studs)", 1),
+ num("Cooldown", "Shot cooldown (s)", 0),
},
},
},
@@ -296,6 +298,22 @@ EngineConfig.SECTIONS = {
},
},
},
+ {
+ id = "Trading",
+ title = "Trading",
+ groups = {
+ {
+ label = "Player trading",
+ fields = {
+ boolean("Enabled", "Enable trading"),
+ num("MaxDistance", "Max trade distance (studs)", 0),
+ num("RequestTimeoutSeconds", "Request timeout (s)", 1),
+ boolean("ResetConfirmOnChange", "Reset confirms on change"),
+ boolean("AllowEquippedItems", "Allow trading worn gear"),
+ },
+ },
+ },
+ },
{
id = "UI",
title = "UI & theme",
@@ -391,6 +409,7 @@ local DEFAULTS_BY_SECTION: { [string]: any } = {
Inventory = InventoryConfig.DEFAULTS,
Consequences = ConsequenceConfig.DEFAULTS,
LootBags = LootBagsConfig.DEFAULTS,
+ Trading = TradingConfig.DEFAULTS,
Quests = QuestsConfig.DEFAULTS,
Achievements = AchievementsConfig.DEFAULTS,
UI = UiConfig.DEFAULTS,
diff --git a/src/shared/TradingConfig.luau b/src/shared/TradingConfig.luau
new file mode 100644
index 0000000..ddf58fa
--- /dev/null
+++ b/src/shared/TradingConfig.luau
@@ -0,0 +1,28 @@
+--!nonstrict
+--[[
+ TradingConfig — tuning for player-to-player trading (issue #15). SHARED. Defines the "Trading"
+ Config section so games retune via `Config.override("Trading", { ... })`. Read the merged
+ section with TradingConfig.get(). Also exposed in the no-code SurvivorCore Studio editor.
+]]
+
+local Config = require(script.Parent.Parent.foundation.Config)
+
+local TradingConfig = {}
+
+TradingConfig.SECTION = "Trading"
+
+TradingConfig.DEFAULTS = {
+ Enabled = true, -- false = trading is off (the "Trade" prompt never appears)
+ MaxDistance = 16, -- studs; how close two players must be to open AND keep a trade open
+ RequestTimeoutSeconds = 20, -- a pending trade invite auto-declines after this
+ ResetConfirmOnChange = true, -- changing either basket clears BOTH confirms (anti-bait)
+ AllowEquippedItems = false, -- v1: only loose backpack stacks trade; worn gear/satchels stay put
+}
+
+Config.defineSection(TradingConfig.SECTION, TradingConfig.DEFAULTS)
+
+function TradingConfig.get(): any
+ return Config.get(TradingConfig.SECTION) or TradingConfig.DEFAULTS
+end
+
+return TradingConfig
diff --git a/src/shared/UiConfig.luau b/src/shared/UiConfig.luau
index e1b3b70..7096bdd 100644
--- a/src/shared/UiConfig.luau
+++ b/src/shared/UiConfig.luau
@@ -27,6 +27,7 @@ UiConfig.DEFAULTS = {
Codex = "K",
Achievements = "J",
Quests = "L",
+ Interact = "E", -- open the interact window on the nearest other player (Trade, …)
},
-- Roblox's CoreGui owns some keys — notably Tab opens the built-in player roster, which
diff --git a/src/systems/Combat.luau b/src/systems/Combat.luau
index 200a33c..3485e05 100644
--- a/src/systems/Combat.luau
+++ b/src/systems/Combat.luau
@@ -40,6 +40,20 @@ local Combat = {}
local started = false
local lastSwing: { [Player]: number } = {}
local drawStart: { [Player]: number } = {}
+local lastShot: { [Player]: number } = {} -- bow release rate limit (see onBowRelease)
+
+-- A client-supplied aim point must be a real, finite, sanely-bounded position. `typeof == "Vector3"`
+-- alone is not enough: a NaN/inf component defeats magnitude comparisons (every comparison against
+-- NaN is false), which would poison the arc simulation's raycasts.
+local MAX_AIM_MAGNITUDE = 1e6
+local function isSanePoint(p: Vector3): boolean
+ local x, y, z = p.X, p.Y, p.Z
+ -- NaN never equals itself; the abs() bound rejects ±inf and absurd coordinates alike.
+ if x ~= x or y ~= y or z ~= z then
+ return false
+ end
+ return math.abs(x) < MAX_AIM_MAGNITUDE and math.abs(y) < MAX_AIM_MAGNITUDE and math.abs(z) < MAX_AIM_MAGNITUDE
+end
local function playerRoot(player: Player): BasePart?
local char = player.Character
@@ -270,7 +284,7 @@ end
-- server recomputes the true fire direction from the bow's own origin, eliminating shoulder-camera
-- parallax and any client direction spoofing.
local function onBowRelease(player: Player, targetPoint: any, _clientAlpha: any)
- if not isAlive(player) or typeof(targetPoint) ~= "Vector3" then
+ if not isAlive(player) or typeof(targetPoint) ~= "Vector3" or not isSanePoint(targetPoint) then
drawStart[player] = nil
return
end
@@ -285,10 +299,32 @@ local function onBowRelease(player: Player, targetPoint: any, _clientAlpha: any)
return
end
- -- Server-timed draw (anti-cheat): how long the player actually held, clamped to the draw time.
local cfg = CombatConfig.get()
+
+ -- Rate limit, mirroring the melee path. This gate sits BEFORE the ammo spend and before
+ -- simulateArrow on purpose: a release costs up to MaxRange/StepSize server raycasts, so an
+ -- ungated handler lets one client burn the server's heartbeat regardless of ammo — and an
+ -- ammo-free bow (weaponAmmo = "", the authoring default) would otherwise have no limit at all.
+ -- Honours a bow's own weaponCooldown, which until now only the melee path read.
+ local cooldown = tonumber(def.weaponCooldown) or tonumber(cfg.Bow.Cooldown) or 0.35
+ local now = os.clock()
+ if lastShot[player] and now - lastShot[player] < cooldown then
+ drawStart[player] = nil
+ return
+ end
+
+ -- A release with no matching draw is not a real shot: the client fires BowDraw on press and
+ -- BowRelease on release. Without this, a spammed release still lands damage at the
+ -- MinDrawDamageMult floor without ever paying the draw time.
+ if not drawStart[player] then
+ return
+ end
+
+ lastShot[player] = now
+
+ -- Server-timed draw (anti-cheat): how long the player actually held, clamped to the draw time.
local drawTime = math.max(0.01, tonumber(def.weaponDrawTime) or 1)
- local held = drawStart[player] and (os.clock() - drawStart[player]) or 0
+ local held = os.clock() - drawStart[player]
drawStart[player] = nil
local alpha = math.clamp(held / drawTime, 0, 1)
@@ -350,6 +386,11 @@ function Combat.start(_options: { [string]: any }?)
onMeleeSwing(player)
end)
Remotes.event("BowDraw").OnServerEvent:Connect(function(player)
+ -- Validated like every other client-driven entry point: only a living player holding a bow
+ -- may open a draw (the timestamp feeds the release's damage scaling).
+ if not isAlive(player) or not equippedWeapon(player, "bow") then
+ return
+ end
drawStart[player] = os.clock()
end)
Remotes.event("BowRelease").OnServerEvent:Connect(function(player, targetPoint, clientAlpha)
@@ -359,6 +400,7 @@ function Combat.start(_options: { [string]: any }?)
Players.PlayerRemoving:Connect(function(player)
lastSwing[player] = nil
drawStart[player] = nil
+ lastShot[player] = nil
end)
end
diff --git a/src/systems/Inventory.luau b/src/systems/Inventory.luau
index 5b0bb03..ff3177f 100644
--- a/src/systems/Inventory.luau
+++ b/src/systems/Inventory.luau
@@ -429,6 +429,116 @@ function Inventory.addUpTo(player: Player, itemId: string, amount: number): numb
return granted
end
+-- Normalize a basket given as either a list ({ { itemId, qty }, … }) or a map ({ itemId = qty })
+-- into a clean list, dropping empty ids / non-positive quantities.
+local function normalizeBasket(basket: any): { { itemId: string, qty: number } }
+ local out = {}
+ if type(basket) ~= "table" then
+ return out
+ end
+ if #basket > 0 then
+ for _, e in ipairs(basket) do
+ if type(e) == "table" then
+ local id = sanitizeItemId(e.itemId)
+ local qty = math.floor(tonumber(e.qty) or 0)
+ if id ~= "" and qty > 0 then
+ table.insert(out, { itemId = id, qty = qty })
+ end
+ end
+ end
+ else
+ for k, v in pairs(basket) do
+ if type(k) == "string" then
+ local id = sanitizeItemId(k)
+ local qty = math.floor(tonumber(v) or 0)
+ if id ~= "" and qty > 0 then
+ table.insert(out, { itemId = id, qty = qty })
+ end
+ end
+ end
+ end
+ return out
+end
+
+-- Non-mutating fit oracle: would ALL of `incoming` fit (weight + slots) if the player ALSO shed
+-- `opts.alsoRemoving` first? Mirrors addQty's fill order exactly (partial stacks, then empties) on
+-- a working copy of the slot occupancy, so it's an exact predicate — the anti-dupe pre-flight the
+-- trade commit needs (there is otherwise no whole-basket capacity check). `incoming` /
+-- `alsoRemoving` accept either a { { itemId, qty } } list or an { itemId = qty } map.
+function Inventory.canAccept(player: Player, incoming: any, opts: { alsoRemoving: any? }?): boolean
+ local maxWeight = tonumber(player:GetAttribute(InventoryTypes.MAX_CARRY_WEIGHT_ATTR)) or BASE_POCKET_WEIGHT
+ local maxSlots = getMaxSlots(player)
+
+ -- Working copy of slot occupancy.
+ local sItem, sQty = {}, {}
+ for n = 1, maxSlots do
+ sItem[n] = getSlotItemId(player, n)
+ sQty[n] = getSlotQty(player, n)
+ end
+ local weightLeft = maxWeight - computeWeight(player)
+
+ -- Simulate the player shedding their own basket first (LIFO, like removeQty), freeing weight.
+ for _, r in normalizeBasket(opts and opts.alsoRemoving) do
+ local def = readDef(r.itemId)
+ local left = r.qty
+ for n = maxSlots, 1, -1 do
+ if left <= 0 then
+ break
+ end
+ if sItem[n] == r.itemId then
+ local t = math.min(left, sQty[n])
+ sQty[n] -= t
+ if sQty[n] == 0 then
+ sItem[n] = ""
+ end
+ left -= t
+ weightLeft += weightOf(def) * t
+ end
+ end
+ end
+
+ -- Simulate placing the incoming basket (partial stacks first, then empty slots) + weight.
+ for _, it in normalizeBasket(incoming) do
+ local def = readDef(it.itemId)
+ if not def then
+ return false
+ end
+ if weightLeft - weightOf(def) * it.qty < -0.001 then
+ return false
+ end
+ weightLeft -= weightOf(def) * it.qty
+ local cap, remaining = stackMax(def), it.qty
+ for n = 1, maxSlots do
+ if remaining <= 0 then
+ break
+ end
+ if sItem[n] == it.itemId then
+ local add = math.min(remaining, cap - sQty[n])
+ if add > 0 then
+ sQty[n] += add
+ remaining -= add
+ end
+ end
+ end
+ for n = 1, maxSlots do
+ if remaining <= 0 then
+ break
+ end
+ if sItem[n] == "" then
+ local take = math.min(remaining, cap)
+ sItem[n] = it.itemId
+ sQty[n] = take
+ remaining -= take
+ end
+ end
+ if remaining > 0 then
+ return false
+ end
+ end
+
+ return true
+end
+
-- Directly restore an item into an EMPTY equip slot (the loot-bag pickup path: re-equipping a
-- dropped satchel FIRST re-grows slots/weight before ordinary stacks restore). Validates the def
-- actually belongs in that slot.
diff --git a/src/systems/Trade.luau b/src/systems/Trade.luau
new file mode 100644
index 0000000..ee97fb5
--- /dev/null
+++ b/src/systems/Trade.luau
@@ -0,0 +1,550 @@
+--!nonstrict
+--[[
+ Trade — server-authoritative player-to-player trading (issue #15). SERVER-ONLY.
+
+ Two nearby players open a shared trade window, each stages loose backpack stacks into their
+ side, and BOTH must confirm before anything moves. The swap is a single synchronous,
+ dupe-proof commit.
+
+ Safety model — staging is BY-REFERENCE, never escrow. While a trade is open the staged items
+ stay in each owner's real inventory; a "basket" is just an { itemId -> qty } intent map. Real
+ inventory mutation happens ONLY inside `commit`, in one no-yield critical section. So every
+ abort (leave / death / walk out of range / cancel / timeout) needs zero item bookkeeping — the
+ items never moved.
+
+ Commit (both confirmed):
+ 1. Re-validate both still HOLD their whole basket (Inventory.has) — zero mutations.
+ 2. Pre-flight FIT on both receivers (Inventory.canAccept, accounting for each also shedding
+ its own basket) — zero mutations.
+ 3. Escrow: Inventory.remove each basket from its owner (atomic; guaranteed by step 1).
+ 4. Grant with Inventory.addUpTo (exact-count) — step 2 guarantees full grants.
+ 5. Reconcile any residue back to the original owner (unreachable if canAccept is honest).
+ Item count is conserved on every branch.
+
+ Initiation: the client PlayerInteract window (walk up to a player → interact → the "Trade"
+ action) fires the TradeRequest remote; the target Accepts/Declines. Tuning: the "Trading" Config
+ section. v1 trades loose backpack stacks only (worn gear/satchels reserved behind
+ AllowEquippedItems). Started by SurvivorCore.start().
+]]
+
+local Players = game:GetService("Players")
+local RunService = game:GetService("RunService")
+
+assert(RunService:IsServer(), "SurvivorCore.Trade is server-only — booted by SurvivorCore.start()")
+
+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 TradingConfig = require(script.Parent.Parent.shared.TradingConfig)
+
+local Trade = {}
+
+local started = false
+
+-- Both participants' keys point at the SAME session table; `sessions` lets the range loop iterate.
+local activeTrade: { [Player]: any } = {}
+local sessions: { any } = {}
+local nextTradeId = 0
+
+-- ── Small helpers ────────────────────────────────────────────────────────────
+
+local function sanitizeItemId(value: any): string
+ if type(value) ~= "string" then
+ return ""
+ end
+ local s = string.sub(value, 1, 40)
+ if s == "" or not string.match(s, "^[%w_%-%s]+$") then
+ return ""
+ end
+ return s
+end
+
+local function basketToList(basket: { [string]: number }): { { itemId: string, qty: number } }
+ local out = {}
+ for itemId, qty in basket do
+ if qty and qty > 0 then
+ table.insert(out, { itemId = itemId, qty = qty })
+ end
+ end
+ return out
+end
+
+local function hrpOf(player: Player): BasePart?
+ local char = player.Character
+ local hrp = char and char:FindFirstChild("HumanoidRootPart")
+ return if hrp and hrp:IsA("BasePart") then hrp else nil
+end
+
+local function alive(player: Player): boolean
+ local char = player.Character
+ local hum = char and char:FindFirstChildOfClass("Humanoid")
+ return hum ~= nil and hum.Health > 0
+end
+
+local function maxDistance(): number
+ return tonumber(TradingConfig.get().MaxDistance) or 16
+end
+
+local function inRange(a: Player, b: Player): boolean
+ local ha, hb = hrpOf(a), hrpOf(b)
+ if not ha or not hb then
+ return false
+ end
+ return (ha.Position - hb.Position).Magnitude <= maxDistance() + 0.001
+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 notify(player: Player, title: string, body: string)
+ if player and player.Parent then
+ Remotes.event("Notify"):FireClient(player, { kind = "trade", title = title, body = body })
+ end
+end
+
+-- ── Client state push (plain data — no Player/Instance refs) ──────────────────
+
+-- The trade state oriented for one participant (mine/theirs swapped per side). `requested` splits
+-- into "invite" (the invitee, who Accepts/Declines) and "waiting" (the requester).
+local function stateFor(session: any, who: Player): { [string]: any }
+ local isA = who == session.a
+ local other = if isA then session.b else session.a
+ local status = session.status
+ if status == "requested" then
+ status = if who == session.b then "invite" else "waiting"
+ end
+ return {
+ tradeId = session.id,
+ status = status,
+ partner = other.Name,
+ mine = basketToList(if isA then session.aBasket else session.bBasket),
+ theirs = basketToList(if isA then session.bBasket else session.aBasket),
+ myConfirm = if isA then session.aConfirm else session.bConfirm,
+ theirConfirm = if isA then session.bConfirm else session.aConfirm,
+ reason = session.reason,
+ }
+end
+
+local function push(session: any)
+ local remote = Remotes.event("TradeState")
+ if session.a.Parent then
+ remote:FireClient(session.a, stateFor(session, session.a))
+ end
+ if session.b.Parent then
+ remote:FireClient(session.b, stateFor(session, session.b))
+ end
+end
+
+-- ── Lifecycle ─────────────────────────────────────────────────────────────────
+
+local function dropSession(session: any)
+ activeTrade[session.a] = nil
+ activeTrade[session.b] = nil
+ local idx = table.find(sessions, session)
+ if idx then
+ table.remove(sessions, idx)
+ end
+end
+
+-- Idempotent teardown. Sends a terminal "cancelled" state so both windows close, then unregisters.
+local function cancel(session: any, reason: string)
+ if session.dead then
+ return
+ end
+ session.dead = true
+ session.status = "cancelled"
+ session.reason = reason
+ push(session)
+ if reason ~= "cancelled" and reason ~= "declined" then
+ local msg = ({
+ timeout = "The trade request expired.",
+ range = "You moved too far apart.",
+ died = "A trader died.",
+ left = "The other player left.",
+ })[reason] or "Trade cancelled."
+ notify(session.a, "Trade cancelled", msg)
+ notify(session.b, "Trade cancelled", msg)
+ end
+ dropSession(session)
+end
+
+-- ── Atomic commit (the anti-dupe core) ─────────────────────────────────────────
+
+local function refundEscrow(player: Player, escrow: { [string]: number })
+ -- Restore exactly what we just removed; the room was freed a step ago with no yield between,
+ -- so addUpTo restores in full.
+ for itemId, qty in escrow do
+ Inventory.addUpTo(player, itemId, qty)
+ end
+end
+
+-- Returns (ok, reason). Runs synchronously with NO yield — nothing else can interleave and mutate
+-- inventories mid-commit, so item count is conserved on every branch.
+local function commit(session: any): (boolean, string?)
+ local A, B = session.a, session.b
+
+ -- Guard: both present, alive, still in range.
+ if not (A.Parent and B.Parent and alive(A) and alive(B) and inRange(A, B)) then
+ return false, "range"
+ end
+
+ -- STEP 1 — both still hold their whole basket. Zero mutations.
+ for itemId, qty in session.aBasket do
+ if not Inventory.has(A, itemId, qty) then
+ return false, "shortfall"
+ end
+ end
+ for itemId, qty in session.bBasket do
+ if not Inventory.has(B, itemId, qty) then
+ return false, "shortfall"
+ end
+ end
+
+ -- STEP 2 — both receivers have room after shedding their own basket. Zero mutations.
+ if not Inventory.canAccept(A, session.bBasket, { alsoRemoving = session.aBasket }) then
+ return false, "full"
+ end
+ if not Inventory.canAccept(B, session.aBasket, { alsoRemoving = session.bBasket }) then
+ return false, "full"
+ end
+
+ -- STEP 3 — escrow: remove each basket from its owner (atomic; guaranteed by step 1).
+ local escrowA: { [string]: number } = {}
+ for itemId, qty in session.aBasket do
+ if Inventory.remove(A, itemId, qty) then
+ escrowA[itemId] = qty
+ else
+ refundEscrow(A, escrowA)
+ return false, "shortfall"
+ end
+ end
+ local escrowB: { [string]: number } = {}
+ for itemId, qty in session.bBasket do
+ if Inventory.remove(B, itemId, qty) then
+ escrowB[itemId] = qty
+ else
+ refundEscrow(A, escrowA)
+ refundEscrow(B, escrowB)
+ return false, "shortfall"
+ end
+ end
+
+ -- STEP 4+5 — grant with the exact-count primitive; reconcile any residue back to its owner.
+ -- Step 2 guarantees full grants, so the residue path is unreachable in practice (loud warn).
+ for itemId, qty in escrowA do
+ local granted = Inventory.addUpTo(B, itemId, qty)
+ if granted < qty then
+ local back = Inventory.addUpTo(A, itemId, qty - granted)
+ if back < qty - granted then
+ warn(`[SurvivorCore.Trade] residue lost: {qty - granted - back}x {itemId} (canAccept bug?)`)
+ end
+ end
+ end
+ for itemId, qty in escrowB do
+ local granted = Inventory.addUpTo(A, itemId, qty)
+ if granted < qty then
+ local back = Inventory.addUpTo(B, itemId, qty - granted)
+ if back < qty - granted then
+ warn(`[SurvivorCore.Trade] residue lost: {qty - granted - back}x {itemId} (canAccept bug?)`)
+ end
+ end
+ end
+
+ return true
+end
+
+local function finalize(session: any)
+ local A, B = session.a, session.b
+ local aList = basketToList(session.aBasket)
+ local bList = basketToList(session.bBasket)
+ -- Fire once per player (Progression's resolver runs per real Player; both get trades_total++).
+ emit("trade:completed", A, { partner = B.Name, gave = aList, got = bList })
+ emit("trade:completed", B, { partner = A.Name, gave = bList, got = aList })
+ notify(A, "Trade complete", `You traded with {B.Name}.`)
+ notify(B, "Trade complete", `You traded with {A.Name}.`)
+ session.status = "done"
+ push(session)
+ dropSession(session)
+end
+
+-- ── Internal ops (remotes AND the test harness call these with explicit Players) ──────────────
+
+function Trade._startTrade(from: Player, target: Player)
+ if typeof(from) ~= "Instance" or typeof(target) ~= "Instance" then
+ return
+ end
+ if TradingConfig.get().Enabled == false then
+ return
+ end
+ if from == target or not from.Parent or not target.Parent then
+ return
+ end
+ if activeTrade[from] then
+ notify(from, "Can't trade", "You're already in a trade.")
+ return
+ end
+ if activeTrade[target] then
+ notify(from, "Can't trade", `{target.Name} is already trading.`)
+ return
+ end
+ if not (alive(from) and alive(target) and inRange(from, target)) then
+ return
+ end
+
+ nextTradeId += 1
+ local session = {
+ id = tostring(nextTradeId),
+ a = from, -- requester
+ b = target, -- invitee
+ status = "requested",
+ aBasket = {},
+ bBasket = {},
+ aConfirm = false,
+ bConfirm = false,
+ dead = false,
+ }
+ activeTrade[from] = session
+ activeTrade[target] = session
+ table.insert(sessions, session)
+ push(session)
+
+ local timeout = math.max(1, tonumber(TradingConfig.get().RequestTimeoutSeconds) or 20)
+ task.delay(timeout, function()
+ if not session.dead and session.status == "requested" then
+ cancel(session, "timeout")
+ end
+ end)
+end
+
+function Trade._respond(player: Player, accept: boolean)
+ local session = activeTrade[player]
+ if not session or session.dead or session.status ~= "requested" or player ~= session.b then
+ return
+ end
+ if accept then
+ if not (alive(session.a) and alive(session.b) and inRange(session.a, session.b)) then
+ cancel(session, "range")
+ return
+ end
+ session.status = "open"
+ emit("trade:started", session.a, { partner = session.b.Name })
+ emit("trade:started", session.b, { partner = session.a.Name })
+ push(session)
+ else
+ cancel(session, "declined")
+ end
+end
+
+local function resetConfirms(session: any)
+ if TradingConfig.get().ResetConfirmOnChange ~= false then
+ session.aConfirm = false
+ session.bConfirm = false
+ end
+end
+
+-- Stage from an inventory slot. The server resolves the itemId from the player's OWN slot (never
+-- trusts a client-sent id) and clamps the quantity to what they actually hold.
+function Trade._stage(player: Player, invSlot: any, qty: any)
+ local session = activeTrade[player]
+ if not session or session.dead or session.status ~= "open" then
+ return
+ end
+ local slot = math.floor(tonumber(invSlot) or 0)
+ if slot < 1 then
+ return
+ end
+ local itemId = ""
+ for _, e in Inventory.getSlots(player) do
+ if e.slot == slot then
+ itemId = e.itemId
+ break
+ end
+ end
+ if itemId == "" then
+ return
+ end
+ local held = Inventory.getQty(player, itemId)
+ local staged = math.clamp(math.floor(tonumber(qty) or 0), 0, held)
+ local basket = if player == session.a then session.aBasket else session.bBasket
+ if staged <= 0 then
+ basket[itemId] = nil
+ else
+ basket[itemId] = staged
+ end
+ resetConfirms(session)
+ push(session)
+end
+
+function Trade._unstage(player: Player, itemId: any, qty: any)
+ local session = activeTrade[player]
+ if not session or session.dead or session.status ~= "open" then
+ return
+ end
+ local id = sanitizeItemId(itemId)
+ if id == "" then
+ return
+ end
+ local basket = if player == session.a then session.aBasket else session.bBasket
+ if basket[id] == nil then
+ return
+ end
+ local reduce = math.floor(tonumber(qty) or 0)
+ if reduce <= 0 then
+ basket[id] = nil
+ else
+ local n = basket[id] - reduce
+ basket[id] = if n <= 0 then nil else n
+ end
+ resetConfirms(session)
+ push(session)
+end
+
+function Trade._confirm(player: Player)
+ local session = activeTrade[player]
+ if not session or session.dead or session.status ~= "open" then
+ return
+ end
+ if player == session.a then
+ session.aConfirm = true
+ elseif player == session.b then
+ session.bConfirm = true
+ else
+ return
+ end
+
+ if session.aConfirm and session.bConfirm then
+ session.status = "committing" -- re-entry latch; commit does not yield
+ local ok, reason = commit(session)
+ if ok then
+ finalize(session)
+ else
+ session.status = "open"
+ session.aConfirm = false
+ session.bConfirm = false
+ local msg = ({
+ full = "Not enough room.",
+ shortfall = "The offer changed — try again.",
+ range = "You moved too far apart.",
+ })[reason or ""] or "Trade failed."
+ notify(session.a, "Trade failed", msg)
+ notify(session.b, "Trade failed", msg)
+ push(session)
+ end
+ else
+ push(session)
+ end
+end
+
+function Trade._cancel(player: Player)
+ local session = activeTrade[player]
+ if session then
+ cancel(session, "cancelled")
+ end
+end
+
+-- Testing accessor: the player's live session (or nil). Used by the demo conservation harness.
+function Trade._activeFor(player: Player): any
+ return activeTrade[player]
+end
+
+-- ── Character lifecycle (abort on death) ───────────────────────────────────────
+-- Initiation lives client-side now: the PlayerInteract window's "Trade" action fires the
+-- TradeRequest remote (server-validated by _startTrade). This hook only cancels a live trade
+-- when a participant dies.
+
+local function onCharacter(player: Player, character: Model)
+ local humanoid = character:FindFirstChildOfClass("Humanoid") or character:WaitForChild("Humanoid", 10)
+ if humanoid and humanoid:IsA("Humanoid") then
+ humanoid.Died:Once(function()
+ local session = activeTrade[player]
+ if session then
+ cancel(session, "died")
+ end
+ end)
+ end
+end
+
+local function watchPlayer(player: Player)
+ player.CharacterAdded:Connect(function(character)
+ onCharacter(player, character)
+ end)
+ if player.Character then
+ task.spawn(onCharacter, player, player.Character)
+ end
+end
+
+local function wireRemotes()
+ Remotes.event("TradeRequest").OnServerEvent:Connect(function(player, targetUserId)
+ local target = Players:GetPlayerByUserId(math.floor(tonumber(targetUserId) or -1))
+ if target then
+ Trade._startTrade(player, target)
+ end
+ end)
+ Remotes.event("TradeRespond").OnServerEvent:Connect(function(player, accept)
+ Trade._respond(player, accept == true)
+ end)
+ Remotes.event("TradeStage").OnServerEvent:Connect(function(player, invSlot, qty)
+ Trade._stage(player, invSlot, qty)
+ end)
+ Remotes.event("TradeUnstage").OnServerEvent:Connect(function(player, itemId, qty)
+ Trade._unstage(player, itemId, qty)
+ end)
+ Remotes.event("TradeConfirm").OnServerEvent:Connect(function(player)
+ Trade._confirm(player)
+ end)
+ Remotes.event("TradeCancel").OnServerEvent:Connect(function(player)
+ Trade._cancel(player)
+ end)
+end
+
+function Trade.start(_options: { [string]: any }?)
+ if started then
+ return
+ end
+ started = true
+
+ -- Completed trades flow into the shared progress stream → trades_total counter.
+ Progression.map("trade:completed", function(_player, _data)
+ return "trade", nil, 1
+ end)
+
+ Remotes.event("TradeState") -- eager S→C so clients can connect at startup
+ wireRemotes()
+
+ for _, player in Players:GetPlayers() do
+ watchPlayer(player)
+ end
+ Players.PlayerAdded:Connect(watchPlayer)
+ Players.PlayerRemoving:Connect(function(player)
+ local session = activeTrade[player]
+ if session then
+ cancel(session, "left")
+ end
+ end)
+
+ -- Throttled range watchdog: a trade auto-cancels if the pair walks apart.
+ local accum = 0
+ RunService.Heartbeat:Connect(function(dt)
+ if #sessions == 0 then
+ return
+ end
+ accum += dt
+ if accum < 0.25 then
+ return
+ end
+ accum = 0
+ for i = #sessions, 1, -1 do
+ local s = sessions[i]
+ if not s.dead and (s.status == "open" or s.status == "requested") and not inRange(s.a, s.b) then
+ cancel(s, "range")
+ end
+ end
+ end)
+end
+
+return Trade
diff --git a/wally.toml b/wally.toml
index daa2efa..01d126c 100644
--- a/wally.toml
+++ b/wally.toml
@@ -1,7 +1,7 @@
[package]
name = "temujincalidius/survivorcore"
description = "Batteries-included, creator-extensible survival game framework for Roblox."
-version = "0.8.0"
+version = "0.9.0"
license = "MIT"
authors = ["Samuel Lison"]
registry = "https://github.com/UpliftGames/wally-index"