SHOP FIXED
Curse Maven Snippet
What's new
Changelog
[0.4.0] - 2026-08-06 — gameplay depth, restored editors, and a large-shop overhaul
The headline is four opt-in systems that give an arena a reason to be replayed. Around them sits a long tail of correctness work: settings that had silently become uneditable are back, arenas no longer leak their mobs, one bad location can no longer take down the server tick, and the whole editing protocol was reworked so a shop with thousands of modded weapons stops breaking every save on that location.
Existing worlds load and play identically until an admin opts in — every new setting takes a default that reproduces the previous behaviour. Compile targets are unchanged: Forge 47.2.0, Minecraft 1.20.1, Java 17.
Upgrading: the network protocol moved to version 9, so client and server must be updated together. If your config still carries maxShopItems = 100 from an older install, raise it — the cap is now enforced on bulk imports too.
Added — endless mode
- A PvE location can now be marked endless: waves never run out, there is no victory, and the score becomes how far did you get.
- The wave list already cycled internally (
(currentWave - 1) % waves.size()), so this needed no new spawn logic — only the two victory gates now defer to the flag. - Mob health and damage scale by +N% per completed loop through the wave list, configurable (default 10%). Scaling is linear, not compounding: compounding turns loop 5 into an unplayable wall, which reads as a bug rather than a challenge. Mob counts already grow through each wave's existing
growthPerWave, so they are deliberately left out of the loop multiplier. - Endless runs get their own leaderboard (
PvE_ENDLESS) ranked by wave reached rather than points — the two are not comparable numbers. - The record is written on death, not on victory. Endless never reaches
triggerVictory, so without this an endless location would silently never produce a leaderboard entry at all. Non-endless deaths are still unranked: dying on wave 3 of 10 is not a result worth listing beside a finished run.
Added — wave modifiers
- Every N-th wave (default 3) rolls a random twist, announced in chat with a one-line explanation of what it does. The waves in between run clean, which is what makes a modifier feel like an event rather than ambient noise — and gives players a wave to recover on.
- Eight modifiers: Swift, Armored, Regenerating, Enraged, Tough, Phantom (invisible), Volatile (explodes on death), and Venomous (poisons on hit).
- Admins can restrict the pool per location; selecting none means all are eligible, and the editor label says so rather than leaving eight empty boxes looking like a misconfiguration.
- Volatile explosions never damage terrain (
ExplosionInteraction.NONE). Arenas are hand-built, and a modifier that quietly demolishes the map over a few runs would be worse than no modifier at all. The blast still hurts players, and it fires on any death — an environment kill is just as dangerous to be standing next to.
Added — difficulty presets
- Easy / Normal / Hard / Nightmare, set per location, scaling mob health, damage, count, and point rewards.
- Rewards scale with difficulty, or nobody would ever pick anything above Normal.
- Each tier ranks on its own leaderboard so a Nightmare clear never has to compete with an Easy one. Normal keeps the unsuffixed key, so existing records stay exactly where they are.
- This composes with, rather than replaces, the existing
WaveAutoScaler: the preset sets the baseline that the adaptive scaler then nudges around, so Nightmare stays harder than Easy even after the scaler settles.
Added — lifetime player progression
- A new per-player profile persists across runs, locations, and restarts: waves survived, best wave, kills, points, matches played/won, deaths, playtime, and XP.
- Level curve is quadratic (
1 + floor(sqrt(xp / 100))) — early levels arrive fast enough to notice, later ones stay meaningful. - Stored in
world/data/wavedefense_profiles.datthrough the same atomic, debounced write path as locations and leaderboards, so a crash mid-write cannot corrupt it. - Profiles are never pruned: a player returning after months keeps their level.
Fixed — features that had quietly become unreachable
Removing the two legacy location editors in v0.3.0 took their UI with them. The data kept serializing and the runtime kept honouring it, so nothing broke loudly — the settings simply could not be changed any more. A player report about missing mob spawn points led to finding the rest.
- Mob spawn points and the default scatter radius are editable again, in a new Gameplay section: add at your own position, add by coordinates, edit, delete. An empty list now explains the consequence — every mob arrives in one spot — instead of saying nothing.
- Starting kit (
StartingItemsScreen) had no opener at all despite the items still being issued on join. Reachable again from the Economy tab. - Seven settings were live in-game but had no control anywhere: completion points reward (the rewards screen only ever edited items), first-wave delay, keep-loot-on-exit, player spawn scatter radius, starting points, and both location-trigger fields — meaning
TriggerEvaluatorpolled every tick for a feature that could never be switched on. - Personal stats (
StatsScreen) had a complete GUI and a working sync packet but nothing opened it. Now in the player menu, and it returns there on close. - Deleted
MobTypeSelectionScreenandWaveMobSettingsScreen— duplicates of the screens actually in use, referenced by nothing.
Fixed — mobs wandered off instead of hunting
- The targeting goal was registered on
goalSelector, but vanilla runs target acquisition on a separatetargetSelector; on the movement selector it competed with the mob's own strolling goals rather than driving them. FOLLOW_RANGEwas never set, leaving the vanilla 16 blocks. On an arena tens of blocks across a mob simply could not perceive anyone. Now derived from the arena diameter, capped at 128 so a large boundary cannot turn every mob into a long-range tracker. Spiders made this obvious because climbing carries them away quickly.- Line of sight is no longer required, so stepping behind cover does not drop aggro, and idle mobs are re-pointed at the nearest player every 2 s.
Fixed — wave size had no ceiling
- Mob count is a product of four independent factors —
count + growthPerWave × (wave-1), player count, the adaptive scaler (up to 5×) and the difficulty preset (up to 1.5×) — and none of them was bounded. A modest arena (count 3, growth 1, two players, Normal) already reaches 104 mobs per entry by wave 50, twice the mod's own lag threshold, and a wave may hold up to 20 entries. - Endless mode guarantees those wave numbers are reached, so this was not a theoretical edge case: any endless arena eventually becomes unplayable.
- Capped at 120 mobs per wave entry, with a one-off warning per wave naming the location, the mob and the requested figure, so an arena that stopped scaling is discoverable instead of mysterious. This is a stability backstop, not a balance knob — difficulty is meant to come from tougher mobs, not from more entities than the server can tick.
Fixed — endless silently disabled five configured settings
Endless never reaches triggerVictory, so completion rewards, completion points, the victory exit, the victory screen and the LOCATION_END loot trigger could all be configured but would never fire. The editor now says so directly under the endless toggle, listing exactly what stops applying and pointing at per-wave points instead.
Fixed — a stored round count of 0 ended PvP matches after one round
pvpTotalRounds was documented as "0 = infinite", but isAllRoundsDone() is currentRound >= totalRounds, so 0 was true immediately. The setter clamped to 1, yet deserialization writes the field directly and bypassed it — a legacy save holding 0 lost every match after the first round. Now clamped on load, and the misleading comment is gone.
Fixed — dead arenas left their mobs in the world forever
- Wave mobs are spawned with
setPersistenceRequired(), so they never despawn on their own.WaveContext.removeSessioncallsdispose(), which clears the tracking set — it does not remove the entities. Every other teardown path callsdespawnSessionMobsfirst; the path taken when the last player in a PvE arena dies did not. - So every run that ended in death stranded its entire live wave: persistent hostile mobs, untracked because the set had just been cleared, accumulating in loaded chunks run after run. The PvP teardown had the same gap for trigger and portal mobs.
- Both paths now despawn before the session is dropped, and the method carries a javadoc stating the ordering requirement so the next teardown path does not repeat it.
Fixed — one bad arena could take down the server tick
onServerTickcalled seven subsystem ticks and every session tick with no exception handling, and the Forge event handler above it had none either. A malformed particle id, a null spawn point or one corrupt wave propagated straight out and killed the tick. The later blocks in the same method were already guarded, so the file was inconsistent with itself.- Each subsystem now runs inside
safeTick, which contains the failure to that subsystem and logs it — at most once per 10 s per subsystem, because a fault that reproduces every tick would otherwise write 20 stack traces a second and bury the actual cause.
Fixed — the crash-safe write contract was only half-implemented
atomicWriteCompressedwrites through.tmp → .bak → ATOMIC_MOVEand documents the guarantee that "after any single crash, the reader can recover from either the main file or the.bak— never both missing." Only one of four readers actually honoured it.LeaderboardManager,PlayerProfileManagerandWaveManager.loadRuntimeStatecaught the read failure and silently started from an empty state — discarding a perfectly good backup sitting next to the corrupt file. A single bad shutdown wiped the leaderboard.- The read half now lives next to the write half as
NbtHelper.readWithBackup, and all four readers go through it. When both copies are unreadable the primary is renamed to.corruptedso the next save cannot destroy evidence. - An in-flight save could still be lost on shutdown.
flushPendingWrites()drained the queue but did not wait for a write that was already running: that task had taken its snapshot, so the drain found nothing, returned, and the JVM killed the daemon save thread mid-write. Combined with the above, that was unrecoverable. Flush now ends with a completion barrier — the save executor is single-threaded, so a no-op submitted at that point cannot start until the running write has finished. - The executor is deliberately not shut down: an integrated server can quit to title and open another world in the same JVM, and a terminated executor would make every later save throw. The barrier gives the same guarantee without that.
Fixed — monitor state grew without bound
playerSessionswas only ever appended to, andonPlayerLeavestreamed the whole deque to find the open session: a leak that also made every logout progressively slower. Now capped, and scanned newest-first, where the match almost always is.playerActivitykept the last position and game mode of every player who had ever connected — data that is stale the moment they log out. Dropped on leave.playerStatisticsretained one object per unique visitor forever. Capped, evicting least-recently-active first. Lifetime numbers that genuinely need to persist live inPlayerProfile; this map only backs the live monitor report.
Fixed — bulk-adding a large TACZ pack killed the connection
Reported: adding 3000+ guns disconnected the client with DecoderException: Payload may not be larger than 32767 bytes. That was the visible symptom of three separate faults, any one of which loses items:
- Packet too large. Items were batched 25 per packet. A single gun carries enough NBT that 25 of them exceed the 32767-byte serverbound payload limit outright.
- Most packets were silently discarded. The handler rate-limited itself to one packet per 500 ms, but the sender fired all ~120 batches in a single frame — so all but the first were dropped without a word. Items were going missing before the size error ever appeared.
- Quadratic work and a response that also overflowed. Every batch triggered a full save, a location broadcast and a shop re-sync carrying the entire location NBT — which grows with each batch. By the end the server was re-serializing thousands of guns per packet and sending it back.
Now one item per packet, paced across client ticks by ClientShopUploadQueue (~160 items/second, so a 3000-gun pack finishes in well under a minute) with a progress readout. Only the final packet carries a last flag, and only that one triggers the save, broadcast and re-sync. The per-packet rate limit is gone — pacing is the client's job now, and op permission plus the shop-size cap are the real guards. An upload in flight is abandoned if the player disconnects.
maxShopItems is raised from 100 to 5000 and is now enforced on the bulk path too, which previously wrote the list directly and bypassed it. Existing config files keep their stored value — if yours still says 100, raise it or the upload will stop there and tell you so.
Fixed — a large shop broke every editor, not just bulk-add
Fixing the TACZ upload exposed that the same limit was hit by four other paths. Once a location held a few thousand guns, editing anything on it stopped working: renaming the arena, changing one price, editing a wave. Each of those sent the whole location — several megabytes — through a 32767-byte serverbound packet.
The shop and the wave list are the only parts of a location whose size follows its content rather than a fixed schema, so they no longer travel inside a location payload:
UpdateLocationPacketexcludesshopItems,shopPointsandwaves. When a preserved list is absent the handler keeps the server's copy, so a partial payload can never wipe one. Stripping is done in the constructor, so no caller can reintroduce it by accident.MergeLocationPacketnow sends only the sections it marked dirty. It always knew which ones the server would apply — it just shipped the rest anyway. Keys the server cannot classify are still included, matching the handler's own rule exactly: dropping one would have been read as a deletion.- New
ShopItemOpPacket— add / update / remove a single entry, in the global shop or in a named shop point.ShopItemEditorScreenuses it instead of resending the location. - Waves move to a chunked replace as well, since
WaveConfigScreenbatches its edits and saves once — per-wave operations would have had no natural caller there. - The location broadcast no longer carries shops at all. It runs on every login and every change, so every player was being handed the server's entire weapon catalogue repeatedly. Shops now arrive on demand via the new
RequestShopDataPacket, and the client cache keeps any shop it already holds when a stripped list arrives.
ShopEditorScreen's old clear-send-restore dance is gone with it — it briefly emptied the client's own list and would have lost it outright had anything thrown in between.
Protocol version bumped to 9: packet ids are assigned by registration order, so an old client must be refused rather than left to misread the stream.
Fixed — the same limit applied to loot, rewards and kits
Splitting the shop out revealed that it was never only about shops. Seven location lists hold modded items, and a modded item's NBT can be enormous: loot spawns (fifty chest-fulls), completion reward tables, per-team starting kits, PvP spawn points. Any one of them can exceed the payload limit on its own.
Fixing them individually turned out to be the wrong instinct — excluding shops from UpdateLocationPacket silently broke shop-point editing, because that list was missed and its absence read as a deletion. So the handling is now generic:
LocationSection.isContentSizedListis the single source of truth for which keys are content-sized.UpdateLocationPacket,MergeLocationPacketand the editor all consult it, so sender and handler cannot drift apart.- New
ReplaceLocationListPacketreplaces any one of those lists, addressed by its NBT key. Adding a list later means registering a key, not writing another packet. - Chunking is by encoded size, not element count. Element weight varies by orders of magnitude — a plain sword against a rifle with attachments — so "N per packet" is either wasteful or unsafe depending on content. It replaces the wave-specific packet written moments earlier, which would have been a fourth near-identical transport.
MergeLocationPacketskips these keys on both sides. Without the handler half, the sender omitting a list would have wiped shops, waves and loot the admin never touched.
New ContentSizedListTest fails the build when a list is declared content-sized with nothing to send it, when only one half of the merge contract skips it, or when the broadcast starts shipping shops again. Writing it immediately caught two more lists — per-team kits and PvP spawn points — that had been excluded without a transport.
Fixed — regressions from the packet split itself
A pass over everything this release touched found that splitting the big lists out had broken two save paths and left one transport orphaned:
ShopEditorScreenlost edits on small shops. It only used the chunked path past a 50-item threshold; below that it relied onUpdateLocationPacket, which now strips shops. Adding or deleting a single item in a modest shop — and every shop-point change — was silently discarded. It now always sends both lists on their own channel, regardless of size.ReplaceShopItemsPackethad no sender left once the generic transport took over. Removed rather than left registered, so the packet table matches what actually runs.- Three imports left dangling by the rewrites are gone.
Changed — the client no longer trusts its own optimistic edits
Shop screens mutate their local copy first so the UI responds instantly, but the server can legitimately disagree: an add may hit maxShopItems, and an index-based update can race another admin. After a bulk upload finishes, and after a single-item edit, the client now re-requests the shop and adopts what was actually stored. Previously the editor could keep showing entries the server never saved.
Fixed — boundary particles cost far more than they were worth
- The whole ring was drawn — a particle column every 2 blocks all the way round — and every column was broadcast to everyone within 32 blocks, including players not in the arena. At the default radius of 50 that is ~471 particle packets per second.
- Each player now receives only the 90° arc they are facing out towards, at 3-block spacing, sent to them individually: roughly 20 columns instead of 157, and nothing at all for bystanders.
boundaryParticleCountwas read into a variable and then ignored — the call passed a hard-coded1, so the admin's slider did nothing. It works now.- Players can turn mod particles off entirely in their own settings. This covers both the boundary ring and the bbox outline, and is the fix for the reported frame drops.
Performance
BattleRoyaleManagerallocated an emptyHashMap20 times a second whenever no Battle Royale was running — which is almost always.PortalManagerbuilt a stream pipeline and a capturing lambda per portal-enabled location per tick to answer a boolean.TriggerEvaluatorwalked the entire server player list inside its per-location loop, and built an emptyArrayListfor every location with nobody nearby.
Fixed — 17 of 31 config options did nothing
Eight were read nowhere; nine only by the config screen that displays them, so the toggle moved but nothing changed. All are now wired to what their description promises:
enableHUDnever gated the HUD. It does now.maxPlayerSpawns,maxShopItems,maxLootSpawnswere not enforced, thoughmaxWavesandmaxMobSpawnswere — the intent was there, the rest was missed.pvpHideEnemyNametagswas ignored: hiding was hard-coded at team creation, so the option did nothing and flipping it later had no effect either.pvpMax*safety caps now actually clamp the values an admin can type.defaultWaveTime,defaultRounds,defaultBuyTime,zoneActivationRadiusandzoneActivationCountdownare applied when a location is created.zoneActivationParticlesgates the activation-zone effect.shopCategoriesEnabledhides the category filter row; the active filter resets to ALL when it is switched off, so nothing stays hidden.
Fixed — filename handling in import/export
All six import/export packets built paths from client-supplied strings, and each did it differently. Consolidated into network/FilePathGuard:
ImportWavePackethad no containment check at all — the only one of the family without one.ImportLocationPacketandImportShopPacketcompared canonical paths without a trailing separator, so a sibling directory sharing a name prefix (shops_evilagainstshops) passed the check.ExportShopPacketconcatenated an unsanitised location name straight into a path.- Location names were validated by
DuplicateLocationPacketbut not byCreateLocationPacket— and that unvalidated name is what fed the export filename. The rule now lives once, inLocationManager.isValidName, and both packets use it. Invalid names are rejected with a translated message rather than silently ignored.
All of these required op level, so this is hardening and consistency work rather than a closed hole.
Added — a guard so this cannot happen again silently
SettingReachabilityTest reads the sources and fails the build when:
- a
Locationsetting the runtime reads has no screen that can change it, - a config option is exposed to admins but nothing reads it,
- a screen exists that nothing opens.
Source scanning rather than reflection, because the question is not "does the method exist" but "does anything call it". Deleting a screen now breaks the build instead of quietly removing a feature.
Added — tests
NbtHelperBackupTest— 9 tests: backup fallback for a corrupt and for a missing primary, fresh install treated as normal, quarantine when both copies are bad, and the flush barrier actually committing a debounced write before returning.FilePathGuardTest— 10 tests including the prefix-sibling bypass and the location-name rules.ProgressionTest— 18 tests covering difficulty parsing and monotonicity, endless loop arithmetic and its linear-not-compounding guarantee, modifier interval and pool rolling, the level curve, and profile NBT round-tripping.
Notes
- 61 new translation keys across all 8 supported locales (1504 each, parity checked).
- Test suite grew from 13 to 82 across nine classes, four of which are structural guards that fail the build rather than describing behaviour.
- No data-format change. Every new setting takes a default that reproduces the previous behaviour, so existing worlds load and play identically until an admin opts in. Compile targets are unchanged: Forge 47.2.0, Minecraft 1.20.1, Java 17.
- Network protocol 8 → 9. Packet ids are assigned by registration order and this release adds, removes and reorders several, so client and server must be updated together. A mismatched client is refused rather than left to misread the stream.
- Config:
maxShopItemsdefault raised 100 → 5000, and it is now enforced on the bulk-import path that previously bypassed it. Existing config files keep their stored value — raise it by hand if yours still says 100. - Removed:
MobTypeSelectionScreenandWaveMobSettingsScreen(duplicates of the screens actually in use), and theReplaceShopItemsPacket/ per-wave transports that the generic chunked list channel superseded. - Minor version bump rather than a patch: four new gameplay systems, several restored features and a protocol change is well past a bug-fix release.
This mod has no additional files

