## [4.1] — 2026-03-14 🔧 *The Bug Purge Update*
> 23 bugs fixed across all severity levels. This release focuses entirely on
> correctness, stability, and developer experience — no new gameplay features,
> just a rock-solid foundation.
---
### 🔴 Critical Fixes
#### 🏰 Turrets lost on every server restart *(BUG-02)*
`loadTurrets()` and `saveTurrets()` were both empty `// TODO` stubs.
Every turret placed by players vanished after a restart.
**Fix:** Full persistence implemented via `turrets_data.yml` using atomic
write (`.tmp` → rename) to prevent data corruption on crashes.
---
#### 📦 Supply Drop loot table was never loaded *(BUG-03)*
`spawnSupplyDrop()` read `supply-drop.loot` from `config.yml`, but this section
is defined in `events.yml`. The config section was always `null`, so every
Supply Drop fell back to a hardcoded 5-item list, ignoring all admin configuration.
**Fix:** `spawnSupplyDrop()` and all related methods now read exclusively from
`eventsConfig` (events.yml). Admin-configured loot tables are now respected.
---
#### 🌕 `blood-moon.chance` config had no effect *(BUG-04)*
The `bloodMoonChance` field was loaded from config (default 5%) but never
referenced in `checkBloodMoon()`. Blood Moon triggered at 100% probability
every N days instead of the configured 5% chance.
**Fix:** `Math.random() < bloodMoonChance` check added in `checkBloodMoon()`.
Blood Moon now correctly triggers by chance each eligible night.
---
#### 💥 `NullPointerException` when Molotov reached max range *(BUG-05)*
`ProjectileHitEvent` fires when a projectile expires at max range, at which point
both `getHitBlock()` and `getHitEntity()` return `null`. The handler assumed at
least one was non-null, causing a server-crashing NPE.
**Fix:** Three-way null check: hit block → hit entity → projectile location (fallback).
---
### 🟠 High Severity Fixes
#### 🩸 Bleeding damage triggered every 40 seconds instead of 2 *(BUG-06)*
`PlayerStatusTask` runs every 20 ticks (1 second). The bleeding check used
`tickCount % 40 == 0`, which fires every 40 *runs* = every **40 seconds**.
Players with bleeding could regenerate faster than they took damage.
**Fix:** Changed to `tickCount % 2 == 0` → bleeds every 2 seconds as intended.
---
#### ⚠️ No combined cap on zombie difficulty multipliers *(BUG-07)*
Three independent scaling systems stacked without a total ceiling:
- Global difficulty multiplier (capped at ×3.0 ✓)
- Evolution tier multiplier (Aberrant ×2.5 ✓)
- Per-player `daysSurvived` scaling (**no cap** ✗)
On a 100-day server, the combined multiplier could reach ×26×, making zombies
practically unkillable.
**Fix:** `daysSurvived` scaling is now capped — health ≤ ×3.0, speed ≤ ×2.0,
spawn count ≤ 5 per spawn event.
---
#### 🔫 Gun reload lock persisted through player relog *(BUG-08)*
`RELOAD_COOLDOWN_KEY` and `FIRE_COOLDOWN_KEY` were stored in
`player.getPersistentDataContainer()` as Unix timestamps. If a player disconnected
mid-reload and reconnected, the stale future timestamp would lock them out of
firing/reloading until the original timer expired.
**Fix:** Both cooldowns moved to `ConcurrentHashMap<UUID, Long>` in-memory maps.
Cleared automatically when the player disconnects.
---
#### 👻 Hallucination Villager stood completely still *(BUG-09)*
`updateHallucinations()` called `mob.setTarget(player)` to make hallucinations
chase their target. While `Zombie` entities respond to `setTarget()` by pathfinding
toward the player, `Villager` ignores it (Villagers don't attack).
**Fix:** Villager hallucinations now use `getPathfinder().moveTo(player, 1.15)`
with periodic re-pathfind if the current path becomes null.
---
#### 👁️ Players who joined late could see hallucination entities *(BUG-10)*
`spawnHallucination()` called `hideEntity()` only for players online *at spawn time*.
Any player joining after a hallucination spawned would see a phantom zombie/villager
wandering around with no explanation.
**Fix:** The existing `onPlayerJoin()` handler correctly hides all active
hallucinations for new joiners. Spawn-time hiding loop now also reliably covers
all current online players.
---
### 🟡 Medium Severity Fixes
#### 🪓 Weapon upgrade chain used hardcoded CustomModelData values *(BUG-11)*
`FeaturesListener` hardcoded CMDs like `3004`, `3011`, `3005` directly in Java.
Changing any `custom-model-data` in `items.yml` would silently break upgrade
chains with no error or warning.
**Fix:** CMDs are now read from `items.yml` and `mechanics.yml` at runtime.
Upgrade chains survive config customization.
---
#### 🔥 Campfire data corrupted if world name contained `_` *(BUG-12)*
Save keys used `worldName_x_y_z` format. Loading split on `_` and took `parts[0]`
as the world name. A world named `my_world` would produce 5 parts instead of 4,
causing `parts[0] = "my"` → `Bukkit.getWorld("my") = null` → all campfires lost.
**Fix:** Keys now use `worldUUID_x_y_z` (UUID with hyphens stripped = always 32
hex chars, no ambiguous underscores). Legacy world-name keys are still parsed as
a migration fallback.
#### 🌙 `blood-moon.enabled` / `supply-drop.enabled` read from wrong config *(BUG-14)*
Both keys are defined in `events.yml`, but `WorldEventTask.run()` read them from
`config.yml`. Since `config.yml` doesn't contain these keys, the fallback `true`
was always used — neither event could be disabled.
**Fix:** All `WorldEventTask` config reads now use `eventsConfig` consistently.
---
#### ☠️ Corpse reanimation could not be disabled *(BUG-15)*
When a player died, items were cleared and a zombie "corpse" was spawned wearing
their gear. There was no config toggle — servers running minigames or PvP arenas
had no way to opt out of this mechanic.
**Fix:** Gated behind `corpse-reanimation.enabled` in `mechanics.yml` (default: `true`).
---
#### 💬 Misleading comment in `ZombieBehaviorTask` *(BUG-16)*
Comment said `// Every 20 ticks = 1s` next to `tickCounter % 2 == 0`. The actual
math (task runs every 10 ticks; `%2` = every 2 runs = every 20 ticks = 1s) was
correct but the comment was confusing enough to invite accidental breakage.
**Fix:** Comment updated to explain the full calculation chain.
---
#### 🎆 Default Screamer particle `"PORTAL"` is invalid in Paper 1.21 *(BUG-17)*
`Particle.valueOf("PORTAL")` throws `IllegalArgumentException` in Paper 1.21+.
The exception was caught and logged, but the screamer ability silently failed every
time on unmodified configs.
**Fix:** Default changed to `"ENTITY_EFFECT"` in both code and `zombies.yml`.
---
### 🟢 Code Quality
| # | Change |
|---|---|
| 🗑️ BUG-18 | Deleted `manager/ZombieManager.java` — 532 lines of dead code never instantiated. Contained a severe anti-pattern (one `BukkitTask` per zombie). |
| 🗑️ BUG-19 | Deleted `manager/EventManager.java` — 739 lines of boilerplate never registered as a Listener in `onEnable()`. |
| 📐 BUG-20 | Added missing constants to `ZombieConstants`: AI ranges, noise radii, bleed values, day-scaling caps. Magic numbers reduced across codebase. |
| 🏷️ BUG-21 | Renamed `trapKey` / `is_trap` PDC key to `engineeringItemKey` / `is_engineering_item` in `EngineeringTableManager`. Ammo is not a trap. |
| 🏷️ BUG-22 | Renamed `ZombieUtils.applyAI()` → `applyFollowRange()`. The method only sets follow range to 40 blocks — the old name implied far more than it did. |
| 🧹 BUG-23 | `ZombieCleanupListener` now removes zombies from the tracking map on chunk unload, freeing strong references to de-activated entities and reducing memory pressure on long-running servers. |
---
### ⚙️ Config Changes
**`mechanics.yml`** — new keys:
```yaml
corpse-reanimation:
enabled: true # Set false to disable zombie corpse on player death
infection:
natural-decay:
enabled: true
chance-per-second: 0.033 # ~1 point per 30s; 3× faster in Campfire safe zones
```
**`events.yml`** — new key:
```yaml
blood-moon:
cycle-days: 7 # (moved from config.yml) eligible cycle in days
```
**`zombies.yml`** — changed default:
```yaml
screamer:
particle: ENTITY_EFFECT # was: PORTAL (invalid in Paper 1.21)
```
---
### 🧩 API Changes
```java
// ✅ Fixed
api.isInfected(player) // now correctly returns true when infected
// ✅ New
api.getInfectionLevel(player) // int 0–5
api.getInfectionPoints(player) // int 0–100
api.setInfectionPoints(player, n)
api.getThreatLevel() // double
api.getZombieTier(zombie) // ZombieTier enum
api.isInSafeZone(location) // boolean
api.spawnZombie(world, loc, type)
// ⚠️ Deprecated
api.infectPlayer(player, ticks) // use setInfectionPoints() instead
```
---
Developer information
Projects on Modrinth are automatically available through a Maven repository for use with JVM build tools such as Gradle.
maven { name = "Modrinth"; url = "https://api.modrinth.com/maven" }