Skip to content

Commit 8e09b5a

Browse files
liamiak1claude
andcommitted
Add an Assimilate API so the keyword lives in one place
Speculative, and separable from the commit before it: the printed card only assimilates from a graveyard, and this also handles a permanent already on the battlefield, which no card asks for yet. Drop this commit and the card still works through StaticEffect$. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8c3b34e commit 8e09b5a

7 files changed

Lines changed: 211 additions & 2 deletions

File tree

docs/Card-scripting-API/AbilityFactory.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,21 @@ Parameters (all optional):
103103
- `RemoveAllAbilities$ True` - Remove all Abilities, Triggers, Statics and Replacement effects
104104
- `sVars` - a comma-delimited list of SVars that should be granted to the animated being
105105

106+
## Assimilate
107+
Handles the keyword action "assimilate" - the target ends up under your control as a Borg artifact
108+
creature with a +1/+1 counter, having lost its other creature types but keeping its other card types.
109+
110+
How it gets there depends on where it starts. A card in a graveyard or exile is put onto the
111+
battlefield, and the type change rides on that zone change (via `StaticEffect$`) so ETB triggers
112+
already see a Borg artifact creature. A permanent already on the battlefield only changes controller
113+
and is animated in place.
114+
115+
Parameters:
116+
- `ValidTgts`/`TgtZone` - as for any targeted effect; `TgtZone$ Graveyard` for the graveyard form
117+
118+
Example (*Borg Queen, Perfection Manifest*):
119+
`SVar:TrigAssimilate:DB$ Assimilate | TgtZone$ Graveyard | ValidTgts$ Creature.OppOwn`
120+
106121
## Attach
107122
Attaches a permanent to an affected card or player.
108123

