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 fileGroupWorkflowsHigh / Medium / LowRendered
01Core Lifecycle, Bootstrap, Persistence & SchedulingPlatform129 / 10 / 6open
02Command Framework, Messages, Placeholders & Platform ServicesPlatform129 / 18 / 9open
03Item Framework, Unique Items & ConvertersPlatform1213 / 17 / 2open
04Inventory GUI Framework, Phone, Scoreboard & HologramsPlatform1312 / 15 / 8open
05Users, Levels, Economy & BankingPlayers229 / 17 / 16open
06Gangs, Members, Ranks, Permissions & MailPlayers1619 / 16 / 2open
07Wanted Levels, Bounties, Combos & Downed PlayersPlayers1513 / 18 / 11open
08Cops, Detainment & JailWorld1621 / 19 / 2open
09Civilians, Traders & ShopsWorld246 / 11 / 9open
10Turf WarsWorld235 / 19 / 15open
11Weapons, Ammunition & ProjectilesGear2214 / 20 / 7open
12Gadgets: Cars, Fuel, Jetpacks & WearablesGear2211 / 12 / 5open
13Loot Chests, Trade Signs & WaypointsGear2610 / 21 / 3open

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: mysql and Database.SQLite.Failed_MySQL: false, a failed MySQL connect leaves type == MYSQL and database == null. The bean then calls database.createSchema()getDatabase().createSchema(...)NPE, which is not an SQLException/IOException, so it escapes the catch and aborts onEnable with a raw NPE instead of a diagnosable message. (Keystone DatabaseHandler.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 calls offlineUserManager.clear(). AbstractRepository.saveAll also copies each supplier's live map.values() on that thread. A player joining/leaving concurrently can produce ConcurrentModificationException mid-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 LifecycleresetWeapons() deletes every weapon row and calls weaponManager.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-108 reached from PeriodicalUpdates.task() on the async timer)
  • Commands & MessagesupdateCheckerInitializer() returns early when Update_Checker.Enable: false, leaving updateChecker null; the LOWEST-priority PlayerJoinEvent handler dereferences it unconditionally (Gangland.java:220-222 + listener/player/CreateAccountListener.java:62-66)
  • Commands & MessagesonSubHelp calls renderHelp directly, never runExecute — no sender.hasPermission(sub.getPermission()) check (gangland-impl/.../command/CommandManager.java:133-153 (and Keystone's onHelp, 273-288))
  • Commands & Messages@CommandHandler(condition = "isGangEnabled") is a getter method name, but the command-side lookup is a settingsMap lookup keyed by field names (gangEnabled); the listener side (ListenerManager.invokeMethod) resolves method names reflectively (command/sub/gang/GangCommand.java:50 vs file/configuration/SettingsLookupImpl.java:16-29 + Settings.java:801)
  • Item FrameworkhasUniqueItem does if (uniqueItem.compareTo(item) == 0) continue; — it skips the matching item and returns true only for a different unique item of the same material. The predicate is inverted. (gangland-infra/gangland-item/.../item/unique/UniqueItemUtil.java:31)
  • Item FrameworkremoveItem has the same inversion: if (uniqueItem.compareTo(contents[i]) == 0) continue; then inventory.setItem(i, null). It nulls the first slot that does not match. (gangland-infra/gangland-item/.../item/listener/unique/LoadUniqueItem.java:101)
  • Item FrameworkcompareTo compares the raw config name (&-codes, unresolved placeholders) with meta.getDisplayName() (§-translated, placeholders resolved by ItemBuilder.setDisplayNameChatUtil.color). (gangland-infra/gangland-item/.../item/unique/UniqueItem.java:73)
  • GUI & ScoreboardgetDriverHandler hands the live scoreboardAddon.getLines() list to the driver, whose constructor does this.lines.add(title). Every board creation (each join, each reload) appends another title reference to the one shared list, which grows without bound. The same Line objects are also shared by every player, so Line.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 & Scoreboardtimer.start(true) schedules runTaskTimerAsynchronously at period 1 tick. The task calls driver.update()Placeholder.convert(player, …) (gang/user/bank lookups, PlaceholderAPI expansions) and FastBoard.updateLine/updateTitle (reflective packet sends). This violates the project's own feedback_repeating_timer_async rule and touches non-thread-safe APIs from an async thread, every tick, per player (scoreboard/Scoreboard.java:27)
  • GUI & Scoreboardrerender() and the same-size/same-title switchTo reuse path call current.clear(), which only clears the Bukkit Inventory. clickableSlots, rightClickSlots, clickableItems and draggableSlots are 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 a Player key (offlineUserManager.create(player)), join looks up with a fresh Player, and bootstrap inserts with Bukkit.getOfflinePlayer(uuid). CraftBukkit's CraftEntity.equals/hashCode compare the entity id, CraftOfflinePlayer compares the UUID, so the key flavours never match: the join-time eviction silently misses and the stale entry survives. PeriodicalUpdates writes 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 & Economyuser.getEconomy().setAmount(Settings.getUserInitialBalance()) runs unconditionally on every join, before the async DB read. With Vault registered, EconomyHandler.setAmount immediately performs withdrawPlayer(entireBalance) + depositPlayer(initial) on the real Vault account (default Initial_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 & EconomyprocessMoney never checks the sign of amount. /glw bank deposit -1000 passes both guards (-1000 > cash is false), setting cash to cash + 1000 and the bank to bank - 1000 — free cash plus a negative bank balance. /glw bank withdraw -1000 is 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 & RanksmemberManager.getMember(uuid) is dereferenced with no null check in ~15 places. A player whose Member is 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(), calls memberManager.assignRank (→ Bukkit.getOfflinePlayer → Vault) and races the synchronous gangManager.remove(gang) / gangRepository.delete(gang) at :294-310 (command/sub/gang/GangDeleteCommand.java:230-285)
  • Gangs & Ranks/glw gang withdraw has no rank or permission check — the lowest-ranked member can drain the entire gang vault. deposit, rename, desc, display, color, invite and every ally subcommand are likewise ungated (command/sub/gang/GangWithdrawCommand.java:67-110)
  • Wanted & Bountyset withdraws the raw value but stores calculateLevelScaledBounty(value, targetLevel) in the ledger; clear refunds the stored (scaled) figure. With the default levelMultiplier = 2 and a level-5 target the refund is 2x the payment. (command/sub/bounty/BountySetCommand.java:112,128 + BountyClearCommand.java:73,89)
  • Wanted & BountyCurrency.parse accepts negatives and there is no min/max. senderBalance.compareTo(-100) < 0 is false, and EconomyHandler.withdrawAmount (keystone-hooks/.../EconomyHandler.java:72-77) does setAmount(current.subtract(-100)). (BountySetCommand.java:79-86,107,112)
  • Wanted & BountyhasBounty() is signum() != 0, so a negative total is "has bounty" and the killer is paid a negative amount. (Bounty.java:46 + EntityDamageListener.java:131-134)
  • Cops & Jaildetainment.jail_id is declared UNIQUE, so a second player detained in the same cell writes a duplicate jail_id. (gangland-impl/src/main/java/org/luckyraven/gangland/database/tables/copsncrooks/DetainmentTable.java (jailId.setUnique(true)))
  • Cops & JailJailService.ID is a mutable static restored by plain assignment per loaded row (last row wins, not max). EntitySpawner.loadStoredSpawners does track the max, so the two are inconsistent. (.../copsncrooks/jail/JailService.java:12,29 and gangland-impl/.../database/repositories/copsncrooks/JailRepository.java (JailService.ID = id))
  • Cops & JailHealth is parsed for every tier in cops.yml but never applied — a repo-wide grep finds no .health() call on the cop path (only CivilianNpcFactory.applyHealthBonus for civilians). (.../npc/police/config/CopTierConfig.java (health) versus CopNpcFactory.createCop)
  • Civilians & TraderscollectOfferedItems grabs every non-air stack in the drop zone and onConfirm then clears every drop-zone slot. Items the barter valuator rejected ("not accepted", value 0) are consumed with the accepted ones. SellView.onConfirm explicitly avoids this by collecting only valued stacks. (gangland-features/cops-n-crooks/…/npc/trader/view/BarterView.java:363,371)
  • Civilians & TraderscancelAll() (called from onClear during a managed reload) only clears the pending set; the BukkitTask scheduled by schedule is never cancelled and still fires TraderManager.spawn(staleData) after the reload. (…/npc/trader/respawn/TraderRespawnService.java:44-51)
  • Civilians & TradersShopEditedEvent fires on every flow end, and the writer nulls every root key before writing. Opening /glw shop edit and 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 Warsturf.setOwnerGangId(newOwnerId) + turfs.persist(turf) run before the newOwner != null check, so a capture completing against a disbanded gang writes a dangling owner id and fires no TurfCapturedEvent — boss bars, defenders and the engaged Quartermaster are never cleaned up until the income task auto-releases the turf. (CaptureService.java:395-408)
  • Turf WarsEffectType.CAPTURE_DEFENSE_BONUS and GARRISON_DISCOUNT have no consumer anywhere in the repo (effectiveMultiplier is called only by TurfIncomeDistributor:70 with INCOME_MULTIPLIER). reinforced_defense (8000) and garrison_discount (2500) in turf_powerups.yml are purchasable and do nothing. (ActiveBuffManager vs. CaptureService / TurfPowerupGarrisonView)
  • Turf Warsdelete does not cancel an in-flight contest, fire TurfCaptureFailedEvent, clear boss bars, delete turf_garrison / turf_active_buff / turf_powerup_npc rows, or despawn the Quartermaster. TurfBossBarListener.barsByTurf keeps the entry forever (refreshProgress just continues). (TurfManager.java:118-126)
  • WeaponsexplosionRadius = weapon.getDamageData().getExplosionDamage() — the configured damage is used as the explosion radius. rocket_launcher.yml has Explosion_Damage: 50, producing a 50-block-radius AOE. (WeaponShooting.java:104)
  • Weapons — Explosion damage is hardcoded 20 * (1 - distance/radius); the configured Explosion_Damage is never used as damage. (SteppedProjectileTask.java:151)
  • Weapons — The impactHandler applies potion effects only. WeaponRaytracer.handleEntityImpact:428-438 returns before the default damage pipeline whenever an impact handler is present, so the computed Base_Damage * level is fired in the event but never dealt. (BiologicalAction.java:145-151)
  • Gadgets & Cars — Pass 1 calls live.eject() while the sessions are still in VehicleRegistry, 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 avoid RootVehicle NBT) is therefore not achieved by pass 1; the passenger is only really removed later by MinecartVehicle.despawn() after vehicleRegistry.clear(). (CarService.destroyAll pass 1 (gangland-features/gangland-gadget/.../car/CarService.java:466-479) vs CarDismountListener.onDismount (.../listener/car/CarDismountListener.java:46-49))
  • Gadgets & Cars — Same cause — the first live.eject() is cancelled by the dismount listener; only parkCar's trailing eject (after unregister) works. The listener's own eject is dead code. (CarQuitListener.onQuit:35-40)
  • Gadgets & Carsplayer.getInventory().addItem(...) return value (the leftover map) is discarded — with a full inventory the car item is destroyed. CarGiveCommand.giveCarItem:122-126 does handle leftovers by dropping them, showing the intended pattern. (CarService.pickupCar:397 and destroyCar: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 list prints 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/setCrackingTimeSeconds have no callers, LootChestSettings does not override isCrackingEnabled(), and completeCracking/addProgress are 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 & WaypointscanExecute for GIVE only requires one free slot (firstEmpty() != -1), while execute does item.setAmount(sign.getAmount()) and discards the leftover map returned by addItem (sign/aspect/ItemTransferAspect.java:56-57 and :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/.