diff --git a/CHANGELOG.md b/CHANGELOG.md index 63fad92..22099e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 16d329c..0a4a720 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ -> **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) diff --git a/demo/server/TradeTestStation.server.luau b/demo/server/TradeTestStation.server.luau new file mode 100644 index 0000000..38b1b7b --- /dev/null +++ b/demo/server/TradeTestStation.server.luau @@ -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) diff --git a/docs/combat.md b/docs/combat.md index 758cc80..046a3b0 100644 --- a/docs/combat.md +++ b/docs/combat.md @@ -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). diff --git a/docs/extending.md b/docs/extending.md index af89923..c8f1fc8 100644 --- a/docs/extending.md +++ b/docs/extending.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, diff --git a/docs/getting-started.md b/docs/getting-started.md index f2b3b98..65a4aec 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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: diff --git a/docs/interact.md b/docs/interact.md new file mode 100644 index 0000000..c971392 --- /dev/null +++ b/docs/interact.md @@ -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). diff --git a/docs/trading.md b/docs/trading.md new file mode 100644 index 0000000..4b40f81 --- /dev/null +++ b/docs/trading.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). diff --git a/plugin/ContentAdmin.luau b/plugin/ContentAdmin.luau index a548d4d..ad0b300 100644 --- a/plugin/ContentAdmin.luau +++ b/plugin/ContentAdmin.luau @@ -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 }, { diff --git a/site/index.html b/site/index.html index b055f44..d899c71 100644 --- a/site/index.html +++ b/site/index.html @@ -55,7 +55,7 @@
- v0.8.0 · pre-release + v0.9.0 · pre-release

The survival game engine for Roblox

@@ -140,6 +140,13 @@

Quests & achievements

Event-driven goals: quest chains with objectives, rewards and quest-giver NPCs, plus milestone achievements with toasts — tracked automatically from what players already do.

+
+ +

Player trading

+

Walk up to another survivor and an interact window opens — their name, their condition, and what you can do. Choose Trade and both of you stage items, see each other's offer live, and confirm. The swap is dupe-proof by construction: nothing leaves an inventory until both sides agree, and then it moves in one atomic step that can't create or destroy an item.

+
@@ -187,7 +195,7 @@

Grab the drop-in model from the latest release, or add it with Wally:

# wally.toml
 [dependencies]
-SurvivorCore = "temujincalidius/survivorcore@0.8.0"
+SurvivorCore = "temujincalidius/survivorcore@0.9.0"

…or drop SurvivorCore.rbxm into ReplicatedStorage.

Latest release ↗ diff --git a/src/client/PlayerInteract.luau b/src/client/PlayerInteract.luau new file mode 100644 index 0000000..06bcdc7 --- /dev/null +++ b/src/client/PlayerInteract.luau @@ -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 diff --git a/src/client/TradeUi.luau b/src/client/TradeUi.luau new file mode 100644 index 0000000..81cc55c --- /dev/null +++ b/src/client/TradeUi.luau @@ -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 …" + 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 diff --git a/src/foundation/Hooks.luau b/src/foundation/Hooks.luau index b175a6e..600542c 100644 --- a/src/foundation/Hooks.luau +++ b/src/foundation/Hooks.luau @@ -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, …). diff --git a/src/init.luau b/src/init.luau index c658341..c02caa9 100644 --- a/src/init.luau +++ b/src/init.luau @@ -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) diff --git a/src/shared/CombatConfig.luau b/src/shared/CombatConfig.luau index f4bc00a..8c1eca5 100644 --- a/src/shared/CombatConfig.luau +++ b/src/shared/CombatConfig.luau @@ -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. }, } diff --git a/src/shared/EngineConfig.luau b/src/shared/EngineConfig.luau index 400676d..a4c3e1b 100644 --- a/src/shared/EngineConfig.luau +++ b/src/shared/EngineConfig.luau @@ -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, diff --git a/src/shared/TradingConfig.luau b/src/shared/TradingConfig.luau new file mode 100644 index 0000000..ddf58fa --- /dev/null +++ b/src/shared/TradingConfig.luau @@ -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 diff --git a/src/shared/UiConfig.luau b/src/shared/UiConfig.luau index e1b3b70..7096bdd 100644 --- a/src/shared/UiConfig.luau +++ b/src/shared/UiConfig.luau @@ -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 diff --git a/src/systems/Combat.luau b/src/systems/Combat.luau index 200a33c..3485e05 100644 --- a/src/systems/Combat.luau +++ b/src/systems/Combat.luau @@ -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 diff --git a/src/systems/Inventory.luau b/src/systems/Inventory.luau index 5b0bb03..ff3177f 100644 --- a/src/systems/Inventory.luau +++ b/src/systems/Inventory.luau @@ -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. diff --git a/src/systems/Trade.luau b/src/systems/Trade.luau new file mode 100644 index 0000000..ee97fb5 --- /dev/null +++ b/src/systems/Trade.luau @@ -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 diff --git a/wally.toml b/wally.toml index daa2efa..01d126c 100644 --- a/wally.toml +++ b/wally.toml @@ -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"