Merge pull request #94 from TemujinCalidius/dev

release: v0.9.0 → main
This commit is contained in:
Samuel Lison
2026-07-30 17:02:33 +10:00
committed by GitHub
22 changed files with 2401 additions and 13 deletions
+36
View File
@@ -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
+5 -2
View File
@@ -14,7 +14,7 @@
</div>
> **Status: v0.8.0 — pre-release.** The core survival loop is in and working; the engine is
> **Status: v0.9.0 — pre-release.** The core survival loop is in and working; the engine is
> being grown toward v1.0 and APIs may still shift. Production-tested in
> [The Counter Earth](https://thecounterearth.com).
@@ -40,6 +40,8 @@ authoring tools. If you know Roblox Studio, you can build a survival game.
- **Hunting & loot bags** — slain animals leave butcherable carcasses (knife required, real
yields); player death drops everything into a lootable bag with a countdown — get back to it
before it's gone.
- **Player trading** — walk up, offer items, both confirm: a server-authoritative, **dupe-proof**
face-to-face swap (nothing moves until both agree, then atomically).
- **SurvivorCore Studio (no-code admin plugin)** — one floating window with sidebar + search:
tune **every engine config section** (movement, combat, mobs, loot bags, UI theme colors &
fonts, …) as locked deltas that survive engine updates; create items, weapons, ammo, mobs,
@@ -69,7 +71,7 @@ into `ReplicatedStorage`, or add it via [Wally](https://wally.run):
```toml
# wally.toml
[dependencies]
SurvivorCore = "temujincalidius/survivorcore@0.8.0"
SurvivorCore = "temujincalidius/survivorcore@0.9.0"
```
Working from source? Clone and `rojo serve` the `demo.project.json` place.
@@ -100,6 +102,7 @@ The **admin plugin** turns all of this into Studio forms — see
[Inventory](docs/inventory.md) · [Harvesting](docs/harvesting.md) · [Crafting](docs/crafting.md) ·
[Combat](docs/combat.md) · [Mobs & AI](docs/mobs.md) · [Quests](docs/quests.md) ·
[Achievements](docs/achievements.md) · [Loot bags](docs/loot-bags.md) ·
[Player trading](docs/trading.md) · [Interact window](docs/interact.md) ·
[No-code content](docs/content-authoring.md) ·
[Admin plugin](docs/admin-plugin.md) · [Design language](docs/design-language.md) ·
[Extending](docs/extending.md)
+199
View File
@@ -0,0 +1,199 @@
--!nonstrict
--[[
TradeTestStation DEMO harness for player trading (issue #15). Not engine code.
Trading needs two players, so it can't be driven from a single-client playtest. This script
drives the internal `SurvivorCore.Trade._*` API with two REAL players and asserts ITEM
CONSERVATION — merge(A,B) before == merge(A,B) after — across the tricky paths.
Run it in a 2-player local server (Studio → Test → Start, 2 players), then have either player
chat `/tradetest`. Results print to the Server output. It can also be invoked from a server
command bar / MCP as `_G.SurvivorCoreTradeTest()`.
It seeds each player's inventory before every scenario, so it overwrites their items a test
tool, not something to leave enabled in a shipping game.
]]
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
-- ── inventory helpers (direct attribute seeding — deterministic setup) ─────────
local function clearSlots(player: Player)
for n = 1, 40 do
player:SetAttribute("InvSlot_" .. n, nil)
player:SetAttribute("InvQty_" .. n, nil)
end
end
local function seed(player: Player, entries: { { itemId: string, slot: number, qty: number } })
clearSlots(player)
for _, e in entries do
player:SetAttribute("InvSlot_" .. e.slot, e.itemId)
player:SetAttribute("InvQty_" .. e.slot, e.qty)
end
end
-- itemId -> total qty across all of a player's slots.
local function snap(player: Player): { [string]: number }
local out = {}
for _, s in SurvivorCore.Inventory.getSlots(player) do
out[s.itemId] = (out[s.itemId] or 0) + s.qty
end
return out
end
local function mergeCounts(a: { [string]: number }, b: { [string]: number }): { [string]: number }
local out = {}
for k, v in a do
out[k] = (out[k] or 0) + v
end
for k, v in b do
out[k] = (out[k] or 0) + v
end
return out
end
local function sameCounts(a: { [string]: number }, b: { [string]: number }): boolean
for k, v in a do
if (b[k] or 0) ~= v then
return false
end
end
for k, v in b do
if (a[k] or 0) ~= v then
return false
end
end
return true
end
local function slotOf(player: Player, itemId: string): number?
for _, s in SurvivorCore.Inventory.getSlots(player) do
if s.itemId == itemId then
return s.slot
end
end
return nil
end
-- ── scenario driver ────────────────────────────────────────────────────────────
local passed, failed = 0, 0
local function check(name: string, ok: boolean, detail: string?)
if ok then
passed += 1
print(`{name}`)
else
failed += 1
warn(`{name}{if detail then "" .. detail else ""}`)
end
end
-- Open a trade, stage each side by itemId, both confirm. Returns after the (synchronous) commit.
local function runTrade(A: Player, B: Player, aStage: { [string]: number }, bStage: { [string]: number })
SurvivorCore.Trade._startTrade(A, B)
SurvivorCore.Trade._respond(B, true)
for itemId, qty in aStage do
local slot = slotOf(A, itemId)
if slot then
SurvivorCore.Trade._stage(A, slot, qty)
end
end
for itemId, qty in bStage do
local slot = slotOf(B, itemId)
if slot then
SurvivorCore.Trade._stage(B, slot, qty)
end
end
SurvivorCore.Trade._confirm(A)
SurvivorCore.Trade._confirm(B)
end
local function runSuite()
local players = Players:GetPlayers()
if #players < 2 then
warn("[TradeTest] need two players in the server — Start a 2-player local server.")
return
end
local A, B = players[1], players[2]
passed, failed = 0, 0
print(`[TradeTest] A={A.Name} B={B.Name}`)
-- 1. Happy-path swap: A gives reed×10, B gives berry×5.
do
seed(A, { { itemId = "reed", slot = 1, qty = 10 } })
seed(B, { { itemId = "berry", slot = 1, qty = 5 } })
local before = mergeCounts(snap(A), snap(B))
runTrade(A, B, { reed = 10 }, { berry = 5 })
local a1, b1 = snap(A), snap(B)
check("happy swap: conserved", sameCounts(before, mergeCounts(a1, b1)))
check("happy swap: A got berries", (a1.berry or 0) == 5 and (a1.reed or 0) == 0)
check("happy swap: B got reeds", (b1.reed or 0) == 10 and (b1.berry or 0) == 0)
check("happy swap: session cleared", SurvivorCore.Trade._activeFor(A) == nil)
end
-- 2. Receiver-full refund: B has no free slots, so A's offer can't land. Nothing moves.
do
local full = {}
for n = 1, 5 do -- base pocket slots
table.insert(full, { itemId = "berry", slot = n, qty = 1 })
end
seed(A, { { itemId = "reed", slot = 1, qty = 3 } })
seed(B, full)
local a0, b0 = snap(A), snap(B)
runTrade(A, B, { reed = 3 }, {}) -- A offers, B offers nothing → B has no room
local a1, b1 = snap(A), snap(B)
check("receiver-full: A unchanged", sameCounts(a0, a1))
check("receiver-full: B unchanged", sameCounts(b0, b1))
SurvivorCore.Trade._cancel(A) -- trade reopened on failure; close it
end
-- 3. Stage-more-than-held clamps to what the player actually holds.
do
seed(A, { { itemId = "reed", slot = 1, qty = 4 } })
seed(B, { { itemId = "berry", slot = 1, qty = 2 } })
SurvivorCore.Trade._startTrade(A, B)
SurvivorCore.Trade._respond(B, true)
SurvivorCore.Trade._stage(A, 1, 999) -- ask for 999, hold 4
SurvivorCore.Trade._stage(B, 1, 2)
local before = mergeCounts(snap(A), snap(B))
SurvivorCore.Trade._confirm(A)
SurvivorCore.Trade._confirm(B)
local a1, b1 = snap(A), snap(B)
check("stage clamp: conserved", sameCounts(before, mergeCounts(a1, b1)))
check("stage clamp: only 4 reeds moved", (b1.reed or 0) == 4)
end
-- 4. Cancel mid-trade leaves both inventories untouched (staging never escrows).
do
seed(A, { { itemId = "reed", slot = 1, qty = 6 } })
seed(B, { { itemId = "berry", slot = 1, qty = 6 } })
local a0, b0 = snap(A), snap(B)
SurvivorCore.Trade._startTrade(A, B)
SurvivorCore.Trade._respond(B, true)
SurvivorCore.Trade._stage(A, 1, 6)
SurvivorCore.Trade._stage(B, 1, 6)
SurvivorCore.Trade._cancel(A)
check("cancel mid-trade: A unchanged", sameCounts(a0, snap(A)))
check("cancel mid-trade: B unchanged", sameCounts(b0, snap(B)))
check("cancel mid-trade: session gone", SurvivorCore.Trade._activeFor(A) == nil)
end
print(`[TradeTest] done — {passed} passed, {failed} failed.`)
end
-- selene: allow(global_usage)
_G.SurvivorCoreTradeTest = runSuite -- so a command bar / MCP can invoke the suite directly
Players.PlayerAdded:Connect(function(player)
player.Chatted:Connect(function(message)
local trimmed = string.gsub(message, "%s+", "")
if string.lower(trimmed) == "/tradetest" then
runSuite()
end
end)
end)
+7 -2
View File
@@ -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).
+1
View File
@@ -157,6 +157,7 @@ Engine systems fire hooks with `Hooks.run("name", ctx)`. The full catalogue live
| `quest:started` / `quest:progress` / `quest:completed` / `quest:blocked` | quests ([docs](quests.md)) |
| `achievement:unlocked` | achievements ([docs](achievements.md)) |
| `player:died` · `lootbag:dropped` / `lootbag:collected` | death & loot bags ([docs](loot-bags.md)) |
| `trade:started` / `trade:completed` | player trading ([docs](trading.md)) |
These gameplay events ALSO cross the **EventBridge** with the same names — that bus is what quests,
achievements, and analytics consume (via the `Progression` translation layer,
+1 -1
View File
@@ -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:
+57
View File
@@ -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).
+76
View File
@@ -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).
+7 -1
View File
@@ -64,7 +64,13 @@ ContentAdmin.CATEGORIES = {
{ attr = "weaponKind", kind = "string", label = "Kind", default = "melee", placeholder = "melee / bow" },
{ attr = "weaponDamage", kind = "number", label = "Damage", default = 10 },
{ attr = "weaponRange", kind = "number", label = "Range (melee)", default = 8 },
{ attr = "weaponCooldown", kind = "number", label = "Cooldown", default = 0.6 },
{
attr = "weaponCooldown",
kind = "number",
label = "Cooldown (s)",
default = 0.6,
placeholder = "melee swings AND bow shots",
},
{ attr = "weaponDrawTime", kind = "number", label = "Draw time (bow)", default = 1 },
{ attr = "weaponProjectileSpeed", kind = "number", label = "Arrow speed (bow)", default = 180 },
{
+10 -2
View File
@@ -55,7 +55,7 @@
<div class="hero-inner">
<img class="hero-logo" src="assets/img/logo-256.png" alt="SurvivorCore logo" width="112" height="112" />
<a class="badge" href="https://github.com/TemujinCalidius/SurvivorCore/releases/latest">
v0.8.0 · pre-release
v0.9.0 · pre-release
</a>
<h1>The survival game engine for <span class="accent">Roblox</span></h1>
<p class="lede">
@@ -140,6 +140,13 @@
<h3>Quests &amp; achievements</h3>
<p>Event-driven goals: quest chains with objectives, rewards and quest-giver NPCs, plus milestone achievements with toasts — tracked automatically from what players already do.</p>
</article>
<article class="card card-wide">
<span class="ico ico-ember" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8h16m0 0l-4-4m4 4l-4 4"/><path d="M20 16H4m0 0l4 4m-4-4l4-4"/></svg>
</span>
<h3>Player trading</h3>
<p>Walk up to another survivor and an <strong>interact window</strong> opens — their name, their condition, and what you can do. Choose <strong>Trade</strong> and both of you stage items, see each other's offer live, and confirm. The swap is <strong>dupe-proof by construction</strong>: nothing leaves an inventory until both sides agree, and then it moves in one atomic step that can't create or destroy an item.</p>
</article>
<article class="card card-wide">
<span class="ico ico-green" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4l-6 6 2 2 6-6a4 4 0 0 0 5.4-5.4l-2.3 2.3-2-2 2.3-2.3z"/></svg>
@@ -172,6 +179,7 @@
<div class="video"><iframe src="https://makertube.net/videos/embed/uSGJ2MHEFjSSKxMiJBJ6Y5" title="Quests &amp; achievements demo" loading="lazy" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-fullscreen"></iframe><span>Quests &amp; achievements</span></div>
<div class="video"><iframe src="https://makertube.net/videos/embed/oVJwYUhCKxA2ZvgvocdhwZ" title="Hunting &amp; loot bags demo" loading="lazy" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-fullscreen"></iframe><span>Hunting &amp; loot bags</span></div>
<div class="video"><iframe src="https://makertube.net/videos/embed/g4oySJeXD4Th7f1zYEu9Bz" title="SurvivorCore Studio admin panel demo" loading="lazy" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-fullscreen"></iframe><span>SurvivorCore Studio</span></div>
<div class="video"><iframe src="https://makertube.net/videos/embed/sJmS6L15jRmwxhQCE4Zgmi" title="Player trading demo" loading="lazy" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-fullscreen"></iframe><span>Player trading</span></div>
</div>
</section>
@@ -187,7 +195,7 @@
<p>Grab the drop-in model from the latest release, or add it with Wally:</p>
<pre><code># wally.toml
[dependencies]
SurvivorCore = "temujincalidius/survivorcore@0.8.0"</code></pre>
SurvivorCore = "temujincalidius/survivorcore@0.9.0"</code></pre>
<p class="muted">…or drop <code>SurvivorCore.rbxm</code> into <code>ReplicatedStorage</code>.</p>
<p>
<a class="btn btn-ghost" href="https://github.com/TemujinCalidius/SurvivorCore/releases/latest">Latest release ↗</a>
+547
View File
@@ -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
+678
View File
@@ -0,0 +1,678 @@
--!nonstrict
--[[
TradeUi client. The player-to-player trade window (issue #15).
NOT a menu tab: a transient, server-driven surface with its own ScreenGui (built on demand, the
Toasts idiom). The server pushes a plain-data `TradeState` and this renders it:
"invite" an incoming request with Accept / Decline (the invitee).
"waiting" "waiting for <partner>…" + Cancel (the requester).
"open" two columns (your offer / their offer), each side's confirm state, Confirm/Cancel.
• "cancelled" / "done" → the window closes (a toast explains why).
The window is SELF-CONTAINED: your carried stacks are listed inside it (read from the replicated
inventory attributes), so trading never depends on the separate inventory menu being open — click
a backpack row to offer one, "All" for the stack. Dragging a slot from the inventory grid onto
the "Your offer" column still works when that menu happens to be open (cross-ScreenGui DragDrop).
Each staged row has /+ steppers and a remove button. The header is a DRAG HANDLE, so the window
can be moved out of the way. Both sides must Confirm; the server does the atomic swap. `mine`
renders from the pushed list; `theirs` is built manually (SlotGrid reads only the local player).
Styled from the UI Config Theme. Booted by SurvivorCore.startClient().
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
assert(RunService:IsClient(), "SurvivorCore.TradeUi is client-only — boot it via SurvivorCore.startClient()")
local Remotes = require(script.Parent.Parent.shared.Remotes)
local UiConfig = require(script.Parent.Parent.shared.UiConfig)
local InventoryTypes = require(script.Parent.Parent.shared.InventoryTypes)
local ItemData = require(script.Parent.Parent.shared.ItemData)
local SlotGrid = require(script.Parent.SlotGrid)
local DragDrop = require(script.Parent.DragDrop)
local TradeUi = {}
local started = false
local localPlayer = Players.LocalPlayer
local root: Frame? = nil
local body: Frame? = nil
local offerZone: Frame? = nil -- the "Your offer" column; the drag drop-zone
local lastStatus: string? = nil
-- ── Theme helpers ──────────────────────────────────────────────────────────────
local function theme(): any
return UiConfig.get().Theme or {}
end
local function corner(inst: Instance, radius: number)
local c = Instance.new("UICorner")
c.CornerRadius = UDim.new(0, radius)
c.Parent = inst
end
local function label(props: { [string]: any }): TextLabel
local t = theme()
local l = Instance.new("TextLabel")
l.BackgroundTransparency = 1
l.TextColor3 = props.color or t.Text or Color3.fromRGB(235, 238, 245)
l.Font = props.font or t.Font or Enum.Font.GothamMedium
l.TextSize = props.size or 13
l.TextXAlignment = props.align or Enum.TextXAlignment.Left
l.Text = props.text or ""
l.Size = props.size2 or UDim2.new(1, 0, 0, 20)
if props.pos then
l.Position = props.pos
end
l.TextTruncate = Enum.TextTruncate.AtEnd
return l
end
local function button(text: string, bg: Color3): TextButton
local t = theme()
local b = Instance.new("TextButton")
b.AutoButtonColor = true
b.Text = text
b.Font = t.FontBold or Enum.Font.GothamBold
b.TextSize = 14
b.TextColor3 = t.Text or Color3.fromRGB(245, 245, 245)
b.BackgroundColor3 = bg
b.BorderSizePixel = 0
corner(b, tonumber(t.CornerRadius) or 8)
return b
end
-- ── Inventory lookups (for staging qty from local attributes) ────────────────
local function slotHolding(itemId: string): number?
local maxN = math.floor(tonumber(localPlayer:GetAttribute(InventoryTypes.MAX_SLOTS_ATTR)) or 0)
for n = 1, maxN do
if localPlayer:GetAttribute(InventoryTypes.invSlotAttr(n)) == itemId then
return n
end
end
return nil
end
-- The local player's carried stacks, read straight off the replicated attributes so the trade
-- window can show your backpack WITHOUT the separate inventory menu being open.
local function carriedStacks(): { { slot: number, itemId: string, qty: number } }
local out = {}
local maxN = math.floor(tonumber(localPlayer:GetAttribute(InventoryTypes.MAX_SLOTS_ATTR)) or 0)
for n = 1, maxN do
local id = localPlayer:GetAttribute(InventoryTypes.invSlotAttr(n))
local qty = tonumber(localPlayer:GetAttribute(InventoryTypes.invQtyAttr(n))) or 0
if typeof(id) == "string" and id ~= "" and qty > 0 then
table.insert(out, { slot = n, itemId = id, qty = math.floor(qty) })
end
end
return out
end
-- Drag the window by its header, so it never sits on top of something you need to see.
local function makeDraggable(panel: Frame, handle: GuiObject)
local dragging = false
local dragStart = Vector3.zero
local startPos = panel.Position
handle.InputBegan:Connect(function(input)
if
input.UserInputType == Enum.UserInputType.MouseButton1
or input.UserInputType == Enum.UserInputType.Touch
then
dragging = true
dragStart = input.Position
startPos = panel.Position
input.Changed:Connect(function()
if input.UserInputState == Enum.UserInputState.End then
dragging = false
end
end)
end
end)
UserInputService.InputChanged:Connect(function(input)
if
dragging
and (
input.UserInputType == Enum.UserInputType.MouseMovement
or input.UserInputType == Enum.UserInputType.Touch
)
then
local delta = input.Position - dragStart
panel.Position =
UDim2.new(startPos.X.Scale, startPos.X.Offset + delta.X, startPos.Y.Scale, startPos.Y.Offset + delta.Y)
end
end)
end
-- ── GUI scaffold ────────────────────────────────────────────────────────────
local function ensureGui(): Frame?
if root and root.Parent then
return root
end
local playerGui = localPlayer:FindFirstChildOfClass("PlayerGui")
if not playerGui then
return nil
end
local t = theme()
local gui = Instance.new("ScreenGui")
gui.Name = "SurvivorCoreTrade"
gui.ResetOnSpawn = false
gui.DisplayOrder = 90 -- above the menu + toasts
gui.Enabled = true
local panel = Instance.new("Frame")
panel.Name = "Panel"
panel.AnchorPoint = Vector2.new(1, 0.5)
panel.Position = UDim2.new(1, -20, 0.5, 0) -- right side by default; the header drags it anywhere
panel.Size = UDim2.fromOffset(420, 476)
panel.BackgroundColor3 = t.PanelColor or Color3.fromRGB(20, 23, 30)
panel.BackgroundTransparency = 0.1
panel.BorderSizePixel = 0
panel.Active = true -- sink input
panel.Visible = false
corner(panel, tonumber(t.CornerRadius) or 10)
local pad = Instance.new("UIPadding")
pad.PaddingTop = UDim.new(0, 12)
pad.PaddingBottom = UDim.new(0, 12)
pad.PaddingLeft = UDim.new(0, 14)
pad.PaddingRight = UDim.new(0, 14)
pad.Parent = panel
-- Header doubles as the drag handle (Active so it receives input).
local head = Instance.new("Frame")
head.Name = "Head"
head.BackgroundTransparency = 1
head.Active = true
head.Size = UDim2.new(1, 0, 0, 22)
head.Parent = panel
local headText = label({
text = "Trade",
font = t.FontBold or Enum.Font.GothamBold,
size = 16,
color = t.Accent or Color3.fromRGB(204, 166, 102),
size2 = UDim2.fromScale(1, 1),
})
headText.Name = "Title"
headText.Parent = head
local dragHint = label({
text = "⠿ drag",
size = 11,
color = t.TextSecondary or Color3.fromRGB(150, 160, 180),
align = Enum.TextXAlignment.Right,
size2 = UDim2.fromScale(1, 1),
})
dragHint.Parent = head
makeDraggable(panel, head)
local content = Instance.new("Frame")
content.Name = "Body"
content.BackgroundTransparency = 1
content.Position = UDim2.fromOffset(0, 28)
content.Size = UDim2.new(1, 0, 1, -28)
content.Parent = panel
panel.Parent = gui
gui.Parent = playerGui
root = panel
body = content
return panel
end
local function clearBody()
if not body then
return
end
for _, c in body:GetChildren() do
c:Destroy()
end
offerZone = nil
end
-- ── Item rows ─────────────────────────────────────────────────────────────────
-- One offer row. `mineControls` adds /+/✕ (for the local player's editable side).
local function offerRow(entry: any, mineControls: boolean): Frame
local t = theme()
local row = Instance.new("Frame")
row.Size = UDim2.new(1, 0, 0, 28)
row.BackgroundColor3 = t.SlotColor or Color3.fromRGB(30, 34, 44)
row.BackgroundTransparency = 0.25
row.BorderSizePixel = 0
corner(row, 6)
local icon = Instance.new("ImageLabel")
icon.Size = UDim2.fromOffset(20, 20)
icon.Position = UDim2.fromOffset(4, 4)
icon.BackgroundTransparency = 1
icon.Image = SlotGrid.resolveItemIcon(entry.itemId)
icon.Parent = row
local def = ItemData.get(entry.itemId)
local name = (def and def.name) or entry.itemId
local nameLabel = label({
text = name,
size2 = UDim2.new(1, if mineControls then -140 else -70, 1, 0),
pos = UDim2.fromOffset(30, 0),
})
nameLabel.Parent = row
local qtyLabel = label({
text = `×{entry.qty}`,
align = Enum.TextXAlignment.Right,
color = t.TextSecondary or Color3.fromRGB(200, 205, 215),
size2 = UDim2.fromOffset(40, 28),
pos = UDim2.new(1, if mineControls then -108 else -44, 0, 0),
})
qtyLabel.Parent = row
if mineControls then
local minus = button("", t.SlotColor or Color3.fromRGB(48, 54, 68))
minus.Size = UDim2.fromOffset(24, 20)
minus.Position = UDim2.new(1, -66, 0.5, -10)
minus.Parent = row
minus.MouseButton1Click:Connect(function()
local slot = slotHolding(entry.itemId)
if entry.qty <= 1 or not slot then
Remotes.event("TradeUnstage"):FireServer(entry.itemId)
else
Remotes.event("TradeStage"):FireServer(slot, entry.qty - 1)
end
end)
local plus = button("+", t.SlotColor or Color3.fromRGB(48, 54, 68))
plus.Size = UDim2.fromOffset(24, 20)
plus.Position = UDim2.new(1, -38, 0.5, -10)
plus.Parent = row
plus.MouseButton1Click:Connect(function()
local slot = slotHolding(entry.itemId)
if slot then
Remotes.event("TradeStage"):FireServer(slot, entry.qty + 1)
end
end)
local remove = button("", Color3.fromRGB(120, 60, 60))
remove.Size = UDim2.fromOffset(20, 20)
remove.Position = UDim2.new(1, -12, 0.5, -10)
remove.AnchorPoint = Vector2.new(1, 0.5)
remove.Parent = row
remove.MouseButton1Click:Connect(function()
Remotes.event("TradeUnstage"):FireServer(entry.itemId)
end)
end
return row
end
-- A scrolling column of offer rows.
local function offerColumn(title: string, entries: { any }, mineControls: boolean, confirmed: boolean): Frame
local t = theme()
local col = Instance.new("Frame")
col.BackgroundTransparency = 1
local heading = label({
text = title,
font = t.FontBold or Enum.Font.GothamBold,
size = 13,
color = if confirmed
then (t.Ok or Color3.fromRGB(120, 200, 120))
else (t.TextSecondary or Color3.fromRGB(200, 205, 215)),
size2 = UDim2.new(1, 0, 0, 18),
})
heading.Parent = col
local list = Instance.new("ScrollingFrame")
list.Position = UDim2.fromOffset(0, 22)
list.Size = UDim2.new(1, 0, 1, -22)
list.BackgroundColor3 = t.PanelColor or Color3.fromRGB(20, 23, 30)
list.BackgroundTransparency = 0.5
list.BorderSizePixel = 0
list.ScrollBarThickness = 5
list.CanvasSize = UDim2.new()
list.AutomaticCanvasSize = Enum.AutomaticSize.Y
list.Active = true
corner(list, 6)
local ll = Instance.new("UIListLayout")
ll.Padding = UDim.new(0, 4)
ll.SortOrder = Enum.SortOrder.LayoutOrder
ll.Parent = list
local lp = Instance.new("UIPadding")
lp.PaddingTop = UDim.new(0, 4)
lp.PaddingBottom = UDim.new(0, 4)
lp.PaddingLeft = UDim.new(0, 4)
lp.PaddingRight = UDim.new(0, 4)
lp.Parent = list
if #entries == 0 then
local hint = label({
text = if mineControls then "Offer items below" else "Nothing yet",
color = t.TextSecondary or Color3.fromRGB(150, 160, 180),
size = 12,
align = Enum.TextXAlignment.Center,
size2 = UDim2.new(1, 0, 0, 24),
})
hint.Parent = list
else
for i, entry in entries do
local r = offerRow(entry, mineControls)
r.LayoutOrder = i
r.Parent = list
end
end
list.Parent = col
return col
end
-- Your carried stacks, INSIDE the trade window — click to offer one more, "All" for the stack.
-- Self-contained on purpose: the trade no longer depends on the separate inventory menu being open.
local function backpackSection(state: any): Frame
local t = theme()
local staged: { [string]: number } = {}
for _, e in state.mine or {} do
staged[e.itemId] = e.qty
end
local holder = Instance.new("Frame")
holder.BackgroundTransparency = 1
local heading = label({
text = "Your backpack — click to offer",
font = t.FontBold or Enum.Font.GothamBold,
size = 13,
color = t.TextSecondary or Color3.fromRGB(200, 205, 215),
size2 = UDim2.new(1, 0, 0, 18),
})
heading.Parent = holder
local list = Instance.new("ScrollingFrame")
list.Position = UDim2.fromOffset(0, 22)
list.Size = UDim2.new(1, 0, 1, -22)
list.BackgroundColor3 = t.PanelColor or Color3.fromRGB(20, 23, 30)
list.BackgroundTransparency = 0.5
list.BorderSizePixel = 0
list.ScrollBarThickness = 5
list.CanvasSize = UDim2.new()
list.AutomaticCanvasSize = Enum.AutomaticSize.Y
list.Active = true
corner(list, 6)
local ll = Instance.new("UIListLayout")
ll.Padding = UDim.new(0, 4)
ll.SortOrder = Enum.SortOrder.LayoutOrder
ll.Parent = list
local lp = Instance.new("UIPadding")
lp.PaddingTop = UDim.new(0, 4)
lp.PaddingBottom = UDim.new(0, 4)
lp.PaddingLeft = UDim.new(0, 4)
lp.PaddingRight = UDim.new(0, 4)
lp.Parent = list
local stacks = carriedStacks()
if #stacks == 0 then
local hint = label({
text = "Your backpack is empty",
color = t.TextSecondary or Color3.fromRGB(150, 160, 180),
size = 12,
align = Enum.TextXAlignment.Center,
size2 = UDim2.new(1, 0, 0, 24),
})
hint.Parent = list
else
for i, s in stacks do
local row = Instance.new("TextButton")
row.Size = UDim2.new(1, 0, 0, 28)
row.BackgroundColor3 = t.SlotColor or Color3.fromRGB(30, 34, 44)
row.BackgroundTransparency = 0.25
row.BorderSizePixel = 0
row.AutoButtonColor = true
row.Text = ""
row.LayoutOrder = i
corner(row, 6)
local icon = Instance.new("ImageLabel")
icon.Size = UDim2.fromOffset(20, 20)
icon.Position = UDim2.fromOffset(4, 4)
icon.BackgroundTransparency = 1
icon.Image = SlotGrid.resolveItemIcon(s.itemId)
icon.Parent = row
local def = ItemData.get(s.itemId)
local nameLabel = label({
text = (def and def.name) or s.itemId,
size2 = UDim2.new(1, -150, 1, 0),
pos = UDim2.fromOffset(30, 0),
})
nameLabel.Parent = row
local offered = staged[s.itemId] or 0
local qtyLabel = label({
text = if offered > 0 then `{offered}/{s.qty} offered` else `×{s.qty}`,
align = Enum.TextXAlignment.Right,
color = if offered > 0
then (t.Accent or Color3.fromRGB(204, 166, 102))
else (t.TextSecondary or Color3.fromRGB(200, 205, 215)),
size = 12,
size2 = UDim2.fromOffset(90, 28),
pos = UDim2.new(1, -140, 0, 0),
})
qtyLabel.Parent = row
local all = button("All", t.SlotColor or Color3.fromRGB(48, 54, 68))
all.Size = UDim2.fromOffset(40, 20)
all.Position = UDim2.new(1, -46, 0.5, -10)
all.TextSize = 12
all.Parent = row
all.MouseButton1Click:Connect(function()
Remotes.event("TradeStage"):FireServer(s.slot, s.qty)
end)
-- Clicking the row itself offers one more.
row.MouseButton1Click:Connect(function()
local next = math.min(offered + 1, s.qty)
if next > offered then
Remotes.event("TradeStage"):FireServer(s.slot, next)
end
end)
row.Parent = list
end
end
list.Parent = holder
return holder
end
-- ── State views ────────────────────────────────────────────────────────────────
local function buildInvite(state: any)
local t = theme()
local msg = label({
text = `{state.partner} wants to trade with you.`,
size2 = UDim2.new(1, 0, 0, 40),
size = 14,
})
msg.TextWrapped = true
msg.Parent = body
local accept = button("Accept", t.Accent or Color3.fromRGB(120, 170, 90))
accept.Size = UDim2.new(0.5, -6, 0, 34)
accept.Position = UDim2.fromOffset(0, 56)
accept.Parent = body
accept.MouseButton1Click:Connect(function()
Remotes.event("TradeRespond"):FireServer(true)
end)
local decline = button("Decline", Color3.fromRGB(90, 60, 60))
decline.Size = UDim2.new(0.5, -6, 0, 34)
decline.Position = UDim2.new(0.5, 6, 0, 56)
decline.Parent = body
decline.MouseButton1Click:Connect(function()
Remotes.event("TradeRespond"):FireServer(false)
end)
end
local function buildWaiting(state: any)
local msg = label({
text = `Waiting for {state.partner} to accept…`,
size2 = UDim2.new(1, 0, 0, 40),
size = 14,
})
msg.TextWrapped = true
msg.Parent = body
local cancel = button("Cancel", Color3.fromRGB(90, 60, 60))
cancel.Size = UDim2.new(1, 0, 0, 34)
cancel.Position = UDim2.fromOffset(0, 56)
cancel.Parent = body
cancel.MouseButton1Click:Connect(function()
Remotes.event("TradeCancel"):FireServer()
end)
end
local function buildOpen(state: any)
local t = theme()
-- Top half: the two offers side by side. Bottom half: your backpack (so the window is
-- self-contained — no dependency on the separate inventory menu).
local OFFERS_H = 168
local mineCol = offerColumn("You offer", state.mine or {}, true, state.myConfirm == true)
mineCol.Position = UDim2.fromOffset(0, 0)
mineCol.Size = UDim2.new(0.5, -6, 0, OFFERS_H)
mineCol.Parent = body
offerZone = mineCol
local theirsTitle = if state.theirConfirm then `{state.partner}` else `{state.partner} offers`
local theirsCol = offerColumn(theirsTitle, state.theirs or {}, false, state.theirConfirm == true)
theirsCol.Position = UDim2.new(0.5, 6, 0, 0)
theirsCol.Size = UDim2.new(0.5, -6, 0, OFFERS_H)
theirsCol.Parent = body
local pack = backpackSection(state)
pack.Position = UDim2.fromOffset(0, OFFERS_H + 10)
pack.Size = UDim2.new(1, 0, 1, -(OFFERS_H + 10) - 80)
pack.Parent = body
-- Footer: status line + Confirm + Cancel.
local statusLabel = label({
text = if state.myConfirm
then "You confirmed — waiting for your partner…"
else "Stage items, then Confirm. Both must confirm.",
color = t.TextSecondary or Color3.fromRGB(200, 205, 215),
size = 12,
size2 = UDim2.new(1, 0, 0, 18),
pos = UDim2.new(0, 0, 1, -76),
})
statusLabel.Parent = body
local confirm = button(
if state.myConfirm then "✓ Confirmed" else "Confirm",
if state.myConfirm
then (t.SlotColor or Color3.fromRGB(60, 66, 80))
else (t.Accent or Color3.fromRGB(120, 170, 90))
)
confirm.Size = UDim2.new(0.5, -6, 0, 34)
confirm.Position = UDim2.new(0, 0, 1, -34)
confirm.Parent = body
confirm.MouseButton1Click:Connect(function()
if not state.myConfirm then
Remotes.event("TradeConfirm"):FireServer()
end
end)
local cancel = button("Cancel", Color3.fromRGB(90, 60, 60))
cancel.Size = UDim2.new(0.5, -6, 0, 34)
cancel.Position = UDim2.new(0.5, 6, 1, -34)
cancel.Parent = body
cancel.MouseButton1Click:Connect(function()
Remotes.event("TradeCancel"):FireServer()
end)
end
-- ── Render ─────────────────────────────────────────────────────────────────────
local lastState: any = nil
local function render(state: any)
local panel = ensureGui()
if not panel then
return
end
lastState = state
local status = state and state.status
if not status or status == "cancelled" or status == "done" then
panel.Visible = false
clearBody()
lastStatus = status
return
end
panel.Visible = true
clearBody()
if status == "invite" then
buildInvite(state)
elseif status == "waiting" then
buildWaiting(state)
elseif status == "open" then
buildOpen(state)
end
lastStatus = status
end
function TradeUi.start(_options: { [string]: any }?)
if started then
return
end
started = true
-- Drag-to-stage: dropping an inventory slot onto the "Your offer" column stages that item.
-- Registered once; hit-tests the live offer zone. Stages the whole held amount (clamped by the
-- server); the row steppers then trim it.
DragDrop.addTarget({
hitTest = function(pos)
return root ~= nil and root.Visible and offerZone ~= nil and DragDrop.hitTestGui(offerZone, pos)
end,
onDrop = function(payload, _pos)
if payload and payload.kind == "invSlot" and offerZone then
Remotes.event("TradeStage"):FireServer(payload.slot, 9999) -- server clamps to held
end
end,
})
Remotes.event("TradeState").OnClientEvent:Connect(function(state)
render(state)
end)
-- Keep the in-window backpack honest if the inventory changes mid-trade (a pickup, a craft…).
-- Coalesced to one re-render per frame; only while an open trade is on screen.
local refreshQueued = false
localPlayer.AttributeChanged:Connect(function(name: string)
if lastStatus ~= "open" or refreshQueued then
return
end
if not (string.match(name, "^InvSlot_%d+$") or string.match(name, "^InvQty_%d+$")) then
return
end
refreshQueued = true
task.defer(function()
refreshQueued = false
if lastStatus == "open" and lastState then
render(lastState)
end
end)
end)
end
return TradeUi
+1
View File
@@ -23,6 +23,7 @@
achievement:unlocked { player, key, def }
player:died { player, position } -- after any death-drop
lootbag:dropped / lootbag:collected { player, bag, position?, items? / emptied }
trade:started / trade:completed { player, partner, gave?, got? } -- fired per player
Per-resource / per-mob-type variants of these dispatch through Reactions (see Reactions.luau):
SurvivorCore.Gather.onReaction(resourceId, ) and SurvivorCore.Mobs.onReaction(mobType, ).
+20 -1
View File
@@ -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)
+2
View File
@@ -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.
},
}
+19
View File
@@ -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,
+28
View File
@@ -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
+1
View File
@@ -27,6 +27,7 @@ UiConfig.DEFAULTS = {
Codex = "K",
Achievements = "J",
Quests = "L",
Interact = "E", -- open the interact window on the nearest other player (Trade, …)
},
-- Roblox's CoreGui owns some keys — notably Tab opens the built-in player roster, which
+45 -3
View File
@@ -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
+110
View File
@@ -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.
+550
View File
@@ -0,0 +1,550 @@
--!nonstrict
--[[
Trade server-authoritative player-to-player trading (issue #15). SERVER-ONLY.
Two nearby players open a shared trade window, each stages loose backpack stacks into their
side, and BOTH must confirm before anything moves. The swap is a single synchronous,
dupe-proof commit.
Safety model staging is BY-REFERENCE, never escrow. While a trade is open the staged items
stay in each owner's real inventory; a "basket" is just an { itemId -> qty } intent map. Real
inventory mutation happens ONLY inside `commit`, in one no-yield critical section. So every
abort (leave / death / walk out of range / cancel / timeout) needs zero item bookkeeping the
items never moved.
Commit (both confirmed):
1. Re-validate both still HOLD their whole basket (Inventory.has) zero mutations.
2. Pre-flight FIT on both receivers (Inventory.canAccept, accounting for each also shedding
its own basket) zero mutations.
3. Escrow: Inventory.remove each basket from its owner (atomic; guaranteed by step 1).
4. Grant with Inventory.addUpTo (exact-count) step 2 guarantees full grants.
5. Reconcile any residue back to the original owner (unreachable if canAccept is honest).
Item count is conserved on every branch.
Initiation: the client PlayerInteract window (walk up to a player interact the "Trade"
action) fires the TradeRequest remote; the target Accepts/Declines. Tuning: the "Trading" Config
section. v1 trades loose backpack stacks only (worn gear/satchels reserved behind
AllowEquippedItems). Started by SurvivorCore.start().
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
assert(RunService:IsServer(), "SurvivorCore.Trade is server-only — booted by SurvivorCore.start()")
local Inventory = require(script.Parent.Inventory)
local Progression = require(script.Parent.Progression)
local Hooks = require(script.Parent.Parent.foundation.Hooks)
local EventBridge = require(script.Parent.Parent.foundation.EventBridge)
local Remotes = require(script.Parent.Parent.shared.Remotes)
local TradingConfig = require(script.Parent.Parent.shared.TradingConfig)
local Trade = {}
local started = false
-- Both participants' keys point at the SAME session table; `sessions` lets the range loop iterate.
local activeTrade: { [Player]: any } = {}
local sessions: { any } = {}
local nextTradeId = 0
-- ── Small helpers ────────────────────────────────────────────────────────────
local function sanitizeItemId(value: any): string
if type(value) ~= "string" then
return ""
end
local s = string.sub(value, 1, 40)
if s == "" or not string.match(s, "^[%w_%-%s]+$") then
return ""
end
return s
end
local function basketToList(basket: { [string]: number }): { { itemId: string, qty: number } }
local out = {}
for itemId, qty in basket do
if qty and qty > 0 then
table.insert(out, { itemId = itemId, qty = qty })
end
end
return out
end
local function hrpOf(player: Player): BasePart?
local char = player.Character
local hrp = char and char:FindFirstChild("HumanoidRootPart")
return if hrp and hrp:IsA("BasePart") then hrp else nil
end
local function alive(player: Player): boolean
local char = player.Character
local hum = char and char:FindFirstChildOfClass("Humanoid")
return hum ~= nil and hum.Health > 0
end
local function maxDistance(): number
return tonumber(TradingConfig.get().MaxDistance) or 16
end
local function inRange(a: Player, b: Player): boolean
local ha, hb = hrpOf(a), hrpOf(b)
if not ha or not hb then
return false
end
return (ha.Position - hb.Position).Magnitude <= maxDistance() + 0.001
end
local function emit(event: string, player: Player, ctx: { [string]: any })
ctx.player = player
Hooks.run(event, ctx)
EventBridge.fire(event, player, ctx)
end
local function notify(player: Player, title: string, body: string)
if player and player.Parent then
Remotes.event("Notify"):FireClient(player, { kind = "trade", title = title, body = body })
end
end
-- ── Client state push (plain data — no Player/Instance refs) ──────────────────
-- The trade state oriented for one participant (mine/theirs swapped per side). `requested` splits
-- into "invite" (the invitee, who Accepts/Declines) and "waiting" (the requester).
local function stateFor(session: any, who: Player): { [string]: any }
local isA = who == session.a
local other = if isA then session.b else session.a
local status = session.status
if status == "requested" then
status = if who == session.b then "invite" else "waiting"
end
return {
tradeId = session.id,
status = status,
partner = other.Name,
mine = basketToList(if isA then session.aBasket else session.bBasket),
theirs = basketToList(if isA then session.bBasket else session.aBasket),
myConfirm = if isA then session.aConfirm else session.bConfirm,
theirConfirm = if isA then session.bConfirm else session.aConfirm,
reason = session.reason,
}
end
local function push(session: any)
local remote = Remotes.event("TradeState")
if session.a.Parent then
remote:FireClient(session.a, stateFor(session, session.a))
end
if session.b.Parent then
remote:FireClient(session.b, stateFor(session, session.b))
end
end
-- ── Lifecycle ─────────────────────────────────────────────────────────────────
local function dropSession(session: any)
activeTrade[session.a] = nil
activeTrade[session.b] = nil
local idx = table.find(sessions, session)
if idx then
table.remove(sessions, idx)
end
end
-- Idempotent teardown. Sends a terminal "cancelled" state so both windows close, then unregisters.
local function cancel(session: any, reason: string)
if session.dead then
return
end
session.dead = true
session.status = "cancelled"
session.reason = reason
push(session)
if reason ~= "cancelled" and reason ~= "declined" then
local msg = ({
timeout = "The trade request expired.",
range = "You moved too far apart.",
died = "A trader died.",
left = "The other player left.",
})[reason] or "Trade cancelled."
notify(session.a, "Trade cancelled", msg)
notify(session.b, "Trade cancelled", msg)
end
dropSession(session)
end
-- ── Atomic commit (the anti-dupe core) ─────────────────────────────────────────
local function refundEscrow(player: Player, escrow: { [string]: number })
-- Restore exactly what we just removed; the room was freed a step ago with no yield between,
-- so addUpTo restores in full.
for itemId, qty in escrow do
Inventory.addUpTo(player, itemId, qty)
end
end
-- Returns (ok, reason). Runs synchronously with NO yield — nothing else can interleave and mutate
-- inventories mid-commit, so item count is conserved on every branch.
local function commit(session: any): (boolean, string?)
local A, B = session.a, session.b
-- Guard: both present, alive, still in range.
if not (A.Parent and B.Parent and alive(A) and alive(B) and inRange(A, B)) then
return false, "range"
end
-- STEP 1 — both still hold their whole basket. Zero mutations.
for itemId, qty in session.aBasket do
if not Inventory.has(A, itemId, qty) then
return false, "shortfall"
end
end
for itemId, qty in session.bBasket do
if not Inventory.has(B, itemId, qty) then
return false, "shortfall"
end
end
-- STEP 2 — both receivers have room after shedding their own basket. Zero mutations.
if not Inventory.canAccept(A, session.bBasket, { alsoRemoving = session.aBasket }) then
return false, "full"
end
if not Inventory.canAccept(B, session.aBasket, { alsoRemoving = session.bBasket }) then
return false, "full"
end
-- STEP 3 — escrow: remove each basket from its owner (atomic; guaranteed by step 1).
local escrowA: { [string]: number } = {}
for itemId, qty in session.aBasket do
if Inventory.remove(A, itemId, qty) then
escrowA[itemId] = qty
else
refundEscrow(A, escrowA)
return false, "shortfall"
end
end
local escrowB: { [string]: number } = {}
for itemId, qty in session.bBasket do
if Inventory.remove(B, itemId, qty) then
escrowB[itemId] = qty
else
refundEscrow(A, escrowA)
refundEscrow(B, escrowB)
return false, "shortfall"
end
end
-- STEP 4+5 — grant with the exact-count primitive; reconcile any residue back to its owner.
-- Step 2 guarantees full grants, so the residue path is unreachable in practice (loud warn).
for itemId, qty in escrowA do
local granted = Inventory.addUpTo(B, itemId, qty)
if granted < qty then
local back = Inventory.addUpTo(A, itemId, qty - granted)
if back < qty - granted then
warn(`[SurvivorCore.Trade] residue lost: {qty - granted - back}x {itemId} (canAccept bug?)`)
end
end
end
for itemId, qty in escrowB do
local granted = Inventory.addUpTo(A, itemId, qty)
if granted < qty then
local back = Inventory.addUpTo(B, itemId, qty - granted)
if back < qty - granted then
warn(`[SurvivorCore.Trade] residue lost: {qty - granted - back}x {itemId} (canAccept bug?)`)
end
end
end
return true
end
local function finalize(session: any)
local A, B = session.a, session.b
local aList = basketToList(session.aBasket)
local bList = basketToList(session.bBasket)
-- Fire once per player (Progression's resolver runs per real Player; both get trades_total++).
emit("trade:completed", A, { partner = B.Name, gave = aList, got = bList })
emit("trade:completed", B, { partner = A.Name, gave = bList, got = aList })
notify(A, "Trade complete", `You traded with {B.Name}.`)
notify(B, "Trade complete", `You traded with {A.Name}.`)
session.status = "done"
push(session)
dropSession(session)
end
-- ── Internal ops (remotes AND the test harness call these with explicit Players) ──────────────
function Trade._startTrade(from: Player, target: Player)
if typeof(from) ~= "Instance" or typeof(target) ~= "Instance" then
return
end
if TradingConfig.get().Enabled == false then
return
end
if from == target or not from.Parent or not target.Parent then
return
end
if activeTrade[from] then
notify(from, "Can't trade", "You're already in a trade.")
return
end
if activeTrade[target] then
notify(from, "Can't trade", `{target.Name} is already trading.`)
return
end
if not (alive(from) and alive(target) and inRange(from, target)) then
return
end
nextTradeId += 1
local session = {
id = tostring(nextTradeId),
a = from, -- requester
b = target, -- invitee
status = "requested",
aBasket = {},
bBasket = {},
aConfirm = false,
bConfirm = false,
dead = false,
}
activeTrade[from] = session
activeTrade[target] = session
table.insert(sessions, session)
push(session)
local timeout = math.max(1, tonumber(TradingConfig.get().RequestTimeoutSeconds) or 20)
task.delay(timeout, function()
if not session.dead and session.status == "requested" then
cancel(session, "timeout")
end
end)
end
function Trade._respond(player: Player, accept: boolean)
local session = activeTrade[player]
if not session or session.dead or session.status ~= "requested" or player ~= session.b then
return
end
if accept then
if not (alive(session.a) and alive(session.b) and inRange(session.a, session.b)) then
cancel(session, "range")
return
end
session.status = "open"
emit("trade:started", session.a, { partner = session.b.Name })
emit("trade:started", session.b, { partner = session.a.Name })
push(session)
else
cancel(session, "declined")
end
end
local function resetConfirms(session: any)
if TradingConfig.get().ResetConfirmOnChange ~= false then
session.aConfirm = false
session.bConfirm = false
end
end
-- Stage from an inventory slot. The server resolves the itemId from the player's OWN slot (never
-- trusts a client-sent id) and clamps the quantity to what they actually hold.
function Trade._stage(player: Player, invSlot: any, qty: any)
local session = activeTrade[player]
if not session or session.dead or session.status ~= "open" then
return
end
local slot = math.floor(tonumber(invSlot) or 0)
if slot < 1 then
return
end
local itemId = ""
for _, e in Inventory.getSlots(player) do
if e.slot == slot then
itemId = e.itemId
break
end
end
if itemId == "" then
return
end
local held = Inventory.getQty(player, itemId)
local staged = math.clamp(math.floor(tonumber(qty) or 0), 0, held)
local basket = if player == session.a then session.aBasket else session.bBasket
if staged <= 0 then
basket[itemId] = nil
else
basket[itemId] = staged
end
resetConfirms(session)
push(session)
end
function Trade._unstage(player: Player, itemId: any, qty: any)
local session = activeTrade[player]
if not session or session.dead or session.status ~= "open" then
return
end
local id = sanitizeItemId(itemId)
if id == "" then
return
end
local basket = if player == session.a then session.aBasket else session.bBasket
if basket[id] == nil then
return
end
local reduce = math.floor(tonumber(qty) or 0)
if reduce <= 0 then
basket[id] = nil
else
local n = basket[id] - reduce
basket[id] = if n <= 0 then nil else n
end
resetConfirms(session)
push(session)
end
function Trade._confirm(player: Player)
local session = activeTrade[player]
if not session or session.dead or session.status ~= "open" then
return
end
if player == session.a then
session.aConfirm = true
elseif player == session.b then
session.bConfirm = true
else
return
end
if session.aConfirm and session.bConfirm then
session.status = "committing" -- re-entry latch; commit does not yield
local ok, reason = commit(session)
if ok then
finalize(session)
else
session.status = "open"
session.aConfirm = false
session.bConfirm = false
local msg = ({
full = "Not enough room.",
shortfall = "The offer changed — try again.",
range = "You moved too far apart.",
})[reason or ""] or "Trade failed."
notify(session.a, "Trade failed", msg)
notify(session.b, "Trade failed", msg)
push(session)
end
else
push(session)
end
end
function Trade._cancel(player: Player)
local session = activeTrade[player]
if session then
cancel(session, "cancelled")
end
end
-- Testing accessor: the player's live session (or nil). Used by the demo conservation harness.
function Trade._activeFor(player: Player): any
return activeTrade[player]
end
-- ── Character lifecycle (abort on death) ───────────────────────────────────────
-- Initiation lives client-side now: the PlayerInteract window's "Trade" action fires the
-- TradeRequest remote (server-validated by _startTrade). This hook only cancels a live trade
-- when a participant dies.
local function onCharacter(player: Player, character: Model)
local humanoid = character:FindFirstChildOfClass("Humanoid") or character:WaitForChild("Humanoid", 10)
if humanoid and humanoid:IsA("Humanoid") then
humanoid.Died:Once(function()
local session = activeTrade[player]
if session then
cancel(session, "died")
end
end)
end
end
local function watchPlayer(player: Player)
player.CharacterAdded:Connect(function(character)
onCharacter(player, character)
end)
if player.Character then
task.spawn(onCharacter, player, player.Character)
end
end
local function wireRemotes()
Remotes.event("TradeRequest").OnServerEvent:Connect(function(player, targetUserId)
local target = Players:GetPlayerByUserId(math.floor(tonumber(targetUserId) or -1))
if target then
Trade._startTrade(player, target)
end
end)
Remotes.event("TradeRespond").OnServerEvent:Connect(function(player, accept)
Trade._respond(player, accept == true)
end)
Remotes.event("TradeStage").OnServerEvent:Connect(function(player, invSlot, qty)
Trade._stage(player, invSlot, qty)
end)
Remotes.event("TradeUnstage").OnServerEvent:Connect(function(player, itemId, qty)
Trade._unstage(player, itemId, qty)
end)
Remotes.event("TradeConfirm").OnServerEvent:Connect(function(player)
Trade._confirm(player)
end)
Remotes.event("TradeCancel").OnServerEvent:Connect(function(player)
Trade._cancel(player)
end)
end
function Trade.start(_options: { [string]: any }?)
if started then
return
end
started = true
-- Completed trades flow into the shared progress stream → trades_total counter.
Progression.map("trade:completed", function(_player, _data)
return "trade", nil, 1
end)
Remotes.event("TradeState") -- eager S→C so clients can connect at startup
wireRemotes()
for _, player in Players:GetPlayers() do
watchPlayer(player)
end
Players.PlayerAdded:Connect(watchPlayer)
Players.PlayerRemoving:Connect(function(player)
local session = activeTrade[player]
if session then
cancel(session, "left")
end
end)
-- Throttled range watchdog: a trade auto-cancels if the pair walks apart.
local accum = 0
RunService.Heartbeat:Connect(function(dt)
if #sessions == 0 then
return
end
accum += dt
if accum < 0.25 then
return
end
accum = 0
for i = #sessions, 1, -1 do
local s = sessions[i]
if not s.dead and (s.status == "open" or s.status == "requested") and not inRange(s.a, s.b) then
cancel(s, "range")
end
end
end)
end
return Trade
+1 -1
View File
@@ -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"