Skip to content

Maintainer notes

How the rarity tiers and the excluded species were chosen is in docs/card-eligibility.md. Read it before changing a threshold or the ban list: tiers are cut on raw rarity, bans are measured on real spawn chance, and the two are not interchangeable.

The unmodified upstream tag builds reproducibly (within 10 bytes of the published jar, only manifest timestamps differ), which is what made forking safe. Only the neo21 module is kept. Built jars for each fork release are in release/.

Changes from upstream

Board geometry

Upstream Fork
Card 4 rows × 7 columns, 28 squares 5 × 5, 25 squares
Payable lines 10 (5 rows + 5 columns on a square card) 12 - diagonals count
Line reward pools lineCompleteRewards (rows) + columnCompleteRewards one lineCompleteRewards for all directions

countDiagonals gates the diagonals and is ignored on a non-square card - a 5×7 grid has no corner-to-corner diagonal, and asking for one is a config mistake rather than a request for something clever.

postSlotComplete(y, x) replaces upstream's three booleans with a count of the lines the square closed. A square can close its row, its column and both diagonals at once - the centre of a 5×5 sits on both - so four is reachable and two booleans cannot describe it. Diagonal membership (y == x, y + x == size - 1) is tested before the diagonal itself, so a filled diagonal cannot pay again on every later catch.

Lifecycle

Upstream had no concept of starting bingo. BingoAttribute.setParent() generated a card the first time a player's data loaded, which meant the 24h clock began before they had seen a single word about the rewards. That is the behaviour this fork exists to change.

  • setParent() leaves bingoCard null and waits.
  • /bingo with no card opens BingoStartUI - a rewards breakdown with a START button. The card is generated, and the clock started, on click.
  • cardDurationSeconds defaults to 21600 (6h).
  • CardResetTask clears an expired card instead of generating a replacement.
  • endCard() is called on expiry and on completion. Completion matters: without it a player keeps a fully-marked card until it times out, and checkCardCompletion() would pay again on every later catch.
  • newCardCooldownSeconds (default 21600) is measured from the previous card's start, which is why started is deliberately not cleared by endCard().

Without it the speed bonus is a farm: clear a card in 40 minutes, immediately start another, bank the top tier again. Set it to 0 for back-to-back cards.

Difficulties

A player picks EASY, MEDIUM, HARD or INSANE on the start screen. HARD is exactly the board this fork already had, and a config written before this change is migrated onto it - see below.

Board Squares Lines Common Uncommon Rare Ultra
EASY 4×4 16 10 10 5 1 0
MEDIUM 4×4 16 10 7 5 3 1
HARD 5×5 25 12 11 7 5 2
INSANE 5×5 25 12 4 6 9 6

The size is the visible difference; the ultra-rare count is the real one.

Payouts come from main's live schedule

Read off the server on 2026-09-07, not taken from upstream's example figures. HARD below is the live schedule verbatim; the other three are its rates scaled and floored.

per square per line full card LIGHTNING ≤1h QUICK ≤3h STEADY ≤6h full card @ best
EASY ×0.4 $4 $80 $600 $1,200 $600 - $2,664
MEDIUM ×0.6 $6 $120 $900 $1,800 $900 - $3,996
HARD ×1 $10 $200 $1,500 $3,000 $1,500 - $7,150
INSANE ×1.3 $13 $260 $1,950 $3,900 $1,950 - $9,295

HARD's $7,150 matches what main pays today exactly, which is the check that the scaling is anchored to something real.

The buttons quote these figures, read back out of the commands

Since snowbird.14 each difficulty's button shows what that board pays. Before it, the money existed nowhere a player could see it: rewardsInfoItem carried hand-written lore describing defaultDifficulty only, so somebody choosing between four boards could see that INSANE had six ultra rares and had no idea it paid more for them.

The figures are parsed out of each pool's reward commands at boot, so re-pricing a board moves the screen with it and there is nothing to keep in step by hand:

eco give %player% 1500        ->  Full card: $1,500

