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>
3.4 KiB
Player trading
Two survivors standing near each other can trade items face-to-face
(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
- Start it. Walk up to another player and trigger the "Trade" prompt on them. They get an Accept / Decline request; the requester waits.
- 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).
- 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:
- Re-check both players still hold everything they offered.
- 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). - 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
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.