Skip to content

Maintainer notes

How the four tiers were built

"Anything from its evo line", and why getBaseEvolution() could not be used

The first three tiers accept any member of the target's evolution line: a hunt for Charmeleon is satisfied by a Charmander, a Charmeleon or a Charizard. Insane is the tier that pins the exact species, which is what "no evolutions allowed" means there.

Two species are in the same line when they walk up to the same base. Pixelmon has a method for that, Stats.getBaseEvolution(), and it cannot be used, because it is not deterministic:

// Stats.getBaseEvolution(), decompiled from 9.3.16
CollectionHelper.getRandomElement(preEvolutions)   // random parent
    .getValueUnsafe().getDefaultForm()             // ...and getDefaultForm() is ALSO random

Same Pokémon, two calls, potentially two different bases, so a player's catch could match their hunt once and fail the next time they opened the turn-in menu. This is the same class of trap as the multi-default-form problem below, and it bites harder because it is on the hot path of every turn-in.

SpeciesLines replaces it with a spelled-out walk: pre-evolutions in declaration order, always the first, preferring a same-named form on the parent so an Alolan line climbs through Alolan forms. Verified live on mcnet with /hunt debug line, three consecutive runs byte-identical:

Charmander / Charmeleon / Charizard (+ megax, megay, gmax)  -> charmander
Vulpix / Ninetales, base and alolan                         -> vulpix
Eevee / Vaporeon / Umbreon                                  -> eevee
Tauros + all three Paldean forms                            -> tauros
Wurmple / Silcoon / Cascoon / Beautifly / Dustox            -> wurmple   (branching line)
Gardevoir / Gallade (+ mega)                                -> ralts     (branching line)
Unown, all 28 forms                                         -> unown

Lines are form-agnostic at the top: Kantonian and Alolan Vulpix share the base vulpix and count for each other on the lower tiers. That follows from the brief, those are the lenient tiers, and a regional variant is the same line by any ordinary reading. Insane pins species and form, so that is where the distinction is enforced.

The IV boost is a share of a perfect IV, per stat

A 10% boost adds round(31 * 0.10) = 3 to every one of the six IVs; 25% adds 8. Not a share of the gap to perfect, and not percentage points of the overall total.

One consequence players will notice: a stat already at 31 gains nothing, so the headline percentage is a ceiling, not a promise. The reward message reports the IV percentage actually gained rather than the configured number, so the GUI never claims more than it delivered.

Hyper training is left alone. IVStore tracks hyper-trained stats separately and a Bottle Cap already displays as 31 in battle, so writing the underlying IV up would be invisible to the player and would quietly consume the value of an item they paid for.

The write sticks because Pokemon.getIVs() returns the IVStore off permanentStats, and PermanentStats.withPokemon() propagates the Pokémon reference into it, so ivs.markDirty() reaches Pokemon.markDirty(IVs, Stats). Verified in the 9.3.16 bytecode; without that call the boost would live only in memory and silently un-apply on the next load.

The Insane IV floor is rolled, not fixed

minIvPercentFloor / minIvPercentCeiling, defaulting to 50–70, rolled per hunt and shown on the hunt itself. A fixed floor turns into a number players memorise; a rolled one means two Insane hunts are not the same difficulty.

"Caught after the hunt started" is recorded twice, on purpose

Pixelmon stores no capture timestamp, so the mod writes one:

  1. The authority, a stamp in Pokemon.getPersistentData(), which readFromNBT/writeToNBT round-trip under the PersistentData key (verified against the 9.3.16 jar). It travels with the Pokémon into the party, the PC, and across a restart.
  2. The fallback, a short per-player log of recent catches keyed on the Pokémon's UUID, pruned to catchLogRetentionHours.

Either one passing is enough. Whether an NBT write inside SuccessfulCapture survives into the stored Pokémon cannot be settled without catching something on a live server, and the failure mode if it does not is the worst this mod has, nobody can ever turn a hunt in. The fallback makes a wrong guess degrade to "still works" instead.

requireOriginalTrainer (default on) closes the other hole: without it, A catches a match, trades it to B, and B turns it in inside their own window.

Rarity tiers still exist, and are a different axis

Cobblemon labels every spawn common / uncommon / rare / ultra-rare, so upstream's SpawnRates is a lookup. Pixelmon has a bare float rarity per spawn entry, 0.01 to 300 across 1775 entries in stock 9.3.16, so the tiers had to be defined. Thresholds are config, defaulting to 60 / 10 / 1. On mcnet that indexes to:

Indexed 1775 Pixelmon spawn entries into 1019 obtainable species/form targets
  COMMON: 333   UNCOMMON: 286   RARE: 290   ULTRA_RARE: 110

Rarity decides which species you are asked for. Difficulty decides how strict the requirements on it are. They are independent: an Easy hunt can roll something rare, and an Insane hunt something common. The brief attaches difficulty to the requirements, not to the species.

