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 <noreply@anthropic.com>
This commit is contained in:
smthspec 2026-06-20 12:37:56 +03:00
parent ff51bde976
commit 24538f365f
4 changed files with 85 additions and 44 deletions

View file

@ -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 },

View file

@ -18,7 +18,8 @@ class YALB_MainConfig
ref array<string> _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<ref YALB_FailEffect>();
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));

View file

@ -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.

View file

@ -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<Object> nearby = new array<Object>();
array<CargoBase> cargos = new array<CargoBase>();
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<ref YALB_ActiveBoxEntry> toRemove = new array<ref YALB_ActiveBoxEntry>();