AI: improved target amount targeting (part of #13638, #13766):

- refactor: migrated AI's target amount code to shared selection logic;
- ai: fixed game freezes on some use cases;
- tests: added AI's testable dialogs for target amount;
- tests: improved load tests result table, added game cycles stats;
- Dwarven Catapult - fixed game error on usage;
This commit is contained in:
Oleg Agafonov 2025-06-20 18:20:50 +04:00
parent 8e7a7e9fc6
commit f3e18e245f
23 changed files with 502 additions and 390 deletions

View file

@ -21,52 +21,81 @@ import org.mage.test.serverside.base.CardTestPlayerBaseAI;
public class TargetPriorityTest extends CardTestPlayerBaseAI {
// TODO: enable _target_ tests after computerPlayer.chooseTarget will be reworks like chooseTargetAmount
@Test
@Ignore
public void test_target_PriorityKillByBigPT() {
public void test_Target_PriorityDamageToGoodOpponent() {
addCard(Zone.HAND, playerA, "Lightning Bolt");
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 1);
//
addCard(Zone.BATTLEFIELD, playerB, "Memnite", 3); // 1/1
addCard(Zone.BATTLEFIELD, playerB, "Balduvian Bears", 3); // 2/2
addCard(Zone.BATTLEFIELD, playerB, "Ashcoat Bear", 3); // 2/2 with ability
addCard(Zone.BATTLEFIELD, playerB, "Golden Bear", 3); // 4/3
addCard(Zone.BATTLEFIELD, playerB, "Battering Sliver", 3); // 4/4 with ability
addCard(Zone.BATTLEFIELD, playerA, "Ashcoat Bear", 3); // 2/2 with ability
// AI must make damage to opponent
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Lightning Bolt");
setStrictChooseMode(false);
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
assertLife(playerA, 20);
assertLife(playerB, 20 - 3);
}
@Test
public void test_Target_PriorityDamageToBadLowestCreature() {
addCard(Zone.HAND, playerA, "Lightning Bolt");
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 1);
//
// You have shroud.
addCard(Zone.BATTLEFIELD, playerB, "Ivory Mask", 1);
//
addCard(Zone.BATTLEFIELD, playerA, "Balduvian Bears", 3); // 2/2
addCard(Zone.BATTLEFIELD, playerA, "Memnite", 3); // 1/1
addCard(Zone.BATTLEFIELD, playerA, "Ashcoat Bear", 3); // 2/2 with ability
// AI can't target opponent so target own lowest creature
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Lightning Bolt");
setStrictChooseMode(false);
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
assertLife(playerA, 20);
assertLife(playerB, 20);
assertPermanentCount(playerA, "Memnite", 3 - 1);
}
@Test
public void test_Target_PriorityDamageToBiggestCreature() {
addCard(Zone.HAND, playerA, "Lightning Bolt");
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 1);
//
// You have shroud.
addCard(Zone.BATTLEFIELD, playerA, "Ivory Mask", 1);
addCard(Zone.BATTLEFIELD, playerB, "Ivory Mask", 1);
//
addCard(Zone.BATTLEFIELD, playerB, "Memnite", 3); // 1/1
addCard(Zone.BATTLEFIELD, playerB, "Balduvian Bears", 3); // 2/2
addCard(Zone.BATTLEFIELD, playerB, "Ashcoat Bear", 3); // 2/2 with ability
addCard(Zone.BATTLEFIELD, playerB, "Golden Bear", 3); // 4/3
//addCard(Zone.BATTLEFIELD, playerB, "Battering Sliver", 3); // 4/4 with ability TODO: add after AI will simulation simple choices too
// AI must choose biggest creature to kill from opponent - 4/3
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Lightning Bolt");
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN);
showBattlefield("as", 1, PhaseStep.PRECOMBAT_MAIN, playerB);
setStrictChooseMode(false);
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
assertLife(playerA, 20);
assertLife(playerB, 20);
assertPermanentCount(playerB, "Memnite", 3);
assertPermanentCount(playerB, "Balduvian Bears", 3);
assertPermanentCount(playerB, "Ashcoat Bear", 3);
assertPermanentCount(playerB, "Golden Bear", 3 - 1);
assertPermanentCount(playerB, "Battering Sliver", 3);
}
@Test
@Ignore
public void test_target_PriorityByKillByLowPT() {
addCard(Zone.HAND, playerA, "Lightning Bolt");
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 1);
//
addCard(Zone.BATTLEFIELD, playerB, "Memnite", 3); // 1/1
//addCard(Zone.BATTLEFIELD, playerB, "Balduvian Bears", 3); // 2/2
//addCard(Zone.BATTLEFIELD, playerB, "Ashcoat Bear", 3); // 2/2 with ability
//addCard(Zone.BATTLEFIELD, playerB, "Golden Bear", 3); // 4/3
addCard(Zone.BATTLEFIELD, playerB, "Battering Sliver", 3); // 4/4 with ability
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Lightning Bolt");
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
assertPermanentCount(playerB, "Memnite", 3 - 1);
//assertPermanentCount(playerB, "Balduvian Bears", 3);
//assertPermanentCount(playerB, "Ashcoat Bear", 3);
//assertPermanentCount(playerB, "Golden Bear", 3);
assertPermanentCount(playerB, "Battering Sliver", 3);
}
@Test
@ -153,7 +182,7 @@ public class TargetPriorityTest extends CardTestPlayerBaseAI {
// TARGET AMOUNT
@Test
public void test_targetAmount_PriorityKillByBigPT() {
public void test_TargetAmount_PriorityKillByBigPT() {
addCard(Zone.HAND, playerA, "Flames of the Firebrand"); // damage 3
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 3);
//
@ -176,7 +205,7 @@ public class TargetPriorityTest extends CardTestPlayerBaseAI {
}
@Test
public void test_targetAmount_PriorityByKillByLowPT() {
public void test_TargetAmount_PriorityByKillByLowPT() {
addCard(Zone.HAND, playerA, "Flames of the Firebrand"); // damage 3
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 3);
//
@ -199,7 +228,7 @@ public class TargetPriorityTest extends CardTestPlayerBaseAI {
}
@Test
public void test_targetAmount_PriorityKillByExtraPoints() {
public void test_TargetAmount_PriorityKillByExtraPoints() {
addCard(Zone.HAND, playerA, "Flames of the Firebrand"); // damage 3
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 3);
//
@ -222,7 +251,7 @@ public class TargetPriorityTest extends CardTestPlayerBaseAI {
}
@Test
public void test_targetAmount_NormalCase() {
public void test_TargetAmount_NormalCase() {
Ability ability = new SimpleActivatedAbility(Zone.ALL, new DamageMultiEffect(), new ManaCostsImpl<>("{R}"));
ability.addTarget(new TargetCreaturePermanentAmount(3, 0, 3));
addCustomCardWithAbility("damage 3", playerA, ability);
@ -247,7 +276,7 @@ public class TargetPriorityTest extends CardTestPlayerBaseAI {
}
@Test
public void test_targetAmount_BadCase() {
public void test_TargetAmount_BadCase() {
// choose targets as enters battlefield (e.g. can't be canceled)
SpellAbility spell = new SpellAbility(new ManaCostsImpl<>("{R}"), "damage 3", Zone.HAND);
Ability ability = new EntersBattlefieldTriggeredAbility(new DamageMultiEffect());
@ -263,14 +292,12 @@ public class TargetPriorityTest extends CardTestPlayerBaseAI {
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "damage 3");
// must damage x3 Balduvian Bears by -1 to keep alive
checkDamage("pt after", 1, PhaseStep.BEGIN_COMBAT, playerA, "Balduvian Bears", 1);
// showBattlefield("after", 1, PhaseStep.BEGIN_COMBAT, playerA);
// up to target is optional, so AI must choose nothing due only bad targets
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
assertPermanentCount(playerA, "damage 3", 1);
assertLife(playerA, 20);
assertLife(playerB, 20);
assertPermanentCount(playerA, "Memnite", 3);
assertPermanentCount(playerA, "Balduvian Bears", 3);
assertPermanentCount(playerA, "Ashcoat Bear", 3);
@ -280,7 +307,7 @@ public class TargetPriorityTest extends CardTestPlayerBaseAI {
@Test
@Ignore // do not enable it in production, only for devs
public void test_targetAmount_Performance() {
public void test_TargetAmount_Performance() {
int cardsMultiplier = 3;
Ability ability = new SimpleActivatedAbility(Zone.ALL, new DamageMultiEffect(), new ManaCostsImpl<>("{R}"));

View file

@ -14,19 +14,22 @@ import org.mage.test.serverside.base.CardTestPlayerBase;
public class VivienTest extends CardTestPlayerBase {
@Test
public void testVivienArkbowRangerAbility1NoTargets() {
setStrictChooseMode(true);
public void test_Distribute_NoTargets() {
// +1: Distribute two +1/+1 counters among up to two target creatures. They gain trample until end of turn.
// 3: Target creature you control deals damage equal to its power to target creature or planeswalker.
// 5: You may choose a creature card you own from outside the game, reveal it, and put it into your hand.
addCard(Zone.HAND, playerA, "Vivien, Arkbow Ranger"); // Planeswalker {1}{G}{G}{G} - starts with 4 Loyality counters
addCard(Zone.BATTLEFIELD, playerA, "Forest", 4);
// You can activate Viviens first ability without choosing any target creatures. The counters wont be
// put on anything. This is a change from previous rules regarding distributing counters.
// (2019-07-12)
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Vivien, Arkbow Ranger", true);
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "+1: Distribute");
addTargetAmount(playerA, TestPlayer.TARGET_SKIP); // stop choosing (not targets)
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "+1: Distribute");
setStrictChooseMode(true);
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
@ -36,8 +39,7 @@ public class VivienTest extends CardTestPlayerBase {
}
@Test
public void testVivienArkbowRangerAbilityOnePossibleTargetWithOne() {
setStrictChooseMode(true);
public void test_Distribute_OneTarget() {
// +1: Distribute two +1/+1 counters among up to two target creatures. They gain trample until end of turn.
// 3: Target creature you control deals damage equal to its power to target creature or planeswalker.
// 5: You may choose a creature card you own from outside the game, reveal it, and put it into your hand.
@ -49,16 +51,16 @@ public class VivienTest extends CardTestPlayerBase {
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Vivien, Arkbow Ranger", true);
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "+1: Distribute");
addTargetAmount(playerA, "Silvercoat Lion", 1);
addTargetAmount(playerA, TestPlayer.TARGET_SKIP); // stop choosing (one target)
addTargetAmount(playerA, "Silvercoat Lion", 2);
setStrictChooseMode(true);
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
assertPermanentCount(playerA, "Vivien, Arkbow Ranger", 1);
assertCounterCount("Vivien, Arkbow Ranger", CounterType.LOYALTY, 5);
assertPowerToughness(playerB, "Silvercoat Lion", 2 + 1, 2 + 1);
assertPowerToughness(playerB, "Silvercoat Lion", 2 + 2, 2 + 2);
}
@Test

View file

@ -75,7 +75,6 @@ public class TestableDialogsTest extends CardTestPlayerBaseWithAIHelps {
}
@Test
@Ignore // TODO: enable and fix all failed dialogs
public void test_RunAll_AI() {
// it's impossible to setup 700+ dialogs, so all choices made by AI
// current AI uses only simple choices in dialogs, not simulations
@ -108,7 +107,7 @@ public class TestableDialogsTest extends CardTestPlayerBaseWithAIHelps {
@Test
@Ignore // debug only - run single dialog by reg number
public void test_RunSingle_Debugging() {
int needRegNumber = 93;
int needRegNumber = 557;
prepareCards();

View file

@ -918,12 +918,17 @@ public class LoadTest {
public int getTotalEffectsCount() {
return finalGameView == null ? 0 : this.finalGameView.getTotalEffectsCount();
}
public int getGameCycle() {
return finalGameView == null ? 0 : this.finalGameView.getGameCycle();
}
}
private static class LoadTestGameResultsList extends HashMap<Integer, LoadTestGameResult> {
private static final String tableFormatHeader = "|%-10s|%-15s|%-20s|%-10s|%-10s|%-10s|%-10s|%-10s|%-15s|%-15s|%-10s|%n";
private static final String tableFormatData = "|%-10s|%15s|%20s|%10s|%10s|%10s|%10s|%10s|%15s|%15s|%10s|%n";
// index, name, random sid, game cycle, errors, effects, turn, life p1, life p2, creatures p1, creatures p2, =time, sec, ~time, sec
private static final String tableFormatHeader = "|%-10s|%-15s|%-20s|%-10s|%-10s|%-10s|%-10s|%-10s|%-10s|%-15s|%-15s|%-15s|%-15s|%n";
private static final String tableFormatData = "|%-10s|%15s|%20s|%10s|%10s|%10s|%10s|%10s|%10s|%15s|%15s|%15s|%15s|%n";
public LoadTestGameResult createGame(int index, String name, long randomSeed) {
if (this.containsKey(index)) {
@ -939,6 +944,7 @@ public class LoadTest {
"index",
"name",
"random sid",
"game cycles",
"errors",
"effects",
"turn",
@ -946,8 +952,8 @@ public class LoadTest {
"life p2",
"creatures p1",
"creatures p2",
"time, sec",
"time per turn, sec"
"=time, sec",
"~time, sec"
);
System.out.printf(tableFormatHeader, data.toArray());
}
@ -961,6 +967,7 @@ public class LoadTest {
String.valueOf(gameResult.index), //"index",
gameResult.name, //"name",
String.valueOf(gameResult.randomSeed), // "random sid",
String.valueOf(gameResult.getGameCycle()), // "game cycles",
String.valueOf(gameResult.getTotalErrorsCount()), // "errors",
String.valueOf(gameResult.getTotalEffectsCount()), // "effects",
gameResult.getTurnInfo(), //"turn",
@ -979,15 +986,16 @@ public class LoadTest {
"TOTAL/AVG", //"index",
String.valueOf(this.size()), //"name",
"total, secs: " + String.format("%.3f", (float) this.getTotalDurationMs() / 1000), // "random sid",
String.valueOf(this.getTotalErrorsCount()), // errors
String.valueOf(this.getAvgEffectsCount()), // effects
String.valueOf(this.getAvgTurn()), // turn
String.valueOf(this.getAvgLife1()), // life p1
String.valueOf(this.getAvgLife2()), // life p2
String.valueOf(this.getAvgCreaturesCount1()), // creatures p1
String.valueOf(this.getAvgCreaturesCount2()), // creatures p2
String.valueOf(String.format("%.3f", (float) this.getAvgDurationMs() / 1000)), // time, sec
String.valueOf(String.format("%.3f", (float) this.getAvgDurationPerTurnMs() / 1000)) // time per turn, sec
"~" + this.getAvgGameCycle(), // game cycles
"=" + this.getTotalErrorsCount(), // errors
"~" + this.getAvgEffectsCount(), // effects
"~" + this.getAvgTurn(), // turn
"~" + this.getAvgLife1(), // life p1
"~" + this.getAvgLife2(), // life p2
"~" + this.getAvgCreaturesCount1(), // creatures p1
"~" + this.getAvgCreaturesCount2(), // creatures p2
"~" + String.format("%.3f", (float) this.getAvgDurationMs() / 1000), // time, sec
"~" + String.format("%.3f", (float) this.getAvgDurationPerTurnMs() / 1000) // time per turn, sec
);
System.out.printf(tableFormatData, data.toArray());
}
@ -996,6 +1004,10 @@ public class LoadTest {
return this.values().stream().mapToInt(LoadTestGameResult::getTotalErrorsCount).sum();
}
private int getAvgGameCycle() {
return this.size() == 0 ? 0 : this.values().stream().mapToInt(LoadTestGameResult::getGameCycle).sum() / this.size();
}
private int getAvgEffectsCount() {
return this.size() == 0 ? 0 : this.values().stream().mapToInt(LoadTestGameResult::getTotalEffectsCount).sum() / this.size();
}

View file

@ -543,7 +543,7 @@ public class TestPlayer implements Player {
if (currentTarget.getOriginalTarget() instanceof TargetCreaturePermanentAmount) {
// supports only to set the complete amount to one target
TargetCreaturePermanentAmount targetAmount = (TargetCreaturePermanentAmount) currentTarget.getOriginalTarget();
targetAmount.setAmount(ability, game);
targetAmount.prepareAmount(ability, game);
int amount = targetAmount.getAmountRemaining();
targetAmount.addTarget(id, amount, ability, game);
targetsSet++;
@ -2101,10 +2101,7 @@ public class TestPlayer implements Player {
if (target == null) {
return "Target: null";
}
UUID abilityControllerId = getId();
if (target.getTargetController() != null && target.getAbilityController() != null) {
abilityControllerId = target.getAbilityController();
}
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
Set<UUID> possibleTargets = target.possibleTargets(abilityControllerId, source, game);
return "Target: selected " + target.getSize() + ", possible " + possibleTargets.size()
@ -2274,10 +2271,7 @@ public class TestPlayer implements Player {
return true;
}
UUID abilityControllerId = this.getId();
if (target.getTargetController() != null && target.getAbilityController() != null) {
abilityControllerId = target.getAbilityController();
}
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
// TODO: warning, some cards call player.choose methods instead target.choose, see #8254
// most use cases - discard and other cost with choice like that method
@ -2509,11 +2503,7 @@ public class TestPlayer implements Player {
@Override
public boolean chooseTarget(Outcome outcome, Target target, Ability source, Game game) {
UUID abilityControllerId = this.getId();
if (target.getTargetController() != null && target.getAbilityController() != null) {
abilityControllerId = target.getAbilityController();
}
UUID sourceId = source != null ? source.getSourceId() : null;
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
assertAliasSupportInTargets(true);
if (!targets.isEmpty()) {
@ -2817,10 +2807,7 @@ public class TestPlayer implements Player {
@Override
public boolean chooseTarget(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) {
UUID abilityControllerId = this.getId();
if (target.getTargetController() != null && target.getAbilityController() != null) {
abilityControllerId = target.getAbilityController();
}
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
assertAliasSupportInTargets(false);
if (!targets.isEmpty()) {
@ -4329,12 +4316,20 @@ public class TestPlayer implements Player {
// chooseTargetAmount calls for EACH target cycle (e.g. one target per click, see TargetAmount)
// if use want to stop choosing then chooseTargetAmount must return false (example: up to xxx)
// nothing to choose
target.prepareAmount(source, game);
if (target.getAmountRemaining() <= 0) {
return false;
}
if (target.getMaxNumberOfTargets() == 0 && target.getMinNumberOfTargets() == 0) {
return false;
}
UUID abilityControllerId = target.getAffectedAbilityControllerId(this.getId());
assertAliasSupportInTargets(true);
if (!targets.isEmpty()) {
while (!targets.isEmpty()) {
// skip targets
if (targets.get(0).equals(TARGET_SKIP)) {
@ -4386,7 +4381,11 @@ public class TestPlayer implements Player {
// can select
target.addTarget(possibleTarget, targetAmount, source, game);
targets.remove(0);
return true; // one target per choose call
// allow test player to choose as much as possible until skip command
if (target.getAmountRemaining() <= 0) {
return true;
}
break; // try next target
}
}
}