From ee77ca9816c10322286e372d4d8d5dec05b3c657 Mon Sep 17 00:00:00 2001 From: Samuel Lison Date: Mon, 6 Jul 2026 15:31:01 +1000 Subject: [PATCH] fix: loot bag + beacon vanished on respawn under StreamingEnabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: with StreamingEnabled (the new-place default), respawning away from the death spot let Roblox evict the bag from the owner's client — the bag looked deleted, and the local Destroying event fired, permanently killing the beacon (server bag was fine all along). - The bag is now a Model set PersistentPerPlayer for its owner (AddPersistentPlayer) — it never streams out of the owner's view. - The beacon is position-driven plain data: no Instance in the remote, so no replication race (the 0.5s delay hack is gone) and no streaming fragility. - The beam clears on an explicit server nil-fire when the bag is truly emptied/expired (plus a lifetime fallback), not on replica Destroying. Live-verified (fresh bytecode confirmed via bag ClassName): beacon appears instantly, survives respawn, bag stays visible to the owner, loot restores everything, beam clears on empty. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 10 +++++++ src/client/LootBagBeacon.luau | 52 ++++++++++++++++++++--------------- src/systems/LootBags.luau | 37 ++++++++++++++++++++----- 3 files changed, 70 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5505011..07d5bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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`. +## Unreleased + +### Fixed +- **Loot bag + beacon vanished on respawn (StreamingEnabled places)** — respawning away from the + death spot let Roblox stream the bag out of the owner's client: the bag looked deleted and the + local `Destroying` fired, killing the beacon. The bag is now a **stream-persistent Model for its + owner** (`AddPersistentPlayer`), and the beacon is **position-driven plain data** — no Instance + reference, so no replication race (the 0.5s delay is gone) and no streaming fragility. The beam + clears on an explicit server signal when the bag is truly emptied/expired. + ## 0.7.2 — 2026-07-06 ### Fixed diff --git a/src/client/LootBagBeacon.luau b/src/client/LootBagBeacon.luau index a45ba3d..5da2547 100644 --- a/src/client/LootBagBeacon.luau +++ b/src/client/LootBagBeacon.luau @@ -2,9 +2,12 @@ --[[ LootBagBeacon — client. Owner-only "your stuff is over there" juice (TCE port). - When the server drops YOUR loot bag it fires `LootBagDropped` at you; this raises a tall golden - beam + a soft ground ring + a point light over the bag — visible only to you (built locally). - Everything cleans itself up when the bag despawns or empties. Booted by startClient(). + The server fires `LootBagDropped` at the owner with a POSITION + lifetime (plain data — immune + to instance-replication races and streaming eviction); this raises a tall golden beam + ground + ring + light there, visible only to this player. It clears when the server signals the bag is + truly gone (a nil fire: looted empty or expired) or when the lifetime lapses — NOT when the + bag's replica streams out, which is what used to kill the beacon on respawn. Booted by + startClient(). ]] local RunService = game:GetService("RunService") @@ -19,18 +22,20 @@ local started = false local GOLD = Color3.fromRGB(255, 220, 80) -local function bagHost(bag: Instance): BasePart? - if bag:IsA("BasePart") then - return bag +local currentBeam: BasePart? = nil +local currentToken = 0 -- invalidates stale lifetime timers when a new bag drops + +local function clearBeacon() + currentToken += 1 + if currentBeam and currentBeam.Parent then + currentBeam:Destroy() end - return bag:FindFirstChildWhichIsA("BasePart") + currentBeam = nil end -local function buildBeacon(bag: Instance) - local host = bagHost(bag) - if not host then - return - end +local function buildBeacon(position: Vector3, lifetime: number) + clearBeacon() + local token = currentToken local beam = Instance.new("Part") beam.Name = "_LootBeacon" @@ -41,7 +46,7 @@ local function buildBeacon(bag: Instance) beam.Color = GOLD beam.Transparency = 0.35 beam.Size = Vector3.new(0.8, 120, 0.8) - beam.CFrame = CFrame.new(host.Position + Vector3.new(0, 60, 0)) + beam.CFrame = CFrame.new(position + Vector3.new(0, 60, 0)) local ring = Instance.new("Part") ring.Name = "_LootBeaconRing" @@ -53,21 +58,22 @@ local function buildBeacon(bag: Instance) ring.Color = GOLD ring.Transparency = 0.55 ring.Size = Vector3.new(0.2, 5, 5) - ring.CFrame = CFrame.new(host.Position + Vector3.new(0, 0.2, 0)) * CFrame.Angles(0, 0, math.rad(90)) + ring.CFrame = CFrame.new(position + Vector3.new(0, 0.2, 0)) * CFrame.Angles(0, 0, math.rad(90)) ring.Parent = beam local light = Instance.new("PointLight") light.Color = GOLD light.Brightness = 2.5 light.Range = 18 - light.Parent = host + light.Parent = beam beam.Parent = workspace + currentBeam = beam - -- Clean up when the bag goes away (looted empty or despawned). - bag.Destroying:Connect(function() - if beam.Parent then - beam:Destroy() + -- Fallback cleanup at lifetime (the server's nil-fire normally clears it first). + task.delay(math.max(1, lifetime), function() + if token == currentToken and currentBeam == beam then + clearBeacon() end end) end @@ -78,9 +84,11 @@ function LootBagBeacon.start(_options: { [string]: any }?) end started = true - Remotes.event("LootBagDropped").OnClientEvent:Connect(function(bag, _lifetime) - if typeof(bag) == "Instance" then - buildBeacon(bag) + Remotes.event("LootBagDropped").OnClientEvent:Connect(function(position, lifetime) + if typeof(position) == "Vector3" then + buildBeacon(position, tonumber(lifetime) or 300) + else + clearBeacon() -- nil = the bag is truly gone (looted empty or expired) end end) end diff --git a/src/systems/LootBags.luau b/src/systems/LootBags.luau index 7dd0aa5..bbe4e1b 100644 --- a/src/systems/LootBags.luau +++ b/src/systems/LootBags.luau @@ -198,9 +198,27 @@ local function dropBag(player: Player, position: Vector3) return -- died empty-handed; no bag end + -- The bag is a MODEL so it can be made stream-persistent for its owner: with the default + -- StreamingEnabled place, respawning far from the death spot would otherwise evict the bag + -- from the owner's client — it looked deleted (and killed the beacon) even though the server + -- still had it. local tmpl = bagTemplate() - local bag: Instance = if tmpl then tmpl:Clone() else buildPlaceholderBag() + local inner: Instance = if tmpl then tmpl:Clone() else buildPlaceholderBag() + local bag: Model + if inner:IsA("Model") then + bag = inner + else + bag = Instance.new("Model") + inner.Parent = bag + if inner:IsA("BasePart") then + bag.PrimaryPart = inner + end + end bag.Name = "LootBag" + pcall(function() + bag.ModelStreamingMode = Enum.ModelStreamingMode.PersistentPerPlayer + bag:AddPersistentPlayer(player) + end) -- Aggregate contents as IntValue children (item id → qty), equipment included. local counts: { [string]: number } = {} @@ -268,13 +286,18 @@ local function dropBag(player: Player, position: Vector3) Hooks.run("lootbag:dropped", ctx) EventBridge.fire("lootbag:dropped", player, { position = position, items = total }) - -- Owner-only client juice: beacon + "your things dropped" toast. The beacon fire is DELAYED a - -- beat — the bag was created this frame, and an Instance sent before it replicates arrives as - -- nil on the client (the beacon would silently never appear). + -- Owner-only client juice: beacon + "your things dropped" toast. The beacon payload is PLAIN + -- DATA (position + lifetime) — no Instance reference, so there is no replication race and no + -- streaming fragility; the client builds the beam at the position and keeps it until the bag + -- is truly gone (the nil-fire below) or the lifetime lapses. + local host2 = bagHost(bag) + local beaconPos = if host2 then host2.Position else position if cfg.OwnerBeacon ~= false then - task.delay(0.5, function() - if bag.Parent and player.Parent then - Remotes.event("LootBagDropped"):FireClient(player, bag, lifetime) + Remotes.event("LootBagDropped"):FireClient(player, beaconPos, lifetime) + bag.Destroying:Connect(function() + -- Truly gone server-side (emptied or expired) → clear the owner's beacon early. + if player.Parent then + Remotes.event("LootBagDropped"):FireClient(player, nil) end end) end