rewardDisplay.paymentCommandPrefixes (default eco give) says which commands move money; the amount is the last number on a matching one, so eco give %player% 1500 silent also reads as 1500. A lore line that is exactly %payouts% expands into the whole block; %per_square%, %per_line%, %full_card% and %best_case% are also available individually.

Warning: List only the command that pays. Every payout ships as a pair - an economywarehouse tag %player% <amount> <label> for attribution and an eco give that actually pays, both carrying the same number. Adding the tag command to the prefixes counts each payout twice and doubles every figure on the screen, which looks entirely plausible.

Anything unreadable renders as nothing. UtilRewardAmount answers "unknown" rather than guessing, and UtilPayoutLore drops that line - an unreadable board loses the whole block and falls back to the lore it had before. Full card: ? reads as a broken mod; a figure the server will not pay is worse. events' inherited placeholder guaranteed reward is exactly this case and quotes nothing at all.

%best_case% is computed but not shipped in the block, on the owner's call: it reads as the number to beat rather than as what the board pays, and a player who sees it first compares four totals instead of four boards.

The one thing this cannot follow is a pool an operator has priced through some other plugin's command shape - displayAmountOverrides (and displayAmount on a speed tier) is the hand-written escape hatch, -1 meaning "read it from the commands".

Warning: ConfigRewardPool.guaranteedReward is private with no getter, so it is read reflectively, once, cached. getRandomRewards() is the only public route and is the wrong one: it rolls the weighted set too, so the same pool answers differently on two calls and a screen would quote a bonus the player has not won. A rename in the envyful API costs the amounts and logs loudly; the buttons still open.

Scaling applies to the rates, so the 4×4 boards land further below HARD than their multiplier suggests - 16 squares and 10 lines against 25 and 12.

INSANE is deliberately not paid in proportion to how much harder it is: six ultra rares against HARD's two is far more than 1.3× the work. At a proportional rate INSANE would be the only rational pick for anyone able to clear one, and the other three boards would stop being played. The extra money is a garnish on a board people choose to prove they can finish it.

The slowest speed tier pays nothing on every difficulty, matching live. Its bound equals the card duration, so it catches every card that finishes at all - a bonus there is a participation payment, not a speed one. It still carries a message, because silence after finishing reads as a bug. The weighted bonus entries are shipped empty for the same reason the network's reward script strips them: behind reward-rolls of 0/0 they never pay, but raising the rolls for a bit of variety would multiply a pool rather than garnish it.

Warning: A server whose HARD is not $10 / $200 / $1,500 must re-price all four, and the boot log says so every time the section is written. There is no way to scale off the live HARD automatically: the amount lives inside the string eco give %player% 1500, so recovering it would mean parsing operator-written console commands to decide what to pay people.

Warning: events is not ready for this. Its config predates the quota and leaderboard work entirely (no common-per-card, no tier thresholds) and its reward commands are still the literal placeholder guaranteed reward, so it has never paid bingo money. Deploying there needs its config refreshed first, or HARD will be seeded from those placeholders while the other three pay real money.

A card records its difficulty and is scored, timed, laid out and paid from that entry for its whole life. Two players can be holding a 4×4 EASY card and a 5×5 INSANE card at the same moment, which is why nothing on that path reads a global board setting any more.

  • /bingo start opens the picker; /bingo start insane skips it.
  • Clicking a difficulty button starts that card - there is no second START press. Each button carries the board, the mix and the timer warning, so one click on a labelled button is as informed as one click on a labelled START was.
  • allowDifficultyChoice: false restores the single-button screen and deals defaultDifficulty.
  • A re-roll keeps the difficulty. Switching board is what starting a card is for; letting a re-roll do it would make the daily re-roll a way onto INSANE without waiting out the cooldown.
  • Disabling a difficulty removes it from the picker but does not end cards already running at it, or hide its leaderboard.

CORRUPTED - the hidden fifth board (snowbird.15)

A fifth difficulty nobody is told about: 25 squares, every one of them ultra rare, six hours, paying exactly what INSANE pays. Clearing it grants the [BINGO] chat tag.

