mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
@@ -5,6 +5,42 @@ 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`.
|
||||
|
||||
## 0.9.0 — 2026-07-30
|
||||
|
||||
### Security
|
||||
- **Bow shots are now rate-limited server-side.** The bow release handler enforced no cooldown — the
|
||||
one client-driven action in the engine that didn't — so a client could fire far faster than a bow's
|
||||
design rate, and each release costs the server up to `MaxRange / Bow.StepSize` raycasts to simulate
|
||||
the arc. The gate runs **before** the arrow is spent and before the simulation, honours a bow's own
|
||||
**`weaponCooldown`** (which previously only melee read, despite being offered for every weapon), and
|
||||
falls back to the new **`Combat.Bow.Cooldown`** (0.35s, tunable in SurvivorCore Studio). A release
|
||||
with no matching draw is rejected, `BowDraw` now validates the sender is alive and holding a bow,
|
||||
and aim points are checked for finiteness so a malformed one can't consume an arrow. Affects
|
||||
v0.8.0 and earlier.
|
||||
|
||||
### Added
|
||||
- **Player interact window** — walk up to another player and an **"[E] Interact"** badge appears
|
||||
over *their* head; press **E** (or tap) to open a window with their name + survival stats and an
|
||||
**action list** (Trade ships built-in). Targeting is a client-side nearest-*other*-player scan, so
|
||||
it can never point at you — this **replaces** the earlier per-character server ProximityPrompt
|
||||
(which wrongly showed on your own character). Games extend it with
|
||||
`SurvivorCore.Interact.addAction{…}`; interact key is `UI.Keybinds.Interact` (default `E`). Ported
|
||||
from The Counter Earth. See [docs/interact.md](docs/interact.md).
|
||||
- **Player trading** (#15) — secure, server-authoritative, **dupe-proof** face-to-face item swaps.
|
||||
Open a player's interact window and choose **Trade**; the target Accepts/Declines. The trade
|
||||
window is **self-contained** — your carried stacks are listed inside it (click to offer, "All" for
|
||||
the whole stack), so trading never depends on the separate inventory menu being open — and its
|
||||
header is a **drag handle** so it can be moved out of the way. 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
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
> **Status: v0.8.0 — pre-release.** The core survival loop is in and working; the engine is
|
||||
> **Status: v0.9.0 — pre-release.** The core survival loop is in and working; the engine is
|
||||
> being grown toward v1.0 and APIs may still shift. Production-tested in
|
||||
> [The Counter Earth](https://thecounterearth.com).
|
||||
|
||||
@@ -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,
|
||||
@@ -69,7 +71,7 @@ into `ReplicatedStorage`, or add it via [Wally](https://wally.run):
|
||||
```toml
|
||||
# wally.toml
|
||||
[dependencies]
|
||||
SurvivorCore = "temujincalidius/survivorcore@0.8.0"
|
||||
SurvivorCore = "temujincalidius/survivorcore@0.9.0"
|
||||
```
|
||||
|
||||
Working from source? Clone and `rojo serve` the `demo.project.json` place.
|
||||
@@ -100,6 +102,7 @@ The **admin plugin** turns all of this into Studio forms — see
|
||||
[Inventory](docs/inventory.md) · [Harvesting](docs/harvesting.md) · [Crafting](docs/crafting.md) ·
|
||||
[Combat](docs/combat.md) · [Mobs & AI](docs/mobs.md) · [Quests](docs/quests.md) ·
|
||||
[Achievements](docs/achievements.md) · [Loot bags](docs/loot-bags.md) ·
|
||||
[Player trading](docs/trading.md) · [Interact window](docs/interact.md) ·
|
||||
[No-code content](docs/content-authoring.md) ·
|
||||
[Admin plugin](docs/admin-plugin.md) · [Design language](docs/design-language.md) ·
|
||||
[Extending](docs/extending.md)
|
||||
|
||||
@@ -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)
|
||||
+7
-2
@@ -127,9 +127,14 @@ end)
|
||||
```lua
|
||||
Config.override("Combat", {
|
||||
MeleeRange = 8, MeleeCooldown = 0.6, RequireLineOfSight = true, FriendlyFire = false,
|
||||
Bow = { Gravity = 80, ProjectileSpeed = 180, MaxRange = 300, MinDrawDamageMult = 0.3, StepSize = 4 },
|
||||
Bow = {
|
||||
Gravity = 80, ProjectileSpeed = 180, MaxRange = 300, MinDrawDamageMult = 0.3, StepSize = 4,
|
||||
Cooldown = 0.35, -- seconds between accepted shots (fallback when a bow sets no weaponCooldown)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Per-weapon `weapon*` values override these fallbacks. **Out of scope:** durability, blocking/parrying,
|
||||
Per-weapon `weapon*` values override these fallbacks — including **`weaponCooldown`, which governs
|
||||
both melee swings and bow shots**. Every shot is rate-limited server-side before any arrow is spent
|
||||
or any arc is simulated, so a client can't out-run its own fire rate. **Out of scope:** durability, blocking/parrying,
|
||||
and AoE are creator content via the hooks above; mob AI lives in [mobs.md](mobs.md).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -34,7 +34,7 @@ Once published, add it to your game's `wally.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
SurvivorCore = "temujincalidius/survivorcore@0.8.0"
|
||||
SurvivorCore = "temujincalidius/survivorcore@0.9.0"
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Player interact window
|
||||
|
||||
> 📹 **Demo:** [the interact window + player trading](https://makertube.net/w/sJmS6L15jRmwxhQCE4Zgmi)
|
||||
|
||||
Walk up to another player and a **"[E] Interact"** badge appears over *their* head. Press **E** (or
|
||||
tap the badge) to open an **interact window** showing that player's name and survival stats, plus a
|
||||
list of **actions** — **Trade** ships built-in; games add their own
|
||||
([`src/client/PlayerInteract.luau`](../src/client/PlayerInteract.luau), ported from The Counter
|
||||
Earth).
|
||||
|
||||
This is the front door for player-to-player interaction. It replaced an earlier per-character
|
||||
proximity prompt that (wrongly) showed on your *own* character — the interact target is chosen by a
|
||||
client-side **nearest-other-player** scan, so the affordance can never point at you.
|
||||
|
||||
## Targeting
|
||||
|
||||
- A throttled scan picks the **nearest other player within range** (`Trading.MaxDistance`, default 16
|
||||
studs) and floats the badge over their head. It never considers the local player.
|
||||
- Light hysteresis keeps the badge from flickering between two players who are the same distance away.
|
||||
- The badge (and any open window) clears when you walk away, when the target leaves or dies, or while
|
||||
you're already in a trade.
|
||||
|
||||
## Actions API
|
||||
|
||||
Actions are a small **client-side registry**, so a game (or a future engine system) can add its own
|
||||
interactions. Trade is registered by the engine as the first entry.
|
||||
|
||||
```lua
|
||||
local SurvivorCore = require(ReplicatedStorage.SurvivorCore)
|
||||
|
||||
SurvivorCore.Interact.addAction({
|
||||
id = "wave", -- unique; re-adding the same id replaces it
|
||||
label = "Wave",
|
||||
order = 50, -- sort key (lower = earlier); default 100
|
||||
enabled = function(ctx) -- optional; return false to hide the button
|
||||
return true
|
||||
end,
|
||||
onActivate = function(ctx)
|
||||
-- ctx = { target: Player, targetUserId: number, distance: number, close: () -> () }
|
||||
Remotes.event("Wave"):FireServer(ctx.targetUserId)
|
||||
ctx.close() -- hide the interact window
|
||||
end,
|
||||
})
|
||||
```
|
||||
|
||||
The built-in **Trade** action simply fires the `TradeRequest` remote with the target's `UserId`; the
|
||||
server (`Trade._startTrade`) validates everything and the [trade flow](trading.md) takes over.
|
||||
|
||||
## Config
|
||||
|
||||
The scan reuses the **`Trading.MaxDistance`** setting, and the interact key is
|
||||
**`UI.Keybinds.Interact`** (default `"E"`) — both editable no-code in SurvivorCore Studio (Engine
|
||||
Config → *Trading* / *UI & theme*) or via `Config.override`.
|
||||
|
||||
---
|
||||
|
||||
See also: [Trading](trading.md) · [Survival stats](survival-stats.md) · [Extending](extending.md).
|
||||
@@ -0,0 +1,76 @@
|
||||
# Player trading
|
||||
|
||||
> 📹 **Demo:** [walk up, offer, confirm — a dupe-proof player trade](https://makertube.net/w/sJmS6L15jRmwxhQCE4Zgmi)
|
||||
|
||||
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 — an **"[E] Interact"** badge appears over *their* head.
|
||||
Press **E** (or tap it) to open the [interact window](interact.md), then choose **Trade**. They
|
||||
get an **Accept / Decline** request; the requester waits.
|
||||
2. **Stage your offer.** Once open, both players see the trade window: *your offer* and *their
|
||||
offer* side by side, with **your backpack listed underneath** — click a row to offer one, or
|
||||
**All** for the whole stack. (You can also drag straight from the inventory grid if you have the
|
||||
menu open.) The **−/+** steppers on a staged row 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). Drag the window by its **header** to move it out of the way.
|
||||
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).
|
||||
@@ -64,7 +64,13 @@ ContentAdmin.CATEGORIES = {
|
||||
{ attr = "weaponKind", kind = "string", label = "Kind", default = "melee", placeholder = "melee / bow" },
|
||||
{ attr = "weaponDamage", kind = "number", label = "Damage", default = 10 },
|
||||
{ attr = "weaponRange", kind = "number", label = "Range (melee)", default = 8 },
|
||||
{ attr = "weaponCooldown", kind = "number", label = "Cooldown", default = 0.6 },
|
||||
{
|
||||
attr = "weaponCooldown",
|
||||
kind = "number",
|
||||
label = "Cooldown (s)",
|
||||
default = 0.6,
|
||||
placeholder = "melee swings AND bow shots",
|
||||
},
|
||||
{ attr = "weaponDrawTime", kind = "number", label = "Draw time (bow)", default = 1 },
|
||||
{ attr = "weaponProjectileSpeed", kind = "number", label = "Arrow speed (bow)", default = 180 },
|
||||
{
|
||||
|
||||
+10
-2
@@ -55,7 +55,7 @@
|
||||
<div class="hero-inner">
|
||||
<img class="hero-logo" src="assets/img/logo-256.png" alt="SurvivorCore logo" width="112" height="112" />
|
||||
<a class="badge" href="https://github.com/TemujinCalidius/SurvivorCore/releases/latest">
|
||||
v0.8.0 · pre-release
|
||||
v0.9.0 · pre-release
|
||||
</a>
|
||||
<h1>The survival game engine for <span class="accent">Roblox</span></h1>
|
||||
<p class="lede">
|
||||
@@ -140,6 +140,13 @@
|
||||
<h3>Quests & achievements</h3>
|
||||
<p>Event-driven goals: quest chains with objectives, rewards and quest-giver NPCs, plus milestone achievements with toasts — tracked automatically from what players already do.</p>
|
||||
</article>
|
||||
<article class="card card-wide">
|
||||
<span class="ico ico-ember" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8h16m0 0l-4-4m4 4l-4 4"/><path d="M20 16H4m0 0l4 4m-4-4l4-4"/></svg>
|
||||
</span>
|
||||
<h3>Player trading</h3>
|
||||
<p>Walk up to another survivor and an <strong>interact window</strong> opens — their name, their condition, and what you can do. Choose <strong>Trade</strong> and both of you stage items, see each other's offer live, and confirm. The swap is <strong>dupe-proof by construction</strong>: nothing leaves an inventory until both sides agree, and then it moves in one atomic step that can't create or destroy an item.</p>
|
||||
</article>
|
||||
<article class="card card-wide">
|
||||
<span class="ico ico-green" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4l-6 6 2 2 6-6a4 4 0 0 0 5.4-5.4l-2.3 2.3-2-2 2.3-2.3z"/></svg>
|
||||
@@ -172,6 +179,7 @@
|
||||
<div class="video"><iframe src="https://makertube.net/videos/embed/uSGJ2MHEFjSSKxMiJBJ6Y5" title="Quests & achievements demo" loading="lazy" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-fullscreen"></iframe><span>Quests & achievements</span></div>
|
||||
<div class="video"><iframe src="https://makertube.net/videos/embed/oVJwYUhCKxA2ZvgvocdhwZ" title="Hunting & loot bags demo" loading="lazy" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-fullscreen"></iframe><span>Hunting & loot bags</span></div>
|
||||
<div class="video"><iframe src="https://makertube.net/videos/embed/g4oySJeXD4Th7f1zYEu9Bz" title="SurvivorCore Studio admin panel demo" loading="lazy" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-fullscreen"></iframe><span>SurvivorCore Studio</span></div>
|
||||
<div class="video"><iframe src="https://makertube.net/videos/embed/sJmS6L15jRmwxhQCE4Zgmi" title="Player trading demo" loading="lazy" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-fullscreen"></iframe><span>Player trading</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -187,7 +195,7 @@
|
||||
<p>Grab the drop-in model from the latest release, or add it with Wally:</p>
|
||||
<pre><code># wally.toml
|
||||
[dependencies]
|
||||
SurvivorCore = "temujincalidius/survivorcore@0.8.0"</code></pre>
|
||||
SurvivorCore = "temujincalidius/survivorcore@0.9.0"</code></pre>
|
||||
<p class="muted">…or drop <code>SurvivorCore.rbxm</code> into <code>ReplicatedStorage</code>.</p>
|
||||
<p>
|
||||
<a class="btn btn-ghost" href="https://github.com/TemujinCalidius/SurvivorCore/releases/latest">Latest release ↗</a>
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
PlayerInteract — client. Walk-up player interaction (ported from The Counter Earth's inspect
|
||||
window). CLIENT-ONLY.
|
||||
|
||||
Get close to another player and a "[E] Interact" billboard appears over THEIR head (never your
|
||||
own). Pressing the interact key (or tapping the billboard) opens an interact window showing that
|
||||
player's name + survival stats and a list of ACTIONS. "Trade" is the first built-in action; games
|
||||
add more with `SurvivorCore.Interact.addAction{…}`.
|
||||
|
||||
This replaces the old per-character server ProximityPrompt (which wrongly showed on your own
|
||||
character). Targeting is a throttled nearest-other-in-range scan — self is skipped in the loop,
|
||||
so the affordance can never point at you. The "Trade" action just fires the existing
|
||||
`TradeRequest` remote; the server (`Trade._startTrade`) does all the validation, and the existing
|
||||
invite/Accept flow (TradeUi) takes over. Booted by SurvivorCore.startClient().
|
||||
]]
|
||||
|
||||
local Players = game:GetService("Players")
|
||||
local RunService = game:GetService("RunService")
|
||||
local UserInputService = game:GetService("UserInputService")
|
||||
|
||||
assert(RunService:IsClient(), "SurvivorCore.PlayerInteract is client-only — boot via SurvivorCore.startClient()")
|
||||
|
||||
local Remotes = require(script.Parent.Parent.shared.Remotes)
|
||||
local UiConfig = require(script.Parent.Parent.shared.UiConfig)
|
||||
local TradingConfig = require(script.Parent.Parent.shared.TradingConfig)
|
||||
local StatConfig = require(script.Parent.Parent.stats.StatConfig)
|
||||
|
||||
local PlayerInteract = {}
|
||||
|
||||
local started = false
|
||||
local localPlayer = Players.LocalPlayer
|
||||
|
||||
local SCAN_INTERVAL = 0.2
|
||||
local SWITCH_MARGIN = 1.5 -- hysteresis: a new target must be this much closer to steal focus
|
||||
|
||||
local currentTarget: Player? = nil
|
||||
local tradeActive = false -- suppress interaction while a trade window is up
|
||||
|
||||
-- ── action registry (buffered; addAction works before or after start) ─────────
|
||||
local actions: { any } = {}
|
||||
|
||||
-- spec = { id, label, order?, icon?, enabled?(ctx)->bool, onActivate(ctx) }
|
||||
-- ctx = { target, targetUserId, distance, close }
|
||||
function PlayerInteract.addAction(spec: any)
|
||||
assert(
|
||||
type(spec) == "table" and type(spec.id) == "string" and type(spec.onActivate) == "function",
|
||||
"Interact.addAction: spec needs { id: string, onActivate: function, … }"
|
||||
)
|
||||
for i, a in actions do
|
||||
if a.id == spec.id then
|
||||
actions[i] = spec -- replace-by-id, so a game can override a built-in
|
||||
return
|
||||
end
|
||||
end
|
||||
table.insert(actions, spec)
|
||||
end
|
||||
|
||||
-- ── theme helpers (same idiom as TradeUi) ──────────────────────────────────────
|
||||
|
||||
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 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
|
||||
|
||||
-- ── the interact window ────────────────────────────────────────────────────────
|
||||
|
||||
local windowPanel: Frame? = nil
|
||||
local windowBody: Frame? = nil
|
||||
local savedMouseBehavior: Enum.MouseBehavior? = nil
|
||||
|
||||
local function ensureWindow(): Frame?
|
||||
if windowPanel and windowPanel.Parent then
|
||||
return windowPanel
|
||||
end
|
||||
local playerGui = localPlayer:FindFirstChildOfClass("PlayerGui")
|
||||
if not playerGui then
|
||||
return nil
|
||||
end
|
||||
local t = theme()
|
||||
|
||||
local gui = Instance.new("ScreenGui")
|
||||
gui.Name = "SurvivorCoreInteract"
|
||||
gui.ResetOnSpawn = false
|
||||
gui.DisplayOrder = 70 -- below TradeUi (90), above menu/HUD
|
||||
gui.Enabled = true
|
||||
|
||||
local panel = Instance.new("Frame")
|
||||
panel.Name = "Panel"
|
||||
panel.AnchorPoint = Vector2.new(0.5, 0.5)
|
||||
panel.Position = UDim2.fromScale(0.5, 0.5)
|
||||
panel.Size = UDim2.fromOffset(320, 300)
|
||||
panel.BackgroundColor3 = t.PanelColor or Color3.fromRGB(20, 23, 30)
|
||||
panel.BackgroundTransparency = 0.1
|
||||
panel.BorderSizePixel = 0
|
||||
panel.Active = true
|
||||
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 close = button("✕", Color3.fromRGB(90, 60, 60))
|
||||
close.Size = UDim2.fromOffset(24, 24)
|
||||
close.AnchorPoint = Vector2.new(1, 0)
|
||||
close.Position = UDim2.fromScale(1, 0)
|
||||
close.Parent = panel
|
||||
close.MouseButton1Click:Connect(function()
|
||||
PlayerInteract.closeWindow()
|
||||
end)
|
||||
|
||||
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
|
||||
windowPanel = panel
|
||||
windowBody = content
|
||||
return panel
|
||||
end
|
||||
|
||||
-- One stat bar row for the target (read from replicated Player attributes).
|
||||
local function statRow(def: any, target: Player, order: number): Frame
|
||||
local t = theme()
|
||||
local attr = def.attribute or def.name
|
||||
local value = tonumber(target:GetAttribute(attr))
|
||||
local max = tonumber(def.max) or 100
|
||||
local ratio = if value and max > 0 then math.clamp(value / max, 0, 1) else 0
|
||||
local fillRatio = if def.invert then 1 - ratio else ratio
|
||||
|
||||
local row = Instance.new("Frame")
|
||||
row.Size = UDim2.new(1, 0, 0, 22)
|
||||
row.BackgroundTransparency = 1
|
||||
row.LayoutOrder = order
|
||||
|
||||
local name = Instance.new("TextLabel")
|
||||
name.Size = UDim2.fromScale(0.34, 1)
|
||||
name.BackgroundTransparency = 1
|
||||
name.Text = def.name
|
||||
name.TextColor3 = t.TextSecondary or Color3.fromRGB(200, 205, 215)
|
||||
name.Font = t.Font or Enum.Font.GothamMedium
|
||||
name.TextSize = 12
|
||||
name.TextXAlignment = Enum.TextXAlignment.Left
|
||||
name.Parent = row
|
||||
|
||||
local track = Instance.new("Frame")
|
||||
track.Size = UDim2.new(0.66, -40, 0, 8)
|
||||
track.Position = UDim2.new(0.34, 0, 0.5, -4)
|
||||
track.BackgroundColor3 = t.SlotColor or Color3.fromRGB(28, 32, 42)
|
||||
track.BorderSizePixel = 0
|
||||
corner(track, 4)
|
||||
local fill = Instance.new("Frame")
|
||||
fill.Size = UDim2.fromScale(fillRatio, 1)
|
||||
fill.BackgroundColor3 = if def.dangerHigh
|
||||
then Color3.fromRGB(200, 90, 80)
|
||||
else (t.Ok or Color3.fromRGB(120, 190, 120))
|
||||
fill.BorderSizePixel = 0
|
||||
corner(fill, 4)
|
||||
fill.Parent = track
|
||||
track.Parent = row
|
||||
|
||||
local val = Instance.new("TextLabel")
|
||||
val.Size = UDim2.fromOffset(38, 22)
|
||||
val.Position = UDim2.new(1, -38, 0, 0)
|
||||
val.BackgroundTransparency = 1
|
||||
val.Text = if value then tostring(math.floor(value)) else "—"
|
||||
val.TextColor3 = t.Text or Color3.fromRGB(235, 238, 245)
|
||||
val.Font = t.Font or Enum.Font.GothamMedium
|
||||
val.TextSize = 12
|
||||
val.TextXAlignment = Enum.TextXAlignment.Right
|
||||
val.Parent = row
|
||||
|
||||
return row
|
||||
end
|
||||
|
||||
local function renderWindow(target: Player)
|
||||
local panel = ensureWindow()
|
||||
if not panel or not windowBody then
|
||||
return
|
||||
end
|
||||
for _, c in windowBody:GetChildren() do
|
||||
c:Destroy()
|
||||
end
|
||||
local t = theme()
|
||||
|
||||
local header = Instance.new("TextLabel")
|
||||
header.Size = UDim2.new(1, -28, 0, 24)
|
||||
header.BackgroundTransparency = 1
|
||||
header.Text = target.DisplayName
|
||||
header.TextColor3 = t.Accent or Color3.fromRGB(204, 166, 102)
|
||||
header.Font = t.FontBold or Enum.Font.GothamBold
|
||||
header.TextSize = 18
|
||||
header.TextXAlignment = Enum.TextXAlignment.Left
|
||||
header.TextTruncate = Enum.TextTruncate.AtEnd
|
||||
header.Parent = windowBody
|
||||
|
||||
-- Stat bars (only stats flagged for display; new stats appear automatically).
|
||||
local statsHolder = Instance.new("Frame")
|
||||
statsHolder.BackgroundTransparency = 1
|
||||
statsHolder.Position = UDim2.fromOffset(0, 30)
|
||||
statsHolder.Size = UDim2.new(1, 0, 1, -74)
|
||||
local sl = Instance.new("UIListLayout")
|
||||
sl.Padding = UDim.new(0, 3)
|
||||
sl.SortOrder = Enum.SortOrder.LayoutOrder
|
||||
sl.Parent = statsHolder
|
||||
local order = 0
|
||||
for _, def in StatConfig.resolve().stats do
|
||||
if def.display ~= false then
|
||||
order += 1
|
||||
statRow(def, target, order).Parent = statsHolder
|
||||
end
|
||||
end
|
||||
statsHolder.Parent = windowBody
|
||||
|
||||
-- Action buttons (registry, sorted, enable-filtered).
|
||||
local ctx = {
|
||||
target = target,
|
||||
targetUserId = target.UserId,
|
||||
distance = 0,
|
||||
close = function()
|
||||
PlayerInteract.closeWindow()
|
||||
end,
|
||||
}
|
||||
local hrp = target.Character and target.Character:FindFirstChild("HumanoidRootPart")
|
||||
local lhrp = localPlayer.Character and localPlayer.Character:FindFirstChild("HumanoidRootPart")
|
||||
if hrp and lhrp then
|
||||
ctx.distance = (hrp.Position - lhrp.Position).Magnitude
|
||||
end
|
||||
|
||||
local shown = {}
|
||||
for _, a in actions do
|
||||
if not a.enabled or a.enabled(ctx) then
|
||||
table.insert(shown, a)
|
||||
end
|
||||
end
|
||||
table.sort(shown, function(a, b)
|
||||
return (tonumber(a.order) or 100) < (tonumber(b.order) or 100)
|
||||
end)
|
||||
|
||||
local actionRow = Instance.new("Frame")
|
||||
actionRow.BackgroundTransparency = 1
|
||||
actionRow.Position = UDim2.new(0, 0, 1, -34)
|
||||
actionRow.Size = UDim2.new(1, 0, 0, 34)
|
||||
local al = Instance.new("UIListLayout")
|
||||
al.FillDirection = Enum.FillDirection.Horizontal
|
||||
al.Padding = UDim.new(0, 6)
|
||||
al.Parent = actionRow
|
||||
if #shown == 0 then
|
||||
local none = Instance.new("TextLabel")
|
||||
none.Size = UDim2.fromScale(1, 1)
|
||||
none.BackgroundTransparency = 1
|
||||
none.Text = "No actions available"
|
||||
none.TextColor3 = t.TextSecondary or Color3.fromRGB(150, 160, 180)
|
||||
none.Font = t.Font or Enum.Font.GothamMedium
|
||||
none.TextSize = 13
|
||||
none.Parent = actionRow
|
||||
else
|
||||
local w = 1 / #shown
|
||||
for i, a in shown do
|
||||
local b = button(a.label or a.id, t.Accent or Color3.fromRGB(120, 170, 90))
|
||||
b.Size = UDim2.new(w, -6, 1, 0)
|
||||
b.LayoutOrder = i
|
||||
b.Parent = actionRow
|
||||
b.MouseButton1Click:Connect(function()
|
||||
local ok, err = pcall(a.onActivate, ctx)
|
||||
if not ok then
|
||||
warn(`[SurvivorCore.Interact] action '{a.id}' errored: {tostring(err)}`)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
actionRow.Parent = windowBody
|
||||
end
|
||||
|
||||
function PlayerInteract.openWindow(target: Player)
|
||||
if not target or target == localPlayer or not target.Parent then
|
||||
return
|
||||
end
|
||||
local panel = ensureWindow()
|
||||
if not panel then
|
||||
return
|
||||
end
|
||||
renderWindow(target)
|
||||
panel.Visible = true
|
||||
-- Free the cursor so the buttons are clickable (first person locks it centred).
|
||||
if savedMouseBehavior == nil then
|
||||
savedMouseBehavior = UserInputService.MouseBehavior
|
||||
end
|
||||
UserInputService.MouseBehavior = Enum.MouseBehavior.Default
|
||||
end
|
||||
|
||||
function PlayerInteract.closeWindow()
|
||||
if windowPanel then
|
||||
windowPanel.Visible = false
|
||||
end
|
||||
if savedMouseBehavior ~= nil then
|
||||
UserInputService.MouseBehavior = savedMouseBehavior
|
||||
savedMouseBehavior = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function windowOpen(): boolean
|
||||
return windowPanel ~= nil and windowPanel.Visible
|
||||
end
|
||||
|
||||
-- ── the billboard affordance ────────────────────────────────────────────────────
|
||||
|
||||
local billboard: BillboardGui? = nil
|
||||
|
||||
local function ensureBillboard(): BillboardGui?
|
||||
if billboard and billboard.Parent then
|
||||
return billboard
|
||||
end
|
||||
local playerGui = localPlayer:FindFirstChildOfClass("PlayerGui")
|
||||
if not playerGui then
|
||||
return nil
|
||||
end
|
||||
local t = theme()
|
||||
local bb = Instance.new("BillboardGui")
|
||||
bb.Name = "SurvivorCoreInteractPrompt"
|
||||
bb.Size = UDim2.fromOffset(150, 30)
|
||||
bb.StudsOffset = Vector3.new(0, 3, 0)
|
||||
bb.AlwaysOnTop = true
|
||||
bb.MaxDistance = 60
|
||||
bb.Enabled = false
|
||||
|
||||
local btn = Instance.new("TextButton")
|
||||
btn.Name = "Btn"
|
||||
btn.Size = UDim2.fromScale(1, 1)
|
||||
btn.BackgroundColor3 = t.PanelColor or Color3.fromRGB(20, 23, 30)
|
||||
btn.BackgroundTransparency = 0.2
|
||||
btn.AutoButtonColor = true
|
||||
btn.Text = ""
|
||||
btn.Font = t.FontBold or Enum.Font.GothamBold
|
||||
btn.TextSize = 13
|
||||
btn.TextColor3 = t.Text or Color3.fromRGB(235, 238, 245)
|
||||
corner(btn, tonumber(t.CornerRadius) or 8)
|
||||
btn.Parent = bb
|
||||
btn.MouseButton1Click:Connect(function()
|
||||
if currentTarget then
|
||||
PlayerInteract.openWindow(currentTarget)
|
||||
end
|
||||
end)
|
||||
|
||||
bb.Parent = playerGui
|
||||
billboard = bb
|
||||
return bb
|
||||
end
|
||||
|
||||
local function setTarget(target: Player?)
|
||||
currentTarget = target
|
||||
local bb = ensureBillboard()
|
||||
if not bb then
|
||||
return
|
||||
end
|
||||
if target and target.Character then
|
||||
local head = target.Character:FindFirstChild("Head") or target.Character:FindFirstChild("HumanoidRootPart")
|
||||
bb.Adornee = head
|
||||
local btn = bb:FindFirstChild("Btn")
|
||||
if btn and btn:IsA("TextButton") then
|
||||
btn.Text = `[E] Interact — {target.DisplayName}`
|
||||
end
|
||||
bb.Enabled = head ~= nil and not tradeActive
|
||||
else
|
||||
bb.Adornee = nil
|
||||
bb.Enabled = false
|
||||
end
|
||||
end
|
||||
|
||||
-- ── scan ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
local function localHRP(): BasePart?
|
||||
local char = localPlayer.Character
|
||||
local hrp = char and char:FindFirstChild("HumanoidRootPart")
|
||||
return if hrp and hrp:IsA("BasePart") then hrp else nil
|
||||
end
|
||||
|
||||
local function targetValid(target: Player?, myPos: Vector3, maxDist: number): boolean
|
||||
if not target or target == localPlayer or not target.Parent then
|
||||
return false
|
||||
end
|
||||
local char = target.Character
|
||||
local hrp = char and char:FindFirstChild("HumanoidRootPart")
|
||||
local hum = char and char:FindFirstChildOfClass("Humanoid")
|
||||
if not hrp or not hum or hum.Health <= 0 then
|
||||
return false
|
||||
end
|
||||
return (hrp.Position - myPos).Magnitude <= maxDist
|
||||
end
|
||||
|
||||
local function scan()
|
||||
if tradeActive then
|
||||
if currentTarget then
|
||||
setTarget(nil)
|
||||
end
|
||||
if windowOpen() then
|
||||
PlayerInteract.closeWindow()
|
||||
end
|
||||
return
|
||||
end
|
||||
local myHRP = localHRP()
|
||||
if not myHRP then
|
||||
if currentTarget then
|
||||
setTarget(nil)
|
||||
PlayerInteract.closeWindow()
|
||||
end
|
||||
return
|
||||
end
|
||||
local maxDist = tonumber(TradingConfig.get().MaxDistance) or 16
|
||||
|
||||
-- Nearest OTHER player in range (self skipped → never targets you).
|
||||
local bestPlayer, bestDist = nil, math.huge
|
||||
for _, plr in Players:GetPlayers() do
|
||||
if plr ~= localPlayer and plr.Character then
|
||||
local hrp = plr.Character:FindFirstChild("HumanoidRootPart")
|
||||
local hum = plr.Character:FindFirstChildOfClass("Humanoid")
|
||||
if hrp and hum and hum.Health > 0 then
|
||||
local d = (hrp.Position - myHRP.Position).Magnitude
|
||||
if d <= maxDist and d < bestDist then
|
||||
bestPlayer, bestDist = plr, d
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Keep the current target unless it's invalid or a new one is meaningfully closer (hysteresis).
|
||||
local cur = currentTarget
|
||||
if cur and targetValid(cur, myHRP.Position, maxDist) then
|
||||
local curChar = cur.Character
|
||||
local curHRP = curChar and curChar:FindFirstChild("HumanoidRootPart")
|
||||
local curDist = if curHRP then (curHRP.Position - myHRP.Position).Magnitude else math.huge
|
||||
if bestPlayer and bestPlayer ~= cur and bestDist < curDist - SWITCH_MARGIN then
|
||||
setTarget(bestPlayer)
|
||||
end
|
||||
else
|
||||
setTarget(bestPlayer)
|
||||
-- If the open window's subject vanished/left range, close it.
|
||||
if windowOpen() and not (currentTarget and targetValid(currentTarget, myHRP.Position, maxDist)) then
|
||||
PlayerInteract.closeWindow()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ── input ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
local function interactKeyCode(): Enum.KeyCode
|
||||
local name = (UiConfig.get().Keybinds or {}).Interact or "E"
|
||||
local ok, kc = pcall(function()
|
||||
return (Enum.KeyCode :: any)[name]
|
||||
end)
|
||||
if ok and typeof(kc) == "EnumItem" then
|
||||
return kc :: Enum.KeyCode
|
||||
end
|
||||
return Enum.KeyCode.E
|
||||
end
|
||||
|
||||
function PlayerInteract.start(_options: { [string]: any }?)
|
||||
if started then
|
||||
return
|
||||
end
|
||||
started = true
|
||||
|
||||
-- Built-in Trade action: fire the existing request remote; the server validates and the
|
||||
-- invite/Accept flow (TradeUi) takes over.
|
||||
PlayerInteract.addAction({
|
||||
id = "trade",
|
||||
label = "Trade",
|
||||
order = 10,
|
||||
enabled = function(ctx)
|
||||
return TradingConfig.get().Enabled ~= false
|
||||
and ctx.distance <= (tonumber(TradingConfig.get().MaxDistance) or 16)
|
||||
end,
|
||||
onActivate = function(ctx)
|
||||
Remotes.event("TradeRequest"):FireServer(ctx.targetUserId)
|
||||
ctx.close()
|
||||
end,
|
||||
})
|
||||
|
||||
-- Suppress interaction while a trade window is up; reopen scanning when it closes.
|
||||
Remotes.event("TradeState").OnClientEvent:Connect(function(state)
|
||||
local status = state and state.status
|
||||
tradeActive = status ~= nil and status ~= "cancelled" and status ~= "done"
|
||||
if tradeActive then
|
||||
PlayerInteract.closeWindow()
|
||||
setTarget(nil)
|
||||
end
|
||||
end)
|
||||
|
||||
UserInputService.InputBegan:Connect(function(input, gameProcessed)
|
||||
if gameProcessed or tradeActive then
|
||||
return
|
||||
end
|
||||
if input.KeyCode == interactKeyCode() and currentTarget then
|
||||
if windowOpen() then
|
||||
PlayerInteract.closeWindow()
|
||||
else
|
||||
PlayerInteract.openWindow(currentTarget)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
Players.PlayerRemoving:Connect(function(plr)
|
||||
if plr == currentTarget then
|
||||
setTarget(nil)
|
||||
PlayerInteract.closeWindow()
|
||||
end
|
||||
end)
|
||||
|
||||
local accum = 0
|
||||
RunService.Heartbeat:Connect(function(dt)
|
||||
accum += dt
|
||||
if accum < SCAN_INTERVAL then
|
||||
return
|
||||
end
|
||||
accum = 0
|
||||
scan()
|
||||
end)
|
||||
end
|
||||
|
||||
return PlayerInteract
|
||||
@@ -0,0 +1,678 @@
|
||||
--!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).
|
||||
|
||||
The window is SELF-CONTAINED: your carried stacks are listed inside it (read from the replicated
|
||||
inventory attributes), so trading never depends on the separate inventory menu being open — click
|
||||
a backpack row to offer one, "All" for the stack. Dragging a slot from the inventory grid onto
|
||||
the "Your offer" column still works when that menu happens to be open (cross-ScreenGui DragDrop).
|
||||
Each staged row has −/+ steppers and a remove button. The header is a DRAG HANDLE, so the window
|
||||
can be moved out of the way. 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")
|
||||
local UserInputService = game:GetService("UserInputService")
|
||||
|
||||
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 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
|
||||
|
||||
-- The local player's carried stacks, read straight off the replicated attributes — so the trade
|
||||
-- window can show your backpack WITHOUT the separate inventory menu being open.
|
||||
local function carriedStacks(): { { slot: number, itemId: string, qty: number } }
|
||||
local out = {}
|
||||
local maxN = math.floor(tonumber(localPlayer:GetAttribute(InventoryTypes.MAX_SLOTS_ATTR)) or 0)
|
||||
for n = 1, maxN do
|
||||
local id = localPlayer:GetAttribute(InventoryTypes.invSlotAttr(n))
|
||||
local qty = tonumber(localPlayer:GetAttribute(InventoryTypes.invQtyAttr(n))) or 0
|
||||
if typeof(id) == "string" and id ~= "" and qty > 0 then
|
||||
table.insert(out, { slot = n, itemId = id, qty = math.floor(qty) })
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Drag the window by its header, so it never sits on top of something you need to see.
|
||||
local function makeDraggable(panel: Frame, handle: GuiObject)
|
||||
local dragging = false
|
||||
local dragStart = Vector3.zero
|
||||
local startPos = panel.Position
|
||||
|
||||
handle.InputBegan:Connect(function(input)
|
||||
if
|
||||
input.UserInputType == Enum.UserInputType.MouseButton1
|
||||
or input.UserInputType == Enum.UserInputType.Touch
|
||||
then
|
||||
dragging = true
|
||||
dragStart = input.Position
|
||||
startPos = panel.Position
|
||||
input.Changed:Connect(function()
|
||||
if input.UserInputState == Enum.UserInputState.End then
|
||||
dragging = false
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
UserInputService.InputChanged:Connect(function(input)
|
||||
if
|
||||
dragging
|
||||
and (
|
||||
input.UserInputType == Enum.UserInputType.MouseMovement
|
||||
or input.UserInputType == Enum.UserInputType.Touch
|
||||
)
|
||||
then
|
||||
local delta = input.Position - dragStart
|
||||
panel.Position =
|
||||
UDim2.new(startPos.X.Scale, startPos.X.Offset + delta.X, startPos.Y.Scale, startPos.Y.Offset + delta.Y)
|
||||
end
|
||||
end)
|
||||
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 by default; the header drags it anywhere
|
||||
panel.Size = UDim2.fromOffset(420, 476)
|
||||
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
|
||||
|
||||
-- Header doubles as the drag handle (Active so it receives input).
|
||||
local head = Instance.new("Frame")
|
||||
head.Name = "Head"
|
||||
head.BackgroundTransparency = 1
|
||||
head.Active = true
|
||||
head.Size = UDim2.new(1, 0, 0, 22)
|
||||
head.Parent = panel
|
||||
|
||||
local headText = label({
|
||||
text = "Trade",
|
||||
font = t.FontBold or Enum.Font.GothamBold,
|
||||
size = 16,
|
||||
color = t.Accent or Color3.fromRGB(204, 166, 102),
|
||||
size2 = UDim2.fromScale(1, 1),
|
||||
})
|
||||
headText.Name = "Title"
|
||||
headText.Parent = head
|
||||
|
||||
local dragHint = label({
|
||||
text = "⠿ drag",
|
||||
size = 11,
|
||||
color = t.TextSecondary or Color3.fromRGB(150, 160, 180),
|
||||
align = Enum.TextXAlignment.Right,
|
||||
size2 = UDim2.fromScale(1, 1),
|
||||
})
|
||||
dragHint.Parent = head
|
||||
|
||||
makeDraggable(panel, head)
|
||||
|
||||
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 "Offer items below" 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
|
||||
|
||||
-- Your carried stacks, INSIDE the trade window — click to offer one more, "All" for the stack.
|
||||
-- Self-contained on purpose: the trade no longer depends on the separate inventory menu being open.
|
||||
local function backpackSection(state: any): Frame
|
||||
local t = theme()
|
||||
|
||||
local staged: { [string]: number } = {}
|
||||
for _, e in state.mine or {} do
|
||||
staged[e.itemId] = e.qty
|
||||
end
|
||||
|
||||
local holder = Instance.new("Frame")
|
||||
holder.BackgroundTransparency = 1
|
||||
|
||||
local heading = label({
|
||||
text = "Your backpack — click to offer",
|
||||
font = t.FontBold or Enum.Font.GothamBold,
|
||||
size = 13,
|
||||
color = t.TextSecondary or Color3.fromRGB(200, 205, 215),
|
||||
size2 = UDim2.new(1, 0, 0, 18),
|
||||
})
|
||||
heading.Parent = holder
|
||||
|
||||
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
|
||||
|
||||
local stacks = carriedStacks()
|
||||
if #stacks == 0 then
|
||||
local hint = label({
|
||||
text = "Your backpack is empty",
|
||||
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, s in stacks do
|
||||
local row = Instance.new("TextButton")
|
||||
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
|
||||
row.AutoButtonColor = true
|
||||
row.Text = ""
|
||||
row.LayoutOrder = i
|
||||
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(s.itemId)
|
||||
icon.Parent = row
|
||||
|
||||
local def = ItemData.get(s.itemId)
|
||||
local nameLabel = label({
|
||||
text = (def and def.name) or s.itemId,
|
||||
size2 = UDim2.new(1, -150, 1, 0),
|
||||
pos = UDim2.fromOffset(30, 0),
|
||||
})
|
||||
nameLabel.Parent = row
|
||||
|
||||
local offered = staged[s.itemId] or 0
|
||||
local qtyLabel = label({
|
||||
text = if offered > 0 then `{offered}/{s.qty} offered` else `×{s.qty}`,
|
||||
align = Enum.TextXAlignment.Right,
|
||||
color = if offered > 0
|
||||
then (t.Accent or Color3.fromRGB(204, 166, 102))
|
||||
else (t.TextSecondary or Color3.fromRGB(200, 205, 215)),
|
||||
size = 12,
|
||||
size2 = UDim2.fromOffset(90, 28),
|
||||
pos = UDim2.new(1, -140, 0, 0),
|
||||
})
|
||||
qtyLabel.Parent = row
|
||||
|
||||
local all = button("All", t.SlotColor or Color3.fromRGB(48, 54, 68))
|
||||
all.Size = UDim2.fromOffset(40, 20)
|
||||
all.Position = UDim2.new(1, -46, 0.5, -10)
|
||||
all.TextSize = 12
|
||||
all.Parent = row
|
||||
all.MouseButton1Click:Connect(function()
|
||||
Remotes.event("TradeStage"):FireServer(s.slot, s.qty)
|
||||
end)
|
||||
|
||||
-- Clicking the row itself offers one more.
|
||||
row.MouseButton1Click:Connect(function()
|
||||
local next = math.min(offered + 1, s.qty)
|
||||
if next > offered then
|
||||
Remotes.event("TradeStage"):FireServer(s.slot, next)
|
||||
end
|
||||
end)
|
||||
|
||||
row.Parent = list
|
||||
end
|
||||
end
|
||||
|
||||
list.Parent = holder
|
||||
return holder
|
||||
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()
|
||||
|
||||
-- Top half: the two offers side by side. Bottom half: your backpack (so the window is
|
||||
-- self-contained — no dependency on the separate inventory menu).
|
||||
local OFFERS_H = 168
|
||||
|
||||
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, 0, OFFERS_H)
|
||||
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, 0, OFFERS_H)
|
||||
theirsCol.Parent = body
|
||||
|
||||
local pack = backpackSection(state)
|
||||
pack.Position = UDim2.fromOffset(0, OFFERS_H + 10)
|
||||
pack.Size = UDim2.new(1, 0, 1, -(OFFERS_H + 10) - 80)
|
||||
pack.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 lastState: any = nil
|
||||
|
||||
local function render(state: any)
|
||||
local panel = ensureGui()
|
||||
if not panel then
|
||||
return
|
||||
end
|
||||
lastState = state
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
-- Keep the in-window backpack honest if the inventory changes mid-trade (a pickup, a craft…).
|
||||
-- Coalesced to one re-render per frame; only while an open trade is on screen.
|
||||
local refreshQueued = false
|
||||
localPlayer.AttributeChanged:Connect(function(name: string)
|
||||
if lastStatus ~= "open" or refreshQueued then
|
||||
return
|
||||
end
|
||||
if not (string.match(name, "^InvSlot_%d+$") or string.match(name, "^InvQty_%d+$")) then
|
||||
return
|
||||
end
|
||||
refreshQueued = true
|
||||
task.defer(function()
|
||||
refreshQueued = false
|
||||
if lastStatus == "open" and lastState then
|
||||
render(lastState)
|
||||
end
|
||||
end)
|
||||
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, …).
|
||||
|
||||
+20
-1
@@ -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.
|
||||
@@ -70,7 +74,7 @@ local EngineConfig = require(script.shared.EngineConfig)
|
||||
|
||||
local SurvivorCore = {}
|
||||
|
||||
SurvivorCore.VERSION = "0.8.0"
|
||||
SurvivorCore.VERSION = "0.9.0"
|
||||
|
||||
-- Foundation
|
||||
SurvivorCore.Config = Config
|
||||
@@ -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,15 @@ 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)
|
||||
|
||||
-- Walk-up interact window (nearest other player → actions like Trade). Exposes an action
|
||||
-- registry so games add their own entries.
|
||||
local interact = require(script.client.PlayerInteract)
|
||||
interact.start(_options)
|
||||
SurvivorCore.Interact = interact
|
||||
|
||||
-- Tool-swing harvesting input (click an equipped tool at a gatherable node).
|
||||
require(script.client.ToolHarvest).start(_options)
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ CombatConfig.DEFAULTS = {
|
||||
MaxRange = 300, -- studs the server simulates a projectile before giving up
|
||||
MinDrawDamageMult = 0.3, -- damage multiplier at zero draw; scales up to 1.0 at full draw
|
||||
StepSize = 4, -- studs per raycast step when simulating the arc (smaller = more precise, costlier)
|
||||
Cooldown = 0.35, -- seconds between accepted shots per player (fallback when a bow def
|
||||
-- sets no weaponCooldown). Rate-limits BOTH the damage and the arc simulation's raycasts.
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -160,6 +161,7 @@ EngineConfig.SECTIONS = {
|
||||
num("MaxRange", "Max range (studs)", 1),
|
||||
num("MinDrawDamageMult", "Min-draw damage mult (0-1)", 0, 1),
|
||||
num("StepSize", "Sim step size (studs)", 1),
|
||||
num("Cooldown", "Shot cooldown (s)", 0),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -296,6 +298,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 +409,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
|
||||
@@ -27,6 +27,7 @@ UiConfig.DEFAULTS = {
|
||||
Codex = "K",
|
||||
Achievements = "J",
|
||||
Quests = "L",
|
||||
Interact = "E", -- open the interact window on the nearest other player (Trade, …)
|
||||
},
|
||||
|
||||
-- Roblox's CoreGui owns some keys — notably Tab opens the built-in player roster, which
|
||||
|
||||
+45
-3
@@ -40,6 +40,20 @@ local Combat = {}
|
||||
local started = false
|
||||
local lastSwing: { [Player]: number } = {}
|
||||
local drawStart: { [Player]: number } = {}
|
||||
local lastShot: { [Player]: number } = {} -- bow release rate limit (see onBowRelease)
|
||||
|
||||
-- A client-supplied aim point must be a real, finite, sanely-bounded position. `typeof == "Vector3"`
|
||||
-- alone is not enough: a NaN/inf component defeats magnitude comparisons (every comparison against
|
||||
-- NaN is false), which would poison the arc simulation's raycasts.
|
||||
local MAX_AIM_MAGNITUDE = 1e6
|
||||
local function isSanePoint(p: Vector3): boolean
|
||||
local x, y, z = p.X, p.Y, p.Z
|
||||
-- NaN never equals itself; the abs() bound rejects ±inf and absurd coordinates alike.
|
||||
if x ~= x or y ~= y or z ~= z then
|
||||
return false
|
||||
end
|
||||
return math.abs(x) < MAX_AIM_MAGNITUDE and math.abs(y) < MAX_AIM_MAGNITUDE and math.abs(z) < MAX_AIM_MAGNITUDE
|
||||
end
|
||||
|
||||
local function playerRoot(player: Player): BasePart?
|
||||
local char = player.Character
|
||||
@@ -270,7 +284,7 @@ end
|
||||
-- server recomputes the true fire direction from the bow's own origin, eliminating shoulder-camera
|
||||
-- parallax and any client direction spoofing.
|
||||
local function onBowRelease(player: Player, targetPoint: any, _clientAlpha: any)
|
||||
if not isAlive(player) or typeof(targetPoint) ~= "Vector3" then
|
||||
if not isAlive(player) or typeof(targetPoint) ~= "Vector3" or not isSanePoint(targetPoint) then
|
||||
drawStart[player] = nil
|
||||
return
|
||||
end
|
||||
@@ -285,10 +299,32 @@ local function onBowRelease(player: Player, targetPoint: any, _clientAlpha: any)
|
||||
return
|
||||
end
|
||||
|
||||
-- Server-timed draw (anti-cheat): how long the player actually held, clamped to the draw time.
|
||||
local cfg = CombatConfig.get()
|
||||
|
||||
-- Rate limit, mirroring the melee path. This gate sits BEFORE the ammo spend and before
|
||||
-- simulateArrow on purpose: a release costs up to MaxRange/StepSize server raycasts, so an
|
||||
-- ungated handler lets one client burn the server's heartbeat regardless of ammo — and an
|
||||
-- ammo-free bow (weaponAmmo = "", the authoring default) would otherwise have no limit at all.
|
||||
-- Honours a bow's own weaponCooldown, which until now only the melee path read.
|
||||
local cooldown = tonumber(def.weaponCooldown) or tonumber(cfg.Bow.Cooldown) or 0.35
|
||||
local now = os.clock()
|
||||
if lastShot[player] and now - lastShot[player] < cooldown then
|
||||
drawStart[player] = nil
|
||||
return
|
||||
end
|
||||
|
||||
-- A release with no matching draw is not a real shot: the client fires BowDraw on press and
|
||||
-- BowRelease on release. Without this, a spammed release still lands damage at the
|
||||
-- MinDrawDamageMult floor without ever paying the draw time.
|
||||
if not drawStart[player] then
|
||||
return
|
||||
end
|
||||
|
||||
lastShot[player] = now
|
||||
|
||||
-- Server-timed draw (anti-cheat): how long the player actually held, clamped to the draw time.
|
||||
local drawTime = math.max(0.01, tonumber(def.weaponDrawTime) or 1)
|
||||
local held = drawStart[player] and (os.clock() - drawStart[player]) or 0
|
||||
local held = os.clock() - drawStart[player]
|
||||
drawStart[player] = nil
|
||||
local alpha = math.clamp(held / drawTime, 0, 1)
|
||||
|
||||
@@ -350,6 +386,11 @@ function Combat.start(_options: { [string]: any }?)
|
||||
onMeleeSwing(player)
|
||||
end)
|
||||
Remotes.event("BowDraw").OnServerEvent:Connect(function(player)
|
||||
-- Validated like every other client-driven entry point: only a living player holding a bow
|
||||
-- may open a draw (the timestamp feeds the release's damage scaling).
|
||||
if not isAlive(player) or not equippedWeapon(player, "bow") then
|
||||
return
|
||||
end
|
||||
drawStart[player] = os.clock()
|
||||
end)
|
||||
Remotes.event("BowRelease").OnServerEvent:Connect(function(player, targetPoint, clientAlpha)
|
||||
@@ -359,6 +400,7 @@ function Combat.start(_options: { [string]: any }?)
|
||||
Players.PlayerRemoving:Connect(function(player)
|
||||
lastSwing[player] = nil
|
||||
drawStart[player] = nil
|
||||
lastShot[player] = nil
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
@@ -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,550 @@
|
||||
--!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: the client PlayerInteract window (walk up to a player → interact → the "Trade"
|
||||
action) fires the TradeRequest remote; the target Accepts/Declines. 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
|
||||
|
||||
-- ── Character lifecycle (abort on death) ───────────────────────────────────────
|
||||
-- Initiation lives client-side now: the PlayerInteract window's "Trade" action fires the
|
||||
-- TradeRequest remote (server-validated by _startTrade). This hook only cancels a live trade
|
||||
-- when a participant dies.
|
||||
|
||||
local function onCharacter(player: Player, character: Model)
|
||||
local humanoid = character:FindFirstChildOfClass("Humanoid") or character:WaitForChild("Humanoid", 10)
|
||||
if humanoid and humanoid:IsA("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
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "temujincalidius/survivorcore"
|
||||
description = "Batteries-included, creator-extensible survival game framework for Roblox."
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
license = "MIT"
|
||||
authors = ["Samuel Lison"]
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
|
||||
Reference in New Issue
Block a user