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 <noreply@anthropic.com>
This commit is contained in:
commit
354dfe34d5
9 changed files with 1296 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
*.pbo
|
||||
*.log
|
||||
*.rpt
|
||||
|
||||
144
CLAUDE_TASK.md
Normal file
144
CLAUDE_TASK.md
Normal file
|
|
@ -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<T>.JsonLoadFile` / `JsonSaveFile` usage matches real DayZ API (class name and static usage pattern)
|
||||
- [ ] Confirm the `map<string, ref array<ref LB_LootEntry>>` (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<string, array<...>>` 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.
|
||||
37
Prefix.cpp
Normal file
37
Prefix.cpp
Normal file
|
|
@ -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.
|
||||
// ============================================================
|
||||
90
config.cpp
Normal file
90
config.cpp
Normal file
|
|
@ -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";
|
||||
};
|
||||
};
|
||||
123
configfiles.readme
Normal file
123
configfiles.readme
Normal file
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
235
scripts/4_World/YALootBoxes_ActionOpen.c
Normal file
235
scripts/4_World/YALootBoxes_ActionOpen.c
Normal file
|
|
@ -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<Man> nearbyPlayers = new array<Man>();
|
||||
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<string>(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<typename> actions)
|
||||
// {
|
||||
// actions.Insert(YALB_ActionOpenLootbox);
|
||||
// }
|
||||
//
|
||||
// This override is included in YALootBoxes_Lootbox.c via a
|
||||
// partial addition — see the updated lootbox entity file.
|
||||
264
scripts/4_World/YALootBoxes_Config.c
Normal file
264
scripts/4_World/YALootBoxes_Config.c
Normal file
|
|
@ -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<ref YALB_RequiredItem> requiredItems;
|
||||
float openChance;
|
||||
float itemDamageOnOpen;
|
||||
int despawnMinutes;
|
||||
ref array<ref YALB_FailEffect> failEffects;
|
||||
|
||||
void YALB_BoxTypeDef()
|
||||
{
|
||||
requiredItems = new array<ref YALB_RequiredItem>();
|
||||
failEffects = new array<ref YALB_FailEffect>();
|
||||
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<ref YALB_BoxTypeDef> BoxTypes;
|
||||
|
||||
void YALB_BoxTypesFile()
|
||||
{
|
||||
BoxTypes = new array<ref YALB_BoxTypeDef>();
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 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<string, ref array<ref YALB_LootEntry>> LootLists;
|
||||
|
||||
void YALB_LootListsFile()
|
||||
{
|
||||
LootLists = new map<string, ref array<ref YALB_LootEntry>>();
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// SPAWN LOCATIONS
|
||||
// ------------------------------------------------------------
|
||||
class YALB_SpawnLocationDef
|
||||
{
|
||||
string name;
|
||||
array<float> 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<ref YALB_SpawnLocationDef> SpawnLocations;
|
||||
|
||||
void YALB_SpawnLocationsFile()
|
||||
{
|
||||
SpawnLocations = new array<ref YALB_SpawnLocationDef>();
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 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<YALB_MainConfig>.JsonLoadFile(YALB_MAIN_CONFIG_FILE, MainConfig);
|
||||
}
|
||||
else
|
||||
{
|
||||
JsonFileLoader<YALB_MainConfig>.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<YALB_BoxTypesFile>.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<YALB_LootListsFile>.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<YALB_SpawnLocationsFile>.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<ref YALB_LootEntry> GetLootListForType(string type)
|
||||
{
|
||||
if (!LootLists) return null;
|
||||
array<ref YALB_LootEntry> 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);
|
||||
}
|
||||
155
scripts/4_World/YALootBoxes_Lootbox.c
Normal file
155
scripts/4_World/YALootBoxes_Lootbox.c
Normal file
|
|
@ -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<ref YALB_LootEntry> 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<typename> actions)
|
||||
{
|
||||
super.GetActions(actions);
|
||||
actions.Insert(YALB_ActionOpenLootbox);
|
||||
}
|
||||
};
|
||||
244
scripts/4_World/YALootBoxes_Manager.c
Normal file
244
scripts/4_World/YALootBoxes_Manager.c
Normal file
|
|
@ -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<ref YALB_ActiveBoxEntry> m_ActiveBoxes;
|
||||
protected ref array<string> m_FreeLocationNames; // locations currently unused
|
||||
protected ref map<string, bool> m_LocationInUse; // name -> occupied
|
||||
protected ref map<int, float> m_PlayerMessageCooldown; // playerId -> last message world time
|
||||
|
||||
void YALootBoxesManager()
|
||||
{
|
||||
m_ActiveBoxes = new array<ref YALB_ActiveBoxEntry>();
|
||||
m_FreeLocationNames = new array<string>();
|
||||
m_LocationInUse = new map<string, bool>();
|
||||
m_PlayerMessageCooldown = new map<int, float>();
|
||||
}
|
||||
|
||||
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<ref YALB_ActiveBoxEntry> toRemove = new array<ref YALB_ActiveBoxEntry>();
|
||||
|
||||
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<Man> players = new array<Man>();
|
||||
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();
|
||||
// }
|
||||
// }
|
||||
// ============================================================
|
||||
Loading…
Add table
Reference in a new issue