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

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

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

Auto-cancels on death / leave / out-of-range (MaxDistance) / request timeout; a
staging change resets both confirms. New Trade server system + TradeUi client
window, the "Trading" Config section (tunable in SurvivorCore Studio), hooks
trade:started / trade:completed, and a trades_total progression counter. v1 is
backpack stacks only (worn gear reserved behind AllowEquippedItems).

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Samuel Lison
2026-07-16 20:19:06 +10:00
co-authored by Claude Opus 4.8
parent 91bc1bb756
commit 177a4118fe
12 changed files with 1479 additions and 0 deletions
+1
View File
@@ -157,6 +157,7 @@ Engine systems fire hooks with `Hooks.run("name", ctx)`. The full catalogue live
| `quest:started` / `quest:progress` / `quest:completed` / `quest:blocked` | quests ([docs](quests.md)) |
| `achievement:unlocked` | achievements ([docs](achievements.md)) |
| `player:died` · `lootbag:dropped` / `lootbag:collected` | death & loot bags ([docs](loot-bags.md)) |
| `trade:started` / `trade:completed` | player trading ([docs](trading.md)) |
These gameplay events ALSO cross the **EventBridge** with the same names — that bus is what quests,
achievements, and analytics consume (via the `Progression` translation layer,
+71
View File
@@ -0,0 +1,71 @@
# Player trading
Two survivors standing near each other can **trade items** face-to-face
([`src/systems/Trade.luau`](../src/systems/Trade.luau), issue #15). The swap is fully
server-authoritative and **dupe-proof**: nothing moves until both players confirm, and even then it
moves in one atomic step that can never create or destroy an item.
## How a trade goes
1. **Start it.** Walk up to another player and trigger the **"Trade"** prompt on them. They get an
**Accept / Decline** request; the requester waits.
2. **Stage your offer.** Once open, both players see a two-column window — *your offer* and *their
offer*. **Drag an item from your inventory grid** onto your column to offer it; the **/+**
steppers set the quantity and **✕** removes it. Changing either offer **clears both confirms**
(so nobody can confirm and then swap the goods out from under you).
3. **Confirm.** Both players press **Confirm**. The instant both are confirmed, the server runs the
atomic swap and the items change hands.
Either side can **Cancel** at any time. A trade also auto-cancels if a trader **dies**, **leaves**,
or **walks out of range** (see `MaxDistance`), and a pending request expires after
`RequestTimeoutSeconds`.
## Why it can't dupe
Staging is **by reference, not escrow** — while the window is open your items stay in your
inventory; the "offer" is just a list of intentions. Real inventory changes happen only in the
commit, in one synchronous step:
1. Re-check both players still **hold** everything they offered.
2. Pre-check both players have **room** for what they're about to receive
(`Inventory.canAccept`, weight + free slots, accounting for what each is giving away).
3. Remove both offers, grant them to the other side with the exact-count primitive
(`Inventory.addUpTo`), and refund anything that somehow doesn't fit.
Because the whole commit runs without yielding, nothing else can slip in between the steps — the
item count is conserved on every path. If a receiver turns out to be full, the trade simply reopens
with a "not enough room" notice and nothing is lost.
## What can be traded
**v1: loose backpack stacks only.** Worn equipment and satchels aren't tradeable yet — they change
carry capacity, which needs extra care. Flip `AllowEquippedItems` on when that lands.
## Configuration
```lua
Config.override("Trading", {
Enabled = true, -- false = trading off (the prompt never appears)
MaxDistance = 16, -- studs; how close to open AND keep a trade
RequestTimeoutSeconds = 20,
ResetConfirmOnChange = true, -- a staging change clears both confirms
AllowEquippedItems = false, -- reserved: trade worn gear/satchels too
})
```
All of these are also editable no-code in **SurvivorCore Studio** (Engine Config → *Trading*).
## Hooks & events
| Event | Payload |
|---|---|
| `trade:started` | `{ player, partner }` — fired once per player when both accept |
| `trade:completed` | `{ player, partner, gave, got }` — fired once per player on a successful swap |
Both also cross the EventBridge, and `trade:completed` feeds the Progression stream as a **`trade`**
counter (`trades_total`), so quests and achievements can reward trading out of the box. Progress is
**session-scoped** — persistence (DataStore) is a future system.
---
See also: [Inventory](inventory.md) · [Loot bags](loot-bags.md) · [Extending](extending.md).