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:
parent
ff51bde976
commit
24538f365f
4 changed files with 85 additions and 44 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue