Civilians, Traders & Shops
<!-- preface:start -->How to use this file. This is a code-traced audit of Civilians, Traders & Shops in Gangland Warfare, taken on 2026-09-02 from branch
0.8.1(Keystone 1.7.3). It describes what the code does today, workflow by workflow, so an agent can fix a bug, tweak behaviour, plan a feature, or write tests without re-tracing the system.
- Citations are pointers, not proof. Every
File.java:linewas checked after writing, but the code moves. Before you change anything, open the cited file and grep the symbol named in the sentence; trust the class and method names over the line number. A citation ending in:line-unverifiedcould not be re-located.- Observations are findings, not confirmed bugs. Each row carries the tracer's confidence. Rows prefixed
WITHDRAWN:were disproved during verification and are kept only so the numbering stays stable. Reproduce a High-risk row in code (or a test) before fixing it.- Sections. Components names the classes; Configuration & Data the YAML keys, tables and message keys; Workflows (
W1,W2, …) the execution paths with trigger, steps, diagram, persistence effects and guards; Cross-feature Dependencies what breaks elsewhere if you change this; Test Surface what can be unit-tested with plain JUnit/Mockito versus what needs Bukkit/Keystone mocks or a live server.- Conventions live elsewhere. For how to add or change code in this repo, follow
CLAUDE.mdat the repo root (Spigot-only APIs, method-brace style, Keystone at provided scope, the SQLite test teardown rules), thecommand-createandpanel-createskills for new commands and GUI panels, and the feedback rules in the project memory (YAML key style,SoundEffectfor sounds,ItemRefresherper item type,setDataSupplierfor every repository, no Paper APIs).- Risk stars on the rendered page: three stars = High (a player can hit it in normal play), two = Medium (situational), one = Low (cosmetic or unlikely).
Rendered page with diagrams and a table of contents: https://claude.ai/code/artifact/dad8da79-b977-4cdc-9712-e080406cb0b5
<!-- preface:end -->Diagrams below are Mermaid source; the rendered version with drawn diagrams is the linked page above.
Overview
Civilians are Citizens-backed ambient NPCs defined in npc/civilians.yml (types + groups), spawned either manually via
/glw civilian spawn or automatically by persisted proximity spawners, and driven by a four-state AI machine
(IDLE / WANDERING / FLEEING / COMBAT) ticked from a single RepeatingTimer in CivilianService. Traders are a
completely separate NPC kind: one Citizens PLAYER NPC per row in the trader database table, each carrying a shop key
and a trait id; they head-track players, reset position, and respawn on a timer after being killed. Shops are per-shop
YAML files under plugins/<plugin>/shop/, loaded by ShopRegistry (a Keystone FolderLoader) into ShopDefinition
objects holding buy entries, sell categories and barter categories. Right-clicking a trader opens a
MultiPanelInventory flow (mode select → shop browser → negotiation → quantity / sell / barter), while a sneaking
admin with gangland.shop.admin gets the shop-admin flow instead. Money always moves through the PaymentHandler
adapter implemented in gangland-impl; every user-facing string routes through ShopMessageContract /
TraderMessageContract. Barter is a pure item-for-item swap with no economy involvement, and trader mood is
positive-only (0.0 … 1.0) and never persisted.
Components
| Class | Location | Role |
|---|---|---|
CivilianService | gangland-features/cops-n-crooks/src/main/java/org/luckyraven/gangland/copsncrooks/npc/civilian/CivilianService.java | Owns the AI tick timer + proximity-spawner timer; group spawning; shutdown |
CivilianNpcRegistry | …/npc/civilian/CivilianNpcRegistry.java | Dependency-free store of active NPCs (by entity UUID) and groups |
CivilianNpc | …/npc/civilian/npc/CivilianNpc.java | Per-NPC state machine host, entity target queue, equipment |
CivilianNpcFactory | …/npc/civilian/npc/CivilianNpcFactory.java | Creates the Citizens NPC, applies health/speed bonuses, weapons, ammo |
CivilianGroup | …/npc/civilian/CivilianGroup.java | Group membership, group center, stray detection |
CivilianSpawnManager | …/npc/civilian/spawn/CivilianSpawnManager.java | EntitySpawner<CivilianSpawner> subclass — spawner CRUD + spawn calls |
CivilianSpawner | …/npc/civilian/spawn/CivilianSpawner.java | Persisted spawn point (typeId xor groupId, both may be null) |
CivilianIdle/Wander/Flee/CombatBehavior | …/npc/civilian/state/behavior/*.java | The four AI states |
CivilianLookController | …/npc/civilian/state/behavior/CivilianLookController.java | Ambient head-turning via entity.teleport(rotated) |
CiviliansLoader / YamlCiviliansConfigProvider | …/npc/civilian/config/ | Parse civilians.yml into CiviliansConfig |
CivilianDamageListener / CivilianDeathListener | …/listener/civilian/ | Damage → flee/combat; death → drops + CivilianDeathEvent |
CivilianDeathRewardListener | gangland-impl/src/main/java/org/luckyraven/gangland/listener/npc/CivilianDeathRewardListener.java | XP reward + wanted-level increment |
TraderManager | …/npc/trader/TraderManager.java | Trader registry, spawn/remove/retarget, head-track + position-reset tasks |
TraderNpc / TraderData | …/npc/trader/ | Citizens wrapper + persisted row model |
TraderTraitRegistry / TraderTraitsLoader | …/npc/trader/trait/ | trader_traits.yml → TraderTraitDefinition map (atomic swap) |
MoodService / MoodState | …/npc/trader/mood/ | In-memory per-(trader, player) mood 0..1 and price multiplier |
TraderRespawnService | …/npc/trader/respawn/TraderRespawnService.java | Delayed respawn after a trader is killed |
ShopViewOpenerImpl | …/npc/trader/ShopViewOpenerImpl.java | Resolves shop + trait, routes to admin flow or trader flow |
TraderFlow / TraderFlowSession | …/npc/trader/view/ | Panel host + shared mutable session for the player flow |
ModeSelectView, ShopView, NegotiationView, QuantitySelectorView, SellView, BarterView | …/npc/trader/view/ | The six player-facing panels |
TraderInteractListener / TraderDamageListener | …/listener/trader/ | NPCRightClickEvent → open flow; EntityDeathEvent → kill handling |
TraderSellSessionListener / BarterSessionListener | …/listener/trader/ | Global click/drag bridges into the drop-zone panels |
ShopRegistry | gangland-ui/shop-api/src/main/java/org/luckyraven/gangland/shop/ShopRegistry.java | Folder scan, in-memory shop map, create/save/delete |
ShopYamlReader / ShopYamlWriter | …/shop/io/ | On-disk format |
ShopDefinition, ShopItemEntry, SellCategory, BarterCategory, EntryKind | …/shop/ | Model |
ShopPurchaseService, ShopSellService, ShopBarterService | …/shop/transaction/ | Money + delivery mutations, outcome records |
PaymentHandler / PaymentException | …/shop/transaction/ | Economy abstraction (implemented anonymously in the impl listeners) |
CategorySellValuator / CategoryBarterValuator | …/shop/valuation/ | Item → ItemValuation using category templates + sell_price NBT |
ShopAdminFlow / ShopAdminFlowSession | …/shop/view/ | Admin panel host, working copies, persist-on-end |
ShopAdminView, PriceEditorView, SellCategoryItemsAdminView, BarterCategoryItemsAdminView | …/shop/view/ | The four admin panels |
ShopEditPersistenceHandler | …/shop/handler/ | ShopEditedEvent → ShopRegistry.save |
ShopConfig | gangland-impl/src/main/java/org/luckyraven/gangland/config/ShopConfig.java | All shop/trader bean wiring (CONFIG phase) |
TraderSettingsImpl, GanglandShopMessages, GanglandTraderMessages, GanglandTraderEconomy, GanglandShopDisplayResolver | gangland-impl/…/file/configuration/{shop,copsncrooks}/ | Contract implementations |
TraderRepository / TraderTable | gangland-impl/…/database/{repositories,tables}/trader/ | Persistence for traders |
CivilianSpawnerRepository / CivilianSpawnerTable | gangland-impl/…/database/{repositories,tables}/copsncrooks/ | Persistence for civilian spawners |
Configuration & Data
YAML files and notable keys
gangland-impl/src/main/resources/npc/civilians.yml (loaded by CiviliansLoader → YamlCiviliansConfigProvider):
Default_Entities.Civilian/.Police— entity-type name lists used for wanted classification.Types.<id>:Display_Name,Entity_Type(falls back toVILLAGERon an unknown name,YamlCiviliansConfigProvider.java:260),Health,Hostile,Wearables.{Helmet,Chestplate,Leggings,Boots},Item_Pool,Weapon_Pool,Drops.{Experience,Items},AI.Wander.{Enabled,Range},AI.Flee_On_Damage.{Enabled,Flee_Range},AI.Combat.{Enabled,Attack_Damage,Attack_Range,Attack_Interval_Ticks,Difficulty}.- Drop entries support a trailing
@<chance>suffix parsed atYamlCiviliansConfigProvider.java:177-191(clamped to 0..1; unparseable suffix → whole string kept as the entry with chance 1.0). Weapon_Poolentries prefixedweapon:go toweaponNamePool; any other entry is added to BOTHweaponNamePoolandweaponPool(YamlCiviliansConfigProvider.java:83-89).Groups.<id>:Display_Name,Hostile,Health_Bonus,Speed_Bonus,Stay_Together_Range,Members.<typeId>: <count>.Item_Poolis parsed intoCivilianTypeConfig.itemPool()but never read anywhere in the codebase (grep foritemPool()returns nothing) — dead config despite being documented in the file header.
gangland-impl/src/main/resources/npc/trader_traits.yml (loaded by TraderTraitsLoader): one top-level key per
trait id with Display_Name, Mood_Per_Tip_Currency, Mood_Per_Purchase, Min_Friend_Discount (clamped 0..1),
Allows_Barter, Sell_Price_Ratio, Barter_Price_Ratio (defaults to Sell_Price_Ratio), Max_Health,
Invulnerable (default true). Ships 6 traits; stubborn is the only killable one.
settings.yml — every key below is read in Settings.java:
Civilians.Behaviour.{Enabled, AI_Tick_Rate}(Settings.java:540-541)Civilians.Spawner_Proximity.{Activation_Radius, Despawn_Radius, Max_Npcs_Per_Spawner, Npc_Soft_Leash_Radius, Npc_Hard_Leash_Radius, Check_Interval, Default_Type_Id}(Settings.java:543-549)Civilians.Spawn.*— 13 keys consumed by the sharedEntitySpawnerlocation search (Settings.java:551-563)NPC_Navigation.*— shared with cops, surfaced to civilians throughCivilianNavigationConfig.from(...)Trader.{Respawn_Cooldown, Head_Track_Radius, Fallback_Trait_Id, Max_Mode_Multiplier, Tip_Amount}andTrader.Sell.{Max_Offer_Slots, Mood_Per_Sale}(Settings.java:710-717)- Shop UI reuses the global
Inventory_Fill_Name/Inventory_Fill_ItemthroughShopUiSettings.
Shop files — plugins/<plugin>/shop/<key>.yml, one per shop. No shop file ships in the jar; the folder starts
empty and /glw shop create <key> writes the first one. Format written by ShopYamlWriter.write:
Title, Size, Buy_Entries (list of {Slot, Item, Price}), Sell_Entries (same shape), Sell_Categories and
Barter_Categories (lists of {Id, Display_Name, Base_Price, Items} where Items is a list of Bukkit-serialized
ItemStacks). Price and Base_Price are written as plain strings; the reader accepts Number, BigDecimal or String
(ShopYamlReader.java:44-53). ShopYamlWriter.clearRoot nulls every root key before writing, so comments and any
foreign keys in a shop file are destroyed on every save.
Reader validation: Size must be a positive multiple of 9 ≤ 54 else it silently becomes 54
(ShopYamlReader.java:71-74); an entry is skipped if the item is null, the slot is < 0, or the price is absent
(ShopYamlReader.java:272-290). There is no upper bound check on Slot versus Size.
Database tables and repositories
| Table | Class | Columns | Repository | Data supplier |
|---|---|---|---|---|
trader | TraderTable | id (PK, String), shop_key, world, x, y, z, yaw, pitch, display_name (nullable), trait_id | TraderRepository (@Repository(TraderData.class)) | TraderManager constructor calls repository.setDataSupplier(this::snapshotData) (TraderManager.java:57) |
civilian_spawner | CivilianSpawnerTable | id, world/x/y/z, typeId, groupId | CivilianSpawnerRepository | EntitySpawner constructor calls repository.setDataSupplier(spawners::values) (EntitySpawner.java:40) |
TraderRepository.doLoadAll (TraderRepository.java:34-60) rebuilds a Location from Bukkit.getWorld(worldName)
without a null check, so a trader stored in an unloaded/renamed world produces a Location with a null world.
Civilian NPCs themselves are not persisted — only their spawn points are. Shops are not in the DB at all.
Message keys / localization
All keys referenced by GanglandShopMessages / GanglandTraderMessages and the commands exist in
gangland-impl/src/main/resources/message/message_en.yml (verified for Commands.Shop.*, Errors.Shop.*,
Commands.Trader.*, Errors.Trader.*, Commands.Civilian.*, Civilian.Spawner_List_Header).
Messages.SHOP_PURCHASE_INVENTORY_FULL (Errors.Shop.Purchase.Inventory_Full, message_en.yml:512) exists in both
the enum and the YAML but is never sent — PurchaseOutcome.INVENTORY_FULL is never produced by
ShopPurchaseService, and TraderBuyListener.java:97-99 handles the branch with an empty body.
Hard-coded English strings that bypass the Messages layer live in the anvil callbacks
(QuantitySelectorView.java:277/283/288, PriceEditorView.java:249/255/282/287, ShopAdminView.java:410/416/447/453)
and in ShopListCommand.java:30.
Commands & Permissions
Permissions are derived by Keystone as gangland.command.<label> (Keystone Command.java:49). The user flag
(3rd ctor arg) means "player-only".
| Command | Class | Permission | What it does |
|---|---|---|---|
/glw civilian | CivilianCommand (user=false) | gangland.command.civilian | Help page |
/glw civilian list | CivilianListCommand | same | Lists active civilian NPCs |
/glw civilian groups | CivilianGroupsCommand | same | Lists active civilian groups |
/glw civilian spawn <typeId> | CivilianSpawnCommand | same | spawnNearLocation(player, typeId) — uses the shared surface search |
/glw civilian spawngroup <groupId> | CivilianSpawnGroupCommand | same | CivilianService.spawnGroup at the sender's location |
/glw civilian spawner set | CivilianSpawnerSetCommand | same | Registers a spawner (with optional pinned type) |
/glw civilian spawner setgroup <groupId> | CivilianSpawnerSetGroupCommand | same | Registers a group spawner |
/glw civilian spawner remove <id> | CivilianSpawnerRemoveCommand | same | EntitySpawner.removeSpawner + repo delete |
/glw civilian spawner list | CivilianSpawnerListCommand | same | Lists spawner ids/locations |
/glw civilian spawner info <id> | CivilianSpawnerInfoCommand | same | Shows typeId/groupId/location |
/glw civilian spawner teleport <id> | CivilianSpawnerTeleportCommand | same | Teleports to the spawner |
/glw shop (alias shops) | ShopCommand (user=true) | gangland.command.shop | Help page |
/glw shop create <key> | ShopCreateCommand | same | Validates [a-z0-9_]+, ShopRegistry.createEmpty |
/glw shop edit <key> | ShopEditCommand | same | Opens ShopAdminFlow |
/glw shop list | ShopListCommand | same | Lists keys |
/glw shop remove <key> | ShopRemoveCommand | same | ShopRegistry.delete (deletes the file) |
/glw shop title <key> | ShopTitleCommand | same | AnvilGUI rename → save(def.withTitle(...)) |
/glw trader (alias traders) | TraderCommand (user=true) | gangland.command.trader | Help page |
/glw trader create <shopKey> <traitId> [displayName] | TraderCreateCommand | same | Validates shop + trait, TraderManager.create at the player's location |
/glw trader edit shop <shopKey> | TraderEditShopCommand | same | Ray-traced trader → retargetShop (clears mood) |
/glw trader edit trait <traitId> | TraderEditTraitCommand | same | → retargetTrait (clears mood) |
/glw trader edit name | TraderEditNameCommand | same | AnvilGUI → rename |
/glw trader remove | TraderRemoveCommand | same | Ray-traced trader → TraderManager.remove |
| (GUI gate) sneak + right-click trader | ShopViewOpenerImpl.java:41 | gangland.shop.admin | Opens the admin editor instead of the buy flow; registered with PermissionManager in ShopConfig.registerPermissions |
commands.json has entries for every leaf listed above, plus root entries shop and trader, but no root
civilian entry (only civilian_help) — commands.json:702 onwards.
Events
| Event | Fired by | Handled by | Purpose |
|---|---|---|---|
CivilianDeathEvent (not Cancellable) | CivilianDeathListener.java:67 | CivilianDeathRewardListener (gangland-impl) | XP reward + wanted-level increment |
PlayerDownedEvent, PlayerDeathEvent | gangland-core / Bukkit | CivilianDamageListener.java:96-104 | Clears civilian combat targets |
EntityDamageByEntityEvent | Bukkit | CivilianDamageListener.java:38 | Triggers FLEEING / COMBAT |
EntityDeathEvent | Bukkit | CivilianDeathListener.java:36, TraderDamageListener.java:21 | Civilian drops; trader kill → respawn schedule |
NPCRightClickEvent (Citizens) | Citizens | TraderInteractListener.java:22 | Opens the trader flow |
TraderBuyRequestEvent (Cancellable) | NegotiationView.java:136, QuantitySelectorView.java:248 | TraderBuyListener | Payment + delivery + mood |
TraderSellRequestEvent (Cancellable) | SellView.java:353 | TraderSellListener | Deposit + mood |
TraderBarterEvent (Cancellable) | BarterView.java:365 | TraderBarterListener | Item swap + mood |
ShopEditedEvent (not Cancellable) | ShopAdminFlow.java:49 (on flow end) | ShopEditPersistenceHandler | Persists the edited definition |
InventoryClickEvent / InventoryDragEvent | Bukkit | TraderSellSessionListener, BarterSessionListener, ShopAdminListener, SellCategoryAdminListener, BarterCategoryAdminListener | Drop-zone and template-add bridges |
Workflows
W1: Civilian AI tick loop
Trigger: RepeatingTimer started at bean initialize, period = Civilians.Behaviour.AI_Tick_Rate (default 20 ticks).
Steps:
CivilianService.onInitialize(…/CivilianService.java:59) — readsciviliansLoader.getLoadedConfig(); ifisCivilianAiEnabled()is false it returns immediately and neither timer is started (so proximity spawning is also disabled by the AI toggle).new RepeatingTimer(plugin, tickRate, 0, timer -> tickAll()); tickTimer.start(false)(CivilianService.java:68-69) — synchronous, as required for Bukkit entity APIs.CivilianService.tickAll(CivilianService.java:309) iteratesregistry.npcMap().entrySet().removeIf(...): marked-for-removal or invalid NPCs getnpc.destroy(entityMarkManager)and are dropped from the map; otherwisenpc.tick()runs inside a try/catch that marks the NPC for removal on exception.CivilianNpc.tick(…/npc/CivilianNpc.java:153) —isValid()gate, cross-world check againstspawnLocation.getWorld(),decrementAttackCooldown(),updateNavigationProgress(), then delegates tobehaviors.get(currentState).tick(this).- Empty groups are pruned at
CivilianService.java:331-335.
Diagram:
flowchart TD
A["RepeatingTimer fires tickAll"] --> B{"markedForRemoval or not isValid"}
B -- yes --> C["npc.destroy(entityMarkManager)"]
C --> D["remove from registry map"]
B -- no --> E["npc.tick()"]
E --> F{"tick threw"}
F -- yes --> G["log.warn + markForRemoval"]
F -- no --> H["behavior.tick"]
D --> I["prune empty groups"]
H --> I
G --> I
State & persistence effects: in-memory only (registry map, per-NPC behavior counters, Citizens navigation). No DB or file writes.
Edge cases & guards observed: exceptions in one NPC never abort the loop; a civilian teleported to another world
is removed rather than leaked; destroy is wrapped in try/catch on both the tick and shutdown paths.
W2: Civilian proximity spawner tick
Trigger: second RepeatingTimer, period = Civilians.Spawner_Proximity.Check_Interval (default 100 ticks).
Steps:
CivilianService.tickProximitySpawners(CivilianService.java:192). If no players are online every spawner is passed todespawnFromSpawnerand the method returns (CivilianService.java:194-197).- Radii are squared once per cycle from
CivilianSettings. - Per spawner:
markStrayNpcsForRemoval(id, spawnerLoc, hardLeashSq)(CivilianService.java:268) marks any NPC in a different world or beyondNpc_Hard_Leash_Radiusfor removal so its cap slot frees. - Player scan: the loop
breaks on the first player insideActivation_Radius; otherwise it records whether anyone is insideDespawn_Radius. - Activation branch:
- group spawner (
getGroupId() != null): spawn one group only if no live group carries thisspawnerId(CivilianService.java:230-239), thengroup.setSpawnerId(...). - type spawner: count live NPCs with this
spawnerId, and if< Max_Npcs_Per_Spawner, spawn one ofspawner.getTypeId()orDefault_Type_Id; a blank id skips the spawner (CivilianService.java:248-256).
- group spawner (
- Else if nobody is within
Despawn_Radius:despawnFromSpawner(id)marks both individual NPCs and group members for removal — actual destruction happens on the nexttickAll.
Diagram:
flowchart TD
A["check timer"] --> B{"any players online"}
B -- no --> C["despawn from every spawner"]
B -- yes --> D["for each spawner"]
D --> E["markStrayNpcsForRemoval (hard leash)"]
E --> F{"player within activation radius"}
F -- yes --> G{"group spawner"}
G -- yes --> H{"live group from this spawner"}
H -- no --> I["spawnGroup + setSpawnerId"]
G -- no --> J{"alive count < max"}
J -- yes --> K["spawnCivilian + setSpawnerId"]
F -- no --> L{"player within despawn radius"}
L -- no --> M["despawnFromSpawner"]
State & persistence effects: registry mutation only; spawner rows themselves are untouched.
Edge cases & guards observed: spawners whose world is unloaded are skipped (spawnerLoc.getWorld() == null);
manually spawned civilians have a null spawnerId and are therefore never despawned by this loop;
markStrayNpcsForRemoval dereferences Objects.requireNonNull(npcLoc.getWorld()) (CivilianService.java:276).
W3: Civilian group spawn & cohesion
Trigger: /glw civilian spawngroup <id> or a group spawner activating.
Steps:
CivilianService.spawnGroup(CivilianService.java:128) resolvesCivilianGroupConfig; unknown group → warn + null.- For every
Membersentry it resolves the type config (unknown type logs and is skipped) and callsnpcFactory.createCivilian(location, typeConfig, groupId, groupConfig)counttimes; each NPC getssetGroup(group)and is added to the group. - If at least one member spawned:
registry.registerGroup(group)(key =groupId + "_" + System.nanoTime()), and registration of the individual NPCs is deferred one tick viarunTaskLater(..., 1L)so Citizens finishes entity init (CivilianService.java:162). CivilianNpcFactory.createCivilian(…/npc/CivilianNpcFactory.java:83): creates the Citizens NPC withsetProtected(false),SHOULD_SAVE=false,USE_MINECRAFT_AI=false, spawns it, destroys it and returns null if!npc.isSpawned(), tags itEntityMark.CIVILIAN, builds a fresh behavior map, applieshealth + groupConfig.healthBonusviaAttribute.MAX_HEALTH, picks a gangland weapon fromweaponNamePool(resolveGanglandWeapon, random start index with wrap-around), gives 3 magazines of ammo in the off-hand with all drop chances zeroed, callsequip(), and setsspeedModifier = 1 + speedBonus.- Cohesion: while WANDERING,
CivilianWanderBehavior.navigateToDestination(…/behavior/CivilianWanderBehavior.java:96) first checksgroup.isMemberStraying(npc)and navigates togroup.getGroupCenter()if so.
State & persistence effects: registry + Citizens NPC registry (non-persistent). No DB writes.
Edge cases & guards observed: members spawned but not yet registered (1-tick window) do not tick and are invisible
to getActiveNpcs(); getGroupCenter returns null when no member is valid; isMemberStraying returns false across
worlds.
W4: Civilian state machine
Trigger: CivilianNpc.transitionTo from behaviors, listeners, or the tick loop.
Steps:
transitionTo(CivilianNpc.java:129) logs, returns if the state is unchanged, updateswantedByPolicefor hostile NPCs (trueonly while in COMBAT), callsoldBehavior.onExit, swaps state, callsnewBehavior.onEnter.- IDLE (
CivilianIdleBehavior): random 40–100 tick idle countdown and 15–35 tick look countdown. Each tick it re-engages a remembered player target withinattackRange * 2(clearing it if the player is offline), then a remembered entity target within the same range, then does ambient look-around, then on countdown expiry rolls 70% → WANDERING whenwanderEnabled, else re-rolls the countdown. - WANDERING (
CivilianWanderBehavior):onEnterresets counters and navigates. Each tick: ambient look; re-navigate when the navigator is idle; a 60–120 tick redirect countdown picks a new destination mid-path; whenisNavigationStuck()fires 3 times it stops navigation and returns to IDLE. Destination priority is group-center → spawn point (when beyondNpc_Soft_Leash_Radius) →findForwardWanderDestination(min 3, max 8); a null destination transitions to IDLE.onExitstops navigation. - FLEEING (
CivilianFleeBehavior):onEntercaptureslastAttackerLocationand navigates to a pointfleeRangeblocks directly away (arbitrary +X direction if the attacker is on top of it), snapped toworld.getHighestBlockYAt. Every 20 ticks it returns to IDLE if navigation ended or is hopeless, or if it has travelledfleeRangefrom the origin.onExitclearslastAttackerLocation. - COMBAT (
CivilianCombatBehavior): resolves target as entity-queue-head first, then remembered player (skipping dead/downed players viaDownedPlayerRegistry). No target → IDLE. BeyondattackRange * 4→ clear targets + IDLE. Otherwise pursue (resolvePursuitLocation, orresolveHopelessFallbackLocationwhen navigation is hopeless) orpauseNavigation()when holding position, and attack only when in range, off cooldown, and line-of-sight holds.onExitstops navigation but deliberately preserves targets so IDLE can re-engage.
Diagram:
stateDiagram-v2
[*] --> IDLE
IDLE --> WANDERING: "countdown expires, wanderEnabled, 70% roll"
IDLE --> COMBAT: "remembered target within 2x attackRange"
WANDERING --> IDLE: "arrived, no destination, or 3 stuck checks"
WANDERING --> COMBAT: "damaged and hostile"
WANDERING --> FLEEING: "damaged and not hostile"
IDLE --> FLEEING: "damaged and not hostile"
IDLE --> COMBAT: "damaged and hostile"
FLEEING --> IDLE: "fled fleeRange, navigation ended, or hopeless"
COMBAT --> IDLE: "target lost, dead, downed, or beyond 4x attackRange"
COMBAT --> IDLE: "player target downed or killed (listener)"
State & persistence effects: none outside the NPC instance.
Edge cases & guards observed: cleanupTransientState (CivilianNpc.java:218) clears the entity queue, resets
wantedByPolice and calls onExit on the current behavior when the NPC is destroyed. transitionTo logs before
the no-op equality check, so repeated same-state transitions still emit debug lines.
W5: Civilian damage → flee / combat
Trigger: EntityDamageByEntityEvent (MONITOR, ignoreCancelled = true).
Steps:
CivilianDamageListener.onCivilianDamage(…/listener/civilian/CivilianDamageListener.java:38) — looks the damaged entity up in the registry by UUID; non-civilians return.- Player attacker (direct or via projectile, excluding Citizens NPCs disguised as players):
hostile +
combatEnabled→setTargetPlayerId+ COMBAT; non-hostile +fleeEnabled→ capture the attacker location and FLEEING. - Non-player
LivingEntityattacker (cop, another civilian, or its projectile): hostile →addEntityTargetToFront(attacker jumps the queue) + COMBAT; non-hostile → FLEEING. PlayerDownedEvent/PlayerDeathEvent→clearCivilianTargets(uuid)transitions every civilian targeting that player to IDLE (CivilianDamageListener.java:106-112).
State & persistence effects: none persisted.
Edge cases & guards observed: a hostile civilian with combatEnabled=false and a non-hostile with
fleeEnabled=false silently ignore damage. clearCivilianTargets transitions to IDLE but does not clear
targetPlayerId, so IDLE's re-engage check will immediately re-enter COMBAT once the respawned player comes within
attackRange * 2.
W6: Civilian death → drops, XP, wanted level
Trigger: EntityDeathEvent (MONITOR, ignoreCancelled = true).
Steps:
CivilianDeathListener.onCivilianDeath(…/listener/civilian/CivilianDeathListener.java:36) — registry lookup; non-civilians return.- Vanilla drops and XP are cleared; for PLAYER-type NPCs the event is also a
PlayerDeathEventandsetKeepInventory(true)suppresses the NMS inventory dump (CivilianDeathListener.java:48-50). - Each configured drop is rolled independently (
chance < 1.0 && random.nextDouble() >= chanceskips) and resolved throughItemParser; resolved stacks are added toevent.getDrops(). CivilianDeathEvent(npc, killer, dropConfig.experience())is fired.npc.markForRemoval()— the tick loop destroys the Citizens NPC on the next pass.CivilianDeathRewardListener(gangland-impl) adds level XP whenexperience > 0, then increments the killer's wanted level unless the victim was hostile and currently in COMBAT (CivilianDeathRewardListener.java:47-49).
State & persistence effects: user level/XP and wanted level mutate through UserManager (persisted by the user
repository autosave).
Edge cases & guards observed: itemParser may be null in the listener, in which case every configured drop
resolves to null and nothing drops. CivilianDeathEvent is not Cancellable, so the listener's
ignoreCancelled = true has no effect.
W7: Trader startup spawn and creation
Trigger: bean initialize, or /glw trader create.
Steps:
TraderManager.onInitialize(TraderManager.java:67) schedulesspawnAllFromRepository40 ticks later and starts the head-track (period 2) and position-reset (period 20) tasks.spawnAllFromRepositorywalksrepository.loadAll()and callsspawn(data)per row.TraderManager.spawn(TraderManager.java:90) resolves the trait (see W8); a null trait logs a warning and aborts. An already-alive trader with the same id is returned unchanged.TraderNpc.spawn(TraderNpc.java:28) creates a Citizens PLAYER NPC named fromdisplayName(or"Trader"), setsSHOULD_SAVE=falseand thegangland.trader.idmetadata key, appliessetProtected(trait.invulnerable()), spawns, then on the living entity setssetInvulnerable,setGravity(false),Attribute.MAX_HEALTHand health fromtrait.maxHealth()(floored at 1)./glw trader create <shop> <trait> [name](TraderCreateCommand.java:69-89) validates the shop key againstShopRegistry.existsand the trait againstTraderTraitRegistry.exists, then builds aTraderDatawith a random UUID at the player's exact location and callsTraderManager.create→repository.save(data)+spawn(data).
Diagram:
sequenceDiagram
participant B as BeanFactory
participant TM as TraderManager
participant R as TraderRepository
participant C as CitizensAPI
B->>TM: onInitialize(firstLoad)
TM->>TM: "runTaskLater 40 ticks"
TM->>TM: startTasks (head track, position reset)
TM->>R: loadAll()
R-->>TM: "List<TraderData>"
loop each trader
TM->>TM: resolveTrait
TM->>C: createNPC + spawn
TM->>TM: "byId.put(id, npc)"
end
State & persistence effects: trader table row on create; byId map; Citizens registry (non-persistent).
Edge cases & guards observed: SHOULD_SAVE=false is honoured on both trader and civilian NPCs.
ShopConfig.traderManager resolves the repository with repositoryRegistry.getRepository(TraderData.class) and passes
it straight into the constructor, which immediately calls setDataSupplier — a missing repository registration would
NPE at bean construction.
W8: Trait resolution and trait reload
Steps:
TraderTraitsLoader.onInitialize→load()(TraderTraitsLoader.java:49) parses every top-level key exceptConfig_Version; if the parse yields zero traits it keeps the previous registry rather than blanking it.TraderTraitRegistry.replaceAllswaps an immutable map inside anAtomicReference— readers never see a partial map.TraderManager.resolveTrait(TraderManager.java:197) returns the stored trait; otherwise the trait named byTrader.Fallback_Trait_Id(logging a warning); otherwisenull, which aborts spawning or showsErrors.Trader.Trait.Invalidwhen opening the GUI.TraderTraitsLoader.onClearempties the registry during a reload;onInitializerefills it.
Edge cases & guards observed: Min_Friend_Discount is required() but defaults to 0.0 when missing
(TraderTraitsLoader.java:82), which makes priceMultiplier fall to 1 - mood, i.e. up to a 100% discount at max
mood.
W9: Trader death and respawn
Trigger: EntityDeathEvent on a trader entity (only reachable when the trait sets Invulnerable: false).
Steps:
TraderDamageListener.onTraderDeath(…/listener/trader/TraderDamageListener.java:21) resolves the trader throughTraderManager.getByEntity(Citizens metadata lookup) and callsonTraderKilled(id).TraderManager.onTraderKilled(TraderManager.java:153) removes the NPC frombyId, destroys the Citizens NPC, clears all mood state for that trader, and callsrespawnService.schedule(data, this::spawn).TraderRespawnService.schedule(TraderRespawnService.java:25) guards against duplicate schedules with apendingset, thenrunTaskLater(plugin, …, Respawn_Cooldown * 20); the task removes the id frompendingand invokes the callback inside a try/catch.
Diagram:
flowchart TD
A["EntityDeathEvent"] --> B["getByEntity via gangland.trader.id metadata"]
B --> C["byId.remove + npc.destroy"]
C --> D["moodService.clearTrader"]
D --> E{"already pending"}
E -- yes --> F["no-op"]
E -- no --> G["runTaskLater(cooldown)"]
G --> H["pending.remove + TraderManager.spawn(data)"]
State & persistence effects: the trader row is untouched (the trader is only removed from memory), so a restart before the respawn window elapses re-spawns it anyway. Nothing suppresses the trader's own death drops.
Edge cases & guards observed: TraderRespawnService.cancelAll() (called from onClear) only clears the
pending set — the scheduled BukkitTask is not cancelled and will still run its callback after a reload.
W10: Trader head-track and position reset
Steps:
tickHeadTrackevery 2 ticks (TraderManager.java:241): for each alive trader, finds the closest player withinTrader.Head_Track_RadiusviafindClosestPlayer(entity.getLocation(), radiusSq)and callstrader.faceLocation(player.getLocation())(feet, deliberately, per the comment atTraderManager.java:252-254).tickPositionResetevery 20 ticks:TraderNpc.resetPositionteleports the entity back tospawnLocationwhen it has drifted more than 0.25 blocks squared.
Edge cases & guards observed: findClosestPlayer calls location.getWorld().getPlayers() without a null check
(TraderManager.java:280).
W11: Mood and price multipliers
Steps:
MoodServicekeepsMap<traderId, Map<playerId, MoodState>>with mood clamped to[0.0, 1.0]— there is no negative mood by design.recordTip(amount * trait.moodPerTipCurrency()),recordPurchase(trait.moodPerPurchase()),recordSale(Trader.Sell.Mood_Per_Sale).priceMultiplier = 1.0 + (trait.minFriendDiscount() - 1.0) * mood(MoodService.java:38-41) — 1.0 at mood 0 andminFriendDiscountat mood 1, so buy prices only ever go down.- Sell and barter invert it:
sellMood = 2.0 - buyMultiplier(SellView.java:109,BarterView.java:104), so those multipliers are always>= 1.0. clearTraderwipes a trader's whole mood map on kill, shop retarget and trait retarget.
Edge cases & guards observed: mood is never persisted — a restart resets every relationship.
BarterView.moodLabel has Wary/Hostile branches (BarterView.java:340-341) that are unreachable because
barterMoodMultiplier >= 1.0 always.
W12: Opening the trader GUI
Trigger: NPCRightClickEvent.
Steps:
TraderInteractListener.onNpcRightClick(…/listener/trader/TraderInteractListener.java:22) →getByEntity→viewOpener.openFor(player, trader).ShopViewOpenerImpl.openFor(…/npc/trader/ShopViewOpenerImpl.java:28):shopRegistry.get(shopKey); missing →Errors.Shop.Not_Defined.traderManager.resolveTrait; missing →Errors.Trader.Trait.Invalid.- If the clicker has
gangland.shop.adminand is sneaking →adminFlow.start(player, def)(W18). - Otherwise
traderFlow.start(player, trader, def, trait)(TraderFlow.java:28) builds a freshTraderFlowSessionand a freshMultiPanelInventory, registers the six panels, and opens atmode_select. ModeSelectViewoffers BUY (switchTo shop), SELL (switchTo sell) and Close (host.end()).ShopView.render(…/view/ShopView.java:67) paginatesdefinition.getBuyEntries()28 per page into fixed interior slots, computingfinalPrice = entry.price * moodMultiplierper entry; clicking an entry stashesselectedEntry,basePrice,moodMultiplieron the session and switches tonegotiation.
Diagram:
flowchart TD
A["NPCRightClickEvent"] --> B["TraderManager.getByEntity"]
B --> C{"shop defined"}
C -- no --> D["Errors.Shop.Not_Defined"]
C -- yes --> E{"trait resolvable"}
E -- no --> F["Errors.Trader.Trait.Invalid"]
E -- yes --> G{"has gangland.shop.admin and sneaking"}
G -- yes --> H["ShopAdminFlow.start"]
G -- no --> I["TraderFlow.start at mode_select"]
I --> J["ShopView paginated browse"]
J --> K["NegotiationView"]
Edge cases & guards observed: ShopView ignores each entry's persisted Slot and lays entries out sequentially in
INTERIOR_SLOTS, so the admin-visible index and the shopper-visible position are the same but the on-disk Slot
value is decorative.
W13: Buy a single copy
Trigger: clicking BUY in NegotiationView.
Steps:
NegotiationView.onBuy(…/view/NegotiationView.java:132) plays a sound, recomputesfinalPrice = basePrice * moodMultiplier, firesTraderBuyRequestEvent(player, trader, entry, finalPrice).TraderBuyListener.onBuyRequest(gangland-impl/…/listener/trader/TraderBuyListener.java:53) resolves theUser, wrapsuser.getEconomy()in an anonymousPaymentHandler, and callspurchaseService.purchase(player, payment, entry, price, 1).ShopPurchaseService.purchase(…/shop/transaction/ShopPurchaseService.java:32): balance check →INSUFFICIENT_FUNDS;payment.withdrawfailure →ECONOMY_ERROR; otherwise, per copy, buildrefresherRegistry.refresh(entry.getItem(), player)andplayer.getInventory().addItem(delivery), dropping any leftover naturally at the player's feet.- Back in the listener: SUCCESS →
moodService.recordPurchase+Commands.Shop.Purchase.Success; failures cancel the event and message the player. NegotiationView.onBuycloses the flow (host.end()) unless the event was cancelled.
Diagram:
sequenceDiagram
participant V as NegotiationView
participant E as Bukkit
participant L as TraderBuyListener
participant S as ShopPurchaseService
participant P as EconomyHandler
V->>E: TraderBuyRequestEvent
E->>L: handle
L->>S: purchase(player, payment, entry, price, 1)
S->>P: getBalance
alt insufficient
S-->>L: INSUFFICIENT_FUNDS
L->>V: setCancelled(true) + message
else
S->>P: withdraw(total)
S->>S: refresh + addItem (leftovers dropped)
S-->>L: SUCCESS
L->>L: recordPurchase + success message
V->>V: host.end()
end
State & persistence effects: economy balance (persisted by the user repository), player inventory, mood map.
Edge cases & guards observed: money is debited before delivery and the inventory-full case is handled by
dropping items on the ground rather than refunding, so PurchaseOutcome.INVENTORY_FULL never occurs.
finalPrice is not rounded or scaled — BigDecimal.valueOf(double multiplier) can produce long decimal tails that are
charged verbatim.
W14: Buy N copies (quantity picker)
Trigger: BUY AMOUNT in NegotiationView, only rendered when entry.getItem().getMaxStackSize() > 1.
Steps:
NegotiationView.onBuyAmount(NegotiationView.java:169) resetsquantityStaged = 1,quantityMode = 1and switches toPANEL_QUANTITY.QuantitySelectorView.render(…/view/QuantitySelectorView.java:76) returns to the previous panel whenselectedEntryis null, clampsquantityStagedto[1, 999]andquantityModeto[1, 8], then renders four green+ (i+1)*modeslots, four mirrored red- (4-i)*modeslots, an anvil copies button, a mode row, and confirm/cancel.- Copies count copies of the template stack, not items — the lore repeats
itemsPerCopy = template.getAmount()andtotalItems = itemsPerCopy * copiesthroughout. confirm(QuantitySelectorView.java:244) computestotal = unitPrice * copiesand firesTraderBuyRequestEvent(..., total, copies); a cancelled event returns to the previous panel, success resets the picker and ends the flow.- The anvil detours use
host.suspend()→AnvilGUI→onClose→runTask(host.resume(); host.switchTo(PANEL_QUANTITY)).
Edge cases & guards observed: MAX_MODE_CYCLE = 8 is hard-coded here, ignoring ShopUiSettings.getMaxModeMultiplier()
(which only PriceEditorView honours). Non-numeric anvil input is rejected with a hard-coded English message and the
anvil stays open. ShopPurchaseService clamps copies < 1 to 1 defensively.
W15: Tip
Steps:
NegotiationView.onTip(NegotiationView.java:146) callseconomy.tryTip(viewer, settings.getTipAmount()).GanglandTraderEconomy.tryTip(gangland-impl/…/GanglandTraderEconomy.java:23): missing user →ECONOMY_ERROR; balance below the amount →INSUFFICIENT_FUNDS; otherwisewithdrawAmountandSUCCESS.- On SUCCESS the view plays a sound, calls
moodService.recordTip, messagesCommands.Trader.Tip.Success, recomputessession.moodMultiplierand re-renders so the new price is visible immediately. ECONOMY_ERRORonly plays a "no" sound — no message is sent (NegotiationView.java:160).
State & persistence effects: economy withdrawal with no counterpart deposit (money is destroyed), mood increase.
W16: Sell flow (drop zone)
Trigger: SELL in ModeSelectView.
Steps:
SellView.render(…/view/SellView.java:101) creates aSellStateon first entry:sellMood = 2 - buyMultiplier, drop-zone slots = firstTrader.Sell.Max_Offer_Slotsof a fixed 20-slot list, registers the drop-zone slots as free-placement withhandler.setItem(slot, null, true), and registers ahost.onEndcallback that returns any uncommitted items.- Clicks and drags arrive through the singleton
TraderSellSessionListener(HIGH priority) which forwards toSellView.handleClick/handleDrag. Clicks inside the drop zone are allowed and schedule a recompute; shift-click from the player inventory is intercepted and manually distributed bytryPlaceInDropzone; anything else in the top inventory is cancelled. recomputeOffer(SellView.java:290) walks the drop zone,decorates each stack (preserving runtime state) and asksCategorySellValuator.value(definition, stack, trait.sellPriceRatio(), sellMood).CategorySellValuator(…/valuation/CategorySellValuator.java:24) finds the first sell category whose template matches byItemSerializerRegistry.serializeidentity, reads the template'ssell_priceNBT tag (falling back to the categoryBase_Price), divides by the template amount to get a per-item price, multiplies bysellPriceRatio * moodMultiplier, and rounds to 2 dp HALF_UP.onConfirm(SellView.java:334) collects only the valued stacks, firesTraderSellRequestEvent, and on a non-cancelled event clears exactly those slots, hands back the unvalued leftovers, removes the state and goes back.TraderSellListener(gangland-impl/…/listener/trader/TraderSellListener.java:52) callsShopSellService.sell, which rejects empty/zero-value offers withNOTHING_VALUEDand otherwise depositsofferedTotaland reports the item count.
Diagram:
flowchart TD
A["open SellView"] --> B["create SellState + register onEnd return"]
B --> C["player drops items into dropzone"]
C --> D["scheduleRecompute next tick"]
D --> E["decorate + CategorySellValuator per slot"]
E --> F["render offer + confirm button"]
F --> G{"confirm clicked and total > 0"}
G -- yes --> H["collect valued stacks"]
H --> I["TraderSellRequestEvent"]
I --> J{"cancelled"}
J -- yes --> K["items stay in dropzone"]
J -- no --> L["clear sold slots, return leftovers, deposit money"]
State & persistence effects: economy deposit, mood increase (Trader.Sell.Mood_Per_Sale), player inventory.
Edge cases & guards observed: onBack, onClear, onConfirm, the onEnd hook and onShutdown all route items
back through returnItemsToPlayer, which drops overflow at the player's feet. scheduleRecompute re-checks
active.get(viewer) == state before touching the inventory.
W17: Barter flow (pure item swap)
Trigger: BARTER in NegotiationView — only rendered when the shop has barter categories and
trait.allowsBarter().
Steps:
BarterView.render(…/view/BarterView.java:97) mirrorsSellView: per-playerBarterState, drop-zone slots,barterMood = 2 - buyMultiplier, andaskingValue = session.basePrice * session.moodMultipliercaptured once at entry.recomputeOffervalues each stack throughCategoryBarterValuatorusingtrait.barterPriceRatio(); unmatched stacks are listed as "not accepted" and contribute 0.onConfirm(BarterView.java:360) requiresofferedValue >= askingValue, collects every non-air stack in the drop zone viacollectOfferedItems, firesTraderBarterEvent, and on a non-cancelled event clears all drop-zone slots, removes the state and goes back.TraderBarterListenercallsShopBarterService.barter(…/shop/transaction/ShopBarterService.java:30), which re-checks the value threshold, clones the offered list intoconsumed, refreshes the entry item and adds it to the player's inventory (leftovers dropped at the feet). No money moves at any point.- Success records
moodService.recordPurchaseand sendsCommands.Shop.Barter.Success.
Diagram:
flowchart TD
A["BARTER clicked"] --> B["BarterState with askingValue snapshot"]
B --> C["player drops items"]
C --> D["CategoryBarterValuator per slot"]
D --> E{"offeredValue >= askingValue"}
E -- no --> F["confirm renders as grey, click is a no-op"]
E -- yes --> G["collectOfferedItems (ALL stacks)"]
G --> H["TraderBarterEvent"]
H --> I{"cancelled"}
I -- yes --> J["items stay"]
I -- no --> K["clear ALL dropzone slots + deliver entry item"]
State & persistence effects: player inventory only. Excess value is explicitly forfeited (the offer lore says so).
Edge cases & guards observed: unlike SellView, the confirm path does not distinguish accepted from
unaccepted stacks — anything sitting in the drop zone is destroyed on confirm.
W18: Shop definition load and reload
Trigger: CONFIG-phase bean creation and every managed reload.
Steps:
ShopConfig.shopRegistry(gangland-impl/…/config/ShopConfig.java:151) constructsShopRegistryand immediately callsinitialize().ShopRegistry.initialize(…/shop/ShopRegistry.java:40) clears both maps and calls theFolderLoaderload(true, this::acceptFile, fileManager)scan ofplugins/<plugin>/shop/.acceptFileparses each file throughShopYamlReader.parseand stores the definition plus itsFileHandler; any exception is logged per-file and the rest of the folder still loads (ShopRegistry.java:110-120).onInitialize(firstLoad)re-runsinitialize()on reloads only.ShopRegistry.save(definition)writes through the cachedFileHandlerand replaces the in-memory entry; a shop with no handler logs "no file handler found" and silently does not persist.
Edge cases & guards observed: reload replaces the shop map wholesale — any MultiPanelInventory flow currently
open still holds the old ShopDefinition reference, so an in-flight admin edit will be written back over the
reloaded file when the flow ends.
W19: Shop admin commands
/glw shop create <key>— lowercases and validates[a-z0-9_]+, refuses duplicates,createEmptywrites a 54-slot shop titled&6<Key>(ShopRegistry.java:66-81)./glw shop list— prints the key set./glw shop edit <key>— opensShopAdminFlowfor the definition (player-only)./glw shop remove <key>—ShopRegistry.deleteremoves the map entries and deletes the file; a key present inshopsByKeybut with no handler reportsErrors.Shop.Untracked./glw shop title <key>— AnvilGUI capture, thensave(def.withTitle(newTitle)).
Edge cases & guards observed: removing a shop does not touch traders pointing at it; those traders then fail
with Errors.Shop.Not_Defined on every interaction.
W20: Shop admin editor flow
Trigger: /glw shop edit <key> or sneak + right-click a trader with gangland.shop.admin.
Steps:
ShopAdminFlow.start(…/shop/view/ShopAdminFlow.java:32) deep-copies the sell and barter category lists (newSellCategory/BarterCategoryobjects, whose constructors copy the item lists), copies the buy-entry list, builds aShopAdminFlowSession, registers the four admin panels and installs anonEndhook that firesShopEditedEvent(admin, session.buildNewDefinition()).ShopAdminView.render(…/shop/view/ShopAdminView.java:88) records the viewer in aWeakHashMap, clears the inventory, renders one of three tabs (BUY entries / SELL categories / BARTER categories) 28-per-page, plus tab buttons, an "add category" button on the category tabs, and pagination.- Adding a BUY entry: shift-click from the player inventory or drop an item on an interior slot →
ShopAdminView.handleClickcancels the event and callsappendEntryAndNavigate, which refreshes the source item, appends aShopItemEntrywithDEFAULT_NEW_ENTRY_PRICE = 100, jumps to the entry's page, re-renders and messagesCommands.Shop.Admin.Entry_Added. The admin keeps their original item. - Left-click a BUY entry → populate the price-edit context and
switchTo(PANEL_PRICE_EDITOR); right-click removes it. - Left-click a category →
switchTothe matching category-items panel; right-click removes the category. - "Add category" opens an AnvilGUI, sanitises the id to
[a-z0-9_], rejects ids already present in either the working copy or the original definition, appendsSellCategory.empty(id)/BarterCategory.empty(id). - On flow end (close button, ESC, or
host.end()),buildNewDefinition(ShopAdminFlowSession.java:75) rebuilds the buy entries with sequential slots0..n-1, refreshes each item with anullplayer context, substitutes price 100 for any null price, and keepsoriginal.getSellEntries()untouched. ShopEditPersistenceHandler.onShopEditedcallsshopRegistry.save(...), messagesCommands.Shop.Savedand logs.
Diagram:
flowchart TD
A["/glw shop edit or sneak right-click"] --> B["deep-copy categories into session"]
B --> C["ShopAdminView tabs"]
C --> D["BUY tab: add / price / remove"]
C --> E["SELL tab: add / edit items / remove"]
C --> F["BARTER tab: add / edit items / remove"]
D --> G["PriceEditorView"]
E --> H["SellCategoryItemsAdminView"]
F --> I["BarterCategoryItemsAdminView"]
H --> G
I --> G
C --> J["flow end (close or ESC)"]
J --> K["buildNewDefinition"]
K --> L["ShopEditedEvent"]
L --> M["ShopRegistry.save writes YAML"]
State & persistence effects: the shop YAML file is rewritten in full on every flow end.
Edge cases & guards observed: the event fires unconditionally, so simply opening and closing the editor rewrites the file (dropping comments and renumbering slots).
W21: Price editor panel
Steps:
PriceEditorView.render(…/shop/view/PriceEditorView.java:74) renders a stub with a BACK button whenpriceEditItemorpriceEditCommitis missing, and defaultspriceEditStagedfrompriceEditOriginal.- Four green
+and four mirrored red-slots scale bypriceEditMode;adjustPricefloors the staged value at 0. - The anvil price entry rejects negatives and non-numeric input; the anvil multiplier entry clamps to
ShopUiSettings.getMaxModeMultiplier()(Trader.Max_Mode_Multiplier, default 1 000 000). - SAVE calls
priceEditCommit.accept(staged)thenclearEditContextandhost.back(); CANCEL clears the context without committing. - Commit targets: a BUY entry replaced in place (
ShopAdminView.java:328-333), a category base price (SellCategoryItemsAdminView.java:265), or a per-itemsell_priceNBT tag written withnew ItemBuilder(existing).addTag(SELL_PRICE_NBT_KEY, value.toPlainString()).build()(SellCategoryItemsAdminView.java:242-248).
W22: Category item editing
Steps:
SellCategoryItemsAdminView.render(…/shop/view/SellCategoryItemsAdminView.java:72) bails back ifsellCategoryInEditis null, records anActiveContextin aWeakHashMap, renders 36 item slots plus chrome (back / info / base-price button).- Adding: shift-click from the player inventory or drop on an item slot →
appendItemrefreshes the source (falling back tosource.clone()if the refresher returns air) and appends it to the category's item list, capped at 36. The admin keeps the original item. - Left-click an item → per-item price editor (reads any existing
sell_priceNBT, else the category base price); right-click removes it. BarterCategoryItemsAdminViewis the same code shape againstbarterCategoryInEdit, and shares the samesell_priceNBT key so a single admin-set per-item value applies to both flows (CategoryBarterValuator.java:54).
State & persistence effects: mutates the working-copy category objects; persisted when the whole flow ends (W20).
W23: Closing the GUI mid-transaction, disconnecting, or plugin shutdown
Steps:
- Normal close / ESC /
host.end()— the framework runs theonEndhooks.SellViewandBarterViewremove their per-player state and, unlesscommittedwas set, callreturnItemsToPlayer, which adds each drop-zone stack back to the player inventory and drops overflow at their feet (SellView.java:374,BarterView.java:388).ShopAdminViewand the category panels simply drop theirActiveContext;ShopAdminFlowfiresShopEditedEvent. - Plugin shutdown —
SellView.onShutdownandBarterView.onShutdown(SellView.java:177,BarterView.java:171) iterate theiractivemaps, mark each state committed, return items and force-close the inventory. - Anvil detours (
suspend()/resume()) keep the flow session alive across the AnvilGUI round trip; theonClosehandler always re-enters the owning panel on the next tick. - Inventory-full on return — overflow is dropped as world items, never deleted.
Edge cases & guards observed: there is no PlayerQuitEvent handler anywhere in this area. Item return on
disconnect depends entirely on Bukkit firing InventoryCloseEvent during logout and on the flow framework wiring
onEnd to it; if that does not happen the drop-zone contents and the WeakHashMap entry both survive until shutdown.
W24: Trader edit and removal commands
Steps:
/glw trader remove(TraderRemoveCommand.java:36) requires the admin to look at the trader (TraderManager.findTargetedTraderray-traces up to a fixed distance and resolves the entity through Citizens metadata).TraderManager.remove(TraderManager.java:111) removes frombyId, destroys the NPC, clears mood, and deletes the matching row by scanningrepository.loadAll()./glw trader edit shop <key>validates the key againstShopRegistry, thenretargetShopmutatesTraderData, saves the row and clears mood./glw trader edit trait <id>validates againstTraderTraitRegistry, thenretargetTrait+ save + clear mood. The already-spawned NPC keeps its old health/invulnerability — the trait profile is only applied at spawn time./glw trader edit nameopens an AnvilGUI, rejects an empty name, thenrenameupdates the data, the Citizens NPC name and the DB row.
Edge cases & guards observed: all three edit commands operate on the ray-traced trader, so a mis-aimed click
reports Errors.Trader.Look_At / Errors.Trader.Not_Trader rather than silently editing the wrong NPC.
Cross-feature Dependencies
- Depends on:
- Keystone:
BeanLifecycle/@Bean/@Configuration,RepeatingTimer,FileLoader/FolderLoader/FileManager/FileHandler,NodeReader/ConfigReport,AbstractRepository/Table/RepositoryRegistry,EconomyHandler,ItemBuilder,SoundEffect,ChatUtil/NumberUtil, the command argument tree andPermissionManager. - Citizens API — NPC creation,
NPCRightClickEvent, NPC metadata (gangland.trader.id), navigation. - AnvilGUI (
net.wesjd.anvilgui) — every custom numeric/text input in the shop and trader panels. - XSeries
XMaterialfor every GUI icon fallback. gangland-ui/inventory-api—MultiPanelInventory,Panel,FlowSession,InventoryHandler,InventoryUtil,Fill.gangland-infra/gangland-item—ItemParser,ItemRefresherRegistry(refresh/decorate),ItemSerializerRegistry(the identity key behind category matching).gangland-features/gangland-weapon—WeaponService,Weapon,Ammunitionfor hostile civilian loadouts and forGanglandShopDisplayResolver.gangland-core—DownedPlayerRegistry(combat target filtering).- cops-n-crooks shared NPC layer —
AbstractNpc,EntitySpawner,EntitySpawnerPoint,EntityMarkManager,NpcNavigationConfig,NpcDifficulty. - gangland-impl —
Settings,Messages,UserManager/User, the four contract implementations.
- Keystone:
- Depended on by:
CopManagerreadsCivilianNpcRegistry(constructor-injected inCopsAndGadgetsConfig.java:365) to find wanted hostile civilians.TurfNpcsConfigreusesCivilianService/CivilianNpcFactoryfor turf defenders (turf_defendercivilian type).- The money-drop classifier and wanted-level logic consume
Default_EntitiesandCivilianDeathEvent. shop-apiis generic: any future shop integration can reuseShopRegistry, the transaction services andShopAdminFlowwithout touching the trader code.
Observations & Potential Issues
| # | Location | Observation | Risk | Confidence |
|---|---|---|---|---|
| 1 | gangland-features/cops-n-crooks/…/npc/trader/view/BarterView.java:363,371 | collectOfferedItems 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. | Silent item destruction — a player who drops one wrong stack loses it on an otherwise successful barter | High |
| 2 | …/npc/trader/respawn/TraderRespawnService.java:44-51 | cancelAll() (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. | Duplicate/ghost trader NPCs after a reload that lands inside a respawn window; the callback also targets a TraderManager whose byId map was cleared | High |
| 3 | …/npc/trader/TraderManager.java:68 | onInitialize schedules spawnAllFromRepository 40 ticks later. A reload (or shutdown) inside that window runs onPreClear→despawnAll first, then the pending task spawns every trader again into a map that may no longer be the live one. | Orphaned Citizens NPCs that no listener can resolve (their byId entry is gone), plus duplicate visible traders | Medium |
| 4 | gangland-ui/shop-api/…/view/ShopAdminFlow.java:49 + …/io/ShopYamlWriter.java:53-57 | ShopEditedEvent 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. | Loss of admin annotations; churn in version control; also see #5 | High |
| 5 | …/view/ShopAdminFlowSession.java:76-83 | buildNewDefinition renumbers every buy entry's slot to its list index, so the persisted Slot values are rewritten on every save. The player-facing ShopView ignores Slot entirely (lays out sequentially into INTERIOR_SLOTS). | Hand-authored slot layouts are silently discarded; the Slot field is effectively dead but still written and validated | High |
| 6 | …/io/ShopYamlReader.java:279-282 | Entry slots are validated as >= 0 but never against Size (or against 54). A hand-edited shop with Slot: 60 loads fine. | Currently harmless because ShopView/ShopAdminView ignore Slot, but any future slot-respecting renderer gets an ArrayIndexOutOfBoundsException | Medium |
| 7 | …/transaction/ShopPurchaseService.java:42-55 | Money is withdrawn before delivery; delivery overflow is dropped on the ground with dropItemNaturally. PurchaseOutcome.INVENTORY_FULL is therefore never returned, Messages.SHOP_PURCHASE_INVENTORY_FULL is never sent, and TraderBuyListener.java:97-99 handles the branch with an empty body. | A player buying 999 copies with a full inventory scatters hundreds of ground items that can despawn or be stolen | Medium |
| 8 | …/npc/trader/view/SellView.java:79 + …/view/BarterView.java:73 | Map<Player, SellState> is a WeakHashMap, but SellState/BarterState hold a strong final Player viewer reference (and a MultiPanelInventory that also references the viewer). The value strongly references the key, defeating weak collection. Entries are only freed by the explicit active.remove paths. | If any exit path is missed (e.g. logout without an InventoryCloseEvent), the Player object and the drop-zone contents leak for the server's lifetime | High |
| 9 | …/npc/civilian/config/YamlCiviliansConfigProvider.java:83-89 | A Weapon_Pool entry that is not weapon:-prefixed is added to both weaponNamePool and weaponPool. resolveGanglandWeapon then calls weaponService.getWeapon("IRON_SWORD") and iterates the whole pool looking for a match that cannot exist. | Wasted lookups plus canUseWeapons() returning true for a purely vanilla loadout; the ammo path is skipped so the NPC holds a vanilla item it may not use as intended | Medium |
| 10 | …/npc/civilian/config/YamlCiviliansConfigProvider.java:75 | Item_Pool is parsed into CivilianTypeConfig.itemPool() but there is no reader anywhere in the repo (grep itemPool() → no hits), despite civilians.yml:40-41 documenting the key as functional. | Config key that silently does nothing; server owners will assume items are being given | High |
| 11 | …/listener/civilian/CivilianDamageListener.java:106-112 | clearCivilianTargets transitions to IDLE but leaves targetPlayerId set; CivilianIdleBehavior.tick re-engages any remembered player within attackRange * 2. | A player who dies to a hostile civilian is re-targeted the moment they walk back within ~2× attack range, defeating the "clear on death" intent | Medium |
| 12 | …/npc/trader/trait/TraderTraitsLoader.java:82 | Min_Friend_Discount defaults to 0.0 when the key is missing, and MoodService.priceMultiplier becomes 1 - mood — a 100% discount at max mood. | A trait file missing one key hands out free items once a player has tipped enough | Medium |
| 13 | …/npc/trader/TraderManager.java:280 | findClosestPlayer calls location.getWorld().getPlayers() with no null check; TraderRepository.doLoadAll:53-54 can produce a Location with a null world when Bukkit.getWorld(name) misses. | NPE inside the 2-tick head-track task, which then stops running for every trader | Medium |
| 14 | …/npc/civilian/CivilianService.java:276 and …/state/behavior/CivilianWanderBehavior.java:116 | Objects.requireNonNull(npcLoc.getWorld()) / Objects.requireNonNull(world) on values Bukkit declares nullable. | NPE aborts the whole proximity-spawner cycle or the NPC tick (the tick is caught and the NPC is removed; the spawner loop is not) | Low |
| 15 | …/npc/civilian/npc/CivilianNpcFactory.java:140 and …/npc/trader/TraderNpc.java:46 | Both use Attribute.MAX_HEALTH, the 1.21.3+ registry name, while CLAUDE.md states the MC floor is 1.16. | If the modules are compiled against a newer Spigot API than the runtime, health application throws NoSuchFieldError on older servers | Medium |
| 16 | …/npc/trader/view/NegotiationView.java:160 | TipResult.ECONOMY_ERROR only plays a sound; no message is sent. | Silent failure — the player sees nothing after clicking TIP | Low |
| 17 | …/npc/trader/view/ShopView.java:82-85 and NegotiationView.java:129 | price.multiply(BigDecimal.valueOf(multiplier)) is never scaled or rounded; the charged amount can carry a long decimal tail (the sell/barter valuators do scale to 2 dp). | Charged price differs from the formatted price shown; inconsistent with the sell side | Medium |
| 18 | …/npc/trader/view/QuantitySelectorView.java:46 | MAX_MODE_CYCLE = 8 is hard-coded, ignoring ShopUiSettings.getMaxModeMultiplier() which PriceEditorView.java:268 does honour. | Trader.Max_Mode_Multiplier only affects half the UI it claims to govern | Low |
| 19 | …/npc/trader/mood/MoodService.java:15 | Mood is a plain in-memory map, never persisted and never pruned when a player logs out. | All trader relationships reset on restart (a design decision, but undocumented in trader_traits.yml); the map grows with the unique-player set | Low |
| 20 | …/npc/trader/TraderManager.java:117-121 | remove() calls repository.loadAll() and streams it to find the row to delete instead of holding the already-known TraderData. | Depending on AbstractRepository caching this may re-read the whole table on every removal; also fails to delete if the row was never flushed | Low |
| 21 | …/listener/trader/TraderDamageListener.java | Nothing suppresses a killed trader's death drops or XP (contrast CivilianDeathListener which clears both). A killable trader (stubborn) is a PLAYER-type Citizens NPC. | Killing a trader may drop its equipment; the NPC's PlayerDeathEvent path is not neutralised | Medium |
| 22 | …/view/SellCategoryItemsAdminView.java:245-247 | new ItemBuilder(existing).addTag(...).build() — if ItemBuilder mutates the wrapped stack in place, the tag lands on the shared ItemStack instance the original (non-copied) definition still references, so a CANCEL in the price editor would not undo it. ShopAdminFlow.deepCopySell copies the list but not the individual ItemStacks. | Price edits could leak into the live definition even when cancelled | Low (unverified — depends on Keystone ItemBuilder semantics) |
| 23 | …/ShopRegistry.java:83-92 | save is not synchronized and FileHandler.save() is a plain write. Two admins ending edit flows on the same shop in the same tick sequence both write full files from their own working copies. | Last-writer-wins; the first admin's changes vanish with no warning | Low |
| 24 | gangland-impl/src/main/resources/commands.json | Root entry "civilian" is absent (only civilian_help), while shop and trader both have root entries. | Help listing for /glw civilian has no description entry | Low |
| 25 | …/npc/civilian/CivilianService.java:62-65 | Civilians.Behaviour.Enabled: false skips starting both timers, including the proximity spawner. | Disabling "AI" silently also disables all automatic civilian spawning — not what the comment in settings.yml:515 implies | Medium |
| 26 | …/npc/civilian/CivilianNpcRegistry.java:36 | registerGroup keys the map by groupId + "_" + System.nanoTime(), so the group map is append-only until tickAll prunes empties. | Fine in practice, but the key is not addressable — getActiveGroups() streams are the only lookup path | Low |
| 27 | …/listener/npc/CivilianDeathRewardListener.java:31 | ignoreCancelled = true on CivilianDeathEvent, which does not implement Cancellable. | No functional effect; misleading | Low |
Test Surface
Pure-logic candidates (plain JUnit/Mockito, no server):
MoodService— clamping at 0 and 1,priceMultiplierat mood 0 / 0.5 / 1,recordTipscaling bymoodPerTipCurrency,clearTraderisolation between traders and players, and theminFriendDiscount = 0.0free-item case (issue #12).CategorySellValuator/CategoryBarterValuator— template matching by serializer key,sell_priceNBT override vsBase_Pricefallback, per-item division by template amount, 2-dp HALF_UP rounding, negative-value clamping, unknown item →ItemValuation.UNKNOWN(needs a mockedItemSerializerRegistryandItemBuilder).ShopPurchaseService— insufficient funds short-circuit (no withdrawal),PaymentException→ECONOMY_ERROR,copies < 1clamping, onerefreshcall per copy, leftovers routed todropItemNaturally.ShopSellService— empty list / null total / zero-signum total →NOTHING_VALUED, item counting across stacks, deposit failure →ECONOMY_ERROR.ShopBarterService—NOT_BARTERABLEon an empty offer,INSUFFICIENT_VALUEbelow the threshold,consumedcloning, no economy interaction at all.ShopYamlReader— size normalisation, entry skipping (null item / negative slot / missing price), price parsing from Number vs String vs garbage,ConfigurationSectionandMapelement shapes, category id validation.ShopAdminFlowSession.buildNewDefinition— slot renumbering, null-price substitution, category pass-through.ShopDefinition—entriesOf,getSellCategoryByIdcase-insensitivity,removeSellCategory,withTitle.YamlCiviliansConfigProvider.parseDropEntry—@chancesuffix parsing, clamping, malformed suffix behaviour (currently the only fully pure method in that class).TraderTraitsLoader.parseDefinitiondefaults — especiallyBarter_Price_Ratiofalling back toSell_Price_Ratioand theMin_Friend_Discountdefault.CivilianGroup—getGroupCenteraveraging,isMemberStrayingacross worlds,pruneDeadMembers.SellView.dropzoneSlots/BarterView.dropzoneSlotscap clamping (extract or use reflection).
Needs Bukkit/Keystone mocks (MockBukkit or heavy Mockito):
CivilianService.tickProximitySpawners— the no-players-online branch, activation/despawn hysteresis, per-spawner cap enforcement, hard-leash marking, blankDefault_Type_Idskip.CivilianService.tickAll— removal of marked/invalid NPCs, exception isolation, empty-group pruning.- The four civilian behaviours — each transition edge in the W4 diagram, driven by a mocked
CivilianNpc. CivilianDamageListener/CivilianDeathListener— attacker classification (player / projectile / NPC), drop rolling with a seeded random,setKeepInventoryon PLAYER-type deaths.TraderManager—resolveTraitfallback chain,spawnidempotence for a live trader,remove/retarget*mood clearing,snapshotDatacontents.TraderRespawnService— the duplicate-schedule guard and the "task survives cancelAll" behaviour in issue #2.ShopRegistry— folder scan with a temp directory,createEmptyduplicate rejection,savewithout a handler,deletereturn values. (Follow the@TempDir(cleanup = CleanupMode.NEVER)rule fromCLAUDE.mdif a realFileHandlertouches disk.)SellView.handleClick/handleDragand the Barter equivalents — drop-zone slot gating, shift-click distribution, and specifically a regression test that a rejected stack survivesonConfirm(issue #1).ShopAdminView.handleClick— shift-click and cursor-drop append paths, index bounds after removals.
Integration-only (real server):
- Citizens NPC lifecycle:
SHOULD_SAVE=falseactually keeping traders and civilians out ofsaves.yml; NPC destruction on reload and shutdown leaving no orphans. - The
MultiPanelInventorytransitions themselves (same-handle re-render vs rebuild) and the AnvilGUI suspend/resume detours inQuantitySelectorView,PriceEditorView,ShopAdminView,ShopTitleCommand,TraderEditNameCommand. - Item return on
PlayerQuitEventwhile a sell/barter drop zone holds items (issue #8) — needs a real logout. - Concurrent shop saves from two admins (issue #23).
- Navigation-dependent behaviour: stuck detection, hopeless re-pathing, group cohesion, soft/hard leash in a live world.
Attribute.MAX_HEALTHon a 1.16–1.20 server (issue #15).
Existing tests covering this area: none. The repository contains exactly two test classes
(gangland-impl/src/test/java/org/luckyraven/gangland/database/repositories/rank/RankRepositorySpiTest.java and
gangland-infra/gangland-item/src/test/java/org/luckyraven/gangland/item/dsl/ItemDslAdapterTest.java), neither of
which touches civilians, traders or shops.