Board Squares Common Uncommon Rare Ultra Duration
CORRUPTED 5×5 25 0 0 0 25 6h

Where it is. Bottom-right corner of the start screen, (8, 4), as a black_stained_glass_pane - the item the screen is filled with. startInterface is a 5-row interface with fill-type: BLOCK and one black pane as its filler, so the button is indistinguishable from the background until somebody hovers the corner. Changing iconMaterial for it to anything else makes the easter egg a visible fifth button.

How it is written. &k is vanilla Minecraft's obfuscated-text code - the glyphs re-randomise every client frame - so the corruption is real formatting rather than a picture of it. The name is &8[&4&l&kINSANE&r&8]: six obfuscated characters between plain brackets, occupying exactly the width of the word it replaces. The brackets stay outside the &k run deliberately; with nothing static the item has no recognisable shape and reads as a rendering fault rather than a name.

What stays legible is chosen. The three zero tiers keep their real placeholders and obfuscate them, so what flickers is a truthful zero rather than invented noise and a re-tuned quota cannot leave the lore lying. %ultra_rare% and %payouts% are the two things left readable - the reveal is that the one number a player can read is the 25.

Warning: The quota must add up to exactly 25. Over the board size, resolveQuota trims from the rarest tier first, so a 26th ultra rare would come straight back off and leave a board of 25 that is quietly easier than the one it claims to be. Under it, the shortfall tops up with commons - a corrupted card with commons on it still looks corrupted and still pays. Two tests pin the resolved quota and the composed board.

Warning: filters caps legendaries at one per card (spec: leg, limit: 1 on main), so a board of 25 needs 24 non-legendary ultra rares to exist. Measured, not assumed: of the ultra-rare tier only 16 species carry a legendary/mythical/ultrabeast tag, leaving ~221 on main's 237-species tier. The filter cannot starve this board.

hidden, not enabled. Disabling a difficulty stops it being dealt; hiding one stops it being advertised. hidden: true takes it out of everything that would name it - the difficulty list /bingo start prints when it does not recognise one, the leaderboard's tab row, and the fallback for a disabled defaultDifficulty - while getPickerDifficulties() still draws its button, because the button is how it is found. /bingo start corrupted works for anyone who works the name out, which is the point of an easter egg rather than a locked door.

The CORRUPTED rarity tier (snowbird.19)

A fifth rarity tier, rarer than ULTRA_RARE, that only the hidden board draws from - one square of it, and the board's other 24 are ultra rare.

Warning: It is not a score band below ultra rare, and it could not be. The four tiers are cut out of the findability score by threshold and everything under rareThreshold (0.3) is already ULTRA_RARE - there is no room underneath. What is underneath is the ban list: species that score in the ultra-rare range but fail tooRareFloor (a vanishing share of the world) or bestCaseFloor (never dominant in any single draw).

So CORRUPTED is the top slice of the ban list - bounded above and below by corruptedFloor/corruptedCeiling, taking the ones that only just failed and leaving the rest banned. That boundary is the whole design: most of the ban list deserves to be there.

At the shipped [0.028, 0.3) the mod rescues 19 species on main's live spawn tree (measured, not asserted - the boot log prints the count). Ranked by findability:

Dragonite    0.257%   (fails world-share; its best single draw is 46%)
Excadrill    0.154%   Magby     0.134%   Yanmega   0.124%   Wormadam  0.081%
Gorebyss     0.067%   Coalossal 0.067%   Swoobat   0.058%   Unown     0.057%
Brambleghast 0.050%   Orbeetle  0.050%   Cleffa    0.050%   Wynaut    0.050%
Rabsca       0.050%   Delcatty  0.049%   Arboliva  0.043%   Glalie    0.033%
Chansey      0.031%   Pidgeot   0.029%
                       ---- floor 0.028 ----
Slowbro      0.027%   and the rest of the ban list stay banned

