Skip to content

Implement Rulebreaker (+Partial implementation of DeckRule) - #11740

Open
BigCrunch22 wants to merge 12 commits into
Card-Forge:masterfrom
BigCrunch22:add-deck-rule-rulebreaker
Open

Implement Rulebreaker (+Partial implementation of DeckRule)#11740
BigCrunch22 wants to merge 12 commits into
Card-Forge:masterfrom
BigCrunch22:add-deck-rule-rulebreaker

Conversation

@BigCrunch22

@BigCrunch22 BigCrunch22 commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Rulebreaker mechanic + partial DeckRule deckbuilding framework

Summary

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:

Rulebreaker — A deck with this commander can have artifact creature and Equipment cards of any color identity and any basic land cards.

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 of K: lines, with rule classes like ColorIdentity and Size. This PR implements that framework — scoped to what Rulebreaker actually needs (ColorIdentity and Size), not the issue's full vision (Copies, Commander, FormatPool, Variants, FormatRestrictions are not attempted here).

New: the DeckRule framework (forge-core)

Three new classes in forge.deck:

  • DeckRule — abstract base and dispatcher. Parses DeckRule:<RuleClass>:<Key$ Value | ...> lines, handles ActiveSection$ 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. parseAll has two overloads — one taking a PaperCard (deckbuilding-time callers) and one taking the raw Iterable<String> of DeckRule: line values directly, for callers like the live game Card that only have a card face's raw data, not a full PaperCard.
  • DeckRuleColorIdentityDeckRule: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 how ValidCard$/Card.isValid() restriction strings already work elsewhere in the engine (traced directly from Card.isValid()'s own split("\\.", 2) then split("\\+")). Recognized tokens: real core/super/subtype words (via CardType.parse), the bare word Permanent, 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 of n extra 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's cmdCI |= missingColors).
  • DeckRuleSizeDeckRule:Size:AdjustMax$ <Unlimited|±n>. Only AdjustMax$ is implemented; AdjustMin$/Cumulative$ are left alone since Advantageous Proclamation already has its own hardcoded check in DeckFormat, and wiring an overlapping rule risked double-applying the adjustment.

Engine plumbing changes

forge-core

  • ICardRawAbilites.java / CardFace.java — new deckRules bucket + getDeckRules(), mirroring the existing keywords bucket exactly (including the functional-variant copy-down logic).
  • CardRules.javaReader.parseLine() gets a DeckRule case (alongside the existing D: cases for DeckHints/DeckNeeds/etc.); new getDeckRules() accessor.
  • DeckFormat.java:
    • getDeckConformanceProblem() — gathers each commander's active DeckRuleColorIdentity/DeckRuleSize rules once, then consults them in both the main-deck and sideboard color-identity loops, and applies Size adjustments 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 prime AllowedAdditionalColor$ state before building the predicate (see below).

forge-game

  • Player.java — new getStartingLibrarySize()/setStartingLibrarySize(), mirroring the existing getStartingLife()/setStartingLife() pattern.
  • Game.java — sets it during player setup via psc.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 as Count$YourStartingLibrarySize, in both places YourStartingLife is already handled.
  • Card.javagetAbilityText(CardState state), the method that actually composes a card's live displayed text, now folds each active DeckRule's Description$ in right after the keyword-reminder text, matching where the Oracle template places it. Previously nothing carried a card's DeckRule: lines from forge-core over to the live game Card at all, so a Rulebreaker's description only ever showed up in its static, manually-duplicated Oracle: 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 in forge-game, it's shared by every GUI (desktop included) rather than being another mobile-only patch.

Mobile UI (forge-gui-mobile)

