From 177a4118feb2a528dff41b8da4fd940e28024692 Mon Sep 17 00:00:00 2001 From: Samuel Lison Date: Thu, 16 Jul 2026 20:19:06 +1000 Subject: [PATCH] feat(trade): secure player-to-player trading (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 15 + README.md | 2 + demo/server/TradeTestStation.server.luau | 199 ++++++++ docs/extending.md | 1 + docs/trading.md | 71 +++ src/client/TradeUi.luau | 460 +++++++++++++++++++ src/foundation/Hooks.luau | 1 + src/init.luau | 13 + src/shared/EngineConfig.luau | 18 + src/shared/TradingConfig.luau | 28 ++ src/systems/Inventory.luau | 110 +++++ src/systems/Trade.luau | 561 +++++++++++++++++++++++ 12 files changed, 1479 insertions(+) create mode 100644 demo/server/TradeTestStation.server.luau create mode 100644 docs/trading.md create mode 100644 src/client/TradeUi.luau create mode 100644 src/shared/TradingConfig.luau create mode 100644 src/systems/Trade.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 63fad92..d985e56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 16d329c..7581d98 100644 --- a/README.md +++ b/README.md @@ -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, 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/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/trading.md b/docs/trading.md new file mode 100644 index 0000000..2f41193 --- /dev/null +++ b/docs/trading.md @@ -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). diff --git a/src/client/TradeUi.luau b/src/client/TradeUi.luau new file mode 100644 index 0000000..fb0af1e --- /dev/null +++ b/src/client/TradeUi.luau @@ -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 …" + 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 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..417f9b3 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. @@ -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) diff --git a/src/shared/EngineConfig.luau b/src/shared/EngineConfig.luau index 400676d..2f35fec 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 = {} @@ -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, 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/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..7c8cc68 --- /dev/null +++ b/src/systems/Trade.luau @@ -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