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>
169 lines
No EOL
5.4 KiB
C
169 lines
No EOL
5.4 KiB
C
// ============================================================
|
|
// YALootBoxes_Lootbox.c
|
|
// The actual in-world lootbox entity.
|
|
// Lives in: scripts/4_World/YALootBoxes_Lootbox.c
|
|
//
|
|
// NOTE ON className: each entry in box_types.json should have a
|
|
// matching config.cpp class inheriting from YALootBoxes_Base
|
|
// (see config.cpp artifact). This script class provides the
|
|
// shared behavior for ALL of them.
|
|
// ============================================================
|
|
|
|
class YALootBoxes_Base extends Container_Base
|
|
{
|
|
protected bool m_IsLocked;
|
|
protected string m_BoxTypeId; // matches "type" in box_types.json
|
|
protected float m_SpawnTime; // world time (seconds) when spawned
|
|
protected float m_UnlockedTime; // world time when it became unlocked (-1 if still locked)
|
|
protected ref YALB_BoxTypeDef m_TypeDef; // cached definition
|
|
|
|
void YALootBoxes_Base()
|
|
{
|
|
m_IsLocked = true;
|
|
m_UnlockedTime = -1;
|
|
RegisterNetSyncVariableBool("m_IsLocked");
|
|
}
|
|
|
|
override void EEInit()
|
|
{
|
|
super.EEInit();
|
|
SetSynchDirty();
|
|
}
|
|
|
|
// Called by the manager right after spawning, before placing in world
|
|
void Init(string boxTypeId)
|
|
{
|
|
m_BoxTypeId = boxTypeId;
|
|
m_TypeDef = YALB_ConfigLoader.GetBoxTypeByType(boxTypeId);
|
|
m_IsLocked = true;
|
|
m_UnlockedTime = -1;
|
|
m_SpawnTime = GetGame().GetTime() / 1000.0;
|
|
|
|
if (!m_TypeDef)
|
|
{
|
|
Log_Warn("Lootbox spawned with unknown type '" + boxTypeId + "'. Using fallback values.");
|
|
}
|
|
|
|
SetSynchDirty();
|
|
}
|
|
|
|
bool IsLocked()
|
|
{
|
|
return m_IsLocked;
|
|
}
|
|
|
|
string GetBoxTypeId()
|
|
{
|
|
return m_BoxTypeId;
|
|
}
|
|
|
|
YALB_BoxTypeDef GetTypeDef()
|
|
{
|
|
if (!m_TypeDef)
|
|
m_TypeDef = YALB_ConfigLoader.GetBoxTypeByType(m_BoxTypeId);
|
|
return m_TypeDef;
|
|
}
|
|
|
|
float GetSpawnTime()
|
|
{
|
|
return m_SpawnTime;
|
|
}
|
|
|
|
float GetUnlockedTime()
|
|
{
|
|
return m_UnlockedTime;
|
|
}
|
|
|
|
// Locked-box lifetime in minutes: per-type despawnMinutes if set (>0),
|
|
// otherwise the global main_config DespawnMinutes.
|
|
int GetLockedLifetimeMinutes()
|
|
{
|
|
if (m_TypeDef && m_TypeDef.despawnMinutes > 0)
|
|
return m_TypeDef.despawnMinutes;
|
|
if (YALB_ConfigLoader.MainConfig)
|
|
return YALB_ConfigLoader.MainConfig.DespawnMinutes;
|
|
return 15;
|
|
}
|
|
|
|
// 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 by HasExpiredAfterOpening once opened
|
|
float ageMinutes = (currentWorldTime - m_SpawnTime) / 60.0;
|
|
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 >= GetOpenedLifetimeMinutes();
|
|
}
|
|
|
|
// Called by YALB_OpenLootboxAction on a successful unlock roll.
|
|
// Converts this from a locked decoration into a real container
|
|
// populated with loot, then starts its post-open despawn clock.
|
|
void UnlockAndFill()
|
|
{
|
|
if (!m_IsLocked) return; // already unlocked, no-op
|
|
|
|
m_IsLocked = false;
|
|
m_UnlockedTime = GetGame().GetTime() / 1000.0;
|
|
SetSynchDirty();
|
|
|
|
PopulateLoot();
|
|
|
|
Log_Info(string.Format("Lootbox '%1' (%2) unlocked and filled.", GetBoxTypeId(), GetPosition().ToString()));
|
|
}
|
|
|
|
protected void PopulateLoot()
|
|
{
|
|
array<ref YALB_LootEntry> lootList = YALB_ConfigLoader.GetLootListForType(m_BoxTypeId);
|
|
if (!lootList)
|
|
{
|
|
Log_Warn("No loot list found for type '" + m_BoxTypeId + "' - box will be empty.");
|
|
return;
|
|
}
|
|
|
|
foreach (YALB_LootEntry entry : lootList)
|
|
{
|
|
float roll = Math.RandomFloat01();
|
|
if (roll > entry.chance) continue;
|
|
|
|
int count = Math.RandomIntInclusive(entry.minCount, entry.maxCount);
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
GetInventory().CreateInInventory(entry.classname);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Player-facing string used by the proximity message and the action menu
|
|
string GetRequiredItemsMessage()
|
|
{
|
|
if (!m_TypeDef) return "Locked (unknown requirements)";
|
|
return "Requires: " + m_TypeDef.GetRequiredItemsDisplayString();
|
|
}
|
|
|
|
// Hooks our custom hold-to-open action onto this object without
|
|
// needing to touch any vanilla cfgVehicles action arrays.
|
|
// NOTE: the engine-called override is SetActions()/AddAction(),
|
|
// NOT GetActions(out array<typename>) - the latter is never
|
|
// invoked by the action manager and silently registers nothing.
|
|
override void SetActions()
|
|
{
|
|
super.SetActions();
|
|
AddAction(YALB_ActionOpenLootbox);
|
|
}
|
|
}; |