No other tier moves. These 19 come off the banned list, not out of ULTRA_RARE, so COMMON/UNCOMMON/RARE/ULTRA_RARE are byte-identical before and after - 113 / 163 / 193 / 237 on mcnet across the change. Card-eligible goes 706 → 725.

Legendaries and Ultra Beasts are blacklisted by isBlacklisted() before any floor is read, so they can never land here however low the floor goes. Four of the twenty most findable banned species are legendary (Enamorus, Latios, Mewtwo, Ting-Lu) and are correctly absent.

How it shows. Two places, both corrupted:

Where Renders as
The card square's own tooltip (%rarity%) rarityCorrupted, &8[&4&l&kULTRA RARE&r&8] - the only rarity a player cannot read
The hidden board's button, as a rarity row &4&l&kULTRA RARE&r&8: &4&l%corrupted% - obfuscated label, legible count

The label flickers and the 1 does not, so the row reads as "one square of something you cannot read". The tier has no name a player is allowed to see.

Warning: The quota must stay 24 + 1. CORRUPTED is declared last and so is trimmed first - a 26th square comes straight off the one square the board exists for, leaving a board that looks right. Four tests pin the preset, the composition, the trim order and the empty-pool fallback.

Warning: Upgrading a config that already has a CORRUPTED block: corrupted-per-card is a new key, so an existing block reads it as 0 and keeps ultra-rare-per-card: 25 - the tier is then configured but never drawn, silently. Set it to 24 + 1 by hand, and add the rarity row to the icon lore. A config with no difficulties section at all (which is every server that has not run .12+) gets it right from the preset and needs nothing. This bit mcnet; it is the third time this class of trap has, after the leaderboard tabs and allow-reroll.

Starting it says something. hiddenCardStartedMessage is sent straight after the normal card-started line, on any board whose hidden is true:

(!) Something is wrong with this card.
You feel that finishing it inside 6h would be remembered.

Deliberately vague about the reward - it says a prize exists without naming the tag, so somebody who finds the board learns there is something to win and still gets the surprise.

It cannot be re-rolled. A re-roll re-draws the squares, and on 25 ultra rares that is not a cosmetic reshuffle - it is fishing for the 25 least impossible ultra rares, once a day, until the board comes up clearable. The one you are dealt is the one you get.

Enforced at three points, because each answers a different question:

DifficultySettings.isRerollAllowed() !hidden - the rule itself
BingoAttribute.canReroll() the choke point, so rerollCard() refuses too
SelfReroll.attempt() the player-facing refusal, with its own message
BingoCardUI.addRerollButton() draws no reroll slot at all

The button has to be handled separately rather than left to canReroll(): a false from that draws the spent item, which would tell somebody holding an unrerollable card they may re-roll again in 0s. A rule and a cooldown are different answers and only one of them is a wait. The refusal also does not spend the daily allowance, so a mistaken click costs nothing.

Warning: isRerollAllowed() is derived from hidden, not its own config key - deliberately. A new key would not reach a config that already defines its difficulties: the block is deserialized over the Java defaults, so an absent allow-reroll reads as whatever the field initialiser says and CORRUPTED would come back re-rollable on every server that already has it. That is the same trap that made the first leaderboard-tab migration write four black panes. Reading a key that is already written is correct everywhere with no migration. Splitting them is a small change if a hidden-but-rerollable board is ever wanted.

Warning: /bingo reroll <player> still works on it. The staff form bypasses every self-reroll rule by design - it calls generateNewCard() directly. If the secret board should be exempt from that too, it has to be added there.

The tag. cardCompleteRewards carries a third command, tagsadmin grant %player% bingo bingo_corrupted, appended to the guaranteed reward - a weighted entry behind rewardRolls of 0/0 would never run. It is on card completion rather than a speed tier, and that is the "under six hours" rule: CardResetTask clears a card the moment its duration elapses, so a card that can be completed at all was completed inside its six hours. A tier bound would have to be repeated on all three tiers to mean the same thing and would silently stop meaning it if a tier were re-timed.

