Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Copy Markdown
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 @@ -6037,7 +6038,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 @@ -8027,6 +8030,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
9 changes: 9 additions & 0 deletions forge-game/src/main/java/forge/game/combat/CombatUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import forge.game.staticability.StaticAbility;
import forge.game.staticability.StaticAbilityBlockRestrict;
import forge.game.staticability.StaticAbilityCantAttackBlock;
import forge.game.staticability.StaticAbilityMustBeBlockedByAll;
import forge.game.staticability.StaticAbilityMustBlock;
import forge.game.trigger.TriggerType;
import forge.game.zone.ZoneType;
Expand Down Expand Up @@ -863,6 +864,10 @@ private static boolean attackerLureSatisfied(final Card attacker, final Card blo
}
}

if (StaticAbilityMustBeBlockedByAll.mustBeBlockedByAll(attacker, blocker)) {
return false;
}

return true;
}

Expand Down Expand Up @@ -947,6 +952,10 @@ public static boolean canBlock(final Card attacker, final Card blocker, final Co
}
}

if (!mustBeBlockedBy && StaticAbilityMustBeBlockedByAll.mustBeBlockedByAll(attacker, blocker)) {
mustBeBlockedBy = true;
}

// 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.")
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 @@ -87,8 +87,7 @@ public boolean canPlay() {
}

final Card c = this.getHostCard();

if (isSuppressed()) {
if (this.isSuppressed()) {
return false;
}
if (c.isDetained()) {
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
@@ -1,4 +1,3 @@

/*
* Forge: Play Magic: the Gathering.
* Copyright (C) 2011 Forge Team
Expand All @@ -24,7 +23,10 @@
import forge.game.zone.Zone;
import forge.game.zone.ZoneType;

public class StaticAbilityCountersRemain {
public final class StaticAbilityCountersRemain {

private StaticAbilityCountersRemain() {
}

public static boolean countersRemain(final Card card, final Zone zone) {
if (zone == null || zone.getZoneType().isHidden()) {
Expand All @@ -48,9 +50,6 @@ public static boolean countersRemain(final Card card, final Zone zone) {
}

public static boolean applyCountersRemainAbility(final StaticAbility stAb, final Card card) {
if (!stAb.matchesValidParam("ValidCard", card)) {
return false;
}
return true;
return stAb.matchesValidParam("ValidCard", card);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,15 @@ public enum StaticAbilityMode {
PlayerMustAttack,
// StaticAbilityMustBlock
MustBlock,
MustBeBlockedByAll,

// StaticAbilityAssignCombatDamageAsUnblocked
AssignCombatDamageAsUnblocked,

// StaticAbilityCombatDamageToughness
CombatDamageToughness,
// StaticAbilityLethalDamageByPower
LethalDamageByPower,

// StaticAbilityColorlessDamageSource
ColorlessDamageSource,
Expand Down Expand Up @@ -97,6 +100,10 @@ public enum StaticAbilityMode {

// StaticAbilityCantBecomeMonarch
CantBecomeMonarch,

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

// StaticAbilityCantAttach
CantAttach,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package forge.game.staticability;

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

public class StaticAbilityMustBeBlockedByAll {

public static boolean mustBeBlockedByAll(final Card attacker, final Card blocker) {
final Card host = attacker; // Default host is attacker if keyword is on attacker

// Check Static Abilities in the game (Global Static Abilities)
for (final Card ca : attacker.getGame().getCardsIn(ZoneType.STATIC_ABILITIES_SOURCE_ZONES)) {
for (final StaticAbility stAb : ca.getStaticAbilities()) {
if (!stAb.checkConditions(StaticAbilityMode.MustBeBlockedByAll)) {
continue;
}
if (applyMustBeBlockedByAll(stAb, attacker, blocker)) {
return true;
}
}
}
return false;
}

public static boolean applyMustBeBlockedByAll(final StaticAbility stAb, final Card attacker, final Card blocker) {
// ValidCard defines which attacker is affected (e.g. "Creature.EnchantedBy")
if (!stAb.matchesValidParam("ValidCard", attacker)) {
return false;
}

// ValidBlocker defines which blockers must block (e.g. "Creature" or specific types)
if (stAb.hasParam("ValidBlocker")) {
if (!blocker.isValid(stAb.getParam("ValidBlocker"), attacker.getController(), attacker, stAb)) {
return false;
}
}

return true;
}
}
52 changes: 52 additions & 0 deletions forge-game/src/test/java/forge/game/ability/ForgetOnMovedTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package forge.game.ability;

import forge.game.Game;
import forge.game.GameRules;
import forge.game.GameType;
import forge.game.Match;
import forge.game.card.Card;
import forge.game.trigger.Trigger;
import forge.util.Lang;
import forge.util.Localizer;
import org.testng.Assert;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;

import java.util.ArrayList;

public class ForgetOnMovedTest {

@BeforeClass
public void initLocalization() {
Localizer.getInstance().initialize("en-US", "/Users/calaespi/Desktop/Proyectos/Personales/forge/forge-gui/res/languages");
Lang.createInstance("en-US");
}

@Test
public void addsChangesZoneTriggerWithExcludedDestinations() {
GameRules rules = new GameRules(GameType.Constructed);
Match match = new Match(rules, new ArrayList<>(), "Test");
Game game = new Game(new ArrayList<>(), rules, match);

Card host = new Card(game.nextCardId(), game);
SpellAbilityEffect.addForgetOnMovedTrigger(host, "Exile");

boolean foundChangesZone = false;
boolean foundExiled = false;
for (Trigger t : host.getTriggers()) {
String mode = t.getParam("Mode");
if ("ChangesZone".equals(mode)) {
foundChangesZone = true;
String excluded = t.getParam("ExcludedDestinations");
Assert.assertNotNull(excluded, "ExcludedDestinations should be present");
Assert.assertTrue(excluded.contains("Stack") && excluded.contains("Exile"),
"ExcludedDestinations must contain Stack and Exile, got: " + excluded);
}
if ("Exiled".equals(mode)) {
foundExiled = true;
}
}
Assert.assertTrue(foundChangesZone, "Expected a ChangesZone trigger for ForgetOnMoved");
Assert.assertTrue(foundExiled, "Expected an Exiled trigger for ForgetOnMoved");
}
}
Loading