Fix open action (register custom action), stop restart-stacking, numeric log level, config docs

Action (the real "no action" cause):
- DayZ does not auto-discover custom actions; ActionConstructor holds a
  hardcoded list and AddAction() silently drops anything missing from it.
  Added a modded ActionConstructor that registers YALB_ActionOpenLootbox, so
  the open action finally attaches to the box.
- Switched the action target condition CCTObject -> CCTCursor (the look-at
  pattern vanilla uses for world-object interactions).

Restart stacking:
- Spawn boxes with CreateObjectEx + ECE_NOPERSISTENCY_WORLD so they are never
  saved to the world DB and don't survive restarts.
- Added CleanupOrphanedBoxes(): on init, scan each spawn location and delete
  any leftover YALootBoxes_Base before spawning (clears boxes persisted before
  this change).

Logging:
- DebugLogLevel changed from a string to a numeric field (0=off 1=ERROR
  2=WARN 3=INFO 4=DEBUG 5=VERBOSE), clamped at load. Removed the string parser.
  NOTE: existing main_config.json has a string value and must be regenerated.

Config docs:
- Each generated JSON now includes a "_comment" array describing every field
  and its range (ignored by the loader). README + configfiles.readme updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
smthspec 2026-06-20 11:31:20 +03:00
parent c6f6cea9ad
commit ff51bde976
5 changed files with 123 additions and 59 deletions

View file

@ -33,35 +33,32 @@ All settings live in four JSON files auto-generated on first run at
Confirm it loaded by looking for these lines in the server `.RPT`:
```
[YALootBoxes][INFO] Config loaded: 3 box types, 7 spawn locations, 3 loot lists. Log level = INFO.
[YALootBoxes][INFO] Config loaded: 3 box types, 7 spawn locations, 3 loot lists. Log level = 3.
[YALootBoxes][INFO] Manager initialized. N lootboxes spawned.
```
## Logging
Logging verbosity is controlled by a single field in `main_config.json`:
Logging verbosity is controlled by a single **numeric** field in `main_config.json`:
```json
{
"DebugLogLevel": "INFO"
"DebugLogLevel": 3
}
```
### Why a string?
The level is stored as a **string** in JSON because it is far friendlier for
admins hand-editing the file (`"DEBUG"` reads better than `4`). It is parsed
**once at load time** into an integer (`YALB_ConfigLoader.LogLevel`) so every
runtime log call is a cheap integer comparison, not a string compare. Parsing
is case-insensitive, and an unrecognised value falls back to `INFO` so a typo
can never silently disable logging.
The value is read once at load into `YALB_ConfigLoader.ActiveLogLevel` and
clamped to 0–5, so every runtime log call is a cheap integer comparison.
### Levels
Levels are ordered. A message is printed only when the configured level is
**at or above** the message's level:
`NONE(0) < ERROR(1) < WARN(2) < INFO(3) < DEBUG(4) < VERBOSE(5)`
`0 = off < 1 = ERROR < 2 = WARN < 3 = INFO < 4 = DEBUG < 5 = VERBOSE`
Set `DebugLogLevel` to the highest level you want to see; everything more severe
is included. Default is `3` (INFO), recommended for production.
| Level | When to use | Examples in this mod |
|-------|-------------|----------------------|

View file

@ -8,13 +8,15 @@
// ------------------------------------------------------------
// FILE: main_config.json
// ------------------------------------------------------------
// On first run the mod also writes a "_comment" array into each generated
// file describing every field and its range. (Delete the json to regenerate.)
{
"MaxActiveLootboxes": 30,
"GlobalCheckIntervalSeconds": 60,
"DefaultDespawnMinutes": 15,
"OpenChannelDurationSeconds": 5,
"ProximityMessageRangeMeters": 3.0,
"DebugLogLevel": "INFO"
"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)
"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
}

View file

