Files
SurvivorCore/demo/server/TradeTestStation.server.luau
T
Samuel LisonandClaude Opus 4.8 177a4118fe 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>
2026-07-16 20:19:06 +10:00

200 lines
6.6 KiB
Luau
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--!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)