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:
- The authority, a stamp in
Pokemon.getPersistentData(), whichreadFromNBT/writeToNBTround-trip under thePersistentDatakey (verified against the 9.3.16 jar). It travels with the Pokémon into the party, the PC, and across a restart. - 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 presentspec.toString()(theoriginalSpecfield) always contains aform: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
/lptab-completion config.jsonandlang.jsongenerate at version3.0.0with 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.jsonpath is created and read (No .../playerdata.json yeton 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
eventsis the local precedent for a Pixelmon API mismatch surfacing as aNoSuchMethodErroron 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
commandslist, e.g.give {player} pixelmon:poke_ball[pixelmon:poke_ball="ultra_ball"](every Pixelmon ball is one item plus a data component)