Targets are drawn from a pre-index of the live PixelmonSpawning.standard, so datapack spawn overrides count and a hunt is always for something obtainable in the wild on this server. Upstream instead generated a random Pokémon and re-rolled in a loop until it landed in the tier it wanted; on Pixelmon that loop would spin, since roughly a third of the species registry has no standard spawn. Re-indexed on OnDatapackSyncEvent, because PixelmonSpawning.initialize() is a datapack-loader task and /reload replaces those lists.

Multi-default-form species

When a spawn entry does not pin a form, Pixelmon resolves it through Species.getDefaultForm(), which picks at random among the species' default forms. Twenty species in 9.3.16 have more than one, Unown has 29. Indexing whichever one came up would mean hunts for "Unown (Q)". The tell was the target count drifting between boots, 986, 987, 988, with nothing else changed.

Neither obvious detection route works, and both were measured reporting 0 entries expanded:

  • spec.getValue(FormRequirement.class) is always present
  • spec.toString() (the originalSpec field) always contains a form: token

because SpawnInfoPokemon.onImport() resolves the form eagerly and writes it back. The decision is therefore made from the species. Result is stable at 1019 across boots, 60 entries expanded.

Persistence

Hunts run for three to five hours, which is longer than the gap between restarts on this network, so they have to survive one. pixelmonhunt/playerdata.json holds each player's hunt, cooldown and catch log; writes are debounced onto the existing one-second tick and flushed again on shutdown.

This is why ActiveHunt stores names and primitives rather than Species and Nature objects - holding live references would serialise Pixelmon's registry graph into the save file. Live objects are resolved on demand and cached in transient fields.

A hunt whose species no longer resolves (a datapack removed it, or the file was hand-edited) is dropped on load rather than kept, so it cannot NPE inside the GUI hours later.

Economy

Payouts run server commands, not a Vault call:

"economyCommands": {
  "beforePayout": ["economywarehouse tag {player} {price} hunt_reward"],
  "payout": "eco give {player} {price}"
}

This is the house pattern. tag registers the reason for the next matching credit and moves no money; eco give pays. Tag after and the credit is already recorded unattributed and shows up in Metabase as admin_grant. tag rather than economywarehouse award so a broken bridge can never stop payouts, if tag fails the payout still runs and only the label is lost.

eco give rather than Vault because XConomy intercepts Essentials' economy commands (disable-essentials: true), so the command path is the proven route to the real balance.

Both commands were exercised on mcnet with these exact strings. Note the bridge's own usage string omits tag; the subcommand exists anyway.

Upstream's Impactor / Pebbles / BlanketEconomy bridges are gone, all Cobblemon-ecosystem mods. HuntEconomyProvider is unchanged, so another mod can still inject its own bridge at a priority.

GUI

Three menus, all plain vanilla ChestMenu: the start page, the active hunt, and the turn-in picker. All are 3 rows behind a full filler border, interior slots 10–16, one button at slot 22. A bordered row is only seven wide, which is what decides the layouts, four difficulties at 10/12/14/16, six party slots at 10–15.

Upstream used GooeyLibs, a Cobblemon-ecosystem library that would have been an unverified runtime dependency on a Youer hybrid. Dropping it also removed the question of whether it loads there.

Four deliberate choices:

  • The target sprite opens its /pwiki <species> GUI. The hunt menu closes first and dispatches the command as the player on the next server tick, avoiding a stale chest screen while preserving the wiki command's normal player context.
  • Every party member is clickable in the turn-in menu, including ones that do not qualify. A menu that greys out five of six slots tells a player they are wrong without telling them why. Each slot lists every requirement with a ✔ or ✘.
  • Cancelling asks twice. The button re-renders as a confirmation. A mis-click there throws away up to five hours and costs the cooldown.
  • Menus close before they message. The chat HUD is not drawn while a container screen is open, so a player told "you are on cooldown" while still looking at the menu sees nothing at all.

Sprites come from SpriteItemHelper.getPhoto(pokemon), Pixelmon's equivalent of Cobblemon's PokemonItem.from().

Icons: the difficulty buttons are a Poké Ball ladder

Poké → Great → Ultra → Master, one rank per tier. All four are the same item. There is exactly one ball in the registry, pixelmon:poke_ball, and the variant is a pixelmon:poke_ball data component:

pixelmon:poke_ball                                    Poké Ball
pixelmon:poke_ball[pixelmon:poke_ball=great_ball]     Great Ball
pixelmon:poke_ball[pixelmon:poke_ball=ultra_ball]     Ultra Ball
pixelmon:poke_ball[pixelmon:poke_ball=master_ball]    Master Ball

pixelmon:great_ball does not exist, and an earlier build used it, so three of the four buttons silently rendered as white glass panes. Utils.parseItemId now takes the full vanilla id[components] syntax through ItemParser against the server's registry access, so any icon can carry components.

