Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
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
3 changes: 3 additions & 0 deletions forge-game/src/main/java/forge/game/StaticEffect.java
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,9 @@ final CardCollectionView remove(Map<StaticAbilityLayer, Set<Card>> affectedPerLa
if (hasParam("CanBlockAmount")) {
affectedCard.removeCanBlockAdditional(getTimestamp());
}
if (hasParam("LethalDamageByPower")) {
affectedCard.removeLethalDamageByPower(getTimestamp());
}
addCard(affectedPerLayer, StaticAbilityLayer.RULES, affectedCard);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import forge.game.event.GameEventCardStatsChanged;
import forge.game.player.Player;
import forge.game.spellability.SpellAbility;
import forge.game.staticability.StaticAbilityCantGainControl;
import forge.game.trigger.TriggerType;
import forge.game.zone.ZoneType;
import forge.util.Localizer;
Expand Down Expand Up @@ -87,7 +88,13 @@ 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.") || StaticAbilityCantGainControl.cantGainControl(c);
Copy link
Contributor

Choose a reason for hiding this comment

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

this seems fine
but there is another place with the KW
remove checking for it from both


if (cantGainControl) {
return;
}
final Game game = host.getGame();
Expand Down
17 changes: 16 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 @@ -176,6 +176,7 @@ public class Card extends GameEntity implements Comparable<Card>, IHasSVars, ITr

private final Map<Long, Integer> canBlockAdditional = Maps.newTreeMap();
private final Set<Long> canBlockAny = Sets.newHashSet();
private final Set<Long> lethalDamageByPower = Sets.newHashSet();

// changes that say "replace each instance of one [color,type] by another - timestamp is the key of maps
private final CardChangedWords changedTextColors = new CardChangedWords();
Expand Down Expand Up @@ -6025,7 +6026,9 @@ 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.") || isLethalDamageByPower();

if (lethalByPower) {
return getNetPower();
}
return getNetToughness();
Expand Down Expand Up @@ -8005,6 +8008,18 @@ public boolean removeCanBlockAny(long timestamp) {
}
return result;
}
public void addLethalDamageByPower(Long timestamp) {
lethalDamageByPower.add(timestamp);
}

public void removeLethalDamageByPower(Long timestamp) {
lethalDamageByPower.remove(timestamp);
}

public boolean isLethalDamageByPower() {
return !lethalDamageByPower.isEmpty();
}

public boolean canBlockAny() {
return !canBlockAny.isEmpty();
}
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
11 changes: 11 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,6 +37,7 @@
import forge.game.player.Player;
import forge.game.player.PlayerController.BinaryChoiceType;
import forge.game.spellability.SpellAbility;
import forge.game.staticability.StaticAbilityBounceAtUntap;
import forge.game.staticability.StaticAbilityCantPhase;
import forge.game.staticability.StaticAbilityUntapOtherPlayer;
import forge.game.trigger.TriggerType;
Expand Down Expand Up @@ -91,6 +92,16 @@ 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;
}
if (StaticAbilityBounceAtUntap.shouldBounceAtUntap(c)) {
bounceList.add(c);
}
}

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
@@ -0,0 +1,28 @@
package forge.game.staticability;

import forge.game.card.Card;
import forge.game.zone.ZoneType;

public class StaticAbilityBounceAtUntap {

public static boolean shouldBounceAtUntap(Card card) {
for (final Card ca : card.getGame().getCardsIn(ZoneType.STATIC_ABILITIES_SOURCE_ZONES)) {
for (final StaticAbility stAb : ca.getStaticAbilities()) {
if (!stAb.checkConditions(StaticAbilityMode.BounceAtUntap)) {
continue;
}
if (applyBounceAtUntapAbility(stAb, card)) {
return true;
}
}
}
return false;
}

public static boolean applyBounceAtUntapAbility(StaticAbility stAb, Card card) {
if (!stAb.matchesValidParam("ValidCard", card)) {
return false;
}
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package forge.game.staticability;

import forge.game.card.Card;
import forge.game.zone.ZoneType;

public class StaticAbilityCantGainControl {

public static boolean cantGainControl(Card card) {
for (final Card ca : card.getGame().getCardsIn(ZoneType.STATIC_ABILITIES_SOURCE_ZONES)) {
for (final StaticAbility stAb : ca.getStaticAbilities()) {
if (!stAb.checkConditions(StaticAbilityMode.CantGainControl)) {
continue;
}
if (applyCantGainControlAbility(stAb, card)) {
return true;
}
}
}
return false;
}

public static boolean applyCantGainControlAbility(StaticAbility stAb, Card card) {
if (!stAb.matchesValidParam("ValidCard", card)) {
return false;
}
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,9 @@ public static CardCollectionView applyContinuousAbility(final StaticAbility stAb
int v = AbilityUtils.calculateAmount(hostCard, params.get("CanBlockAmount"), stAb, true);
affectedCard.addCanBlockAdditional(v, se.getTimestamp());
}
if (params.containsKey("LethalDamageByPower")) {
affectedCard.addLethalDamageByPower(se.getTimestamp());
}
}

if (controllerMayPlay && (mayPlayLimit == null || stAb.getMayPlayTurn() < mayPlayLimit)) {
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,11 @@ public enum StaticAbilityMode {

// StaticAbilityCantBecomeMonarch
CantBecomeMonarch,

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

// StaticAbilityCantAttach
CantAttach,
Expand Down
Loading
Loading