The command is not a payment and UtilRewardAmount does not read it - only a command starting with a paymentCommandPrefixes entry is considered, so CORRUPTED still quotes $13 / $260 / $1,950. The bingo tag must exist in SnowbirdTags' catalog or the grant answers unknown-tag; see documentation/plugins/snowbirdtags.md.

The chain from completion to tag, verified end to end in the bytecode:

BingoCardCompleteListener.payCardComplete
  -> attribute.settings()                  the difficulty the CARD was dealt at, not the default
  -> settings.getCardCompleteRewards().give(player, %player%, %time%, %tier%, %difficulty%)
  -> ConfigRewardPool.give -> getRandomRewards()   includes the GUARANTEED reward
  -> ConfigReward.execute(ServerPlayer, ...)       adds its own %player% from getName()
  -> PlatformProxy.executeConsoleCommands(...)     all three commands, as CONSOLE

%player% resolves twice over - the listener passes it and execute adds it again from the live ServerPlayer - and the player is by definition online at completion, which is what tagsadmin grant <name> requires. eco give is likewise a Bukkit plugin command and has paid every bingo card on this network from this same path, so the console dispatcher reaching a Bukkit plugin is not in question.

Warning: CORRUPTED must stay LAST in the Difficulty enum. Declaration order is picker order, so anywhere else puts the hidden button in among the four real ones - and it is drawn as the screen's own filler item, so it would silently occupy a visible button's slot.

Warning: 25 ultra rares in six hours may simply never be cleared. Every species in that tier scores under 1% findability by the mod's own measure. That is the intent of an easter egg rather than a balance problem, but the [BINGO] tag should be expected to stay unearned.

The leaderboard is now per difficulty

One board each, with tabs to switch. Not a display preference: a 16-square card drawn almost entirely from the common tier and a 25-square card with six ultra rares have completion times that differ by more than any two players do. Ranked together, every easy card would sit above every insane one and the list would measure which difficulty somebody picked rather than how they played.

Since snowbird.14 the tabs are each difficulty's own colour, matching that board's button on the start screen, with a BOARDS label at the left of the row and the selected tab prefixed, re-lored and enchanted.

Until then all four were gray_stained_glass_pane - on a screen whose filler is black_stained_glass_pane. They read as the same furniture as the fill, so a player who never hovered one had no way of knowing three other boards existed, and took the ranking for everybody's times against their own. Only the lime selected tab stood out, and the one hint that the row was interactive lived in the info book's hover lore. Each difficulty now carries leaderboard-tab; leaderboardDifficultyItem is legacy and read only to carry a customised tab across the upgrade.

Warning: ensureLeaderboardTabs() cannot test the new key for null. Configurate does not leave a missing nested key null - it hands back new ConfigItem(), whose own defaults are a black stained glass pane named with a single space and no lore. The first attempt checked for null, skipped every difficulty, and wrote four black panes into config.yml: the tabs came out as literal furniture, a more thorough version of the bug being fixed. The test is now "does this equal the API's untouched default", compared against a real instance of it.

reforged_bingo_results gains a difficulty column and both queries filter on it. The per-player query filters twice - inside the subquery that finds each player's best time and again outside it. Both are needed: without the inner one a player's best time overall is compared against this difficulty's rows and matches nothing; without the outer one, a player whose best easy time happens to equal their best hard time appears on both boards.

Warning: Upgrading: HARD is seeded from the old top-level keys

ensureDifficulties() builds any difficulty the config does not define. For HARD it does not use the built-in preset - it copies the legacy top-level height, width, cardPositions, the four *PerCard counts, countDiagonals, the two durations and all four reward pools.

Those legacy keys are kept in BingoConfig for exactly this and are read nowhere else. Their getters were deleted so that a call site wanting the live board and reaching for the global one does not compile.

Deleting them would mean a server that upgraded into this build silently reverting a tuned payout schedule to the example figures shipped here. On main that is a 25× payout increase - the live schedule is $10/square against this file's $250 - with nothing logged and no player likely to report it.

