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()leavesbingoCardnull and waits./bingowith no card opensBingoStartUI- a rewards breakdown with a START button. The card is generated, and the clock started, on click.cardDurationSecondsdefaults to21600(6h).CardResetTaskclears 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, andcheckCardCompletion()would pay again on every later catch.newCardCooldownSeconds(default21600) is measured from the previous card's start, which is whystartedis deliberately not cleared byendCard().
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 aneco givethat 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.guaranteedRewardis 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 startopens the picker;/bingo start insaneskips 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: falserestores the single-button screen and dealsdefaultDifficulty.- 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,
resolveQuotatrims 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:
filterscaps legendaries at one per card (spec: leg, limit: 1onmain), 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 alegendary/mythical/ultrabeasttag, leaving ~221 onmain'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 failtooRareFloor(a vanishing share of the world) orbestCaseFloor(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-cardis a new key, so an existing block reads it as 0 and keepsultra-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 nodifficultiessection 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 andallow-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 fromhidden, 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 absentallow-rerollreads 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 callsgenerateNewCard()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
Difficultyenum. 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 backnew 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 intoconfig.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
startedmoved to now - so the timer restarts at the fullcardDurationSecondsand the speed bonus is measured from the re-roll. allowSelfReroll(default on) andselfRerollCooldownSeconds(default86400) 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 onreforged_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
newCardCooldownSecondswas still running, sincegenerateNewCard()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:
resetcooldownrefuses on a player holding a card, and has to.startedis 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 makesgetElapsedSeconds()the entire Unix epoch: the card is instantlyhasExpired()andCardResetTaskdeletes it on its next pass. "Clear the cooldown" would have quietly meant "and destroy the card, in about a minute".clearNewCardCooldown()refuses whilehasActiveCard(), 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 writingreforged_bingo_cardsdirectly - and a player logging in afterwards would overwrite it from their own session anyway.resetTargetOfflineMessagesays 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 intolineCompleteRewards.staticResetTimeEnabled/staticResetTime- cron-scheduled global resets are meaningless once each player starts their own card, andcheckCardExpiry()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.tomldeclarespixelmon [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 aNoSuchMethodErroron 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.