@ -40,8 +40,8 @@ class YALB_ActionOpenLootbox : ActionContinuousBase
override void CreateConditionComponents()
{
m_ConditionItem = new CCINone();
m_ConditionTarget = new CCTObject(UAMaxDistances.DEFAULT);
m_ConditionItem = new CCINone(); // no item-in-hands restriction here
m_ConditionTarget = new CCTCursor(UAMaxDistances.DEFAULT); // look-at-object targeting
}
override string GetText()
@ -253,16 +253,27 @@ void YALB_NotifyPlayer(PlayerBase player, string message)
}
// ------------------------------------------------------------
// NOTE: Hooking this action onto the lootbox class
// ACTION REGISTRATION (REQUIRED)
// ------------------------------------------------------------
// The cleanest approach for a fully custom action without
// touching vanilla configs is to override SetActions() directly
// on YALootBoxes_Base:
//
// override void SetActions()
// {
// super.SetActions();
// AddAction(YALB_ActionOpenLootbox);
// }
//
// This override lives in YALootBoxes_Lootbox.c.
// DayZ does NOT auto-discover custom actions. ActionConstructor
// holds a hardcoded list of every action; AddAction() looks an
// action up there and silently drops it if missing. So a custom
// action MUST be added via a modded ActionConstructor, or it will
// never attach to anything (this is why the open prompt was
// missing even though the box and SetActions() were correct).
// ------------------------------------------------------------
modded class ActionConstructor
{
override void RegisterActions(TTypenameArray actions)
{
super.RegisterActions(actions);
actions.Insert(YALB_ActionOpenLootbox);
}
}
// ------------------------------------------------------------
// NOTE: the action is attached to the box via SetActions()/
// AddAction() in YALootBoxes_Lootbox.c. ContinuousInteractActionInput
// detects actions from the TARGET object you look at, so registering
// it on the box (not the held tool) is correct.
// ------------------------------------------------------------

View file