The section is written back to config.yml on the boot that creates it, so it is editable afterwards. If that write fails the mod still plays correctly (the map is rebuilt every boot) and warns that the presets are not yet editable.

Stored cards and stored results both default to 'HARD', in the column default and in UtilBingoMigration.readDifficulty(). Every card that predates this change was the 5×5 board now called HARD, so that is a correct reading rather than a fallback - and defaulting to EASY would have both re-priced and re-ranked the network's entire card history.

Warning: scripts/build_bingo_rewards_config.py no longer matches this config. It patches the top-level pools by regex and asserts each replacement fires exactly once; there are now four copies of each pool and the top-level ones are inert. It will fail loudly rather than half-patch, but it has to be taught about difficulties before payouts can be re-tuned that way.

Daily self re-roll

A player can throw away the card they were dealt and take another, once a day.

  • /bingo reroll, or the button on the card screen.
  • It is a full reset, not a reshuffle. New squares, every mark lost, and started moved to now - so the timer restarts at the full cardDurationSeconds and the speed bonus is measured from the re-roll.
  • allowSelfReroll (default on) and selfRerollCooldownSeconds (default 86400) control it. The window is measured from the last re-roll, not from a fixed daily reset, so there is no midnight boundary where two land minutes apart.
  • The window is persisted (lastReroll, new column on reforged_bingo_cards). An in-memory field would hand out a fresh re-roll on every reconnect.
  • Requires a card. Re-rolling nothing would otherwise mint one while newCardCooldownSeconds was still running, since generateNewCard() does not consult it.

Warning: Restarting the timer restarts the new-card cooldown with it, because that cooldown is measured from started. So each re-roll is worth roughly one extra card window, and selfRerollCooldownSeconds is the only thing bounding how many a player gets. At the default that is one extra window a day; at 3600 it is six, and the speed bonus becomes farmable in exactly the way newCardCooldownSeconds exists to prevent.

It cannot bank an extra completion at any setting: completing a card clears it, and there has to be a card to re-roll.

The card screen's button asks for confirmation first (rerollRequiresConfirmation, default on), stating the marked-square count and the time being given up. The command does not - typing it is deliberate in a way that clicking a slot is not.

Staff resets (snowbird.16)

Two admin commands for a player who is stuck - a card they cannot finish, a board dealt at the wrong difficulty, a cooldown being served for a card that went wrong.

Command Permission Does
/bingo reset <player> reforged.bingo.command.reset cancels their card and clears their new-card cooldown
/bingo resetcooldown <player>
(alias clearcooldown)
reforged.bingo.command.resetcooldown clears the cooldown only

Nothing is paid and nothing is recorded. A cancelled card never reaches BingoCardCompleteListener, so no money is given and no reforged_bingo_results row is filed. That is the intent: this is for a card that should not have counted, not a way to hand somebody a payout.

Both report what actually changed, not a flat "done" - %changed% reads card cancelled, cooldown cleared, or one of them, or nothing to reset. An admin told "reset" either way cannot tell a fixed player from a mistyped name that happened to match somebody else.

Warning: resetcooldown refuses on a player holding a card, and has to. started is one timestamp and both the card's own expiry and the new-card cooldown are measured against it, so zeroing it under a live card makes getElapsedSeconds() the entire Unix epoch: the card is instantly hasExpired() and CardResetTask deletes it on its next pass. "Clear the cooldown" would have quietly meant "and destroy the card, in about a minute". clearNewCardCooldown() refuses while hasActiveCard(), and the command says so and points at /bingo reset - which does both, in the order that works (cancel first, or the clear is refused).

Warning: Online players only, matching /bingo reroll <player>. The attribute is loaded per session and every mutation is to that loaded copy, so an offline reset would mean writing reforged_bingo_cards directly - and a player logging in afterwards would overwrite it from their own session anyway. resetTargetOfflineMessage says so rather than appearing to work.

