Workflow Audit (September 2026)
A code-traced audit of every Gangland Warfare system, taken on 2026-09-02 from branch 0.8.1 (Keystone 1.7.3).
Thirteen case files describe what the code does today, workflow by workflow: the trigger, the execution path with
source citations, what is persisted, which guards exist, and a table of observations rated High, Medium or Low risk.
Together they cover 235 workflows. The audit is the input for the bug-fixing and test-writing work that follows;
observations are findings to reproduce, not confirmed bugs.
Rendered versions with drawn diagrams, a table of contents and a risk-sorted roll-up of every observation: Case file board.
Case files
| # | Case file | Group | Workflows | High / Medium / Low | Rendered |
|---|---|---|---|---|---|
| 01 | Core Lifecycle, Bootstrap, Persistence & Scheduling | Platform | 12 | 9 / 10 / 6 | open |
| 02 | Command Framework, Messages, Placeholders & Platform Services | Platform | 12 | 9 / 18 / 9 | open |
| 03 | Item Framework, Unique Items & Converters | Platform | 12 | 13 / 17 / 2 | open |
| 04 | Inventory GUI Framework, Phone, Scoreboard & Holograms | Platform | 13 | 12 / 15 / 8 | open |
| 05 | Users, Levels, Economy & Banking | Players | 22 | 9 / 17 / 16 | open |
| 06 | Gangs, Members, Ranks, Permissions & Mail | Players | 16 | 19 / 16 / 2 | open |
| 07 | Wanted Levels, Bounties, Combos & Downed Players | Players | 15 | 13 / 18 / 11 | open |
| 08 | Cops, Detainment & Jail | World | 16 | 21 / 19 / 2 | open |
| 09 | Civilians, Traders & Shops | World | 24 | 6 / 11 / 9 | open |
| 10 | Turf Wars | World | 23 | 5 / 19 / 15 | open |
| 11 | Weapons, Ammunition & Projectiles | Gear | 22 | 14 / 20 / 7 | open |
| 12 | Gadgets: Cars, Fuel, Jetpacks & Wearables | Gear | 22 | 11 / 12 / 5 | open |
| 13 | Loot Chests, Trade Signs & Waypoints | Gear | 26 | 10 / 21 / 3 | open |
Headline findings
Up to three High-risk observations per case file, as written by the tracer. Each links to a file and line; verify in code before acting.
- Core Lifecycle — With
Database.Type: mysqlandDatabase.SQLite.Failed_MySQL: false, a failed MySQL connect leavestype == MYSQLanddatabase == null. The bean then callsdatabase.createSchema()→getDatabase().createSchema(...)→ NPE, which is not anSQLException/IOException, so it escapes thecatchand abortsonEnablewith a raw NPE instead of a diagnosable message. (KeystoneDatabaseHandler.java:137-142+DatabaseConfig.java:46-63) - Core Lifecycle — The autosave task runs on an async thread and iterates
userManager.getUsers().values()/offlineUserManager.getUsers().values()and then callsofflineUserManager.clear().AbstractRepository.saveAllalso copies each supplier's livemap.values()on that thread. A player joining/leaving concurrently can produceConcurrentModificationExceptionmid-save (partial save, some repositories skipped) or a lost write. Reproduce: heavy join/leave churn at the autosave tick. (PeriodicalUpdates.java:207(start(true)) with:108-120) - Core Lifecycle —
resetWeapons()deletes everyweaponrow and callsweaponManager.clear()from an async thread while the main thread may be reading/writing the weapon cache. Also, the very next step of the same tick saves weapons from the now-empty cache, so the wipe is immediately re-persisted — intended, but any weapon created between the clear and the save is lost. (PluginDataCleanupService.java:98-108reached fromPeriodicalUpdates.task()on the async timer) - Commands & Messages —
updateCheckerInitializer()returns early whenUpdate_Checker.Enable: false, leavingupdateCheckernull; the LOWEST-priorityPlayerJoinEventhandler dereferences it unconditionally (Gangland.java:220-222+listener/player/CreateAccountListener.java:62-66) - Commands & Messages —
onSubHelpcallsrenderHelpdirectly, neverrunExecute— nosender.hasPermission(sub.getPermission())check (gangland-impl/.../command/CommandManager.java:133-153(and Keystone'sonHelp, 273-288)) - Commands & Messages —
@CommandHandler(condition = "isGangEnabled")is a getter method name, but the command-side lookup is asettingsMaplookup keyed by field names (gangEnabled); the listener side (ListenerManager.invokeMethod) resolves method names reflectively (command/sub/gang/GangCommand.java:50vsfile/configuration/SettingsLookupImpl.java:16-29+Settings.java:801) - Item Framework —
hasUniqueItemdoesif (uniqueItem.compareTo(item) == 0) continue;— it skips the matching item and returnstrueonly for a different unique item of the same material. The predicate is inverted. (gangland-infra/gangland-item/.../item/unique/UniqueItemUtil.java:31) - Item Framework —
removeItemhas the same inversion:if (uniqueItem.compareTo(contents[i]) == 0) continue;theninventory.setItem(i, null). It nulls the first slot that does not match. (gangland-infra/gangland-item/.../item/listener/unique/LoadUniqueItem.java:101) - Item Framework —
compareTocompares the raw configname(&-codes, unresolved placeholders) withmeta.getDisplayName()(§-translated, placeholders resolved byItemBuilder.setDisplayName→ChatUtil.color). (gangland-infra/gangland-item/.../item/unique/UniqueItem.java:73) - GUI & Scoreboard —
getDriverHandlerhands the livescoreboardAddon.getLines()list to the driver, whose constructor doesthis.lines.add(title). Every board creation (each join, each reload) appends another title reference to the one shared list, which grows without bound. The sameLineobjects are also shared by every player, soLine.index(the rotating-content cursor) advances once per player per tick — the animated title cycles N× faster with N players and all boards show the same frame (scoreboard/ScoreboardManager.java:58-63+scoreboard/driver/DriverHandler.java:34) - GUI & Scoreboard —
timer.start(true)schedulesrunTaskTimerAsynchronouslyat period 1 tick. The task callsdriver.update()→Placeholder.convert(player, …)(gang/user/bank lookups, PlaceholderAPI expansions) andFastBoard.updateLine/updateTitle(reflective packet sends). This violates the project's ownfeedback_repeating_timer_asyncrule and touches non-thread-safe APIs from an async thread, every tick, per player (scoreboard/Scoreboard.java:27) - GUI & Scoreboard —
rerender()and the same-size/same-titleswitchToreuse path callcurrent.clear(), which only clears the BukkitInventory.clickableSlots,rightClickSlots,clickableItemsanddraggableSlotsare never cleared, so a slot the new render leaves empty still fires the previous render's click action, and previously-draggable slots stay draggable (inventory/flow/MultiPanelInventory.java:120-121, 184-187+inventory/InventoryHandler.java:196-198) - Users & Economy — The offline cache is
Map<OfflinePlayer, User<OfflinePlayer>>but quit inserts with aPlayerkey (offlineUserManager.create(player)), join looks up with a freshPlayer, and bootstrap inserts withBukkit.getOfflinePlayer(uuid). CraftBukkit'sCraftEntity.equals/hashCodecompare the entity id,CraftOfflinePlayercompares the UUID, so the key flavours never match: the join-time eviction silently misses and the stale entry survives.PeriodicalUpdateswrites online users then offline users (PeriodicalUpdates.java:108-117), so on the first autosave after a rejoin the quit-time snapshot overwrites the live row for that uuid, rolling back everything earned since rejoining. (RemoveAccountListener.java:95, CreateAccountListener.java:71, UserManager.java:28) - Users & Economy —
user.getEconomy().setAmount(Settings.getUserInitialBalance())runs unconditionally on every join, before the async DB read. With Vault registered,EconomyHandler.setAmountimmediately performswithdrawPlayer(entireBalance)+depositPlayer(initial)on the real Vault account (defaultInitial_Balance: 0), zeroing a returning player's Vault money for the duration of the async round-trip — permanently if the row is missing or the query fails. (CreateAccountListener.java:68) - Users & Economy —
processMoneynever checks the sign ofamount./glw bank deposit -1000passes both guards (-1000 > cashis false), setting cash tocash + 1000and the bank tobank - 1000— free cash plus a negative bank balance./glw bank withdraw -1000is the mirror: it moves cash into the bank with no daily-cap and no tier-cap check, and drives cash negative when the player holds less than the amount. (BankCommand.java:61-76, used by BankDepositCommand.java:126 and BankWithdrawCommand.java:97) - Gangs & Ranks —
memberManager.getMember(uuid)is dereferenced with no null check in ~15 places. A player whoseMemberis not cached (never persisted, cache cleared by a reload, joined before the plugin) NPEs mid-command (command/sub/gang/GangCreateCommand.java:88,116;GangDeleteCommand.java:87,95,148,155,203,206;GangKickCommand.java:146,178;GangLeaveCommand.java:85,95,132,139;GangPromoteCommand.java:78,117,194,201;GangDemoteCommand.java:74,111;GangDepositCommand.java:74;GangWithdrawCommand.java:74;command/sub/gang/invite/GangInviteAcceptCommand.java:168;command/sub/debug/ComponentExecutorCommand.java:143,170) - Gangs & Ranks — The offline-payout block runs on an async thread yet mutates
gang.getEconomy(), callsmemberManager.assignRank(→Bukkit.getOfflinePlayer→ Vault) and races the synchronousgangManager.remove(gang)/gangRepository.delete(gang)at :294-310 (command/sub/gang/GangDeleteCommand.java:230-285) - Gangs & Ranks —
/glw gang withdrawhas no rank or permission check — the lowest-ranked member can drain the entire gang vault.deposit,rename,desc,display,color,inviteand everyallysubcommand are likewise ungated (command/sub/gang/GangWithdrawCommand.java:67-110) - Wanted & Bounty —
setwithdraws the rawvaluebut storescalculateLevelScaledBounty(value, targetLevel)in the ledger;clearrefunds the stored (scaled) figure. With the defaultlevelMultiplier = 2and a level-5 target the refund is 2x the payment. (command/sub/bounty/BountySetCommand.java:112,128+BountyClearCommand.java:73,89) - Wanted & Bounty —
Currency.parseaccepts negatives and there is no min/max.senderBalance.compareTo(-100) < 0is false, andEconomyHandler.withdrawAmount(keystone-hooks/.../EconomyHandler.java:72-77) doessetAmount(current.subtract(-100)). (BountySetCommand.java:79-86,107,112) - Wanted & Bounty —
hasBounty()issignum() != 0, so a negative total is "has bounty" and the killer is paid a negative amount. (Bounty.java:46+EntityDamageListener.java:131-134) - Cops & Jail —
detainment.jail_idis declared UNIQUE, so a second player detained in the same cell writes a duplicatejail_id. (gangland-impl/src/main/java/org/luckyraven/gangland/database/tables/copsncrooks/DetainmentTable.java(jailId.setUnique(true))) - Cops & Jail —
JailService.IDis a mutable static restored by plain assignment per loaded row (last row wins, notmax).EntitySpawner.loadStoredSpawnersdoes track the max, so the two are inconsistent. (.../copsncrooks/jail/JailService.java:12,29andgangland-impl/.../database/repositories/copsncrooks/JailRepository.java(JailService.ID = id)) - Cops & Jail —
Healthis parsed for every tier incops.ymlbut never applied — a repo-wide grep finds no.health()call on the cop path (onlyCivilianNpcFactory.applyHealthBonusfor civilians). (.../npc/police/config/CopTierConfig.java(health) versusCopNpcFactory.createCop) - Civilians & Traders —
collectOfferedItemsgrabs every non-air stack in the drop zone andonConfirmthen clears every drop-zone slot. Items the barter valuator rejected ("not accepted", value 0) are consumed with the accepted ones.SellView.onConfirmexplicitly avoids this by collecting only valued stacks. (gangland-features/cops-n-crooks/…/npc/trader/view/BarterView.java:363,371) - Civilians & Traders —
cancelAll()(called fromonClearduring a managed reload) only clears thependingset; theBukkitTaskscheduled byscheduleis never cancelled and still firesTraderManager.spawn(staleData)after the reload. (…/npc/trader/respawn/TraderRespawnService.java:44-51) - Civilians & Traders —
ShopEditedEventfires on every flow end, and the writer nulls every root key before writing. Opening/glw shop editand pressing ESC rewrites the file, stripping comments and any hand-written keys. (gangland-ui/shop-api/…/view/ShopAdminFlow.java:49+…/io/ShopYamlWriter.java:53-57) - Turf Wars —
turf.setOwnerGangId(newOwnerId)+turfs.persist(turf)run before thenewOwner != nullcheck, so a capture completing against a disbanded gang writes a dangling owner id and fires noTurfCapturedEvent— boss bars, defenders and the engaged Quartermaster are never cleaned up until the income task auto-releases the turf. (CaptureService.java:395-408) - Turf Wars —
EffectType.CAPTURE_DEFENSE_BONUSandGARRISON_DISCOUNThave no consumer anywhere in the repo (effectiveMultiplieris called only byTurfIncomeDistributor:70withINCOME_MULTIPLIER).reinforced_defense(8000) andgarrison_discount(2500) inturf_powerups.ymlare purchasable and do nothing. (ActiveBuffManagervs.CaptureService/TurfPowerupGarrisonView) - Turf Wars —
deletedoes not cancel an in-flight contest, fireTurfCaptureFailedEvent, clear boss bars, deleteturf_garrison/turf_active_buff/turf_powerup_npcrows, or despawn the Quartermaster.TurfBossBarListener.barsByTurfkeeps the entry forever (refreshProgressjustcontinues). (TurfManager.java:118-126) - Weapons —
explosionRadius = weapon.getDamageData().getExplosionDamage()— the configured damage is used as the explosion radius.rocket_launcher.ymlhasExplosion_Damage: 50, producing a 50-block-radius AOE. (WeaponShooting.java:104) - Weapons — Explosion damage is hardcoded
20 * (1 - distance/radius); the configuredExplosion_Damageis never used as damage. (SteppedProjectileTask.java:151) - Weapons — The
impactHandlerapplies potion effects only.WeaponRaytracer.handleEntityImpact:428-438returns before the default damage pipeline whenever an impact handler is present, so the computedBase_Damage * levelis fired in the event but never dealt. (BiologicalAction.java:145-151) - Gadgets & Cars — Pass 1 calls
live.eject()while the sessions are still inVehicleRegistry, so the dismount listener sees a non-sneaking, non-dead player and cancels every one of those ejects. The stated purpose of the two-pass design (unmount everyone before persisting, to avoidRootVehicleNBT) is therefore not achieved by pass 1; the passenger is only really removed later byMinecartVehicle.despawn()aftervehicleRegistry.clear(). (CarService.destroyAllpass 1 (gangland-features/gangland-gadget/.../car/CarService.java:466-479) vsCarDismountListener.onDismount(.../listener/car/CarDismountListener.java:46-49)) - Gadgets & Cars — Same cause — the first
live.eject()is cancelled by the dismount listener; onlyparkCar's trailing eject (afterunregister) works. The listener's own eject is dead code. (CarQuitListener.onQuit:35-40) - Gadgets & Cars —
player.getInventory().addItem(...)return value (the leftover map) is discarded — with a full inventory the car item is destroyed.CarGiveCommand.giveCarItem:122-126does handle leftovers by dropping them, showing the intended pattern. (CarService.pickupCar:397anddestroyCar:429) - Loot, Signs & Waypoints — The teleport, select and list paths perform no
waypoint.getPermission()or gang check. Permission/gang filtering exists only in the tab-completers./glw waypoint listprints every waypoint with a click-to-run teleport component (command/sub/waypoint/TeleportCommand.java:117-211,WaypointSelectCommand.java:46-80,WaypointListCommand.java:31-56) - Loot, Signs & Waypoints — The cracking mini-game is unreachable:
setCrackingEnabled/setCrackingTimeSecondshave no callers,LootChestSettingsdoes not overrideisCrackingEnabled(), andcompleteCracking/addProgressare never invoked. Five events, four handler chains and a full session class are dead code (lootchest/LootChestService.java:262-263,lootchest/data/LootChestData.java:42-47,file/configuration/lootchest/LootChestSettings.java) - Loot, Signs & Waypoints —
canExecutefor GIVE only requires one free slot (firstEmpty() != -1), whileexecutedoesitem.setAmount(sign.getAmount())and discards the leftover map returned byaddItem(sign/aspect/ItemTransferAspect.java:56-57and:25-34)
How the audit was made
Each case file was produced by an agent that read every class in its area, followed calls across module boundaries,
and wrote the report to a fixed structure. Citations were then checked by a script against the source tree and
repaired by a second pass. The markdown sources are kept in the repository under
brainstorming/workflow-audit-2026-09-02/.