@ -15,15 +15,13 @@ const string YALB_SPAWN_LOCATIONS_FILE = YALB_CONFIG_DIR + "spawn_locations.js
// ------------------------------------------------------------
class YALB_MainConfig
{
ref array<string> _comment; // admin-facing field docs (ignored by logic)
int MaxActiveLootboxes;
int GlobalCheckIntervalSeconds;
int DefaultDespawnMinutes;
int OpenChannelDurationSeconds;
float ProximityMessageRangeMeters;
// Logging verbosity. Stored as a STRING in JSON (admin-friendly to hand-edit)
// and parsed once at load into YALB_ConfigLoader.LogLevel (an int) for cheap
// runtime comparisons. Valid: NONE, ERROR, WARN, INFO, DEBUG, VERBOSE.
string DebugLogLevel;
int DebugLogLevel; // numeric verbosity, see _comment / YALB_LogLevel
void YALB_MainConfig()
{
@ -33,7 +31,15 @@ class YALB_MainConfig
DefaultDespawnMinutes = 15;
OpenChannelDurationSeconds = 5;
ProximityMessageRangeMeters = 3.0;
DebugLogLevel = "INFO";
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).",
"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."
};
}
};
@ -102,11 +108,24 @@ class YALB_BoxTypeDef
class YALB_BoxTypesFile
{
ref array<string> _comment;
ref array<ref YALB_BoxTypeDef> BoxTypes;
void YALB_BoxTypesFile()
{
BoxTypes = new array<ref YALB_BoxTypeDef>();
_comment = {
"className: must match a CfgVehicles class in the mod's config.cpp.",
"type: id linking this box to a loot_lists entry and to spawn_locations.",
"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).",
"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).",
" agent: Salmonella|Influenza|Cholera|Brain|Wound_Bacterial (SICKNESS)."
};
}
};
@ -139,11 +158,17 @@ class YALB_LootList
class YALB_LootListsFile
{
ref array<string> _comment;
ref array<ref YALB_LootList> LootLists;
void YALB_LootListsFile()
{
LootLists = new array<ref YALB_LootList>();
_comment = {
"type: must match a box_types 'type'. entries[] are rolled INDEPENDENTLY when a box opens.",
"classname: item to spawn. chance: 0.0-1.0 independent probability.",
"minCount/maxCount: quantity range when an item is rolled (integers, >=1, max>=min)."
};
}
};
@ -166,11 +191,17 @@ class YALB_SpawnLocationDef
class YALB_SpawnLocationsFile
{
ref array<string> _comment;
ref array<ref YALB_SpawnLocationDef> SpawnLocations;
void YALB_SpawnLocationsFile()
{
SpawnLocations = new array<ref YALB_SpawnLocationDef>();
_comment = {
"name: unique label for this spot. boxType: must match a box_types 'type'.",
"position: [x, y, z] world coords. Only one box spawns per location at a time.",
"Add as many locations as you like; boxes are placed at a random free one each cycle."
};
}
};
@ -256,8 +287,11 @@ class YALB_ConfigLoader
else
JsonFileLoader<YALB_MainConfig>.JsonLoadFile(YALB_MAIN_CONFIG_FILE, MainConfig);
// Parse the configured level NOW so every log below honours it.
ActiveLogLevel = YALB_ParseLogLevel(MainConfig.DebugLogLevel);
// Apply the configured level NOW (clamped to a valid range) so every
// log below honours it.
ActiveLogLevel = MainConfig.DebugLogLevel;
if (ActiveLogLevel < YALB_LogLevel.NONE) ActiveLogLevel = YALB_LogLevel.NONE;
if (ActiveLogLevel > YALB_LogLevel.VERBOSE) ActiveLogLevel = YALB_LogLevel.VERBOSE;
if (createdMain) Log_Info("main_config.json not found - created with defaults.");
// --- box types ---
@ -432,25 +466,6 @@ enum YALB_LogLevel
VERBOSE = 5
};
// Parse the admin-facing string (case-insensitive) to a YALB_LogLevel int.
// Unrecognised values fall back to INFO so a typo never silences the log.
int YALB_ParseLogLevel(string raw)
{
string s = raw;
s.ToUpper();
switch (s)
{
case "NONE": return YALB_LogLevel.NONE;
case "ERROR": return YALB_LogLevel.ERROR;
case "WARN": return YALB_LogLevel.WARN;
case "WARNING": return YALB_LogLevel.WARN; // tolerate common variant
case "INFO": return YALB_LogLevel.INFO;
case "DEBUG": return YALB_LogLevel.DEBUG;
case "VERBOSE": return YALB_LogLevel.VERBOSE;
}
return YALB_LogLevel.INFO;
}
// Shared gate + format for the non-error levels.
void YALB_Emit(int level, string tag, string msg)
{

View file

@ -42,6 +42,10 @@ class YALootBoxesManager
m_FreeLocationNames.Insert(loc.name);
}
// Remove any lootboxes left over from a previous session (e.g. ones
// that persisted) BEFORE spawning, so they don't stack on restart.
CleanupOrphanedBoxes();
SpawnInitialBatch();
// Periodic lifecycle tick (despawn/respawn checks)
@ -55,6 +59,37 @@ 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()
{
int removed = 0;
array<Object> nearby = new array<Object>();
array<CargoBase> cargos = new array<CargoBase>();
foreach (YALB_SpawnLocationDef loc : YALB_ConfigLoader.SpawnLocations.SpawnLocations)
{
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)
{
GetGame().ObjectDelete(box);
removed++;
}
}
}
if (removed > 0)
Log_Info("Removed " + removed + " leftover lootbox(es) from a previous session before spawning.");
}
protected void SpawnInitialBatch()
{
int target = YALB_ConfigLoader.MainConfig.MaxActiveLootboxes;
@ -89,7 +124,11 @@ class YALootBoxesManager
return SpawnOneAtRandomFreeLocation();
}
Object obj = GetGame().CreateObject(typeDef.className, locDef.GetPosition(), false, true, true);
// 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.
Object obj = GetGame().CreateObjectEx(typeDef.className, locDef.GetPosition(),
ECE_SETUP | ECE_CREATEPHYSICS | ECE_NOPERSISTENCY_WORLD);
YALootBoxes_Base box = YALootBoxes_Base.Cast(obj);
if (!box)
{