Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
86e59a4
Refactor: Hidden keywords to StaticAbilityMode (Issue #3307)
calaespi Feb 9, 2026
fb46ce8
test(forge-game): add unit test verifying ForgetOnMoved triggers (Cha…
calaespi Feb 10, 2026
a1b78cf
test(harness): run Upkeep step and execute queued TestAction precondi…
calaespi Feb 10, 2026
5919a1d
Refactor Lure to use StaticAbilityMustBeBlockedByAll
calaespi Feb 10, 2026
ad80ccb
Fix Outpost Siege interaction and update Card lethal logic
calaespi Feb 10, 2026
7e6d6ff
Merge pull request #2 from calaespi/pr-2-harness-outpost-siege
calaespi Feb 10, 2026
bbc9d48
test(forge-game): add unit test verifying ForgetOnMoved triggers (Cha…
calaespi Feb 10, 2026
d45740c
Merge pull request #1 from calaespi/pr-0-forgetonmoved-unit-test
calaespi Feb 10, 2026
5245103
Merge branch 'master' into refactor/hidden-keywords
calaespi Feb 11, 2026
1799153
Refactor hidden keywords to use StaticAbility pattern
calaespi Feb 11, 2026
8ab9fd7
Refactor LethalDamageByPower to use Card field instead of iterating s…
calaespi Feb 11, 2026
4261ce6
Merge branch 'master' into refactor/hidden-keywords
calaespi Feb 11, 2026
ab08814
Refactor: Address PR review comments (Revert MustBeBlocked/CountersRe…
calaespi Feb 12, 2026
30bef57
Merge branch 'master' into refactor/hidden-keywords
calaespi Feb 12, 2026
bd7955e
Resolve conflict in AbilityActivated.java by keeping isDetained check
calaespi Feb 12, 2026
d61fd3d
Merge branch 'master' into refactor/hidden-keywords
calaespi Feb 12, 2026
0832f1a
Fix PR: restore StaticAbilityCountersRemain, remove enum duplicate, c…
calaespi Feb 13, 2026
31e6367
Merge branch 'master' into refactor/hidden-keywords
calaespi Feb 14, 2026
cc46dd5
Remove unused script compile-and-test-pr.sh
calaespi Feb 14, 2026
866f474
Merge branch 'master' into refactor/hidden-keywords
calaespi Feb 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions forge-game/src/main/java/forge/game/GameAction.java
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,17 @@ private Card changeZone(final Zone zoneFrom, Zone zoneTo, final Card c, Integer
lastKnownInfo = CardCopyService.getLKICopy(c);
}

if (!lastKnownInfo.hasKeyword("Counters remain on CARDNAME as it moves to any zone other than a player's hand or library.")) {
boolean retainCounters = lastKnownInfo.hasKeyword("Counters remain on CARDNAME as it moves to any zone other than a player's hand or library.");
if (!retainCounters) {
for (final StaticAbility sa : lastKnownInfo.getStaticAbilities()) {
if (sa.checkConditions(StaticAbilityMode.RetainCounters)) {
retainCounters = true;
break;
}
}
}

if (!retainCounters) {
copied.clearCounters();
}
} else {
Expand Down Expand Up @@ -248,7 +258,16 @@ private Card changeZone(final Zone zoneFrom, Zone zoneTo, final Card c, Integer
}

// need to copy counters when card enters another zone than hand or library
if (lastKnownInfo.hasKeyword("Counters remain on CARDNAME as it moves to any zone other than a player's hand or library.") &&
boolean retainCounters = lastKnownInfo.hasKeyword("Counters remain on CARDNAME as it moves to any zone other than a player's hand or library.");
if (!retainCounters) {
for (final StaticAbility sa : lastKnownInfo.getStaticAbilities()) {
if (sa.checkConditions(StaticAbilityMode.RetainCounters)) {
retainCounters = true;
break;
}
}
}
if (retainCounters &&
!(zoneTo.is(ZoneType.Hand) || zoneTo.is(ZoneType.Library))) {
copied.setCounters(Maps.newHashMap(lastKnownInfo.getCounters()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import forge.game.event.GameEventCardStatsChanged;
import forge.game.player.Player;
import forge.game.spellability.SpellAbility;
import forge.game.staticability.StaticAbility;
import forge.game.staticability.StaticAbilityMode;
import forge.game.trigger.TriggerType;
import forge.game.zone.ZoneType;
import forge.util.Localizer;
Expand Down Expand Up @@ -87,7 +89,21 @@ protected String getStackDescription(SpellAbility sa) {
}

private static void doLoseControl(final Card c, final Card host, final long tStamp) {
if (null == c || c.hasKeyword("Other players can't gain control of CARDNAME.")) {
if (null == c) {
return;
}

boolean cantGainControl = c.hasKeyword("Other players can't gain control of CARDNAME.");
if (!cantGainControl) {
for (final StaticAbility sa : c.getStaticAbilities()) {
if (sa.checkConditions(StaticAbilityMode.CantGainControl)) {
cantGainControl = true;
break;
}
}
}

if (cantGainControl) {
return;
}
final Game game = host.getGame();
Expand Down
12 changes: 11 additions & 1 deletion forge-game/src/main/java/forge/game/card/Card.java
Original file line number Diff line number Diff line change
Expand Up @@ -6025,7 +6025,17 @@ public final int getTotalDamageDoneBy() {

// this is the amount of damage a creature needs to receive before it dies
public final int getLethal() {
if (hasKeyword("Lethal damage dealt to CARDNAME is determined by its power rather than its toughness.")) {
boolean lethalByPower = hasKeyword("Lethal damage dealt to CARDNAME is determined by its power rather than its toughness.");
if (!lethalByPower) {
for (final StaticAbility sa : this.getStaticAbilities()) {
if (sa.checkConditions(StaticAbilityMode.LethalDamageByPower)) {
lethalByPower = true;
break;
}
}
}

if (lethalByPower) {
return getNetPower();
}
return getNetToughness();
Expand Down
56 changes: 48 additions & 8 deletions forge-game/src/main/java/forge/game/combat/CombatUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import forge.game.player.Player;
import forge.game.spellability.SpellAbility;
import forge.game.staticability.StaticAbility;
import forge.game.staticability.StaticAbilityMode;
import forge.game.staticability.StaticAbilityBlockRestrict;
import forge.game.staticability.StaticAbilityCantAttackBlock;
import forge.game.staticability.StaticAbilityMustBlock;
Expand Down Expand Up @@ -832,9 +833,28 @@ public static boolean mustBlockAnAttacker(final Card blocker, final Combat comba
}

private static boolean attackerLureSatisfied(final Card attacker, final Card blocker, final CardCollection blockers) {
if (attacker.hasStartOfKeyword("All creatures able to block CARDNAME do so.")
|| (attacker.hasStartOfKeyword("CARDNAME must be blocked if able.")
&& blockers.isEmpty())
boolean lure = attacker.hasStartOfKeyword("All creatures able to block CARDNAME do so.");
if (!lure) {
for (StaticAbility sa : attacker.getStaticAbilities()) {
if (sa.checkConditions(StaticAbilityMode.MustBeBlockedByAll)) {
lure = true;
break;
}
}
}

boolean mustBeBlocked = attacker.hasStartOfKeyword("CARDNAME must be blocked if able.");
if (!mustBeBlocked) {
for (StaticAbility sa : attacker.getStaticAbilities()) {
if (sa.checkConditions(StaticAbilityMode.MustBeBlocked)) {
mustBeBlocked = true;
break;
}
}
}

if (lure
|| (mustBeBlocked && blockers.isEmpty())
|| (attacker.hasStartOfKeyword("CARDNAME must be blocked by exactly one creature if able.")
&& blockers.size() != 1)
|| (attacker.hasStartOfKeyword("CARDNAME must be blocked by two or more creatures if able.")
Expand Down Expand Up @@ -925,14 +945,15 @@ public static boolean canBlock(final Card attacker, final Card blocker, final Co
}

// TODO remove with HiddenKeyword or Static Ability
final java.util.List<Card> currentBlockers = combat != null ? combat.getBlockers(attacker) : java.util.Collections.emptyList();
boolean mustBeBlockedBy = false;
for (KeywordInterface inst : attacker.getKeywords()) {
String keyword = inst.getOriginal();
// MustBeBlockedBy <valid>
if (keyword.startsWith("MustBeBlockedBy ")) {
final String valid = keyword.substring("MustBeBlockedBy ".length());
if (blocker.isValid(valid, null, null, null) &&
CardLists.getValidCardCount(combat.getBlockers(attacker), valid, null, null, null) == 0) {
CardLists.getValidCardCount(currentBlockers, valid, null, null, null) == 0) {
mustBeBlockedBy = true;
break;
}
Expand All @@ -949,10 +970,29 @@ public static boolean canBlock(final Card attacker, final Card blocker, final Co

// if the attacker has no lure effect, but the blocker can block another
// attacker with lure, the blocker can't block the former
if (!attacker.hasKeyword("All creatures able to block CARDNAME do so.")
&& !(attacker.hasKeyword("CARDNAME must be blocked if able.") && combat.getBlockers(attacker).isEmpty())
&& !(attacker.hasKeyword("CARDNAME must be blocked by exactly one creature if able.") && combat.getBlockers(attacker).size() != 1)
&& !(attacker.hasKeyword("CARDNAME must be blocked by two or more creatures if able.") && combat.getBlockers(attacker).size() < 2)
boolean lure = attacker.hasKeyword("All creatures able to block CARDNAME do so.");
if (!lure) {
for (final StaticAbility sa : attacker.getStaticAbilities()) {
if (sa.checkConditions(StaticAbilityMode.MustBeBlockedByAll)) {
lure = true;
break;
}
}
}
boolean mustBeBlockedIfAble = attacker.hasKeyword("CARDNAME must be blocked if able.");
if (!mustBeBlockedIfAble) {
for (final StaticAbility sa : attacker.getStaticAbilities()) {
if (sa.checkConditions(StaticAbilityMode.MustBeBlocked)) {
mustBeBlockedIfAble = true;
break;
}
}
}

if (!lure
&& !(mustBeBlockedIfAble && currentBlockers.isEmpty())
&& !(attacker.hasKeyword("CARDNAME must be blocked by exactly one creature if able.") && currentBlockers.size() != 1)
&& !(attacker.hasKeyword("CARDNAME must be blocked by two or more creatures if able.") && currentBlockers.size() < 2)
&& !blocker.getMustBlockCards().contains(attacker)
&& !mustBeBlockedBy
&& mustBlockAnAttacker(blocker, combat, null)) {
Expand Down
15 changes: 15 additions & 0 deletions forge-game/src/main/java/forge/game/phase/Untap.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@
import forge.game.player.Player;
import forge.game.player.PlayerController.BinaryChoiceType;
import forge.game.spellability.SpellAbility;
import forge.game.staticability.StaticAbility;
import forge.game.staticability.StaticAbilityCantPhase;
import forge.game.staticability.StaticAbilityMode;
import forge.game.trigger.TriggerType;
import forge.game.zone.ZoneType;

Expand Down Expand Up @@ -90,6 +92,19 @@ private void doUntap() {

CardZoneTable triggerList = new CardZoneTable(game.getLastStateBattlefield(), game.getLastStateGraveyard());
CardCollection bounceList = CardLists.getKeyword(untapList, "During your next untap step, as you untap your permanents, return CARDNAME to its owner's hand.");

for (final Card c : untapList) {
if (bounceList.contains(c)) {
continue;
}
for (final StaticAbility sa : c.getStaticAbilities()) {
if (sa.checkConditions(StaticAbilityMode.BounceAtUntap)) {
bounceList.add(c);
break;
}
}
}

for (final Card c : bounceList) {
Card moved = game.getAction().moveToHand(c, null);
triggerList.put(ZoneType.Battlefield, moved.getZone().getZoneType(), moved);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
import forge.game.player.Player;
import forge.game.player.PlayerController.FullControlFlag;
import forge.game.staticability.StaticAbilityCantBeCast;
import forge.game.staticability.StaticAbility;
import forge.game.staticability.StaticAbilityMode;

/**
* <p>
Expand Down Expand Up @@ -87,8 +89,18 @@ public boolean canPlay() {
}

final Card c = this.getHostCard();

boolean cantBeActivated = c.hasKeyword("CARDNAME's activated abilities can't be activated.");
if (!cantBeActivated) {
for (final StaticAbility sa : c.getStaticAbilities()) {
if (sa.checkConditions(StaticAbilityMode.CantBeActivated)) {
cantBeActivated = true;
break;
}
}
}

if (c.hasKeyword("CARDNAME's activated abilities can't be activated.") || this.isSuppressed()) {
if (cantBeActivated || this.isSuppressed()) {
return false;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public enum StaticAbilityMode {
PlayerMustAttack,
// StaticAbilityMustBlock
MustBlock,
MustBeBlockedByAll,
MustBeBlocked,

// StaticAbilityAssignCombatDamageAsUnblocked
AssignCombatDamageAsUnblocked,
Expand Down Expand Up @@ -97,6 +99,12 @@ public enum StaticAbilityMode {

// StaticAbilityCantBecomeMonarch
CantBecomeMonarch,

// Hidden Keywords Refactoring (Issue #3307)
CantGainControl,
BounceAtUntap,
RetainCounters,
LethalDamageByPower,

// StaticAbilityCantAttach
CantAttach,
Expand Down
Loading