mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
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:
co-authored by
Claude Opus 4.8
parent
91bc1bb756
commit
177a4118fe
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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, …).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user