From 24538f365fd517288b51454e0d522d5c5fb3dd81 Mon Sep 17 00:00:00 2001 From: smthspec Date: Sat, 20 Jun 2026 12:37:56 +0300 Subject: [PATCH] Stop box stacking (clear location on spawn + self-healing sweep); split despawn timers Stacking root cause: boxes that persisted from sessions before the non-persistency change load into the world AFTER MissionServer.OnInit, so the one-shot init cleanup missed them; they were untracked and never despawned, and new boxes spawned on top. - RemoveUntrackedBoxesAt(pos): deletes YALootBoxes_Base near a point that are NOT in m_ActiveBoxes (skips live boxes via IsTrackedBox), so it is safe to call any time. - SpawnOneAtRandomFreeLocation now clears its exact location first, guaranteeing one box per location and never stacking on a leftover. - CleanupOrphanedBoxes now runs at startup AND at the start of every TickLifecycle (self-healing), so ghosts that stream in late are removed within one check interval. Radius tightened 5m -> 3m. Despawn timers (per request): split into two main_config keys - DespawnMinutes: locked/unopened box lifetime (per-type despawnMinutes still overrides when > 0; default per-type is now 0 = use global). - OpenedDespawnMinutes: lifetime after a box is opened. Replaces the single DefaultDespawnMinutes. Lootbox uses GetLockedLifetimeMinutes / GetOpenedLifetimeMinutes accordingly. README/configfiles.readme + _comment docs updated. Co-Authored-By: Claude Opus 4.8 --- configfiles.readme | 11 ++--- scripts/4_World/YALootBoxes_Config.c | 21 +++++---- scripts/4_World/YALootBoxes_Lootbox.c | 33 +++++++++----- scripts/4_World/YALootBoxes_Manager.c | 64 +++++++++++++++++++-------- 4 files changed, 85 insertions(+), 44 deletions(-) diff --git a/configfiles.readme b/configfiles.readme index b7b086b..a728ef5 100644 --- a/configfiles.readme +++ b/configfiles.readme @@ -12,8 +12,9 @@ // file describing every field and its range. (Delete the json to regenerate.) { "MaxActiveLootboxes": 30, // max boxes alive at once (1-100; capped by spawn_locations count) - "GlobalCheckIntervalSeconds": 60, // seconds between despawn/respawn checks (10-600) - "DefaultDespawnMinutes": 15, // fallback lifetime if a box type omits despawnMinutes (1-1440) + "GlobalCheckIntervalSeconds": 60, // seconds between despawn/respawn checks + leftover sweep (10-600) + "DespawnMinutes": 15, // how long a LOCKED (unopened) box stays before despawn/respawn (1-1440) + "OpenedDespawnMinutes": 5, // how long a box stays in the world after being OPENED (1-1440) "OpenChannelDurationSeconds": 5, // hold-to-open time in seconds (1-30) "ProximityMessageRangeMeters": 3.0,// range of the "Requires: X" hint, meters (1-20) "DebugLogLevel": 3 // 0=off 1=ERROR 2=WARN 3=INFO 4=DEBUG 5=VERBOSE @@ -34,7 +35,7 @@ ], "openChance": 0.65, "itemDamageOnOpen": 8.0, - "despawnMinutes": 15, + "despawnMinutes": 0, "failEffects": [ { "type": "EXPLOSIVE", "chance": 0.15, "damage": 45.0, "radius": 2.5 }, { "type": "HEALTH", "chance": 0.25, "damage": 15.0 }, @@ -51,7 +52,7 @@ ], "openChance": 0.80, "itemDamageOnOpen": 4.0, - "despawnMinutes": 15, + "despawnMinutes": 0, "failEffects": [ { "type": "SICKNESS", "chance": 0.50, "agent": "Influenza" }, { "type": "HEALTH", "chance": 0.30, "damage": 8.0 }, @@ -67,7 +68,7 @@ ], "openChance": 0.50, "itemDamageOnOpen": 12.0, - "despawnMinutes": 20, + "despawnMinutes": 0, "failEffects": [ { "type": "EXPLOSIVE", "chance": 0.20, "damage": 60.0, "radius": 3.0 }, { "type": "BROKEN_BONE", "chance": 0.25 }, diff --git a/scripts/4_World/YALootBoxes_Config.c b/scripts/4_World/YALootBoxes_Config.c index fec750f..562a576 100644 --- a/scripts/4_World/YALootBoxes_Config.c +++ b/scripts/4_World/YALootBoxes_Config.c @@ -18,7 +18,8 @@ class YALB_MainConfig ref array _comment; // admin-facing field docs (ignored by logic) int MaxActiveLootboxes; int GlobalCheckIntervalSeconds; - int DefaultDespawnMinutes; + int DespawnMinutes; // locked-box lifetime (minutes) + int OpenedDespawnMinutes; // opened-box lifetime (minutes) int OpenChannelDurationSeconds; float ProximityMessageRangeMeters; int DebugLogLevel; // numeric verbosity, see _comment / YALB_LogLevel @@ -28,14 +29,16 @@ class YALB_MainConfig // Sane defaults if file is missing/corrupt MaxActiveLootboxes = 30; GlobalCheckIntervalSeconds = 60; - DefaultDespawnMinutes = 15; + DespawnMinutes = 15; + OpenedDespawnMinutes = 5; OpenChannelDurationSeconds = 5; ProximityMessageRangeMeters = 3.0; DebugLogLevel = 3; // INFO _comment = { "MaxActiveLootboxes: max boxes alive at once (1-100; also capped by number of spawn_locations).", - "GlobalCheckIntervalSeconds: seconds between despawn/respawn checks (10-600).", - "DefaultDespawnMinutes: fallback box lifetime if a box type omits despawnMinutes (1-1440).", + "GlobalCheckIntervalSeconds: seconds between despawn/respawn checks; also how often leftover boxes are swept (10-600).", + "DespawnMinutes: how long a LOCKED (unopened) box stays before it despawns and respawns elsewhere (1-1440). A box type can override this with its own despawnMinutes.", + "OpenedDespawnMinutes: how long a box stays in the world AFTER being opened, then despawns (1-1440).", "OpenChannelDurationSeconds: hold-to-open time in seconds (1-30).", "ProximityMessageRangeMeters: range of the 'Requires: X' hint, in meters (1-20).", "DebugLogLevel: 0=off, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG, 5=VERBOSE." @@ -80,7 +83,7 @@ class YALB_BoxTypeDef failEffects = new array(); openChance = 0.5; itemDamageOnOpen = 5.0; - despawnMinutes = 15; + despawnMinutes = 0; // 0 = use main_config DespawnMinutes (per-type override otherwise) } // Builds the player-facing "requires: X, Y" string @@ -120,7 +123,7 @@ class YALB_BoxTypesFile "requiredItems: holding ANY one of these enables the open action (classname=item, displayName=shown text).", "openChance: success probability, 0.0-1.0.", "itemDamageOnOpen: damage dealt to the used tool on success (health points, >=0).", - "despawnMinutes: box lifetime in minutes, locked or after opening (1-1440).", + "despawnMinutes: OPTIONAL per-type override for the LOCKED lifetime; 0 = use main_config DespawnMinutes. (Opened lifetime is always main_config OpenedDespawnMinutes.)", "failEffects: on failure exactly ONE is picked, weighted by 'chance' (weights need not sum to 1).", " type: EXPLOSIVE | HEALTH | BLEEDING | SICKNESS | BROKEN_BONE.", " damage: HP (EXPLOSIVE/HEALTH). radius: meters (EXPLOSIVE). bleedSources: count (BLEEDING).", @@ -344,7 +347,7 @@ class YALB_ConfigLoader { YALB_BoxTypesFile f = new YALB_BoxTypesFile(); - YALB_BoxTypeDef mil = YALB_MkBoxType("YALootBoxes_Crate_Military", "military", 0.65, 8.0, 15); + YALB_BoxTypeDef mil = YALB_MkBoxType("YALootBoxes_Crate_Military", "military", 0.65, 8.0, 0); mil.requiredItems.Insert(YALB_MkReq("BoltCutters", "Bolt Cutters")); mil.requiredItems.Insert(YALB_MkReq("Crowbar", "Crowbar")); mil.failEffects.Insert(YALB_MkFail("EXPLOSIVE", 0.15, 45.0, 2.5)); @@ -354,14 +357,14 @@ class YALB_ConfigLoader mil.failEffects.Insert(YALB_MkFail("BROKEN_BONE", 0.15)); f.BoxTypes.Insert(mil); - YALB_BoxTypeDef med = YALB_MkBoxType("YALootBoxes_Crate_Medical", "medical", 0.80, 4.0, 15); + YALB_BoxTypeDef med = YALB_MkBoxType("YALootBoxes_Crate_Medical", "medical", 0.80, 4.0, 0); med.requiredItems.Insert(YALB_MkReq("Hacksaw", "Hacksaw")); med.failEffects.Insert(YALB_MkFail("SICKNESS", 0.50, 0.0, 0.0, 0, "Influenza")); med.failEffects.Insert(YALB_MkFail("HEALTH", 0.30, 8.0)); med.failEffects.Insert(YALB_MkFail("BLEEDING", 0.20, 0.0, 0.0, 1)); f.BoxTypes.Insert(med); - YALB_BoxTypeDef ind = YALB_MkBoxType("YALootBoxes_Crate_Industrial", "industrial", 0.50, 12.0, 20); + YALB_BoxTypeDef ind = YALB_MkBoxType("YALootBoxes_Crate_Industrial", "industrial", 0.50, 12.0, 0); ind.requiredItems.Insert(YALB_MkReq("Crowbar", "Crowbar")); ind.requiredItems.Insert(YALB_MkReq("WeldingMask", "Angle Grinder")); ind.failEffects.Insert(YALB_MkFail("EXPLOSIVE", 0.20, 60.0, 3.0)); diff --git a/scripts/4_World/YALootBoxes_Lootbox.c b/scripts/4_World/YALootBoxes_Lootbox.c index a9abf0e..959c910 100644 --- a/scripts/4_World/YALootBoxes_Lootbox.c +++ b/scripts/4_World/YALootBoxes_Lootbox.c @@ -74,30 +74,41 @@ class YALootBoxes_Base extends Container_Base return m_UnlockedTime; } - // How many minutes this box should live for, based on its current state - int GetDespawnMinutes() + // Locked-box lifetime in minutes: per-type despawnMinutes if set (>0), + // otherwise the global main_config DespawnMinutes. + int GetLockedLifetimeMinutes() { - if (m_TypeDef) + if (m_TypeDef && m_TypeDef.despawnMinutes > 0) return m_TypeDef.despawnMinutes; - return YALB_ConfigLoader.MainConfig.DefaultDespawnMinutes; + if (YALB_ConfigLoader.MainConfig) + return YALB_ConfigLoader.MainConfig.DespawnMinutes; + return 15; } - // Returns true if (locked, never-opened) box has expired and should - // be removed/respawned elsewhere. Locked boxes use the SAME despawn - // timer as unlocked ones by design (keeps the loot table fresh even - // if nobody opens it) - manager checks this each cycle. + // Opened-box lifetime in minutes: always the global OpenedDespawnMinutes. + int GetOpenedLifetimeMinutes() + { + if (YALB_ConfigLoader.MainConfig) + return YALB_ConfigLoader.MainConfig.OpenedDespawnMinutes; + return 5; + } + + // True once a still-locked, never-opened box has outlived DespawnMinutes; + // the manager then removes it and respawns one elsewhere (keeps the loot + // table fresh even if nobody opens it). bool HasExpiredUnopened(float currentWorldTime) { - if (!m_IsLocked) return false; // handled separately once opened + if (!m_IsLocked) return false; // handled by HasExpiredAfterOpening once opened float ageMinutes = (currentWorldTime - m_SpawnTime) / 60.0; - return ageMinutes >= GetDespawnMinutes(); + return ageMinutes >= GetLockedLifetimeMinutes(); } + // True once an opened box has outlived OpenedDespawnMinutes since unlock. bool HasExpiredAfterOpening(float currentWorldTime) { if (m_IsLocked || m_UnlockedTime < 0) return false; float ageMinutes = (currentWorldTime - m_UnlockedTime) / 60.0; - return ageMinutes >= GetDespawnMinutes(); + return ageMinutes >= GetOpenedLifetimeMinutes(); } // Called by YALB_OpenLootboxAction on a successful unlock roll. diff --git a/scripts/4_World/YALootBoxes_Manager.c b/scripts/4_World/YALootBoxes_Manager.c index 8c08890..5a0efc7 100644 --- a/scripts/4_World/YALootBoxes_Manager.c +++ b/scripts/4_World/YALootBoxes_Manager.c @@ -59,35 +59,52 @@ class YALootBoxesManager Log_Info("Manager initialized. " + m_ActiveBoxes.Count() + " lootboxes spawned."); } - // Scans every configured spawn location and deletes any YALootBoxes_Base - // found there. Catches boxes that persisted from a previous session so a - // restart doesn't stack a fresh set on top of the old one. (New boxes are - // also spawned non-persistent below, so this is mostly a one-time cleanup - // for boxes created before that change.) - protected void CleanupOrphanedBoxes() + // True if this box is one we currently manage (in m_ActiveBoxes). + protected bool IsTrackedBox(YALootBoxes_Base b) + { + foreach (YALB_ActiveBoxEntry e : m_ActiveBoxes) + { + if (e.box == b) return true; + } + return false; + } + + // Deletes every YALootBoxes_Base near 'pos' that we do NOT track - i.e. + // leftovers that persisted from a previous session, or any stray duplicate. + // Tracked (live) boxes are skipped, so this is safe to call any time. + // Returns how many were removed. + protected int RemoveUntrackedBoxesAt(vector pos) { int removed = 0; array nearby = new array(); array cargos = new array(); - - foreach (YALB_SpawnLocationDef loc : YALB_ConfigLoader.SpawnLocations.SpawnLocations) + GetGame().GetObjectsAtPosition3D(pos, 3.0, nearby, cargos); + foreach (Object o : nearby) { - nearby.Clear(); - cargos.Clear(); - GetGame().GetObjectsAtPosition3D(loc.GetPosition(), 5.0, nearby, cargos); - foreach (Object o : nearby) + YALootBoxes_Base box = YALootBoxes_Base.Cast(o); + if (box && !IsTrackedBox(box)) { - YALootBoxes_Base box = YALootBoxes_Base.Cast(o); - if (box) - { - GetGame().ObjectDelete(box); - removed++; - } + GetGame().ObjectDelete(box); + removed++; } } + return removed; + } + // Sweeps every configured spawn location for untracked leftover boxes. + // Runs at startup AND on every lifecycle tick, so persisted ghosts that + // stream in after init get cleaned up within one check interval. Because + // persisted boxes can load AFTER OnInit, a single init-time sweep is not + // enough - hence the per-tick sweep. + protected void CleanupOrphanedBoxes() + { + int removed = 0; + foreach (YALB_SpawnLocationDef loc : YALB_ConfigLoader.SpawnLocations.SpawnLocations) + { + removed += RemoveUntrackedBoxesAt(loc.GetPosition()); + } if (removed > 0) - Log_Info("Removed " + removed + " leftover lootbox(es) from a previous session before spawning."); + Log_Info("Removed " + removed + " untracked leftover lootbox(es)."); } protected void SpawnInitialBatch() @@ -124,6 +141,11 @@ class YALootBoxesManager return SpawnOneAtRandomFreeLocation(); } + // Clear this exact spot first: delete any untracked leftover box here + // so we never stack a new box on top of an old/persisted one. (The + // location is free, so nothing we track is here - only ghosts.) + RemoveUntrackedBoxesAt(locDef.GetPosition()); + // ECE_NOPERSISTENCY_WORLD: never saved to the world DB, so boxes don't // survive a restart and stack. ECE_SETUP gives full entity init (cargo // etc.); ECE_CREATEPHYSICS gives collision/selection geometry. @@ -165,6 +187,10 @@ class YALootBoxesManager // then immediately tries to fill the gap at a NEW random location. void TickLifecycle() { + // Self-healing: clear any untracked leftover/persisted boxes that may + // have streamed in since the last tick (skips boxes we manage). + CleanupOrphanedBoxes(); + float now = GetGame().GetTime() / 1000.0; array toRemove = new array();