/give cannot check an id on Youer. It resolves against Bukkit's item registry, which does not carry NeoForge mod items, it reports pixelmon:rare_candy as "Unknown item name" while that resolves perfectly well through BuiltInRegistries.ITEM. Checking icon ids with /give gives a confident wrong answer. Use /hunt debug item <id>, which goes through the same code path the menus do and prints what the icon actually renders as, for a component id the display name is the only proof, since a Great Ball and a Poké Ball are the same item.

A banned material makes a button inert. On a server running BanItem, a material banned with a bare '*' action has INVENTORYCLICK banned with it, so using it as a menu icon gives a button that renders correctly and does nothing. Every icon is config (*Material in lang.json). The Insane button is a Master Ball, which is the single most likely item on this network to be restricted, if that one button ever stops responding while the other three work, this is why.

Upstream bugs fixed along the way

Where Bug
CurrentHunts.addHunt Re-rolled a duplicate species by recursing with no depth limit, a board wider than its rarity tier blew the stack.
CurrentHunts.addHunt Returned hunts.put(...), the value previously stored under a freshly generated UUID, always null. ReplacedHunt.getNewHunt() was therefore always null for anything using the public event API.
CurrentHunts / HuntManager Plain HashMaps written on the async executor and read from the server thread. Now concurrent.
ServerTickEvent Submitted an executor task every tick, 20/sec, forever, to check timestamps that change once an hour. Now once a second.
Utils.parseItemId ItemStack.parse(...).get() on an empty HolderLookup.Provider; one typo in the config filler material threw NoSuchElementException and took the whole GUI down.
Utils.runCommands Rethrew a rejected command as a RuntimeException. Here that propagates out of the Pixelmon capture listener, so one bad config command would break catching for everyone.
BaseCommand Built the root literal with no requires(), so /hunt was ungated while its alias /hunts was gated. On NeoForge requires() is also what hides a command from tab-completion.
Broadcaster.sendMessage Unguarded getPlayer(owner).sendSystemMessage(...), NPE if the owner logged off between the hunt rolling over and the message being sent.
SingleHunt.check Unguarded getPlayerHunts(owner) in the individual-hunts branch.
config/Logs Plain HashMap written from a two-thread executor. Now concurrent, and Gson's LinkedTreeMap is copied into one on load rather than assigned over it.

Removed by the rework: the shared board (CurrentHunts, SingleHunt, ReplacedHunt), the whole broadcast/ package and its buffering (there is nothing server-wide left to buffer), and the config blocks that no longer mean anything, huntAmount, huntDuration, individualHunts, matchProperties, rewards, customPrices, Lore.

config/Logs.java was dead in upstream; it is now live, and records what each player has been paid.

What is verified, and what is not

Boot-tested on mcnet main (Youer + NeoForge 21.1.x + Pixelmon 9.3.16, the same version this compiles against).

Verified on a live server:

  • mod loads clean, no errors, no warnings from this mod anywhere in the boot
  • spawn index builds and is deterministic (1019 targets, identical tier counts to the previous build, so the rework did not disturb it)
  • evolution-line resolution is correct and stable, the table above, three consecutive runs byte-identical, including both branching lines and Unown's 28 forms
  • Capture listener registered on Pixelmon.EVENT_BUS, the registration this mod is most likely to get silently wrong
  • all 5 permission nodes registered, and mirrored into Bukkit for /lp tab-completion
  • config.json and lang.json generate at version 3.0.0 with exactly the specced tier numbers
  • all four difficulty icons resolve, Poké / Great / Ultra / Master Ball, confirmed by display name through the menus' own code path (/hunt debug item)
  • pixelmonhunt/playerdata.json path is created and read (No .../playerdata.json yet on a clean start)
  • no hunt is rolled at boot, the old build logged a 7-hunt board here, this one logs nothing, which is the rework working

NOT verified, needs a Minecraft client, which the harness cannot drive:

  • a full round trip: pick a difficulty, catch a match, turn it in. The payout, the IV boost actually landing on the Pokémon, and the cooldown starting. This is the acceptance test that matters. A clean boot is not a pass: the ReforgedBingo trial on events is the local precedent for a Pixelmon API mismatch surfacing as a NoSuchMethodError on the first catch, not at boot.
  • whether the capture stamp survives into the stored Pokémon, the fallback catch log exists because of this, but which of the two is doing the work can only be seen with /hunt debug <slot> after a real catch.
  • the three menus rendering, sprites, borders, the ✔/✘ lore, and that the cancel confirmation and Back buttons behave.
  • hunts surviving a restart, the code path is exercised on every boot, but only a hunt that actually exists proves it round-trips.

Not ported

  • item rewards (balls, rare candies alongside money), expressible today through each tier's commands list, e.g. give {player} pixelmon:poke_ball[pixelmon:poke_ball="ultra_ball"] (every Pixelmon ball is one item plus a data component)