Writes immediately. Attribute saves are throttled and otherwise happen on logout, which is fine for a card being played and wrong for a staff fix: the admin has just told somebody they are reset, and an ungraceful stop in the next minute would silently undo it. StaffReset.persist() calls the adapter directly; a failed write is logged and does not hold up the command, because the reset is already correct in memory and the periodic save will retry.

Deliberately untouched: the daily re-roll window (lastReroll), so a player who has already re-rolled today still cannot re-roll their fresh card, and completedCards, which is lifetime history rather than current state.

The permission moved

reforged.bingo.command.reroll was a class-level @Permissible on the whole reroll subcommand. It is now checked by hand in ReRollCommand and gates only /bingo reroll <player>. The player's own /bingo reroll is not permission-gated.

A subcommand name can only be claimed once, and reroll is the only name either form could sensibly have - leaving the staff command on it would have meant every player who typed the obvious thing got "you do not have permission" for a feature built for them.

The cost is tab completion on the staff form. @Completable(PlayerTabCompleter.class) attaches to a declared @Argument, and a declared argument cannot be made optional: AnnotationPlatformCommandExecutor injects null for a missing one and then returns without calling the method, so /bingo reroll would have done nothing at all, silently. Raw String[] args is the only shape that can tell "no argument" from "one argument", and it carries no completer.

Rewards

cardCompleteRewards became a base payout; speedBonusTiers pays on top. First tier whose maxSeconds >= elapsed wins, and the list is sorted on read so a tier appended to the end cannot silently become unreachable. Defaults are 1h / 3h / 6h. An empty list degrades to a flat card reward rather than to nothing.

Display

getTimeRemaining() returns a formatted string ("5h 12m"). Upstream returned whole hours from TimeUnit.toHours, which truncates - on a 6h card the final 59 minutes all displayed as 0, and any card shorter than an hour read as zero for its entire life. util/UtilTime does the formatting.

Transition off a stock-shaped card

ensureCardValid() replaces upstream's checkCardSize(). Upstream called generateNewCard() when a stored card's dimensions no longer matched the config; carried into this fork that is a hole straight through its purpose, because every player still holding a 4x7 card from the stock jar would have had a fresh 5x5 minted on login with the clock already running and the rewards screen never shown. It now ends the card instead, so the player lands on the start screen.

All four call sites (setParent, catchPokemon, completeSlot, display) guard on the return value rather than assuming a card survives the call. hasUsableCard() is what the UI and commands branch on; hasActiveCard() alone would route a wrongly-shaped card to the card screen and render a broken grid.

Removed

  • columnCompleteRewards - merged into lineCompleteRewards.
  • staticResetTimeEnabled / staticResetTime - cron-scheduled global resets are meaningless once each player starts their own card, and checkCardExpiry() branched on them.

Metadata

  • Compiles against Pixelmon 9.3.16 and NeoForge 21.1.232, matching the live servers. Upstream targeted 9.3.2 / 21.1.162.
  • neoforge.mods.toml declares pixelmon [9.3.16,). Upstream declares no Pixelmon dependency at all despite calling the Pixelmon API throughout, which is how an API break there surfaces as a NoSuchMethodError on a player's first catch instead of as a boot failure the operator sees.

Known limitation

Partly superseded: since snowbird.14 each difficulty button reads its payout figures from the reward commands (see above). The note below still applies to the hand-written rewardsInfoItem lore.

The reward figures on the pre-start screen are operator-maintained text. A ConfigRewardPool is a weighted set of command lists, not an amount, so the mod cannot render the numbers it is about to pay. Only derivable facts are substituted: %card_size%, %total_slots%, %total_lines%, %card_duration%, %cooldown_remaining%, %tier_N_name%, %tier_N_time%.

Change a reward pool and you must edit rewardsInfoItem's lore in the same pass, or the screen misquotes the payout at the exact moment a player is deciding whether to start a 6 hour clock.

Files touched

