Implement Rulebreaker (+Partial implementation of DeckRule) - #11740
Open
BigCrunch22 wants to merge 12 commits into
Open
Implement Rulebreaker (+Partial implementation of DeckRule)#11740BigCrunch22 wants to merge 12 commits into
BigCrunch22 wants to merge 12 commits into
Conversation
BigCrunch22
marked this pull request as ready for review
August 31, 2026 12:12
tool4ever
reviewed
Aug 31, 2026
tool4ever
reviewed
Aug 31, 2026
tool4ever
reviewed
Aug 31, 2026
tool4ever
reviewed
Aug 31, 2026
- Fix double spacing issues - Change ActivationLimit$ to ResolvedLimit$
Contributor
|
How well do these hold up in Adventure Mode? It has its own enforcement logic for certain deck rules. |
Jetz72
reviewed
Aug 31, 2026
Comment on lines
+217
to
+264
| /** | ||
| * The approved-colors mask this rule would end up with if {@code candidate} were let through its | ||
| * budget, or -1 if the candidate is out of scope or the budget can't cover it. Shared by | ||
| * {@link #tryApproveAdditionalColor} and {@link #wouldApproveAdditionalColor}, which only differ | ||
| * in whether they commit the result. | ||
| */ | ||
| private byte wouldBeApprovedColors(final CardRules candidate, final byte commanderCI) { | ||
| if (additionalColorCount == 0) { | ||
| return -1; | ||
| } | ||
| boolean matchesScope = false; | ||
| for (final Branch b : additionalColorBranches) { | ||
| if (b.matches(candidate)) { | ||
| matchesScope = true; | ||
| break; | ||
| } | ||
| } | ||
| if (!matchesScope) { | ||
| return -1; | ||
| } | ||
| final byte baseline = (byte) (commanderCI | additionalColorsApproved); | ||
| // 0 here means already covered by commander CI or a previously-approved color. | ||
| final byte missing = candidate.getColorIdentity().getMissingColors(baseline).getColor(); | ||
| final byte wouldBeApproved = (byte) (additionalColorsApproved | missing); | ||
| if (ColorSet.fromMask(wouldBeApproved).countColors() > additionalColorCount) { | ||
| return -1; // would need more distinct extra colors than the budget allows | ||
| } | ||
| return wouldBeApproved; | ||
| } | ||
|
|
||
| /** | ||
| * Approves the candidate against the {@code AllowedAdditionalColor$} budget, remembering any | ||
| * newly-used color. Stateful - call once per candidate, only after | ||
| * {@link #allowsOffColorIdentity} returns false; prime with cards already in the deck first. | ||
| */ | ||
| public boolean tryApproveAdditionalColor(final CardRules candidate, final byte commanderCI) { | ||
| final byte wouldBeApproved = wouldBeApprovedColors(candidate, commanderCI); | ||
| if (wouldBeApproved < 0) { | ||
| return false; | ||
| } | ||
| additionalColorsApproved = wouldBeApproved; | ||
| return true; | ||
| } | ||
|
|
||
| /** Read-only {@link #tryApproveAdditionalColor}: same check, doesn't commit the color. */ | ||
| public boolean wouldApproveAdditionalColor(final CardRules candidate, final byte commanderCI) { | ||
| return wouldBeApprovedColors(candidate, commanderCI) >= 0; | ||
| } |
Contributor
There was a problem hiding this comment.
Rather than try to determine which other CI the player wants to use automatically, would it be simpler to just ask them what additional color they want upfront? We have some precedent for choosing color identities at deck-construction time through Cryptic Spires. Could we use something similar to that?
Doing it automatically does seem like a smoother experience, but I worry that it'll be harder to maintain as rule-breaker effects become more complex.
tool4ever
reviewed
Aug 31, 2026
- Applied getRegisteredPlayer().getDeck to relevant parts of the code - Removed "Type:" for parsing Exempt$ - AllowedAdditionalColor$ now includes AllowedAdditionalColorType$ to determine what type it affects
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rulebreaker mechanic + partial
DeckRuledeckbuilding frameworkSummary
This adds a new custom keyword, Rulebreaker, and the engine infrastructure needed to support it: a partial implementation of the
DeckRule:line-type framework sketched in #8066 ("Rework deckbuilding rules into a set of objects"). Rulebreaker cards bend specific deckbuilding restrictions (color identity, deck size) when they're your commander. All 12 cards in the Rulebreaker cycle are implemented and scripted.Background
Rulebreaker cards read like:
This is a deckbuilding-legality effect, not a live-game one — it changes which cards are legal to put in the deck, not anything that happens once a game starts.
#8066 already proposes the right shape for this: a new top-level script line,
DeckRule:, parsed independently ofK:lines, with rule classes likeColorIdentityandSize. This PR implements that framework — scoped to what Rulebreaker actually needs (ColorIdentityandSize), not the issue's full vision (Copies,Commander,FormatPool,Variants,FormatRestrictionsare not attempted here).New: the
DeckRuleframework (forge-core)Three new classes in
forge.deck:DeckRule— abstract base and dispatcher. ParsesDeckRule:<RuleClass>:<Key$ Value | ...>lines, handlesActiveSection$gating (e.g. a rule can be scoped to apply only while its card is the actual Commander), and routes to the right subclass by rule class name.parseAllhas two overloads — one taking aPaperCard(deckbuilding-time callers) and one taking the rawIterable<String>ofDeckRule:line values directly, for callers like the live gameCardthat only have a card face's raw data, not a fullPaperCard.DeckRuleColorIdentity—DeckRule:ColorIdentity:.... Supports:Exempt$ Type:<branches>— exempts matching cards from the color-identity check entirely. Branches are comma-separated (OR); within a branch, tokens are dot-then-plus, mirroring howValidCard$/Card.isValid()restriction strings already work elsewhere in the engine (traced directly fromCard.isValid()'s ownsplit("\\.", 2)thensplit("\\+")). Recognized tokens: real core/super/subtype words (viaCardType.parse), the bare wordPermanent, or a stat filter using the engine's existing shorthand (powerGE4,cmcGE7, ...).Disable$ True— waives the color-identity check entirely (e.g. a Paradise Bird-style card).AllowedAdditionalColor$ <n>:Type:<branches>— grants a shared budget ofnextra colors (beyond the commander's own color identity) to cards matching the branch grammar. The budget caps how many distinct extra colors get used across the whole deck, not how many cards use them (two mono-red instants both fit a budget of 1; a mono-red and a mono-green together do not). Mirrors the pattern the engine's own pre-existing commander-wildcard-color mechanism already uses (DeckFormat'scmdCI |= missingColors).DeckRuleSize—DeckRule:Size:AdjustMax$ <Unlimited|±n>. OnlyAdjustMax$is implemented;AdjustMin$/Cumulative$are left alone since Advantageous Proclamation already has its own hardcoded check inDeckFormat, and wiring an overlapping rule risked double-applying the adjustment.Engine plumbing changes
forge-coreICardRawAbilites.java/CardFace.java— newdeckRulesbucket +getDeckRules(), mirroring the existingkeywordsbucket exactly (including the functional-variant copy-down logic).CardRules.java—Reader.parseLine()gets aDeckRulecase (alongside the existingD:cases forDeckHints/DeckNeeds/etc.); newgetDeckRules()accessor.DeckFormat.java:getDeckConformanceProblem()— gathers each commander's activeDeckRuleColorIdentity/DeckRuleSizerules once, then consults them in both the main-deck and sideboard color-identity loops, and appliesSizeadjustments before the deck-size check.isLegalCardForCommanderPredicate(commanders, currentMain)— the predicate that actually controls which cards a live deck-editor UI shows in its catalog. Now takes the deck's current main-deck contents as a second argument, used to primeAllowedAdditionalColor$state before building the predicate (see below).forge-gamePlayer.java— newgetStartingLibrarySize()/setStartingLibrarySize(), mirroring the existinggetStartingLife()/setStartingLife()pattern.Game.java— sets it during player setup viapsc.getDeck().getMain().countAll(), in the same unconditional per-player loop every format already goes through (Commander and constructed are both covered without any format-specific branching).AbilityUtils.java— exposes it asCount$YourStartingLibrarySize, in both placesYourStartingLifeis already handled.Card.java—getAbilityText(CardState state), the method that actually composes a card's live displayed text, now folds each activeDeckRule'sDescription$in right after the keyword-reminder text, matching where the Oracle template places it. Previously nothing carried a card'sDeckRule:lines from forge-core over to the live gameCardat all, so a Rulebreaker's description only ever showed up in its static, manually-duplicatedOracle:text — visible in out-of-game previews (deck editor, card database) but never on an actual permanent, in hand, or in the command zone. This runs through the same view-update path every card already relies on for its keyword/trigger/static text (updateStateForView() → updateKeywords() → updateAbilityText()), so it's populated reliably from the moment a card is created, with no extra per-zone plumbing needed. Because this lives inforge-game, it's shared by every GUI (desktop included) rather than being another mobile-only patch.Mobile UI (
forge-gui-mobile)FDeckEditor.javaneeded several fixes to actually reflectDeckRulein the deck editor's live catalog (Android and iOS share this file viaforge-gui-mobile, so one fix covers all three;forge-gui-desktopwas checked and doesn't need equivalent changes — see Desktop below):canOnlyBePartnerCommander()— was doing its own rawhasNoColorsExcept()check with zeroDeckRuleawareness, wrongly gating the "Add to deck" menu item for legitimately-exempt cards. Now consults activeDeckRuleColorIdentityrules (allowsOffColorIdentity) and, forAllowedAdditionalColor$, primes from the deck's current main section before testing the candidate card.CatalogPage.refresh()— this is what actually controls catalog visibility (viacardPool.retainIf(...)), a different and more consequential gate than the menu-item check above. It callsDeckFormat.isLegalCardForCommanderPredicate(), which needed the sameAllowedAdditionalColor$awareness; a read-onlyDeckRuleColorIdentity.wouldApproveAdditionalColor()was added alongside the existing mutatingtryApproveAdditionalColor()specifically so that browsing the catalog can never itself commit to a color choice — only cards actually added to the deck do.DeckSectionPage.updateCaption()— re-narrows the catalog as the additional-color budget is used up. Scoped carefully to avoid two regressions found in testing:AllowedAdditionalColor$budget (hasAllowedAdditionalColorBudget()), not for every Commander deck.lastApprovedAdditionalColorssignature,computeApprovedAdditionalColors()), not on every single card added. Both were needed — the first alone still reset the catalog's scroll position on every add once a color was already locked in.Desktop (
forge-gui-desktop)No changes needed. Desktop's Commander editor doesn't do live catalog filtering at all — its own class doc comment says "least restrictive mode; all cards are available," and
Main/Sideboardalways show the fully unfiltered pool regardless of commander. It only enforces color identity at match-start time viaGameLobby.javacallingDeckFormat.getDeckConformanceProblem(), which is alreadyDeckRule-aware through the forge-core changes above.Cards implemented
All 12 cards in the Rulebreaker cycle:
Exempt$ Type:Artifact.Creature,Equipment,Basic.LandExempt$ Type:Creature.powerGE4Exempt$ Type:Instant,SorceryExempt$ Type:Creature.cmcGE7,Basic.LandExempt$ Type:LandAllowedAdditionalColor$ 1:Type:Instant,Sorcery+Exempt$ Type:Basic.LandDeckRule:Size:AdjustMax$ UnlimitedExempt$ Type:Phyrexian,Basic.LandExempt$ Type:Artifact,EnchantmentExempt$ Type:Permanent.LegendaryExempt$ Type:Aura,Basic.LandExempt$ Type:Angel,Basic.LandKnown limitations
DeckRule:Copies,DeckRule:Commander,DeckRule:FormatPool, etc. from Rework deckbuilding rules into a set of objects #8066's fuller vision are not implemented — onlyColorIdentityandSize, which is what this cycle needs.AllowedAdditionalColor$'s live UI support is mobile-only.isLegalCardForCommanderPredicate()in forge-core is fullyDeckRule-aware and correct at save/match-start time on every platform; the live, as-you-build-the-deck re-narrowing behavior was only worth wiring up inforge-gui-mobile, since desktop has no equivalent live filtering to extend in the first place.DeckRule:Size; migrating it wasn't attempted, to avoid double-applying the adjustment.Code changes made with heavy assistance from Claude