diff --git a/README.md b/README.md new file mode 100644 index 0000000..1583ce2 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# YALootBoxes + +A DayZ server mod that spawns lockable lootboxes at config-defined locations. +Players need a specific held item to attempt opening a box via a hold-to-open +action: success unlocks and fills the box; failure rolls one random negative +effect. Locked boxes despawn/respawn on a timer so the loot stays fresh; opened +boxes despawn a configurable number of minutes after being unlocked. + +All settings live in four JSON files auto-generated on first run at +`$profile:YALootBoxes\`: + +| File | Purpose | +|------|---------| +| `main_config.json` | Global tunables (limits, timers, logging) | +| `box_types.json` | Box definitions: required items, open chance, fail effects | +| `loot_lists.json` | Per-type loot tables | +| `spawn_locations.json` | Named world positions and which box type spawns there | + +> Edit the copies under `$profile:YALootBoxes\`, **not** the ones inside the PBO. +> Reference/default versions of all four are in `configfiles.readme`. + +## Logging + +Logging verbosity is controlled by a single field in `main_config.json`: + +```json +{ + "DebugLogLevel": "INFO" +} +``` + +### 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. + +### 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)` + +| Level | When to use | Examples in this mod | +|-------|-------------|----------------------| +| `NONE` | Silence all mod logging. | — | +| `ERROR` | The mod cannot do its job; needs admin attention. Routed through `ErrorEx()` so it stands out in the `.RPT`. | `box_types.json` / `spawn_locations.json` missing; an entity class failed to spawn. | +| `WARN` | Recoverable problem; the mod continues with degraded behaviour. | Unknown `boxType` on a spawn location; `loot_lists.json` missing (boxes open empty); unknown fail-effect type. | +| `INFO` | Normal high-level operational milestones. **Recommended for production.** | Manager initialized; config loaded summary; box spawned; box unlocked and filled. | +| `DEBUG` | Periodic internal state useful when diagnosing behaviour. | Per-tick lifecycle summary (`removed N, active now M`). | +| `VERBOSE` | Very high-frequency, per-player / per-roll detail. | Each proximity message sent; each fail-effect roll resolved. | + +Setting the level to `X` enables `X` **and everything more severe**. For example +`DEBUG` shows ERROR + WARN + INFO + DEBUG, but not VERBOSE. + +### A note on VERBOSE + +`VERBOSE` fires on hot paths (proximity messages run every ~1.5s per nearby +player; fail-effect rolls run on every failed open). It will flood your `.RPT` +on a populated server and is intended **only for active debugging sessions**. +Leave production servers at `INFO` (or `WARN`/`ERROR` if you want it quieter). + +## Log code API (for modders) + +```c +Log_Error("..."); // ERROR — also ErrorEx() to the .RPT +Log_Warn("..."); // WARN +Log_Info("..."); // INFO +Log_Debug("..."); // DEBUG +Log_Verbose("..."); // VERBOSE +``` + +Each prefixes its output as `[YALootBoxes][LEVEL] ...` and self-gates on the +configured level — no need to check the level at the call site. diff --git a/configfiles.readme b/configfiles.readme index 184b31b..238ad6a 100644 --- a/configfiles.readme +++ b/configfiles.readme @@ -14,7 +14,7 @@ "DefaultDespawnMinutes": 15, "OpenChannelDurationSeconds": 5, "ProximityMessageRangeMeters": 3.0, - "EnableDebugLogging": true + "DebugLogLevel": "INFO" } diff --git a/scripts/4_World/YALootBoxes_ActionOpen.c b/scripts/4_World/YALootBoxes_ActionOpen.c index 2e0ce7a..937e3dd 100644 --- a/scripts/4_World/YALootBoxes_ActionOpen.c +++ b/scripts/4_World/YALootBoxes_ActionOpen.c @@ -94,13 +94,11 @@ class YALB_ActionOpenLootbox : ActionContinuousBase } } - // Player can bail out early (per requirements: fixed channel, - // instant roll at the end, but interruption is allowed and - // simply cancels - no roll, no penalty, no progress saved). - override void OnActionInfoUpdate(PlayerBase player, ActionData action_data) - { - super.OnActionInfoUpdate(player, action_data); - } + // NOTE: early bail-out (per requirements - fixed channel, instant roll + // at the end, interruption allowed) is handled by ActionContinuousBase + // itself: releasing the button cancels the action WITHOUT calling + // OnFinishProgressServer, so no roll/penalty/progress is saved. No + // override is needed here. }; // ------------------------------------------------------------ @@ -133,8 +131,10 @@ void YALB_ApplyRandomFailEffect(PlayerBase player, YALB_BoxTypeDef def, ItemBase if (!chosen) return; - YALB_Log(string.Format("Player %1 triggered fail effect '%2' on box type '%3'.", - player.GetIdentity().GetName(), chosen.type, def.type)); + string playerName = ""; + if (player.GetIdentity()) playerName = player.GetIdentity().GetName(); + Log_Verbose(string.Format("Player %1 triggered fail effect '%2' on box type '%3'.", + playerName, chosen.type, def.type)); switch (chosen.type) { @@ -164,7 +164,7 @@ void YALB_ApplyRandomFailEffect(PlayerBase player, YALB_BoxTypeDef def, ItemBase break; default: - YALB_Log("Unknown fail effect type: " + chosen.type); + Log_Warn("Unknown fail effect type: " + chosen.type); break; } } diff --git a/scripts/4_World/YALootBoxes_Config.c b/scripts/4_World/YALootBoxes_Config.c index 6e73e71..3244409 100644 --- a/scripts/4_World/YALootBoxes_Config.c +++ b/scripts/4_World/YALootBoxes_Config.c @@ -20,7 +20,10 @@ class YALB_MainConfig int DefaultDespawnMinutes; int OpenChannelDurationSeconds; float ProximityMessageRangeMeters; - bool EnableDebugLogging; + // 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; void YALB_MainConfig() { @@ -30,7 +33,7 @@ class YALB_MainConfig DefaultDespawnMinutes = 15; OpenChannelDurationSeconds = 5; ProximityMessageRangeMeters = 3.0; - EnableDebugLogging = true; + DebugLogLevel = "INFO"; } }; @@ -181,6 +184,10 @@ class YALB_ConfigLoader static ref YALB_LootListsFile LootLists; static ref YALB_SpawnLocationsFile SpawnLocations; + // Parsed-once numeric mirror of MainConfig.DebugLogLevel (see YALB_LogLevel). + // Defaults to INFO so logging behaves sanely before LoadAll() runs. + static int LogLevel = YALB_LogLevel.INFO; + static void EnsureDirectoryExists() { if (!FileExist(YALB_CONFIG_DIR)) @@ -194,16 +201,22 @@ class YALB_ConfigLoader EnsureDirectoryExists(); MainConfig = new YALB_MainConfig(); - if (FileExist(YALB_MAIN_CONFIG_FILE)) + bool mainConfigExisted = FileExist(YALB_MAIN_CONFIG_FILE); + if (mainConfigExisted) { JsonFileLoader.JsonLoadFile(YALB_MAIN_CONFIG_FILE, MainConfig); } else { JsonFileLoader.JsonSaveFile(YALB_MAIN_CONFIG_FILE, MainConfig); - YALB_Log("main_config.json not found - created with defaults."); } + // Parse the configured level NOW so every log below honours it. + LogLevel = YALB_ParseLogLevel(MainConfig.DebugLogLevel); + + if (!mainConfigExisted) + Log_Info("main_config.json not found - created with defaults."); + BoxTypes = new YALB_BoxTypesFile(); if (FileExist(YALB_BOX_TYPES_FILE)) { @@ -211,7 +224,7 @@ class YALB_ConfigLoader } else { - YALB_Log("box_types.json not found - no lootbox types loaded! Mod will not spawn anything until this exists."); + Log_Error("box_types.json not found - no lootbox types loaded! Mod will not spawn anything until this exists."); } LootLists = new YALB_LootListsFile(); @@ -221,7 +234,7 @@ class YALB_ConfigLoader } else { - YALB_Log("loot_lists.json not found - lootboxes will open empty!"); + Log_Warn("loot_lists.json not found - lootboxes will open empty!"); } SpawnLocations = new YALB_SpawnLocationsFile(); @@ -231,11 +244,11 @@ class YALB_ConfigLoader } else { - YALB_Log("spawn_locations.json not found - no spawn points available!"); + Log_Error("spawn_locations.json not found - no spawn points available!"); } - YALB_Log(string.Format("Config loaded: %1 box types, %2 spawn locations, %3 loot lists.", - BoxTypes.BoxTypes.Count(), SpawnLocations.SpawnLocations.Count(), LootLists.LootLists.Count())); + Log_Info(string.Format("Config loaded: %1 box types, %2 spawn locations, %3 loot lists. Log level = %4.", + BoxTypes.BoxTypes.Count(), SpawnLocations.SpawnLocations.Count(), LootLists.LootLists.Count(), MainConfig.DebugLogLevel)); } static YALB_BoxTypeDef GetBoxTypeByClassName(string className) @@ -273,11 +286,57 @@ class YALB_ConfigLoader }; // ------------------------------------------------------------ -// LOGGING HELPER +// LOGGING SYSTEM // ------------------------------------------------------------ -void YALB_Log(string msg) +// Ordered severity. A message at level X is emitted only when the +// configured level >= X. NONE silences everything. +enum YALB_LogLevel { - if (YALB_ConfigLoader.MainConfig && !YALB_ConfigLoader.MainConfig.EnableDebugLogging) + NONE = 0, + ERROR = 1, + WARN = 2, + INFO = 3, + DEBUG = 4, + 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) +{ + if (YALB_ConfigLoader.LogLevel < level) return; - Print("[YALootBoxes] " + msg); -} \ No newline at end of file + Print("[YALootBoxes][" + tag + "] " + msg); +} + +// ERROR is routed through ErrorEx() so it stands out in the .RPT, in +// addition to honouring the level gate. (ErrorEx is standard DayZ API.) +void Log_Error(string msg) +{ + if (YALB_ConfigLoader.LogLevel < YALB_LogLevel.ERROR) + return; + ErrorEx("[YALootBoxes][ERROR] " + msg); +} + +void Log_Warn(string msg) { YALB_Emit(YALB_LogLevel.WARN, "WARN", msg); } +void Log_Info(string msg) { YALB_Emit(YALB_LogLevel.INFO, "INFO", msg); } +void Log_Debug(string msg) { YALB_Emit(YALB_LogLevel.DEBUG, "DEBUG", msg); } +void Log_Verbose(string msg) { YALB_Emit(YALB_LogLevel.VERBOSE, "VERBOSE", msg); } \ No newline at end of file diff --git a/scripts/4_World/YALootBoxes_Lootbox.c b/scripts/4_World/YALootBoxes_Lootbox.c index 45cb027..a9abf0e 100644 --- a/scripts/4_World/YALootBoxes_Lootbox.c +++ b/scripts/4_World/YALootBoxes_Lootbox.c @@ -41,7 +41,7 @@ class YALootBoxes_Base extends Container_Base if (!m_TypeDef) { - YALB_Log("WARNING: Lootbox spawned with unknown type '" + boxTypeId + "'. Using fallback values."); + Log_Warn("Lootbox spawned with unknown type '" + boxTypeId + "'. Using fallback values."); } SetSynchDirty(); @@ -113,7 +113,7 @@ class YALootBoxes_Base extends Container_Base PopulateLoot(); - YALB_Log(string.Format("Lootbox '%1' (%2) unlocked and filled.", GetBoxTypeId(), GetPosition().ToString())); + Log_Info(string.Format("Lootbox '%1' (%2) unlocked and filled.", GetBoxTypeId(), GetPosition().ToString())); } protected void PopulateLoot() @@ -121,7 +121,7 @@ class YALootBoxes_Base extends Container_Base array lootList = YALB_ConfigLoader.GetLootListForType(m_BoxTypeId); if (!lootList) { - YALB_Log("No loot list found for type '" + m_BoxTypeId + "' - box will be empty."); + Log_Warn("No loot list found for type '" + m_BoxTypeId + "' - box will be empty."); return; } diff --git a/scripts/4_World/YALootBoxes_Manager.c b/scripts/4_World/YALootBoxes_Manager.c index b22b82e..fb30c61 100644 --- a/scripts/4_World/YALootBoxes_Manager.c +++ b/scripts/4_World/YALootBoxes_Manager.c @@ -17,15 +17,13 @@ class YALB_ActiveBoxEntry class YALootBoxesManager { protected ref array m_ActiveBoxes; - protected ref array m_FreeLocationNames; // locations currently unused - protected ref map m_LocationInUse; // name -> occupied + protected ref array m_FreeLocationNames; // locations currently unused (the live pool) protected ref map m_PlayerMessageCooldown; // playerId -> last message world time void YALootBoxesManager() { m_ActiveBoxes = new array(); m_FreeLocationNames = new array(); - m_LocationInUse = new map(); m_PlayerMessageCooldown = new map(); } @@ -35,14 +33,13 @@ class YALootBoxesManager if (!YALB_ConfigLoader.SpawnLocations || YALB_ConfigLoader.SpawnLocations.SpawnLocations.Count() == 0) { - YALB_Log("ERROR: No spawn locations loaded. Manager will not spawn any lootboxes."); + Log_Error("No spawn locations loaded. Manager will not spawn any lootboxes."); return; } foreach (YALB_SpawnLocationDef loc : YALB_ConfigLoader.SpawnLocations.SpawnLocations) { m_FreeLocationNames.Insert(loc.name); - m_LocationInUse.Insert(loc.name, false); } SpawnInitialBatch(); @@ -55,7 +52,7 @@ class YALootBoxesManager // lifecycle check since it needs to feel responsive to players GetGame().GetCallQueue(CALL_CATEGORY_SYSTEM).CallLater(TickProximityMessages, 1500, true); - YALB_Log("Manager initialized. " + m_ActiveBoxes.Count() + " lootboxes spawned."); + Log_Info("Manager initialized. " + m_ActiveBoxes.Count() + " lootboxes spawned."); } protected void SpawnInitialBatch() @@ -87,7 +84,7 @@ class YALootBoxesManager YALB_BoxTypeDef typeDef = YALB_ConfigLoader.GetBoxTypeByType(locDef.boxType); if (!typeDef) { - YALB_Log("WARNING: spawn location '" + locName + "' references unknown boxType '" + locDef.boxType + "'. Skipping."); + Log_Warn("Spawn location '" + locName + "' references unknown boxType '" + locDef.boxType + "'. Skipping."); m_FreeLocationNames.Remove(idx); return SpawnOneAtRandomFreeLocation(); } @@ -96,7 +93,7 @@ class YALootBoxesManager YALootBoxes_Base box = YALootBoxes_Base.Cast(obj); if (!box) { - YALB_Log("ERROR: Failed to spawn class '" + typeDef.className + "' at '" + locName + "'."); + Log_Error("Failed to spawn class '" + typeDef.className + "' at '" + locName + "'."); m_FreeLocationNames.Remove(idx); return SpawnOneAtRandomFreeLocation(); } @@ -110,9 +107,8 @@ class YALootBoxesManager m_ActiveBoxes.Insert(entry); m_FreeLocationNames.Remove(idx); - m_LocationInUse.Set(locName, true); - YALB_Log(string.Format("Spawned '%1' at location '%2' (%3).", typeDef.className, locName, locDef.GetPosition().ToString())); + Log_Info(string.Format("Spawned '%1' at location '%2' (%3).", typeDef.className, locName, locDef.GetPosition().ToString())); return true; } @@ -161,13 +157,12 @@ class YALootBoxesManager if (!SpawnOneAtRandomFreeLocation()) break; } - YALB_Log(string.Format("Lifecycle tick: removed %1, active now %2.", toRemove.Count(), m_ActiveBoxes.Count())); + Log_Debug(string.Format("Lifecycle tick: removed %1, active now %2.", toRemove.Count(), m_ActiveBoxes.Count())); } protected void RemoveAndFreeLocation(YALB_ActiveBoxEntry entry) { m_ActiveBoxes.RemoveItem(entry); - m_LocationInUse.Set(entry.locationName, false); m_FreeLocationNames.Insert(entry.locationName); if (entry.box) @@ -204,6 +199,7 @@ class YALootBoxesManager YALB_NotifyPlayer(player, nearest.GetRequiredItemsMessage()); m_PlayerMessageCooldown.Set(playerId, now); + Log_Verbose("Proximity message sent to '" + player.GetIdentity().GetName() + "' near box type '" + nearest.GetBoxTypeId() + "'."); } }