Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 0 additions & 1 deletion docs/Card-scripting-API/Card-scripting-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ CARDNAME is replaced by the card's name ingame.
- CARDNAME must be blocked if able.
- Remove CARDNAME from your deck before playing if you're not playing for ante.
- You may choose not to untap CARDNAME during your untap step.
- CantSearchLibrary

# General SVars
* `SoundEffect:<file.mp3>`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import forge.game.replacement.ReplacementType;
import forge.game.spellability.SpellAbility;
import forge.game.spellability.SpellAbilityStackInstance;
import forge.game.staticability.StaticAbilitySearchLibrary;
import forge.game.trigger.TriggerType;
import forge.game.zone.Zone;
import forge.game.zone.ZoneType;
Expand Down Expand Up @@ -1015,9 +1016,9 @@ else if (!origin.contains(ZoneType.Library) && !origin.contains(ZoneType.Hand)
if (origin.contains(ZoneType.Library) && !sa.hasParam("NoLooking")) {
searchedLibrary = true;

if (decider.hasKeyword("LimitSearchLibrary")) { // Aven Mindcensor
Integer fetchNum = StaticAbilitySearchLibrary.limitSearchLibraryConsideringSize(decider);
if (fetchNum != null) {
fetchList.removeAll(player.getCardsIn(ZoneType.Library));
final int fetchNum = Math.min(player.getCardsIn(ZoneType.Library).size(), 4);
if (fetchNum == 0) {
searchedLibrary = false;
} else {
Expand All @@ -1040,9 +1041,9 @@ else if (!origin.contains(ZoneType.Library) && !origin.contains(ZoneType.Hand)
Set<ZoneType> revealZones = Sets.newHashSet();
Iterable<Card> toReveal = null;
if (origin.contains(ZoneType.Library) && searchedLibrary) {
final int fetchNum = Math.min(player.getCardsIn(ZoneType.Library).size(), 4);
Integer fetchNum = StaticAbilitySearchLibrary.limitSearchLibraryConsideringSize(decider);
// Look at whole library before moving onto choosing a card
toReveal = !decider.hasKeyword("LimitSearchLibrary") ? player.getCardsIn(ZoneType.Library) : player.getCardsIn(ZoneType.Library, fetchNum);
toReveal = fetchNum != null ? player.getCardsIn(ZoneType.Library, fetchNum) : player.getCardsIn(ZoneType.Library);
revealZones.add(ZoneType.Library);
}
if (origin.contains(ZoneType.Hand) && player.isOpponentOf(decider)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import forge.game.player.PlayerActionConfirmMode;
import forge.game.player.PlayerCollection;
import forge.game.spellability.SpellAbility;
import forge.game.staticability.StaticAbilitySearchLibrary;
import forge.game.zone.ZoneType;
import forge.util.Aggregates;
import forge.util.Lang;
Expand Down Expand Up @@ -251,9 +252,8 @@ public void resolve(SpellAbility sa) {
}

final Player searched = AbilityUtils.getDefinedPlayers(host, sa.getParam("QuasiLibrarySearch"), sa).get(0);
final int fetchNum = Math.min(searched.getCardsIn(ZoneType.Library).size(), 4);
CardCollectionView shown = !p.hasKeyword("LimitSearchLibrary")
? searched.getCardsIn(ZoneType.Library) : searched.getCardsIn(ZoneType.Library, fetchNum);
final Integer fetchNum = StaticAbilitySearchLibrary.limitSearchLibraryConsideringSize(p);
final CardCollectionView shown = fetchNum != null ? searched.getCardsIn(ZoneType.Library, fetchNum) : searched.getCardsIn(ZoneType.Library);
DelayedReveal delayedReveal = new DelayedReveal(shown, ZoneType.Library, PlayerView.get(searched),
host.getTranslatedName() + " - " +
Localizer.getInstance().getMessage("lblLookingCardIn") + " ");
Expand Down
6 changes: 1 addition & 5 deletions forge-game/src/main/java/forge/game/player/Player.java
Original file line number Diff line number Diff line change
Expand Up @@ -3722,11 +3722,7 @@ public boolean canSearchLibraryWith(SpellAbility sa, Player targetPlayer) {
return true;
}

if (hasKeyword("CantSearchLibrary")) {
return false;
}
return targetPlayer == null || !targetPlayer.equals(sa.getActivatingPlayer())
|| !hasKeyword("Spells and abilities you control can't cause you to search your library.");
return !StaticAbilitySearchLibrary.cantSearchLibrary(this, sa);
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.

I'm a bit skeptical about the usefulness of these tests in general
(you're not even using targetPlayer any longer)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agree. These tests help me to understand and experiment with Forge engine and give me some (false?) confidence. I can easily remove them if the rest is OK.

Copy link
Copy Markdown
Contributor

@tool4ever tool4ever Apr 10, 2026

Choose a reason for hiding this comment

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

Let's try to break it down:
Ashiok needs the following information
a) SA controller
b) Searcher
c) Searched

then the condition has to check a = b = c = Opponent (all the same player)

I have a hard time seeing that this refactor has this equivalently covered yet

}

public void addAdditionalVote(long timestamp, int value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ public enum StaticAbilityMode {

// StaticAbilityCountersRemain
CountersRemain,

// StaticAbilityCantSearchLibrary
LimitSearchLibrary,
CantSearchLibrary
;

public static StaticAbilityMode smartValueOf(final String value) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package forge.game.staticability;

import forge.game.player.Player;
import forge.game.spellability.SpellAbility;
import forge.game.zone.ZoneType;

import java.util.Optional;

import static forge.game.staticability.StaticAbilityMode.CantSearchLibrary;
import static forge.game.staticability.StaticAbilityMode.LimitSearchLibrary;


public class StaticAbilitySearchLibrary {

/**
* @return maximum number of cards which can be fetched from a library considering its size and search limit or null if there is no limit
*/
public static Integer limitSearchLibraryConsideringSize(Player player) {
Comment thread
kojotak marked this conversation as resolved.
Integer limit = limitSearchLibrary(player);
if (limit != null) {
return Math.min(player.getCardsIn(ZoneType.Library).size(), limit);
} else {
return null;
}
}

/**
* @return maximum number of cards which can be revealed from a library or null if there is no limit
*/
public static Integer limitSearchLibrary(Player player) {
return findStaticAbilityForValidPlayer(player, LimitSearchLibrary)
.map(stAb -> Integer.valueOf(stAb.getParam("LimitNum")))
.orElse(null);
}

public static boolean cantSearchLibrary(Player player, SpellAbility sa) {
return findStaticAbilityForValidPlayer(player, CantSearchLibrary)
.filter(stAb -> !stAb.getIgnoreEffectPlayers().contains(player))
.filter(stAb -> stAb.matchesValidParam("ValidPlayerCauseRelative", player, sa.getHostCard()))
.isPresent();
}

private static Optional<StaticAbility> findStaticAbilityForValidPlayer(final Player player, final StaticAbilityMode mode) {
return player.getGame()
.getCardsIn(ZoneType.STATIC_ABILITIES_SOURCE_ZONES)
.stream()
.flatMap(card -> card.getStaticAbilities().stream())
.filter(stAb -> stAb.checkConditions(mode) && stAb.matchesValidParam("ValidPlayer", player))
.findAny();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package forge.ai.ability;

import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;

import java.util.List;

import forge.game.*;
import forge.game.card.CounterEnumType;
import forge.game.spellability.AbilitySub;
import forge.game.zone.ZoneType;
import org.testng.annotations.Test;

import com.google.common.collect.Lists;

import forge.ai.AITest;
import forge.ai.LobbyPlayerAi;
import forge.deck.Deck;
import forge.game.card.Card;
import forge.game.phase.PhaseType;
import forge.game.player.Player;
import forge.game.player.RegisteredPlayer;
import forge.game.spellability.SpellAbility;

public class StaticAbilitySearchLibraryTest extends AITest {

//Overrides 2 player game with 3 player game
@Override
public Game resetGame() {
List<RegisteredPlayer> players = Lists.newArrayList();
Deck d1 = new Deck();
players.add(new RegisteredPlayer(d1).setPlayer(new LobbyPlayerAi("p1", null)));
players.add(new RegisteredPlayer(d1).setPlayer(new LobbyPlayerAi("p2", null)));
players.add(new RegisteredPlayer(d1).setPlayer(new LobbyPlayerAi("p3", null)));
GameRules rules = new GameRules(GameType.Constructed);
Match match = new Match(rules, players, "Test 3 player game");
Game game = new Game(players, rules, match);
game.setAge(GameStage.Play);
game.getPhaseHandler().devModeSet(PhaseType.MAIN1, game.getPlayers().get(0));
game.getPhaseHandler().onStackResolved();
return game;
}

@Test
public void testMindlockOrb_canSearchWhenNotInPlay() {
Game game = initAndCreateGame();
Player p1 = game.getPlayers().get(1);

Card aridMesa = addCard("Arid Mesa", p1);

game.getAction().checkStateEffects(true);

assertTrue(p1.canSearchLibraryWith(findSearchLibraryAbility(aridMesa), p1));
}

@Test
public void testMindlockOrb_canNotSearchIfPlayerOwnsIt() {
Game game = initAndCreateGame();
Player p1 = game.getPlayers().get(1);

addCard("Mindlock Orb", p1);
Card aridMesa = addCard("Arid Mesa", p1);

game.getAction().checkStateEffects(true);

assertFalse(p1.canSearchLibraryWith(findSearchLibraryAbility(aridMesa), p1));
}

@Test
public void testMindlockOrb_canNotSearchIfOpponentOwnsIt() {
Game game = initAndCreateGame();

Player p1 = game.getPlayers().get(1);
Card aridMesa = addCard("Arid Mesa", p1);

Player p2 = game.getPlayers().get(0);
addCard("Mindlock Orb", p2);

game.getAction().checkStateEffects(true);

assertFalse(p1.canSearchLibraryWith(findSearchLibraryAbility(aridMesa), p1));
}

@Test
public void testAshiok_ownerCanSearchOwnLibrary() {
Game game = initAndCreateGame();
Player p1 = game.getPlayers().get(0);

addCard("Ashiok, Dream Render", p1);
Card aridMesa = addCard("Arid Mesa", p1);

game.getAction().checkStateEffects(true);

assertTrue(p1.canSearchLibraryWith(findSearchLibraryAbility(aridMesa), p1));
}

@Test
public void testAshiok_opponentCannotSearchOwnLibrary() {
Game game = initAndCreateGame();
Player p1 = game.getPlayers().get(0);
Player p2 = game.getPlayers().get(1);

Card ashiok = addCard("Ashiok, Dream Render", p2);
ashiok.setCounters(CounterEnumType.LOYALTY, 5);
Card aridMesa = addCard("Arid Mesa", p1);

game.getAction().checkStateEffects(true);

assertFalse(p1.canSearchLibraryWith(findSearchLibraryAbility(aridMesa), p1));
}

@Test
public void testAshiok_opponentCanCauseOtherOpponentToSearchTheirLibrary() {
Comment thread
kojotak marked this conversation as resolved.
Game game = initAndCreateGame();
Player p1 = game.getPlayers().get(0);
Player p2 = game.getPlayers().get(1);
Player p3 = game.getPlayers().get(2);

Card ashiok = addCard("Ashiok, Dream Render", p1);
ashiok.setCounters(CounterEnumType.LOYALTY, 5);

Card grizzlyBears = addCard("Grizzly Bears", p2);

Card pathToExile = addCardToZone("Path to Exile", p3, ZoneType.Hand);

SpellAbility pathToExileSA = pathToExile.getSpellAbilities().get(0);
pathToExileSA.getTargets().add(grizzlyBears);
pathToExileSA.setActivatingPlayer(p3);

game.getAction().checkStateEffects(true);

AbilitySub sub = pathToExileSA.getSubAbility();

assertTrue(p2.canSearchLibraryWith(sub, p2));
}

@Test
public void testAshiok_opponentCanNotCauseToSearchTheirOwnLibrary() {
Game game = initAndCreateGame();
Player p1 = game.getPlayers().get(0);
Player p3 = game.getPlayers().get(2);

Card ashiok = addCard("Ashiok, Dream Render", p1);
ashiok.setCounters(CounterEnumType.LOYALTY, 5);

Card grizzlyBears = addCard("Grizzly Bears", p3);

Card pathToExile = addCardToZone("Path to Exile", p3, ZoneType.Hand);

SpellAbility pathToExileSA = pathToExile.getSpellAbilities().get(0);
pathToExileSA.getTargets().add(grizzlyBears);
pathToExileSA.setActivatingPlayer(p3);

game.getAction().checkStateEffects(true);

AbilitySub sub = pathToExileSA.getSubAbility();
assertFalse(p3.canSearchLibraryWith(sub, p3), "P3 can not search their own library by sending his own creature to exile");
}

private SpellAbility findSearchLibraryAbility(Card card){
return card.getSpellAbilities()
.stream()
.filter( sa -> sa.getDescription().toLowerCase().contains("search your library"))
.peek( sa -> sa.setActivatingPlayer(card.getOwner()))
.findFirst()
.orElse(null);
}
}
2 changes: 1 addition & 1 deletion forge-gui/res/cardsfolder/a/ashiok_dream_render.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Name:Ashiok, Dream Render
ManaCost:1 UB UB
Types:Legendary Planeswalker Ashiok
Loyalty:5
S:Mode$ Continuous | Affected$ Opponent | AddKeyword$ Spells and abilities you control can't cause you to search your library. | Description$ Spells and abilities your opponents control can't cause their controller to search their library.
S:Mode$ CantSearchLibrary | ValidPlayer$ Opponent | ValidPlayerCauseRelative$ You | Description$ Spells and abilities your opponents control can't cause their controller to search their library.
A:AB$ Mill | Cost$ SubCounter<1/LOYALTY> | Planeswalker$ True | NumCards$ 4 | ValidTgts$ Player | SubAbility$ DBExileGrave | SpellDescription$ Target player mills four cards. Then exile each opponent's graveyard.
SVar:DBExileGrave:DB$ ChangeZoneAll | Origin$ Graveyard | Destination$ Exile | Defined$ Opponent | ChangeType$ Card
Oracle:Spells and abilities your opponents control can't cause their controller to search their library.\n[-1]: Target player mills four cards. Then exile each opponent's graveyard.
2 changes: 1 addition & 1 deletion forge-gui/res/cardsfolder/a/aven_mindcensor.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ Types:Creature Bird Wizard
PT:2/1
K:Flash
K:Flying
S:Mode$ Continuous | Affected$ Player.Opponent | AddKeyword$ LimitSearchLibrary | Description$ If an opponent would search a library, that player searches the top four cards of that library instead.
S:Mode$ LimitSearchLibrary | ValidPlayer$ Player.Opponent | LimitNum$ 4 | Description$ If an opponent would search a library, that player searches the top four cards of that library instead.
Comment thread
tool4ever marked this conversation as resolved.
Oracle:Flash\nFlying\nIf an opponent would search a library, that player searches the top four cards of that library instead.
2 changes: 1 addition & 1 deletion forge-gui/res/cardsfolder/l/leonin_arbiter.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Name:Leonin Arbiter
ManaCost:1 W
Types:Creature Cat Cleric
PT:2/2
S:Mode$ Continuous | Affected$ Player | AddKeyword$ CantSearchLibrary | IgnoreEffectCost$ 2 | Description$ Players can't search libraries. Any player may pay {2} for that player to ignore this effect until end of turn.
S:Mode$ Continuous,CantSearchLibrary | ValidPlayer$ Player | Affected$ Player | IgnoreEffectCost$ 2 | Description$ Players can't search libraries. Any player may pay {2} for that player to ignore this effect until end of turn.
# TODO: The AI won't activate the effect yet, but then again, it won't activate it even if the human is playing with this card, so it doesn't affect specifically the AI playability of this card.
AI:RemoveDeck:Random
Oracle:Players can't search libraries. Any player may pay {2} for that player to ignore this effect until end of turn.
2 changes: 1 addition & 1 deletion forge-gui/res/cardsfolder/m/mindlock_orb.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Name:Mindlock Orb
ManaCost:3 U
Types:Artifact
S:Mode$ Continuous | Affected$ Player | AddKeyword$ CantSearchLibrary | Description$ Players can't search libraries.
S:Mode$ CantSearchLibrary | ValidPlayer$ Player | Description$ Players can't search libraries.
Oracle:Players can't search libraries.
2 changes: 1 addition & 1 deletion forge-gui/res/cardsfolder/s/shadow_of_doubt.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Name:Shadow of Doubt
ManaCost:UB UB
Types:Instant
A:SP$ Effect | StaticAbilities$ STCantSearch | SubAbility$ DBDraw | SpellDescription$ Players can't search libraries this turn. Draw a card.
SVar:STCantSearch:Mode$ Continuous | Affected$ Player | AddKeyword$ CantSearchLibrary | Description$ Players can't search libraries.
SVar:STCantSearch:Mode$ CantSearchLibrary | ValidPlayer$ Player | Description$ Players can't search libraries.
SVar:DBDraw:DB$ Draw
AI:RemoveDeck:All
Oracle:({U/B} can be paid with either {U} or {B}.)\nPlayers can't search libraries this turn.\nDraw a card.
2 changes: 1 addition & 1 deletion forge-gui/res/cardsfolder/s/stranglehold.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Name:Stranglehold
ManaCost:3 R
Types:Enchantment
S:Mode$ Continuous | Affected$ Opponent | AddKeyword$ CantSearchLibrary | Description$ Your opponents can't search libraries.
S:Mode$ CantSearchLibrary | ValidPlayer$ Opponent | Description$ Your opponents can't search libraries.
R:Event$ BeginTurn | ActiveZones$ Battlefield | ValidPlayer$ Opponent | ExtraTurn$ True | Skip$ True | Description$ If an opponent would begin an extra turn, that player skips that turn instead.
SVar:NonStackingEffect:True
AI:RemoveDeck:Random
Expand Down
Loading