feat(trade): secure player-to-player trading (#15)

A new server-authoritative, dupe-proof trade system. Walk up to another player,
trigger the "Trade" prompt; they Accept/Decline; both stage loose backpack
stacks (drag from the inventory grid, with −/+ qty steppers) and must Confirm
before anything moves.

Anti-dupe by construction: staging is by-reference (items never leave the owner
until commit), and the commit is one synchronous, no-yield critical section —
re-validate holds → pre-flight both receivers have room (new Inventory.canAccept)
→ remove both → grant with addUpTo → refund any residue. Item count is conserved
on every branch (verified: 200k-iteration conservation + fit-oracle fuzz).

Auto-cancels on death / leave / 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 is
backpack stacks only (worn gear reserved behind AllowEquippedItems).

- src/systems/Trade.luau, src/client/TradeUi.luau, src/shared/TradingConfig.luau (new)
- src/systems/Inventory.luau: exported canAccept (weight+slot fit oracle)
- src/shared/EngineConfig.luau: Trading section; src/init.luau boot wiring
- docs/trading.md, README, CHANGELOG (Unreleased), Hooks catalogue, extending.md
- demo/server/TradeTestStation.server.luau: /tradetest 2-player conservation harness

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Samuel Lison
2026-07-16 20:19:06 +10:00
co-authored by Claude Opus 4.8
parent 91bc1bb756
commit 177a4118fe
12 changed files with 1479 additions and 0 deletions
+15
View File
@@ -5,6 +5,21 @@ All notable changes to SurvivorCore are recorded here. The format follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). At release time, `## Unreleased`
is promoted to the new version and `main` is tagged `vX.Y.Z`.
## Unreleased
### Added
- **Player trading** (#15) — secure, server-authoritative, **dupe-proof** face-to-face item swaps.
Walk up and trigger a **"Trade"** prompt; the target Accepts/Declines; 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
+2
View File
@@ -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,
+199
View File
@@ -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)
+1
View File
@@ -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,
+71
View File
@@ -0,0 +1,71 @@
# Player trading
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 and trigger the **"Trade"** prompt on them. They get an
**Accept / Decline** request; the requester waits.
2. **Stage your offer.** Once open, both players see a two-column window — *your offer* and *their
offer*. **Drag an item from your inventory grid** onto your column to offer it; the **/+**
steppers 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).
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).
+460
View File
@@ -0,0 +1,460 @@
--!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 <partner>…" + 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).
Staging is drag-to-offer: drag a slot out of the inventory grid onto the "Your offer" column and
the server stages that item (cross-ScreenGui drag via DragDrop). Each staged row has /+ steppers
and a remove button. 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")
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 PanelManager = require(script.Parent.PanelManager)
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
-- ── 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, leaving the centred menu reachable
panel.Size = UDim2.fromOffset(380, 400)
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
local head = label({
text = "Trade",
font = t.FontBold or Enum.Font.GothamBold,
size = 16,
color = t.Accent or Color3.fromRGB(204, 166, 102),
size2 = UDim2.new(1, 0, 0, 22),
})
head.Name = "Head"
head.Parent = panel
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 "Drag items here" 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
-- ── 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()
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, 1, -84)
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, 1, -84)
theirsCol.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 function render(state: any)
local panel = ensureGui()
if not panel then
return
end
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)
-- Pop the inventory menu open on entering a trade so items are draggable.
if lastStatus ~= "open" then
pcall(function()
PanelManager.open("inventory")
end)
end
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)
end
return TradeUi
+1
View File
@@ -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, ).
+13
View File
@@ -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.
@@ -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,9 @@ 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)
-- Tool-swing harvesting input (click an equipped tool at a gatherable node).
require(script.client.ToolHarvest).start(_options)
+18
View File
@@ -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 = {}
@@ -296,6 +297,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 +408,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,
+28
View File
@@ -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
+110
View File
@@ -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.
+561
View File
@@ -0,0 +1,561 @@
--!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: a "Trade" ProximityPrompt on each player's character; triggering it asks that player
to trade with you (they Accept/Decline). 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
-- ── Initiation + character lifecycle ───────────────────────────────────────────
local function onCharacter(player: Player, character: Model)
local hrp = character:WaitForChild("HumanoidRootPart", 10)
local humanoid = character:FindFirstChildOfClass("Humanoid")
if hrp and hrp:IsA("BasePart") and TradingConfig.get().Enabled ~= false then
local prompt = Instance.new("ProximityPrompt")
prompt.Name = "SC_TradePrompt"
prompt.ActionText = "Trade"
prompt.ObjectText = player.Name
prompt.HoldDuration = 0.3
prompt.RequiresLineOfSight = false
prompt.MaxActivationDistance = maxDistance()
prompt.Parent = hrp
prompt.Triggered:Connect(function(triggerer)
Trade._startTrade(triggerer, player)
end)
end
if 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