## [4.5.2] — Bug-Fix Release
### 🔴 Critical Fixes
#### BUG-1 · Boss Zombie drops Diamond Armour on death
- **File:** `ZombieSpawnService.java` — `spawnBossZombie()`
- **Root cause:** `setArmorContents()` was called without setting drop chances to 0.
Bukkit's default drop chance is ~8.5%, so players could farm Diamond Helmet /
Chestplate by killing the Boss.
- **Fix:** Added explicit `setHelmetDropChance(0f)`, `setChestplateDropChance(0f)`,
`setLeggingsDropChance(0f)`, and `setBootsDropChance(0f)` after every armour
assignment, including the custom-model-data head path.
#### BUG-2 · Boss HP inconsistent — `evolveZombie()` multiplier overridden
- **File:** `ZombieSpawnService.java` — `spawnBossZombie()`
- **Root cause:** `spawnCustomZombie()` internally called `evolveZombie()` which
applied a tier health multiplier (up to ×2.5 for ABERRANT), but `spawnBossZombie()`
then hard-reset the attribute to `boss.health` config value, discarding the tier
scaling entirely.
- **Fix:** `spawnBossZombie()` now sets `bossHealth` first, *then* calls
`evolveZombie()` so the tier multiplier compounds on top of the intended base.
Boss HP in late-game now scales correctly with the threat level.
---
### 🟡 Medium Fixes
#### BUG-3 · ABERRANT zombies appear ~35 days too early
- **File:** `ZombieEvolutionService.java` — `determineTier()`
- **Root cause:** The `else` branch covered `threat >= 30`, but the class-level
comment stated *"Threat > 50: Aberrant possible"*. Players hit ABERRANT-tier
enemies during Phase 3 (Day ~15) instead of Phase 5 (Day ~25+).
- **Fix:** Inserted an explicit `threat < 50` bracket (no ABERRANT, Mutated max)
and moved ABERRANT to `threat >= 50` only, matching the original design intent.
#### BUG-4 · `/zapoc infect` shows hardcoded `"10m"` regardless of config
- **File:** `ZombieApoc.java` — `onCommand()` case `"infect"`
- **Root cause:** `incubationTicks` was read from config but immediately discarded;
the player message used the literal string `"10m"`.
- **Fix:** `timeDisplay` is now derived from `incubationTicks / 20` seconds so the
displayed time always reflects the actual configured value.
#### BUG-5 · ZombieBehaviorTask batch-skipping uses `hashCode() % 2` (uneven distribution)
- **File:** `ZombieBehaviorTask.java` — `run()`
- **Root cause:** `UUID.hashCode()` is a 32-bit hash with no uniformity guarantee.
In practice this can result in 70 % of zombies processed on one tick and 30 % on
the other, defeating the purpose of two-bucket batching.
- **Fix:** Changed the modulus operand to `UUID.getLeastSignificantBits()` which is
a raw 64-bit random value from the UUID v4 spec, giving near-perfect 50/50 split.
#### BUG-6 · `HallucinationListener` double-registration on `/zapoc reload`
- **File:** `ZombieApoc.java` — `startTasks()`
- **Root cause:** `startTasks()` checked `if (hallucinationListener == null)` before
creating a new instance, but `cancelAllTasks()` already set it to `null`.
In specific edge cases (e.g. `psychological-horror.enabled` toggled via config
between reloads) the old instance could survive unregistered yet the new instance
would be registered on top.
- **Fix:** `startTasks()` now unconditionally calls `HandlerList.unregisterAll()` and
nulls the field before constructing a fresh `HallucinationListener`, making the
lifecycle explicit and immune to ordering issues.
#### BUG-7 · `WorldEventTask` blood-moon maps leak UUID entries for unloaded worlds
- **File:** `WorldEventTask.java`
- **Root cause:** `bloodMoonStates` and `bloodMoonRolledDay` used world UUIDs as keys
but never removed entries when a world was unloaded (e.g. on map rotation or
`/mv unload`). Over time this created an unbounded set of stale entries.
- **Fix:** `WorldEventTask` now implements `Listener` and handles `WorldUnloadEvent`
to remove both maps' entries for the unloaded world. The task is registered and
unregistered as a listener in `startTasks()` / `cancelAllTasks()`.
---
### 🟢 Minor Improvements
#### PERF-1 · ZombieBehaviorTask reads 7 YAML values every 10-tick cycle
- **File:** `ZombieBehaviorTask.java`
- **Root cause:** `moanEnabled`, `moanChance`, `moanVol`, `moanPitch`,
`mutationEnabled`, `mutationInterval`, and `breakingEnabled` were fetched from
`FileConfiguration` on *every* call to `run()`.
- **Fix:** All seven values are now cached as class-level fields alongside the
existing sun / screamer caches and refreshed once every 100 ticks inside
`reloadSunConfigCache()`.
#### PERF-2 · `handleExplosive()` reads `explosive.power` from YAML at explosion time
- **File:** `ZombieBehaviorTask.java`
- **Fix:** Added `cachedExplosivePower` field, populated in `reloadSunConfigCache()`.
#### PERF-3 · `CampfireManager.isInsideSafeZone()` reads `check-radius` from YAML per call
- **File:** `CampfireManager.java`
- **Root cause:** `plugin.getMechanicsConfig().getInt("campfires.check-radius", 100)`
was called on every invocation. `isInsideSafeZone()` is called once per second
per online player from `PlayerStatusTask`, making this a hot path.
- **Fix:** Added `cachedCheckRadius` field, initialised in the constructor and
refreshed in `reload()`.
#### DEPRECATION-1 · `TurretListener` used legacy `ChatColor` string API
- **File:** `TurretListener.java`
- **Fix:** All player messages converted to `net.kyori.adventure.text.Component`
using `NamedTextColor` and `TextDecoration`, the standard approach on Paper 1.16+.
#### DEPRECATION-2 · `ZombieApoc.sendActionBar()` used BungeeCord spigot bridge
- **File:** `ZombieApoc.java`
- **Fix:** Replaced `player.spigot().sendMessage(ChatMessageType.ACTION_BAR, ...)`
with the Paper-native `player.sendActionBar(Component)` call.
Removed unused `net.md_5.bungee.api.*` imports.
---
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" }