Replaces the buggy per-character Trade ProximityPrompt — which rendered over
your OWN head (no per-viewer exclusion) — with a TCE-ported interact window.
Walk up to another player → an "[E] Interact" badge floats over THEIR head →
press E / tap → a window shows their name + survival stats and an action list.
Targeting is a client-side nearest-OTHER-player scan (self skipped in the loop,
with hysteresis to stop flicker), so the affordance can never point at you —
the reported bug is fixed by construction.
Extensible: SurvivorCore.Interact.addAction{ id, label, order?, enabled?,
onActivate } — Trade is registered as the first built-in action and just fires
the existing (server-validated) TradeRequest remote, so the invite/Accept flow
is unchanged. Cursor is freed while the window is open; the window auto-closes
on walk-away / target-leaves / death / trade-start.
- src/client/PlayerInteract.luau (new): scan + billboard + window + action registry
- src/systems/Trade.luau: delete the per-character prompt block (keep Died abort)
- src/init.luau: boot PlayerInteract, expose SurvivorCore.Interact
- src/shared/UiConfig.luau: UI.Keybinds.Interact = "E"
- docs/interact.md (new), docs/trading.md, CHANGELOG (Unreleased)
Ported from The Counter Earth's PlayerInspectService/InspectController.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3.5 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 — an "[E] Interact" badge appears over their head. Press E (or tap it) to open the interact window, then choose Trade. 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.