FDeckEditor.java needed several fixes to actually reflect DeckRule in the deck editor's live catalog (Android and iOS share this file via forge-gui-mobile, so one fix covers all three;
forge-gui-desktop was checked and doesn't need equivalent changes — see Desktop below):

  • canOnlyBePartnerCommander() — was doing its own raw hasNoColorsExcept() check with zero DeckRule awareness, wrongly gating the "Add to deck" menu item for legitimately-exempt cards. Now consults active DeckRuleColorIdentity rules (allowsOffColorIdentity) and, for AllowedAdditionalColor$, primes from the deck's current main section before testing the candidate card.
  • CatalogPage.refresh() — this is what actually controls catalog visibility (via cardPool.retainIf(...)), a different and more consequential gate than the menu-item check above. It calls DeckFormat.isLegalCardForCommanderPredicate(), which needed the same AllowedAdditionalColor$ awareness; a read-only DeckRuleColorIdentity.wouldApproveAdditionalColor() was added alongside the existing mutating tryApproveAdditionalColor() 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:
    • Only triggers for decks whose commander actually grants an AllowedAdditionalColor$ budget (hasAllowedAdditionalColorBudget()), not for every Commander deck.
    • Only triggers when adding a card actually changed the approved-colors state (tracked via a per-page lastApprovedAdditionalColors signature, 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/Sideboard always show the fully unfiltered pool regardless of commander. It only enforces color identity at match-start time via GameLobby.java calling DeckFormat.getDeckConformanceProblem(), which is already DeckRule-aware through the forge-core changes above.

Cards implemented

All 12 cards in the Rulebreaker cycle:

Card DeckRule clause Other abilities
The Everforger Exempt$ Type:Artifact.Creature,Equipment,Basic.Land Once-per-turn copy trigger for artifact creature/Equipment spells
Hadran, Naya Sunseeder Exempt$ Type:Creature.powerGE4 Any-color mana ability; draw on power-4+ ETB
Daxiver, Izzet Electromancer Exempt$ Type:Instant,Sorcery Any-color mana restricted to instant/sorcery spells
Maular, the Next Evolution Exempt$ Type:Creature.cmcGE7,Basic.Land Doubles power/toughness of attacking mv7+ creatures
Grizzlegom, Hurloon Hero Exempt$ Type:Land 5-clause attack trigger, one per basic land type
Tolabow, Loch Rascal AllowedAdditionalColor$ 1:Type:Instant,Sorcery + Exempt$ Type:Basic.Land Prowess Otter token on instant/sorcery cast
Whtz, the Bibliophile DeckRule:Size:AdjustMax$ Unlimited Draw+gain ability, discounted with 200+ starting cards
Valko Indorian Exempt$ Type:Phyrexian,Basic.Land Phyrexian creatures you control have menace and lifelink
Valko Indorian, Researcher Exempt$ Type:Artifact,Enchantment Artifacts/enchantments you own become both types, all zones; +1/+1 counter on cast
Arvad of the Weatherlight Exempt$ Type:Permanent.Legendary Combat damage puts a legendary permanent from hand onto the battlefield
The Unluckiest Planeswalker Exempt$ Type:Aura,Basic.Land Can be your commander; two loyalty abilities (mana+token, discard/draw/reattach)
Seluma, Light of Aysen Exempt$ Type:Angel,Basic.Land Flying; combat damage reanimates an Angel

Known limitations

  • DeckRule:Copies, DeckRule:Commander, DeckRule:FormatPool, etc. from Rework deckbuilding rules into a set of objects #8066's fuller vision are not implemented — only ColorIdentity and Size, which is what this cycle needs.
  • AllowedAdditionalColor$'s live UI support is mobile-only. isLegalCardForCommanderPredicate() in forge-core is fully DeckRule-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 in forge-gui-mobile, since desktop has no equivalent live filtering to extend in the first place.
  • Advantageous Proclamation still uses its own hardcoded deck-size check rather than DeckRule:Size; migrating it wasn't attempted, to avoid double-applying the adjustment.

Code changes made with heavy assistance from Claude

@BigCrunch22
BigCrunch22 marked this pull request as ready for review August 31, 2026 12:12
Comment thread forge-gui/res/cardsfolder/t/the_everforger.txt Outdated
Comment thread forge-gui/res/cardsfolder/t/the_everforger.txt Outdated
Comment thread forge-gui/res/cardsfolder/v/valko_indorian_researcher.txt Outdated
Comment thread forge-gui/res/cardsfolder/t/tolabow_loch_rascal.txt Outdated
- Fix double spacing issues
- Change ActivationLimit$ to ResolvedLimit$
@Jetz72

Jetz72 commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

How well do these hold up in Adventure Mode? It has its own enforcement logic for certain deck rules.

Comment thread forge-core/src/main/java/forge/deck/DeckRuleColorIdentity.java Outdated
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread forge-game/src/main/java/forge/game/player/Player.java Outdated
BigCrunch22 and others added 4 commits September 1, 2026 10:08
- Applied getRegisteredPlayer().getDeck to relevant parts of the code
- Removed "Type:" for parsing Exempt$
- AllowedAdditionalColor$ now includes AllowedAdditionalColorType$ to determine what type it affects
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants