commit 354dfe34d51dcbb235ff49016c2635e2217c7651 Author: smthspec Date: Fri Jun 19 21:27:38 2026 +0300 Phase 1: rename audit complete (LootboxSpawner -> YALootBoxes) - Renamed code prefix LB_ -> YALB_ across all four script files - Renamed source files LootboxSpawner_*.c -> YALootBoxes_*.c - Deleted three empty 0-byte stub files (_BoxTypes/_LootLists/_SpawnLocations) - Fixed folder case scripts/4_world -> scripts/4_World to match config.cpp - Verified box_types.json className values match CfgVehicles class names Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..752f023 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.pbo +*.log +*.rpt + diff --git a/CLAUDE_TASK.md b/CLAUDE_TASK.md new file mode 100644 index 0000000..156a68e --- /dev/null +++ b/CLAUDE_TASK.md @@ -0,0 +1,144 @@ +# YALootBoxes — Polish, Verify, and Extend Logging + +**Project root:** `P:\YALootBoxes` +**Context:** This is a DayZ server mod (Enforce Script). It was originally scaffolded under the name `LootboxSpawner` and has since been renamed to `YALootBoxes`. The mod spawns lockable lootboxes at config-defined locations; players need a specific held item to attempt opening one via a hold-to-open action; success unlocks+fills the box, failure rolls one random negative effect. All settings live in four JSON files under `$profile:YALootBoxes\`. + +This is a **source-level review and polish pass**. There is no DayZ Server/Tools setup available yet, so do not attempt to pack a PBO or boot a server — focus on static correctness, consistency, and completeness of the Enforce Script source and JSON schemas. + +Work through the phases below **in order**. Do not skip ahead. At the end of each phase, summarize what you found/changed before moving to the next. + +--- + +## Phase 0 — Orientation + +1. Recursively list all files under `P:\YALootBoxes`. +2. Read every `.c`, `.cpp`, and `.json` file you find. +3. Build a mental (or written, in scratch notes) map of: mod folder structure vs. the expected layout below, and flag any structural differences immediately. + +**Expected layout:** +``` +YALootBoxes/ +├── config.cpp +├── Prefix.cpp +└── scripts/ + └── 4_World/ + ├── YALootBoxes_Config.c + ├── YALootBoxes_Lootbox.c + ├── YALootBoxes_ActionOpen.c + └── YALootBoxes_Manager.c +``` +(File names above assume the rename also touched filenames — verify and report actual names found, don't assume.) + +--- + +## Phase 1 — Rename Audit (the user says rename "should" be complete — verify, don't trust) + +Search the **entire** project for every leftover instance of the old naming scheme. Check ALL of: + +- [ ] String `LootboxSpawner` (any casing) — in `.c`, `.cpp`, `.json`, comments, file names, folder names +- [ ] String `LB_` prefix on classes/functions/constants (e.g. `LB_Log`, `LB_MainConfig`, `LB_ConfigLoader`, `LB_BoxTypeDef`, `LB_FailEffect`, `LB_ActionOpenLootbox`, `LB_ApplyRandomFailEffect`, etc.) — decide on and apply a **consistent new prefix** (suggest `YLB_` or `YALB_`) if these were meant to be renamed too +- [ ] `CfgPatches` / `CfgMods` class name (`LootboxSpawner`) in `config.cpp` +- [ ] `CfgVehicles` class names (`LootboxSpawner_Crate_Military`, `_Medical`, `_Industrial`) — these **must** exactly match the `className` fields inside `box_types.json`, so check both sides together +- [ ] `$profile:LootboxSpawner\` path constant — should become `$profile:YALootBoxes\` +- [ ] Any `displayName` strings, log prefixes (`Print("[LootboxSpawner] ...")`), or code comments referencing the old name +- [ ] `Prefix.cpp` content itself + +**Action:** Fix every occurrence found. Keep a running checklist in your response of exactly what was renamed, file by file, so the user can review the diff. Do not silently rename things — call out each change. + +**Cross-check after renaming:** confirm `box_types.json`'s `className` values still exactly match the `CfgVehicles` class names in `config.cpp` post-rename (this is the #1 way these mods silently fail to spawn anything — a one-character mismatch and the server just won't spawn the object, often without an obvious error). + +--- + +## Phase 2 — Structural / Completeness Review + +Go file by file and check for the following. This codebase was written without ever being compiled against the real DayZ SDK headers, so treat it as a first draft that needs adversarial review, not a finished product. + +### General Enforce Script correctness +- [ ] Confirm every class that's referenced is actually defined somewhere (no dangling references to types that don't exist, e.g. double check `ContinuousInteractActionInput`, `ActionContinuousBase`, `CCINone`, `CCTObject`, `UAMaxDistances` are real DayZ API symbols available in the modding SDK — if you have access to DayZ SDK docs/headers via web search, verify; otherwise flag as "needs verification against SDK" rather than assuming correctness) +- [ ] Check `RegisterNetSyncVariableBool` usage — confirm sync var registration pattern matches how `ContainerObject`/`ItemBase` subclasses normally do it in DayZ mods +- [ ] Check for null-safety: every place a JSON-loaded reference (`m_TypeDef`, `LB_ConfigLoader.MainConfig`, etc.) is used, confirm there's a null check or a guaranteed-non-null path +- [ ] Check `GetActions()` override signature/pattern is correct for hooking custom actions onto a `ContainerObject`-derived class — this was a best-effort implementation and deserves scrutiny +- [ ] Verify `eAgents` enum members used (`SALMONELLA`, `INFLUENZA`, `CHOLERA`, `BRAIN`, `WOUND_AGENT`) are the real DayZ agent enum names +- [ ] Verify `GetBleedingManagerServer()`, `GetHealthDamageManager()`, `SetFracture()`, `InsertAgent()` are real PlayerBase/HealthDamageManager API calls and used with correct signatures + +### Config loading +- [ ] Confirm `JsonFileLoader.JsonLoadFile` / `JsonSaveFile` usage matches real DayZ API (class name and static usage pattern) +- [ ] Confirm the `map>` (or renamed equivalent) is a supported Enforce Script generic — DayZ's Enforce Script has known limitations with nested generic containers in JSON deserialization. **This is a likely problem area** — flag clearly if you believe `JsonFileLoader` cannot deserialize a `map>` directly. If so, propose a fix: e.g. restructure `loot_lists.json` as an array of `{type, entries}` objects instead of a map, since flat arrays deserialize far more reliably in Enforce Script's JSON layer. +- [ ] Check that `EnsureDirectoryExists` / `MakeDirectory` calls match the real API (case, return type) +- [ ] Confirm default-object construction (e.g. `LB_MainConfig()` constructor) actually runs before `JsonLoadFile` overwrites fields, so a partially-specified JSON file doesn't leave other fields zero/null + +### Lifecycle & manager logic +- [ ] Re-derive the despawn timer logic by hand against the user's original spec: locked boxes despawn/respawn after N minutes (config-driven per box type) **even if never opened**; opened boxes despawn N minutes after becoming unlocked. Confirm `HasExpiredUnopened` / `HasExpiredAfterOpening` actually implement this without overlap or gaps. +- [ ] Check for a race/edge case: what happens if a box is mid-open-action (channeling) at the exact moment the lifecycle tick deletes it for expiring? Flag this as a known edge case if not already handled, and propose a minimal guard (e.g. skip despawn-delete if an action is actively targeting it, or just accept and document the rare edge case). +- [ ] Check `CallLater(..., true)` repeating-timer usage is correct DayZ API usage (3rd param = repeat flag, confirm order of args). +- [ ] Verify location pool logic (`m_FreeLocationNames`, `m_LocationInUse`) doesn't have an off-by-one or duplicate-assignment bug when `SpawnOneAtRandomFreeLocation` recurses on failure. + +### Action / interaction logic +- [ ] Confirm the "fixed channel time, instant roll, but failure can interrupt early" requirement is actually satisfiable with `ActionContinuousBase` + `OnFinishProgressServer` — i.e. confirm that bailing out early (releasing the button) does NOT call `OnFinishProgressServer` and does NOT roll anything. Flag if the current code doesn't clearly guarantee this. +- [ ] Confirm the proximity message system (manager's `TickProximityMessages`) and the action's "show prompt when holding right item" are not redundant/conflicting — i.e. one always shows "Requires: X", the other only enables the action — and that nothing in the action class duplicates the proximity broadcast. +- [ ] Verify `RPCSingleParam` / `ERPCs.RPC_USER_ACTION_MESSAGE` is a real, appropriate way to send a HUD text message to a single client in DayZ's API. If this is not correct, propose the standard pattern used by DayZ mods for "send toast/notification to one player" (often a custom RPC type registered in `Init.c`/`Missions`, since `RPC_USER_ACTION_MESSAGE` may not exist or may not behave as a generic toast). + +### Config/JSON schema consistency +- [ ] Confirm every field referenced in `.c` files has a matching field (same name, same casing) in the example JSON files, and vice versa. +- [ ] Confirm `failEffects[].chance` semantics are documented clearly as "weight" not "independent probability" (since the code does a weighted pick among effects, not N independent rolls) — fix any comment/naming that implies otherwise. + +Produce a written list of every issue found in this phase, categorized as: +- 🔴 **Will likely fail to compile or run** (must fix) +- 🟡 **Will likely behave incorrectly at runtime** (should fix) +- 🟢 **Style/maintainability nit** (optional) + +Fix all 🔴 and 🟡 items. For 🟢 items, list them but ask before changing anything purely stylistic. + +--- + +## Phase 3 — Add Debug Logging Levels + +Extend the logging system to support **NONE / ERROR / WARN / INFO / DEBUG / VERBOSE** levels, replacing the current single `EnableDebugLogging: bool`. + +### Requirements: +1. In the main config class, replace `EnableDebugLogging` (bool) with `DebugLogLevel` (string or int — your call, but document which and why; string is more admin-friendly for hand-editing JSON, int is cheaper to compare at runtime — consider storing as string in JSON but parsing to an int enum at load time for fast comparisons). +2. Define the level ordering: `NONE(0) < ERROR(1) < WARN(2) < INFO(3) < DEBUG(4) < VERBOSE(5)`. A message at level X is printed only if `DebugLogLevel >= X`. +3. Replace the single `LB_Log(string msg)` (or renamed equivalent) helper with level-specific variants, e.g.: + - `Log_Error(string msg)` + - `Log_Warn(string msg)` + - `Log_Info(string msg)` + - `Log_Debug(string msg)` + - `Log_Verbose(string msg)` + + Each should prefix output appropriately, e.g. `[YALootBoxes][ERROR] ...`, and route to both `Print()` (.log/.rpt via engine default) and, if straightforward, `ErrorEx()` for ERROR-level messages specifically (DayZ's standard pattern for messages that should be prominent in the `.RPT` file) — verify `ErrorEx` is real API before using it; if uncertain, just use `Print()` consistently and note the limitation. +4. Go through **every existing log call in the codebase** and reclassify it to the correct level based on its actual content: + - Config file missing / failed to parse / unknown class referenced → `ERROR` or `WARN` depending on whether it's recoverable + - "Spawned box at location X", "Manager initialized" → `INFO` + - Per-tick lifecycle summaries ("removed N, active now M") → `DEBUG` + - Per-player proximity message sends, per-effect-roll details → `VERBOSE` +5. Update the example `main_config.json` to use the new field with a sensible default (`INFO` recommended for normal server operation). +6. Update the default-constructor fallback value to match. + +Add a short section to the project's README (create one at `P:\YALootBoxes\README.md` if it doesn't exist) documenting: +- What each log level is meant to be used for +- The config field name and JSON example +- A reminder that VERBOSE will be noisy and is meant for active debugging sessions only, not production + +--- + +## Phase 4 — Final Consistency Pass + +1. Re-read every file once more, top to bottom, now that renaming and logging changes have landed, specifically checking that: + - No file still calls the old single-arg `LB_Log(...)` (or its renamed equivalent) — every call site should now use a leveled function + - No JSON file and `.c` file have drifted out of sync on field names again as a side effect of your edits + - All four JSON example files are valid JSON (mentally parse them, or note if you want to validate via a tool) +2. Produce a final summary report with these sections: + - **Renames applied** (file-by-file list) + - **Bugs fixed** (🔴 and 🟡 items from Phase 2, with before/after description) + - **Known limitations / things that need real DayZ SDK to verify** (be explicit — this matters since nothing has been compiled yet) + - **Logging system** (what changed, default level, example config) + - **Suggested next steps** for the user once they have DayZ Tools available (e.g. "pack PBO, boot test server, spawn near a military crate empty-handed, confirm proximity message text, pick up a crowbar, confirm action prompt appears, hold to open, confirm one of the two outcomes fires correctly") + +--- + +## Ground rules throughout + +- **Do not invent DayZ API behavior you're not confident about.** If you can't verify something against real SDK docs/headers, say so explicitly in the summary rather than presenting a guess as fact. This is real game-server code the user intends to run. +- **Do not silently delete functionality.** If you think something should be removed (e.g. a redundant message path), propose it and explain why before removing it, in your phase summary. +- **Keep commit-sized changes traceable** — if this is a git repo, make small logical commits per phase rather than one giant diff; if it's not a git repo, mention that initializing one would make this kind of iterative work much easier to review going forward. +- This is **review + polish + logging**, not a redesign. Don't change the core mechanics (proximity message always shown, fixed-channel hold-to-open, weighted single fail-effect roll, post-open despawn timer) unless you find them to be actually broken as currently spec'd — flag concerns rather than unilaterally redesigning. \ No newline at end of file diff --git a/Prefix.cpp b/Prefix.cpp new file mode 100644 index 0000000..0169c28 --- /dev/null +++ b/Prefix.cpp @@ -0,0 +1,37 @@ + +// ============================================================ +// FILE: Prefix.cpp +// Place at mod root: YALootBoxes/Prefix.cpp +// (Tells the engine the include-prefix for this mod's paths) +// ============================================================ +// Prefix.cpp content: +// +// YALootBoxes + + +// ============================================================ +// FOLDER LAYOUT SUMMARY +// ============================================================ +// YALootBoxes/ +// ├── config.cpp +// ├── Prefix.cpp +// └── scripts/ +// └── 4_World/ +// ├── YALootBoxes_Config.c +// ├── YALootBoxes_Lootbox.c +// ├── YALootBoxes_ActionOpen.c +// └── YALootBoxes_Manager.c +// +// Server mission init.c needs the small hook shown at the +// bottom of YALootBoxes_Manager.c. +// +// IMPORTANT NOTE ON simulationModule / class binding: +// DayZ's Enforce Script binds a .c class to a cfgVehicles entry +// either by the entry's class name matching the script class +// name directly, OR via simulationModule for non-standard cases. +// For most setups, simply naming the cfgVehicles class identically +// to your Enforce Script class (as done above: e.g. +// "YALootBoxes_Crate_Military") is what actually binds them - +// if your DayZ Server/SDK version doesn't honor simulationModule, +// delete that line; the name match alone is sufficient. +// ============================================================ \ No newline at end of file diff --git a/config.cpp b/config.cpp new file mode 100644 index 0000000..414f664 --- /dev/null +++ b/config.cpp @@ -0,0 +1,90 @@ +// ============================================================ +// FILE: config.cpp +// Place at mod root: YALootBoxes/config.cpp +// ============================================================ + +class CfgPatches +{ + class YALootBoxes + { + units[] = {}; + weapons[] = {}; + requiredVersion = 0.1; + requiredAddons[] = { "DZ_Data", "DZ_Scripts" }; + }; +}; + +class CfgMods +{ + class YALootBoxes + { + dir = "YALootBoxes"; + picture = ""; + action = ""; + hideName = 1; + hidePicture = 1; + name = "YALootBoxes"; + credits = ""; + author = ""; + authorID = "0"; + version = "1.0.0"; + extra = 0; + type = "mod"; + dependencies[] = { "Game", "World", "Mission" }; + + class defs + { + class gameScriptModule + { + value = ""; + files[] = { "YALootBoxes/scripts/4_World" }; + }; + class worldScriptModule + { + value = ""; + files[] = { "YALootBoxes/scripts/4_World" }; + }; + }; + }; +}; + +// ------------------------------------------------------------ +// Lootbox entity classes - one per box type defined in +// box_types.json. The "className" field in that JSON file +// MUST exactly match one of these class names. +// +// Swap "ContainerObject" inheritance/model/physics as needed - +// these reuse a vanilla static container model purely as a +// placeholder. Replace 'model=' with your own p3d if you have one. +// ------------------------------------------------------------ +class CfgVehicles +{ + class ContainerObject; + + class YALootBoxes_Crate_Military : ContainerObject + { + scope = 2; + displayName = "Military Crate (Locked)"; + model = "\dz\structures\wood\wooden_crate.p3d"; + vehicleClass = "Containers"; + simulationModule = "YALootBoxes_Base"; + }; + + class YALootBoxes_Crate_Medical : ContainerObject + { + scope = 2; + displayName = "Medical Crate (Locked)"; + model = "\dz\structures\wood\wooden_crate.p3d"; + vehicleClass = "Containers"; + simulationModule = "YALootBoxes_Base"; + }; + + class YALootBoxes_Crate_Industrial : ContainerObject + { + scope = 2; + displayName = "Industrial Crate (Locked)"; + model = "\dz\structures\wood\wooden_crate.p3d"; + vehicleClass = "Containers"; + simulationModule = "YALootBoxes_Base"; + }; +}; \ No newline at end of file diff --git a/configfiles.readme b/configfiles.readme new file mode 100644 index 0000000..fac0872 --- /dev/null +++ b/configfiles.readme @@ -0,0 +1,123 @@ +// ============================================================ +// These are REFERENCE/DEFAULT files. The mod auto-generates +// these on first run at: $profile:YALootBoxes\*.json +// Edit them there - do NOT edit inside the .pbo. +// ============================================================ + + +// ------------------------------------------------------------ +// FILE: main_config.json +// ------------------------------------------------------------ +{ + "MaxActiveLootboxes": 30, + "GlobalCheckIntervalSeconds": 60, + "DefaultDespawnMinutes": 15, + "OpenChannelDurationSeconds": 5, + "ProximityMessageRangeMeters": 3.0, + "EnableDebugLogging": true +} + + +// ------------------------------------------------------------ +// FILE: box_types.json +// ------------------------------------------------------------ +{ + "BoxTypes": [ + { + "className": "YALootBoxes_Crate_Military", + "type": "military", + "requiredItems": [ + { "classname": "BoltCutters", "displayName": "Bolt Cutters" }, + { "classname": "Crowbar", "displayName": "Crowbar" } + ], + "openChance": 0.65, + "itemDamageOnOpen": 8.0, + "despawnMinutes": 15, + "failEffects": [ + { "type": "EXPLOSIVE", "chance": 0.15, "damage": 45.0, "radius": 2.5 }, + { "type": "HEALTH", "chance": 0.25, "damage": 15.0 }, + { "type": "BLEEDING", "chance": 0.30, "bleedSources": 2 }, + { "type": "SICKNESS", "chance": 0.15, "agent": "Salmonella" }, + { "type": "BROKEN_BONE", "chance": 0.15 } + ] + }, + { + "className": "YALootBoxes_Crate_Medical", + "type": "medical", + "requiredItems": [ + { "classname": "Hacksaw", "displayName": "Hacksaw" } + ], + "openChance": 0.80, + "itemDamageOnOpen": 4.0, + "despawnMinutes": 15, + "failEffects": [ + { "type": "SICKNESS", "chance": 0.50, "agent": "Influenza" }, + { "type": "HEALTH", "chance": 0.30, "damage": 8.0 }, + { "type": "BLEEDING", "chance": 0.20, "bleedSources": 1 } + ] + }, + { + "className": "YALootBoxes_Crate_Industrial", + "type": "industrial", + "requiredItems": [ + { "classname": "Crowbar", "displayName": "Crowbar" }, + { "classname": "WeldingMask", "displayName": "Angle Grinder" } + ], + "openChance": 0.50, + "itemDamageOnOpen": 12.0, + "despawnMinutes": 20, + "failEffects": [ + { "type": "EXPLOSIVE", "chance": 0.20, "damage": 60.0, "radius": 3.0 }, + { "type": "BROKEN_BONE", "chance": 0.25 }, + { "type": "BLEEDING", "chance": 0.30, "bleedSources": 2 }, + { "type": "HEALTH", "chance": 0.25, "damage": 20.0 } + ] + } + ] +} + + +// ------------------------------------------------------------ +// FILE: loot_lists.json +// ------------------------------------------------------------ +{ + "LootLists": { + "military": [ + { "classname": "AKM", "chance": 0.10, "minCount": 1, "maxCount": 1 }, + { "classname": "Mag_AKM_30Rnd", "chance": 0.40, "minCount": 1, "maxCount": 3 }, + { "classname": "PlateCarrierVest","chance": 0.20, "minCount": 1, "maxCount": 1 }, + { "classname": "TacticalHelmet", "chance": 0.15, "minCount": 1, "maxCount": 1 }, + { "classname": "Ammo_762x39", "chance": 0.50, "minCount": 1, "maxCount": 2 } + ], + "medical": [ + { "classname": "Morphine", "chance": 0.30, "minCount": 1, "maxCount": 1 }, + { "classname": "SalineBagIV", "chance": 0.25, "minCount": 1, "maxCount": 2 }, + { "classname": "VitaminBottle", "chance": 0.50, "minCount": 1, "maxCount": 3 }, + { "classname": "Bandage", "chance": 0.60, "minCount": 2, "maxCount": 5 }, + { "classname": "TetracyclineAntibiotics", "chance": 0.20, "minCount": 1, "maxCount": 2 } + ], + "industrial": [ + { "classname": "WeldingMask", "chance": 0.15, "minCount": 1, "maxCount": 1 }, + { "classname": "Battery_Car", "chance": 0.30, "minCount": 1, "maxCount": 1 }, + { "classname": "SparkPlug", "chance": 0.40, "minCount": 1, "maxCount": 2 }, + { "classname": "MetalPipe", "chance": 0.50, "minCount": 1, "maxCount": 4 }, + { "classname": "DuctTape", "chance": 0.45, "minCount": 1, "maxCount": 2 } + ] + } +} + + +// ------------------------------------------------------------ +// FILE: spawn_locations.json +// ------------------------------------------------------------ +{ + "SpawnLocations": [ + { "name": "NWAF_Hangar1", "position": [4985.3, 320.5, 2456.8], "boxType": "military" }, + { "name": "NWAF_Hangar2", "position": [5102.7, 318.9, 2401.2], "boxType": "military" }, + { "name": "Tisy_Bunker_A", "position": [1606.4, 280.1, 14383.9],"boxType": "military" }, + { "name": "Berezino_Hospital", "position": [7245.1, 12.4, 13889.6], "boxType": "medical" }, + { "name": "Zelenogorsk_Clinic", "position": [2543.9, 8.2, 5234.7], "boxType": "medical" }, + { "name": "Tractor_Plant_1", "position": [9821.2, 145.6, 8421.3], "boxType": "industrial" }, + { "name": "Tractor_Plant_2", "position": [9890.5, 142.1, 8390.7], "boxType": "industrial" } + ] +} \ No newline at end of file diff --git a/scripts/4_World/YALootBoxes_ActionOpen.c b/scripts/4_World/YALootBoxes_ActionOpen.c new file mode 100644 index 0000000..fabaa9d --- /dev/null +++ b/scripts/4_World/YALootBoxes_ActionOpen.c @@ -0,0 +1,235 @@ +// ============================================================ +// YALootBoxes_ActionOpen.c +// Custom UserAction: hold-to-open a locked lootbox. +// Lives in: scripts/4_World/YALootBoxes_ActionOpen.c +// +// Registration of this action onto YALootBoxes_Base happens +// via GetActionComponentName/cfgVehicles "actions[]" in config.cpp, +// OR (simpler, used here) by overriding GetActions() — see note +// at bottom of file. +// ============================================================ + +class YALB_OpenLootboxActionData : ActionData +{ + // currently empty - base ActionData carries everything we need +}; + +class YALB_ActionOpenLootbox : ActionContinuousBase +{ + void YALB_ActionOpenLootbox() + { + m_CommandUID = DayZPlayerConstants.CMD_ACTIONFB_ONCE; // generic "use" anim hook + m_FullBody = false; + m_ReloadFinishAnim = false; + } + + override void CreateConditionComponents() + { + m_ConditionItem = new CCINone(); + m_ConditionTarget = new CCTObject(UAMaxDistances.DEFAULT); + } + + override string GetText() + { + return "Open lootbox"; + } + + override typename GetInputType() + { + return ContinuousInteractActionInput; + } + + // Only offer the action if: target is our lootbox, it's locked, + // and the player is holding a valid key item for it. + override bool ActionCondition(PlayerBase player, ActionTarget target, ItemBase item) + { + YALootBoxes_Base box = YALootBoxes_Base.Cast(target.GetObject()); + if (!box) return false; + if (!box.IsLocked()) return false; + + ItemBase handItem = player.GetItemInHands(); + if (!handItem) return false; + + YALB_BoxTypeDef def = box.GetTypeDef(); + if (!def) return false; + + return def.IsValidKeyItem(handItem.GetType()); + } + + override int GetDuration(ActionData action_data) + { + if (YALB_ConfigLoader.MainConfig) + return YALB_ConfigLoader.MainConfig.OpenChannelDurationSeconds * 1000; // ms + return 5000; + } + + // Fired server-side when the channel completes WITHOUT being cancelled. + override void OnFinishProgressServer(ActionData action_data) + { + super.OnFinishProgressServer(action_data); + + YALootBoxes_Base box = YALootBoxes_Base.Cast(action_data.m_Target.GetObject()); + PlayerBase player = action_data.m_Player; + if (!box || !player) return; + + YALB_BoxTypeDef def = box.GetTypeDef(); + if (!def) return; + + ItemBase handItem = player.GetItemInHands(); + + float roll = Math.RandomFloat01(); + if (roll <= def.openChance) + { + // SUCCESS + box.UnlockAndFill(); + if (handItem) + handItem.DecreaseHealth(def.itemDamageOnOpen, false); + + YALB_NotifyPlayer(player, "The lock gives way."); + } + else + { + // FAILURE - apply exactly one weighted-random effect + YALB_ApplyRandomFailEffect(player, def, handItem); + YALB_NotifyPlayer(player, "The attempt failed."); + } + } + + // 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); + } +}; + +// ------------------------------------------------------------ +// Failure-effect resolution +// ------------------------------------------------------------ +void YALB_ApplyRandomFailEffect(PlayerBase player, YALB_BoxTypeDef def, ItemBase handItem) +{ + if (!def.failEffects || def.failEffects.Count() == 0) return; + + // Weighted pick: sum chances, roll within sum, walk the list. + float totalWeight = 0; + foreach (YALB_FailEffect e : def.failEffects) + totalWeight += e.chance; + + if (totalWeight <= 0) return; + + float roll = Math.RandomFloat(0, totalWeight); + float cumulative = 0; + YALB_FailEffect chosen = null; + + foreach (YALB_FailEffect effect : def.failEffects) + { + cumulative += effect.chance; + if (roll <= cumulative) + { + chosen = effect; + break; + } + } + + if (!chosen) return; + + YALB_Log(string.Format("Player %1 triggered fail effect '%2' on box type '%3'.", + player.GetIdentity().GetName(), chosen.type, def.type)); + + switch (chosen.type) + { + case "EXPLOSIVE": + YALB_ApplyExplosiveDamage(player, chosen.damage, chosen.radius); + break; + + case "HEALTH": + player.DecreaseHealth("", "", chosen.damage); + break; + + case "BLEEDING": + for (int i = 0; i < chosen.bleedSources; i++) + { + player.GetBleedingManagerServer().AttemptAddBleedingSourceByMaxCount(); + } + break; + + case "SICKNESS": + YALB_ApplySickness(player, chosen.agent); + break; + + case "BROKEN_BONE": + player.GetHealthDamageManager().SetHealthLimitDamage(true); + player.SetHealth("", "Health", player.GetHealth("", "Health") - 1); // nudge to trigger fracture check + player.GetHealthDamageManager().SetFracture(true); + break; + + default: + YALB_Log("Unknown fail effect type: " + chosen.type); + break; + } +} + +void YALB_ApplyExplosiveDamage(PlayerBase player, float damage, float radius) +{ + // Direct damage to the player rather than a real grenade explosion entity, + // since we don't want a separate live explosive object to manage/clean up. + vector pos = player.GetPosition(); + player.DecreaseHealth("", "", damage); + + // Optional: damage nearby players too, to sell the "explosion" effect + array nearbyPlayers = new array(); + GetGame().GetWorld().GetPlayerList(nearbyPlayers); + foreach (Man m : nearbyPlayers) + { + PlayerBase other = PlayerBase.Cast(m); + if (!other || other == player) continue; + float dist = vector.Distance(other.GetPosition(), pos); + if (dist <= radius) + { + float falloff = 1.0 - (dist / radius); + other.DecreaseHealth("", "", damage * falloff); + } + } +} + +void YALB_ApplySickness(PlayerBase player, string agentName) +{ + // Maps to vanilla DayZ agent system (e.g. eAgents.SALMONELLA, eAgents.INFLUENZA) + int agentId = YALB_AgentNameToId(agentName); + if (agentId >= 0) + { + player.InsertAgent(agentId, 1000); // 1000 = full dose, matches vanilla scale + } +} + +int YALB_AgentNameToId(string agentName) +{ + if (agentName == "Salmonella") return eAgents.SALMONELLA; + if (agentName == "Influenza") return eAgents.INFLUENZA; + if (agentName == "Cholera") return eAgents.CHOLERA; + if (agentName == "Brain") return eAgents.BRAIN; + if (agentName == "Wound_Bacterial") return eAgents.WOUND_AGENT; + return -1; +} + +void YALB_NotifyPlayer(PlayerBase player, string message) +{ + if (!player || !player.GetIdentity()) return; + GetGame().RPCSingleParam(player, ERPCs.RPC_USER_ACTION_MESSAGE, new Param1(message), true, player.GetIdentity()); +} + +// ------------------------------------------------------------ +// NOTE: Hooking this action onto the lootbox class +// ------------------------------------------------------------ +// The cleanest approach for a fully custom action without +// touching vanilla configs is to override GetActions() directly +// on YALootBoxes_Base: +// +// override void GetActions(out array actions) +// { +// actions.Insert(YALB_ActionOpenLootbox); +// } +// +// This override is included in YALootBoxes_Lootbox.c via a +// partial addition — see the updated lootbox entity file. \ No newline at end of file diff --git a/scripts/4_World/YALootBoxes_Config.c b/scripts/4_World/YALootBoxes_Config.c new file mode 100644 index 0000000..9193b45 --- /dev/null +++ b/scripts/4_World/YALootBoxes_Config.c @@ -0,0 +1,264 @@ +// ============================================================ +// YALootBoxes_Config.c +// Typed data classes for all JSON files + load/save/defaults. +// Lives in: scripts/4_World/YALootBoxes_Config.c +// ============================================================ + +const string YALB_CONFIG_DIR = "$profile:YALootBoxes\\"; +const string YALB_MAIN_CONFIG_FILE = YALB_CONFIG_DIR + "main_config.json"; +const string YALB_BOX_TYPES_FILE = YALB_CONFIG_DIR + "box_types.json"; +const string YALB_LOOT_LISTS_FILE = YALB_CONFIG_DIR + "loot_lists.json"; +const string YALB_SPAWN_LOCATIONS_FILE = YALB_CONFIG_DIR + "spawn_locations.json"; + +// ------------------------------------------------------------ +// MAIN CONFIG +// ------------------------------------------------------------ +class YALB_MainConfig +{ + int MaxActiveLootboxes; + int GlobalCheckIntervalSeconds; + int DefaultDespawnMinutes; + int OpenChannelDurationSeconds; + float ProximityMessageRangeMeters; + bool EnableDebugLogging; + + void YALB_MainConfig() + { + // Sane defaults if file is missing/corrupt + MaxActiveLootboxes = 30; + GlobalCheckIntervalSeconds = 60; + DefaultDespawnMinutes = 15; + OpenChannelDurationSeconds = 5; + ProximityMessageRangeMeters = 3.0; + EnableDebugLogging = true; + } +}; + +// ------------------------------------------------------------ +// BOX TYPES +// ------------------------------------------------------------ +class YALB_RequiredItem +{ + string classname; + string displayName; +}; + +class YALB_FailEffect +{ + string type; // EXPLOSIVE, HEALTH, BLEEDING, SICKNESS, BROKEN_BONE + float chance; // 0.0 - 1.0 + float damage; // used by EXPLOSIVE / HEALTH + float radius; // used by EXPLOSIVE + int bleedSources; // used by BLEEDING + string agent; // used by SICKNESS +}; + +class YALB_BoxTypeDef +{ + string className; + string type; + ref array requiredItems; + float openChance; + float itemDamageOnOpen; + int despawnMinutes; + ref array failEffects; + + void YALB_BoxTypeDef() + { + requiredItems = new array(); + failEffects = new array(); + openChance = 0.5; + itemDamageOnOpen = 5.0; + despawnMinutes = 15; + } + + // Builds the player-facing "requires: X, Y" string + string GetRequiredItemsDisplayString() + { + string result = ""; + for (int i = 0; i < requiredItems.Count(); i++) + { + if (i > 0) result += ", "; + result += requiredItems[i].displayName; + } + return result; + } + + bool IsValidKeyItem(string itemClassName) + { + for (int i = 0; i < requiredItems.Count(); i++) + { + if (requiredItems[i].classname == itemClassName) + return true; + } + return false; + } +}; + +class YALB_BoxTypesFile +{ + ref array BoxTypes; + + void YALB_BoxTypesFile() + { + BoxTypes = new array(); + } +}; + +// ------------------------------------------------------------ +// LOOT LISTS +// ------------------------------------------------------------ +class YALB_LootEntry +{ + string classname; + float chance; + int minCount; + int maxCount; +}; + +class YALB_LootListsFile +{ + // Map: box "type" string -> array of loot entries + ref map> LootLists; + + void YALB_LootListsFile() + { + LootLists = new map>(); + } +}; + +// ------------------------------------------------------------ +// SPAWN LOCATIONS +// ------------------------------------------------------------ +class YALB_SpawnLocationDef +{ + string name; + array position; // [x, y, z] + string boxType; + + vector GetPosition() + { + if (position && position.Count() == 3) + return Vector(position[0], position[1], position[2]); + return vector.Zero; + } +}; + +class YALB_SpawnLocationsFile +{ + ref array SpawnLocations; + + void YALB_SpawnLocationsFile() + { + SpawnLocations = new array(); + } +}; + +// ------------------------------------------------------------ +// LOADER / SAVER +// ------------------------------------------------------------ +class YALB_ConfigLoader +{ + static ref YALB_MainConfig MainConfig; + static ref YALB_BoxTypesFile BoxTypes; + static ref YALB_LootListsFile LootLists; + static ref YALB_SpawnLocationsFile SpawnLocations; + + static void EnsureDirectoryExists() + { + if (!FileExist(YALB_CONFIG_DIR)) + { + MakeDirectory(YALB_CONFIG_DIR); + } + } + + static void LoadAll() + { + EnsureDirectoryExists(); + + MainConfig = new YALB_MainConfig(); + if (FileExist(YALB_MAIN_CONFIG_FILE)) + { + 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."); + } + + BoxTypes = new YALB_BoxTypesFile(); + if (FileExist(YALB_BOX_TYPES_FILE)) + { + JsonFileLoader.JsonLoadFile(YALB_BOX_TYPES_FILE, BoxTypes); + } + else + { + YALB_Log("box_types.json not found - no lootbox types loaded! Mod will not spawn anything until this exists."); + } + + LootLists = new YALB_LootListsFile(); + if (FileExist(YALB_LOOT_LISTS_FILE)) + { + JsonFileLoader.JsonLoadFile(YALB_LOOT_LISTS_FILE, LootLists); + } + else + { + YALB_Log("loot_lists.json not found - lootboxes will open empty!"); + } + + SpawnLocations = new YALB_SpawnLocationsFile(); + if (FileExist(YALB_SPAWN_LOCATIONS_FILE)) + { + JsonFileLoader.JsonLoadFile(YALB_SPAWN_LOCATIONS_FILE, SpawnLocations); + } + else + { + YALB_Log("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())); + } + + static YALB_BoxTypeDef GetBoxTypeByClassName(string className) + { + if (!BoxTypes) return null; + foreach (YALB_BoxTypeDef def : BoxTypes.BoxTypes) + { + if (def.className == className) + return def; + } + return null; + } + + static YALB_BoxTypeDef GetBoxTypeByType(string type) + { + if (!BoxTypes) return null; + foreach (YALB_BoxTypeDef def : BoxTypes.BoxTypes) + { + if (def.type == type) + return def; + } + return null; + } + + static array GetLootListForType(string type) + { + if (!LootLists) return null; + array list; + if (LootLists.LootLists.Find(type, list)) + return list; + return null; + } +}; + +// ------------------------------------------------------------ +// LOGGING HELPER +// ------------------------------------------------------------ +void YALB_Log(string msg) +{ + if (YALB_ConfigLoader.MainConfig && !YALB_ConfigLoader.MainConfig.EnableDebugLogging) + return; + Print("[YALootBoxes] " + msg); +} \ No newline at end of file diff --git a/scripts/4_World/YALootBoxes_Lootbox.c b/scripts/4_World/YALootBoxes_Lootbox.c new file mode 100644 index 0000000..2f0e41d --- /dev/null +++ b/scripts/4_World/YALootBoxes_Lootbox.c @@ -0,0 +1,155 @@ +// ============================================================ +// 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 ContainerObject +{ + 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) + { + YALB_Log("WARNING: 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; + } + + // How many minutes this box should live for, based on its current state + int GetDespawnMinutes() + { + if (m_TypeDef) + return m_TypeDef.despawnMinutes; + return YALB_ConfigLoader.MainConfig.DefaultDespawnMinutes; + } + + // 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. + bool HasExpiredUnopened(float currentWorldTime) + { + if (!m_IsLocked) return false; // handled separately once opened + float ageMinutes = (currentWorldTime - m_SpawnTime) / 60.0; + return ageMinutes >= GetDespawnMinutes(); + } + + bool HasExpiredAfterOpening(float currentWorldTime) + { + if (m_IsLocked || m_UnlockedTime < 0) return false; + float ageMinutes = (currentWorldTime - m_UnlockedTime) / 60.0; + return ageMinutes >= GetDespawnMinutes(); + } + + // 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(); + + YALB_Log(string.Format("Lootbox '%1' (%2) unlocked and filled.", GetBoxTypeId(), GetPosition().ToString())); + } + + protected void PopulateLoot() + { + array lootList = YALB_ConfigLoader.GetLootListForType(m_BoxTypeId); + if (!lootList) + { + YALB_Log("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. + override void GetActions(out array actions) + { + super.GetActions(actions); + actions.Insert(YALB_ActionOpenLootbox); + } +}; \ No newline at end of file diff --git a/scripts/4_World/YALootBoxes_Manager.c b/scripts/4_World/YALootBoxes_Manager.c new file mode 100644 index 0000000..b22b82e --- /dev/null +++ b/scripts/4_World/YALootBoxes_Manager.c @@ -0,0 +1,244 @@ +// ============================================================ +// YALootBoxes_Manager.c +// Owns the lifecycle of all lootboxes: initial spawn, periodic +// despawn/respawn checks, and proximity "requires X" messages. +// Lives in: scripts/4_World/YALootBoxes_Manager.c +// +// Instantiated once from mission init (see init.c snippet at +// bottom of this file). +// ============================================================ + +class YALB_ActiveBoxEntry +{ + YALootBoxes_Base box; + string locationName; // which spawn_locations.json entry it's using +}; + +class YALootBoxesManager +{ + protected ref array m_ActiveBoxes; + protected ref array m_FreeLocationNames; // locations currently unused + protected ref map m_LocationInUse; // name -> occupied + 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(); + } + + void Init() + { + YALB_ConfigLoader.LoadAll(); + + if (!YALB_ConfigLoader.SpawnLocations || YALB_ConfigLoader.SpawnLocations.SpawnLocations.Count() == 0) + { + YALB_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(); + + // Periodic lifecycle tick (despawn/respawn checks) + float intervalMs = YALB_ConfigLoader.MainConfig.GlobalCheckIntervalSeconds * 1000; + GetGame().GetCallQueue(CALL_CATEGORY_SYSTEM).CallLater(TickLifecycle, intervalMs, true); + + // Proximity message tick - runs more frequently than the + // 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."); + } + + protected void SpawnInitialBatch() + { + int target = YALB_ConfigLoader.MainConfig.MaxActiveLootboxes; + for (int i = 0; i < target; i++) + { + if (!SpawnOneAtRandomFreeLocation()) + break; // ran out of free locations + } + } + + // Picks a free location, picks the box type config matching that + // location's boxType, spawns the entity, marks location occupied. + protected bool SpawnOneAtRandomFreeLocation() + { + if (m_FreeLocationNames.Count() == 0) return false; + + int idx = Math.RandomInt(0, m_FreeLocationNames.Count()); + string locName = m_FreeLocationNames[idx]; + + YALB_SpawnLocationDef locDef = FindLocationDef(locName); + if (!locDef) + { + m_FreeLocationNames.Remove(idx); + return SpawnOneAtRandomFreeLocation(); // try again with a different one + } + + YALB_BoxTypeDef typeDef = YALB_ConfigLoader.GetBoxTypeByType(locDef.boxType); + if (!typeDef) + { + YALB_Log("WARNING: spawn location '" + locName + "' references unknown boxType '" + locDef.boxType + "'. Skipping."); + m_FreeLocationNames.Remove(idx); + return SpawnOneAtRandomFreeLocation(); + } + + Object obj = GetGame().CreateObject(typeDef.className, locDef.GetPosition(), false, true, true); + YALootBoxes_Base box = YALootBoxes_Base.Cast(obj); + if (!box) + { + YALB_Log("ERROR: Failed to spawn class '" + typeDef.className + "' at '" + locName + "'."); + m_FreeLocationNames.Remove(idx); + return SpawnOneAtRandomFreeLocation(); + } + + box.SetPosition(locDef.GetPosition()); + box.Init(locDef.boxType); + + YALB_ActiveBoxEntry entry = new YALB_ActiveBoxEntry(); + entry.box = box; + entry.locationName = locName; + 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())); + return true; + } + + protected YALB_SpawnLocationDef FindLocationDef(string name) + { + foreach (YALB_SpawnLocationDef d : YALB_ConfigLoader.SpawnLocations.SpawnLocations) + { + if (d.name == name) return d; + } + return null; + } + + // Called every GlobalCheckIntervalSeconds. Removes expired boxes + // (whether still locked or already opened) and frees their location, + // then immediately tries to fill the gap at a NEW random location. + void TickLifecycle() + { + float now = GetGame().GetTime() / 1000.0; + array toRemove = new array(); + + foreach (YALB_ActiveBoxEntry entry : m_ActiveBoxes) + { + if (!entry.box) + { + toRemove.Insert(entry); + continue; + } + + bool expired = entry.box.HasExpiredUnopened(now) || entry.box.HasExpiredAfterOpening(now); + if (expired) + { + toRemove.Insert(entry); + } + } + + foreach (YALB_ActiveBoxEntry deadEntry : toRemove) + { + RemoveAndFreeLocation(deadEntry); + } + + // Backfill up to MaxActiveLootboxes using newly-freed (and any + // previously unused) locations. + int deficit = YALB_ConfigLoader.MainConfig.MaxActiveLootboxes - m_ActiveBoxes.Count(); + for (int i = 0; i < deficit; i++) + { + if (!SpawnOneAtRandomFreeLocation()) break; + } + + YALB_Log(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) + { + GetGame().ObjectDelete(entry.box); + } + } + + // Broadcasts "Requires: X, Y" to any player standing within range + // of a LOCKED box, regardless of what they're holding (per design: + // always show required items, even empty-handed). + void TickProximityMessages() + { + if (!YALB_ConfigLoader.MainConfig) return; + float range = YALB_ConfigLoader.MainConfig.ProximityMessageRangeMeters; + float now = GetGame().GetTime() / 1000.0; + + array players = new array(); + GetGame().GetWorld().GetPlayerList(players); + + foreach (Man m : players) + { + PlayerBase player = PlayerBase.Cast(m); + if (!player || !player.GetIdentity()) continue; + + YALootBoxes_Base nearest = FindNearestLockedBox(player.GetPosition(), range); + if (!nearest) continue; + + // Light cooldown per player so we're not spamming every 1.5s + int playerId = player.GetIdentity().GetPlainId().HashCode(); + float lastShown = 0; + m_PlayerMessageCooldown.Find(playerId, lastShown); + if (now - lastShown < 4.0) continue; + + YALB_NotifyPlayer(player, nearest.GetRequiredItemsMessage()); + m_PlayerMessageCooldown.Set(playerId, now); + } + } + + protected YALootBoxes_Base FindNearestLockedBox(vector pos, float maxRange) + { + YALootBoxes_Base closest = null; + float closestDist = maxRange; + + foreach (YALB_ActiveBoxEntry entry : m_ActiveBoxes) + { + if (!entry.box || !entry.box.IsLocked()) continue; + float dist = vector.Distance(entry.box.GetPosition(), pos); + if (dist <= closestDist) + { + closest = entry.box; + closestDist = dist; + } + } + return closest; + } +}; + +// ============================================================ +// MISSION INIT HOOK +// Add this to your mission's init.c (server side only): +// +// YALootBoxesManager g_YALootBoxesManager; +// +// void main() +// { +// ... +// if (GetGame().IsServer()) +// { +// g_YALootBoxesManager = new YALootBoxesManager(); +// g_YALootBoxesManager.Init(); +// } +// } +// ============================================================ \ No newline at end of file