neo21/build.gradle                                        Pixelmon 9.3.16, NeoForge 21.1.232
build.gradle                                              version 3.1.0-snowbird.1
neo21/src/main/resources/META-INF/neoforge.mods.toml      version, pixelmon dependency
config/BingoConfig.java                                   5x5, diagonals, speed tiers, cooldown,
                                                          pre-start GUI items; cron removed
config/BingoLocaleConfig.java                             lifecycle messages
player/BingoAttribute.java                                on-demand cards, diagonals, line counting,
                                                          formatted time remaining
event/BingoSlotCompleteEvent.java                         linesCompleted
listener/BingoCardCompleteListener.java                   unified line payout, speed tiers, endCard
task/CardResetTask.java                                   expire instead of reissue
ui/BingoStartUI.java                                      NEW - pre-start rewards screen
ui/BingoCardUI.java                                       time placeholder, start-screen fallback
command/BingoCardCommand.java                             routes to card or start screen
command/StartCommand.java                                 NEW - /bingo start
util/UtilTime.java                                        NEW - duration formatting

card/Difficulty.java                                      NEW - EASY/MEDIUM/HARD/INSANE
card/DifficultyPreset.java                                NEW - board + quota per difficulty
                                                          (pure, tested)
config/BingoConfig.java                                   DifficultySettings; per-difficulty board,
                                                          quota, clock and all four reward pools;
                                                          legacy keys kept as migration source
card/BoardComposer.java                                   composes for a difficulty, not a global
player/CardSnapshot.java                                  carries the difficulty it was played at
leaderboard/BingoLeaderboard.java                         a board per difficulty, cache and all
leaderboard/BingoRecord.java                              records its difficulty
ui/BingoStartUI.java                                      the difficulty picker
ui/BingoLeaderboardUI.java                                per-difficulty boards and tabs
command/StartCommand.java                                 /bingo start <difficulty>
player/RerollCooldown.java                                NEW - daily re-roll window (pure, tested)
player/SelfReroll.java                                    NEW - re-roll + the three refusal messages
player/UtilBingoMigration.java                            NEW - ADD COLUMN for tables already live
ui/BingoRerollConfirmUI.java                              NEW - re-roll confirmation screen
command/ReRollCommand.java                                self re-roll; permission now manual and
                                                          scoped to the <player> form
player/SQL*BingoAttributeAdapter.java                     lastReroll column + migration; DDL is now
                                                          synchronous and ordered

util/UtilRewardAmount.java                                NEW - reads a payout out of the reward
                                                          commands; unknown beats wrong
                                                          (pure, tested)
util/UtilPayoutLore.java                                  NEW - the payout block, dropping any
                                                          line it cannot fill (pure, tested)
config/BingoConfig.java                                   RewardDisplaySettings, AmountOverrides,
                                                          SelectedTabSettings; per-difficulty
                                                          leaderboardTab; ensureLeaderboardTabs()
ui/BingoStartUI.java                                      money placeholders + the %payouts% block
ui/BingoLeaderboardUI.java                                per-difficulty coloured tabs, BOARDS
                                                          label, selected marker

card/Difficulty.java                                      NEW constant CORRUPTED, last
card/DifficultyPreset.java                                CORRUPTED: 5x5, 25 ultra rares
                                                          (pure, tested)
config/BingoConfig.java                                   `hidden`; CORRUPTED name, lore, corner
                                                          position and tag command;
                                                          getPickerDifficulties() vs
                                                          getSelectableDifficulties()
ui/BingoStartUI.java                                      draws hidden buttons too
ui/BingoLeaderboardUI.java                                no tab for a hidden difficulty

command/ResetCommand.java                                 NEW - /bingo reset <player>
command/ResetCooldownCommand.java                         NEW - /bingo resetcooldown <player>
command/StaffReset.java                                   NEW - target lookup + immediate save
player/BingoAttribute.java                                cancelCard(), clearNewCardCooldown()
config/BingoLocaleConfig.java                             the reset messages, incl. %changed%

Deployment bundle: manual_uploads/2026-08-07-reforgedbingo-5x5-events/README.md. Push script: scripts/push_reforgedbingo_5x5_to_events.sh.