forge-ai/src/main/java/forge/ai/SpellApiToAi.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public enum SpellApiToAi {
3232
.put(ApiType.Attach, AttachAi.class)
3333
.put(ApiType.AssembleContraption, AssembleContraptionAi.class)
3434
.put(ApiType.AssignGroup, AssignGroupAi.class)
35+
.put(ApiType.Assimilate, AssimilateAi.class)
3536
.put(ApiType.Balance, BalanceAi.class)
3637
.put(ApiType.BecomeMonarch, BecomeMonarchAi.class)
3738
.put(ApiType.BecomesBlocked, BecomesBlockedAi.class)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package forge.ai.ability;
2+
3+
import java.util.List;
4+
5+
import forge.ai.AiAbilityDecision;
6+
import forge.ai.AiPlayDecision;
7+
import forge.ai.ComputerUtilCard;
8+
import forge.ai.SpellAbilityAi;
9+
import forge.game.card.Card;
10+
import forge.game.card.CardCollection;
11+
import forge.game.player.Player;
12+
import forge.game.spellability.SpellAbility;
13+
14+
/**
15+
* The card ends up under our control either way, so any legal target is a gain; the only decision
16+
* is which one.
17+
*/
18+
public class AssimilateAi extends SpellAbilityAi {
19+
20+
@Override
21+
protected AiAbilityDecision checkApiLogic(final Player ai, final SpellAbility sa) {
22+
if (!sa.usesTargeting()) {
23+
return new AiAbilityDecision(100, AiPlayDecision.WillPlay);
24+
}
25+
return takeBest(ai, sa) ? new AiAbilityDecision(100, AiPlayDecision.WillPlay)
26+
: new AiAbilityDecision(0, AiPlayDecision.CantPlayAi);
27+
}
28+
29+
@Override
30+
protected AiAbilityDecision doTriggerNoCost(final Player ai, final SpellAbility sa, final boolean mandatory) {
31+
if (!sa.usesTargeting()) {
32+
return new AiAbilityDecision(100, AiPlayDecision.WillPlay);
33+
}
34+
if (takeBest(ai, sa)) {
35+
return new AiAbilityDecision(100, AiPlayDecision.WillPlay);
36+
}
37+
// a mandatory trigger with no legal target simply does nothing
38+
return new AiAbilityDecision(0, AiPlayDecision.CantPlayAi);
39+
}
40+
41+
private boolean takeBest(final Player ai, final SpellAbility sa) {
42+
sa.resetTargets();
43+
final List<Card> options = new CardCollection(
44+
ai.getGame().getCardsIn(sa.getTargetRestrictions().getZone()));
45+
final CardCollection legal = new CardCollection();
46+
for (final Card c : options) {
47+
if (sa.canTarget(c)) {
48+
legal.add(c);
49+
}
50+
}
51+
if (legal.isEmpty()) {
52+
return false;
53+
}
54+
sa.getTargets().add(ComputerUtilCard.getBestCreatureAI(legal));
55+
return true;
56+
}
57+
}

forge-game/src/main/java/forge/game/ability/ApiType.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public enum ApiType {
2626
AnimateAll (AnimateAllEffect.class),
2727
Attach (AttachEffect.class),
2828
AssembleContraption (AssembleContraptionEffect.class),
29+
Assimilate (AssimilateEffect.class),
2930
AssignGroup (AssignGroupEffect.class),
3031
Balance (BalanceEffect.class),
3132
BecomeMonarch (BecomeMonarchEffect.class),
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package forge.game.ability.effects;
2+
3+
import java.util.Arrays;
4+
import java.util.Map;
5+
6+
import com.google.common.collect.Lists;
7+
8+
import forge.card.CardType;
9+
import forge.game.Game;
10+
import forge.game.GameEntityCounterTable;
11+
import forge.game.ability.AbilityKey;
12+
import forge.game.ability.SpellAbilityEffect;
13+
import forge.game.card.Card;
14+
import forge.game.card.CardCollection;
15+
import forge.game.card.CardZoneTable;
16+
import forge.game.card.CounterEnumType;
17+
import forge.game.card.CounterType;
18+
import forge.game.player.Player;
19+
import forge.game.spellability.SpellAbility;
20+
import forge.util.Lang;
21+
22+
/**
23+
* CR 701.x assimilate: the card ends up under your control as a Borg artifact creature with a
24+
* +1/+1 counter, but how it gets there depends on where it started - a card in a graveyard has to
25+
* be moved, a permanent only changes hands.
26+
*/
27+
public class AssimilateEffect extends SpellAbilityEffect {
28+
29+
private static final String ANIMATE = "Mode$ Continuous | Affected$ Card.IsRemembered"
30+
+ " | AddType$ Artifact & Creature & Borg | RemoveCreatureTypes$ True";
31+
32+
@Override
33+
protected String getStackDescription(SpellAbility sa) {
34+
return "Assimilate " + Lang.joinHomogenous(getCardsfromTargets(sa)) + ".";
35+
}
36+
37+
@Override
38+
public void resolve(SpellAbility sa) {
39+
final Game game = sa.getHostCard().getGame();
40+
final Player controller = sa.getActivatingPlayer();
41+
final CounterType p1p1 = CounterEnumType.P1P1;
42+
43+
final CardZoneTable triggerList = new CardZoneTable();
44+
final GameEntityCounterTable alreadyInPlay = new GameEntityCounterTable();
45+
46+
for (final Card tgt : getCardsfromTargets(sa)) {
47+
final Card gameCard = game.getCardState(tgt, null);
48+
if (gameCard == null || !tgt.equalsWithGameTimestamp(gameCard) || gameCard.isPhasedOut()) {
49+
continue;
50+
}
51+
52+
// has to happen before any move, or the card enters under the wrong controller
53+
if (!controller.equals(gameCard.getController())) {
54+
gameCard.runChangeControllerCommands();
55+
gameCard.setController(controller, game.getNextTimestamp());
56+
}
57+
58+
if (gameCard.isInPlay()) {
59+
gameCard.addCounter(p1p1, 1, controller, alreadyInPlay);
60+
animateInPlace(gameCard, sa, game);
61+
} else {
62+
// riding the zone change is what makes the types apply as the card enters, so a
63+
// "whenever an artifact enters" trigger sees it as one
64+
sa.putParam("StaticEffect", "AssimilateAnimate");
65+
sa.setSVar("AssimilateAnimate", ANIMATE);
66+
67+
final Map<AbilityKey, Object> moveParams = AbilityKey.newMap();
68+
AbilityKey.addCardZoneTableParams(moveParams, triggerList);
69+
moveParams.put(AbilityKey.SimultaneousETB, entering(sa));
70+
final GameEntityCounterTable counters = new GameEntityCounterTable();
71+
counters.put(controller, gameCard, p1p1, 1);
72+
moveParams.put(AbilityKey.CounterTable, counters);
73+
game.getAction().moveToPlay(gameCard, controller, sa, moveParams);
74+
}
75+
}
76+
77+
alreadyInPlay.replaceCounterEffect(game, sa);
78+
triggerList.triggerChangesZoneAll(game, sa);
79+
}
80+
81+
/** Everything being assimilated out of a hidden zone enters together. */
82+
private static CardCollection entering(final SpellAbility sa) {
83+
final CardCollection cards = new CardCollection();
84+
for (final Card c : getCardsfromTargets(sa)) {
85+
if (!c.isInPlay()) {
86+
cards.add(c);
87+
}
88+
}
89+
return cards;
90+
}
91+
92+
/** Nothing is entering, so there is no zone change for the static effect to ride on. */
93+
private void animateInPlace(final Card c, final SpellAbility sa, final Game game) {
94+
final CardType add = new CardType(true);
95+
add.addAll(Arrays.asList("Artifact", "Creature", "Borg"));
96+
sa.putParam("RemoveCreatureTypes", "True");
97+
AnimateEffectBase.doAnimate(c, sa, null, null, add, new CardType(true), null,
98+
Lists.newArrayList(), Lists.newArrayList(), Lists.newArrayList(),
99+
Lists.newArrayList(), Lists.newArrayList(), Lists.newArrayList(), Lists.newArrayList(),
100+
game.getNextTimestamp(), "Permanent");
101+
}
102+
}

forge-gui-desktop/src/test/java/forge/game/card/AssimilateTest.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
import org.testng.annotations.Test;
44

55
import forge.ai.AITest;
6+
import forge.game.ability.AbilityFactory;
7+
import forge.game.ability.AbilityUtils;
68
import forge.game.Game;
79
import forge.game.player.Player;
10+
import forge.game.spellability.SpellAbility;
811
import forge.game.zone.ZoneType;
912

1013
import static org.testng.AssertJUnit.assertEquals;
@@ -112,6 +115,37 @@ public void itKeepsCardTypesAndOnlyShedsCreatureTypes() {
112115
assertFalse("lost Sheep", ram.getType().hasCreatureType("Sheep"));
113116
}
114117

118+
/**
119+
* No printed card assimilates a permanent yet, so this drives the API directly. It is the half
120+
* of the effect that exists on speculation, and it is the half a future card would use.
121+
*/
122+
@Test
123+
public void assimilatingAPermanentTakesItWhereItStands() {
124+
Game game = initAndCreateGame();
125+
Player ai = game.getPlayers().get(1);
126+
Player opp = game.getPlayers().get(0);
127+
128+
Card host = addCard(QUEEN, ai);
129+
Card bears = addCard("Grizzly Bears", opp);
130+
game.getAction().checkStateEffects(true);
131+
132+
SpellAbility sa = AbilityFactory.getAbility(
133+
"DB$ Assimilate | ValidTgts$ Creature.OppCtrl", host);
134+
sa.setActivatingPlayer(ai);
135+
sa.getTargets().add(bears);
136+
AbilityUtils.resolve(sa);
137+
game.getAction().checkStateEffects(true);
138+
139+
assertEquals("stays on the battlefield", ZoneType.Battlefield, bears.getZone().getZoneType());
140+
assertEquals("changes hands", ai, bears.getController());
141+
assertEquals(1, bears.getCounters(CounterEnumType.P1P1));
142+
assertTrue("is an artifact", bears.isArtifact());
143+
assertTrue("is a Borg", bears.getType().hasCreatureType("Borg"));
144+
assertFalse("lost Bear", bears.getType().hasCreatureType("Bear"));
145+
// 2/2 base, +1/+1 counter, +2/+0 now that it is our artifact creature
146+
assertEquals(5, bears.getNetPower());
147+
}
148+
115149
@Test
116150
public void aCreatureOnTheirBattlefieldIsNotReachable() {
117151
Game game = initAndCreateGame();

forge-gui/res/cardsfolder/upcoming/borg_queen_perfection_manifest.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ Types:Legendary Artifact Creature Borg Noble
44
PT:1/4
55
S:Mode$ Continuous | Affected$ Creature.Artifact+YouCtrl | AddPower$ 2 | Description$ Artifact creatures you control get +2/+0.
66
T:Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigAssimilate | TriggerDescription$ When NICKNAME enters, assimilate target creature card from an opponent's graveyard. (Put it onto the battlefield under your control with a +1/+1 counter. It's a Borg artifact creature and loses all other creature types.)
7-
SVar:TrigAssimilate:DB$ ChangeZone | Origin$ Graveyard | Destination$ Battlefield | ValidTgts$ Creature.OppOwn | TgtPrompt$ Select target creature card in an opponent's graveyard | GainControl$ True | WithCountersType$ P1P1 | StaticEffect$ Animate
8-
SVar:Animate:Mode$ Continuous | Affected$ Card.IsRemembered | AddType$ Artifact & Creature & Borg | RemoveCreatureTypes$ True
7+
SVar:TrigAssimilate:DB$ Assimilate | TgtZone$ Graveyard | ValidTgts$ Creature.OppOwn | TgtPrompt$ Select target creature card in an opponent's graveyard
98
DeckHas:Ability$Graveyard|Counters
109
Oracle:Artifact creatures you control get +2/+0.\nWhen Borg Queen enters, assimilate target creature card from an opponent's graveyard. (Put it onto the battlefield under your control with a +1/+1 counter. It's a Borg artifact creature and loses all other creature types.)

0 commit comments

Comments
 (0)