AI: reworked blockers selections:

* fixed game freezes for no-possible block configurations like Menace (#13290);
* fixed computer cheating to ignore block requirements like Menace (now AI will choose all required blockers instead 1);
* improved computer logic for blockers selection (try to sacrifice a creature instead game loose, simple use cases only);
* added freeze protection for bad or unsupported attacker-block configuration;
* refactor: deleted outdated AI code;
This commit is contained in:
Oleg Agafonov 2025-02-04 01:14:49 +04:00
parent a99871988d
commit 92b7ed8efc
12 changed files with 495 additions and 202 deletions

View file

@ -174,7 +174,7 @@ public interface Game extends MageItem, Serializable, Copyable<Game> {
*
* Warning, it will return leaved players until end of turn. For dialogs and one shot effects use excludeLeavedPlayers
*/
// TODO: check usage of getOpponents in cards and replace with correct call of excludeLeavedPlayers
// TODO: check usage of getOpponents in cards and replace with correct call of excludeLeavedPlayers, see #13289
default Set<UUID> getOpponents(UUID playerId) {
return getOpponents(playerId, false);
}

View file

@ -3174,7 +3174,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.INFO, message, this);
}
@ -3183,7 +3183,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.STATUS, message, withTime, withTurnInfo, this);
}
@ -3192,7 +3192,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.UPDATE, null, this);
getState().clearLookedAt();
getState().clearRevealed();
@ -3203,23 +3203,23 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.END_GAME_INFO, null, this);
}
@Override
public void fireErrorEvent(String message, Exception ex) {
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.ERROR, message, ex, this);
}
private void makeSureCalledOutsideLayersEffects() {
private void makeSureCalledOutsideLayerEffects() {
// very slow, enable/comment it for debug or load/stability tests only
// TODO: enable check and remove/rework all wrong usages
if (true) return;
Arrays.stream(Thread.currentThread().getStackTrace()).forEach(e -> {
if (e.toString().contains("GameState.applyEffects")) {
throw new IllegalStateException("Wrong code usage: client side events can't be called from layers effects (wrong informPlayers usage?");
throw new IllegalStateException("Wrong code usage: client side events can't be called from layers effects (wrong informPlayers usage?)");
}
});
}
@ -3890,7 +3890,7 @@ public abstract class GameImpl implements Game {
@Override
public void initTimer(UUID playerId) {
if (priorityTime > 0) {
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.INIT_TIMER, playerId, null, this);
}
}
@ -3898,7 +3898,7 @@ public abstract class GameImpl implements Game {
@Override
public void resumeTimer(UUID playerId) {
if (priorityTime > 0) {
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.RESUME_TIMER, playerId, null, this);
}
}
@ -3906,7 +3906,7 @@ public abstract class GameImpl implements Game {
@Override
public void pauseTimer(UUID playerId) {
if (priorityTime > 0) {
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.PAUSE_TIMER, playerId, null, this);
}
}

View file

@ -670,9 +670,25 @@ public class Combat implements Serializable, Copyable<Combat> {
}
// choosing until good block configuration
int aiTries = 0;
while (true) {
aiTries++;
if (controller.isComputer() && aiTries > 20) {
// TODO: AI must use real attacker/blocker configuration with all possible combination
// (current human like logic will fail sometime, e.g. with menace and big/low creatures)
// real game: send warning
// test: fast fail
game.informPlayers(controller.getLogName() + ": WARNING - AI can't find good blocker combination and will skip it - report your battlefield to github - " + game.getCombat());
if (controller.isTestsMode()) {
// how-to fix: AI code must support failed abilities or use cases
throw new IllegalArgumentException("AI can't find good blocker combination");
}
break;
}
// declare normal blockers
// TODO: need reseach - is it possible to concede on bad blocker configuration (e.g. user can't continue)
// TODO: need research - is it possible to concede on bad blocker configuration (e.g. user can't continue)
controller.selectBlockers(source, game, defenderId);
if (game.isPaused() || game.checkIfGameIsOver() || game.executingRollback()) {
return;
@ -776,18 +792,16 @@ public class Combat implements Serializable, Copyable<Combat> {
/**
* Check the block restrictions
*
* @param player
* @param game
* @return false - if block restrictions were not complied
*/
public boolean checkBlockRestrictions(Player player, Game game) {
public boolean checkBlockRestrictions(Player defender, Game game) {
int count = 0;
boolean blockWasLegal = true;
for (CombatGroup group : groups) {
count += group.getBlockers().size();
}
for (CombatGroup group : groups) {
blockWasLegal &= group.checkBlockRestrictions(game, count);
blockWasLegal &= group.checkBlockRestrictions(game, defender, count);
}
return blockWasLegal;
}

View file

@ -773,11 +773,27 @@ public class CombatGroup implements Serializable, Copyable<CombatGroup> {
}
}
public boolean checkBlockRestrictions(Game game, int blockersCount) {
public boolean checkBlockRestrictions(Game game, Player defender, int blockersCount) {
boolean blockWasLegal = true;
if (attackers.isEmpty()) {
return blockWasLegal;
}
// collect possible blockers
Map<UUID, Set<UUID>> possibleBlockers = new HashMap<>();
for (UUID attackerId : attackers) {
Permanent attacker = game.getPermanent(attackerId);
Set<UUID> goodBlockers = new HashSet<>();
for (Permanent blocker : game.getBattlefield().getActivePermanents(StaticFilters.FILTER_PERMANENT_CREATURES_CONTROLLED, defender.getId(), game)) {
if (blocker.canBlock(attackerId, game)) {
goodBlockers.add(blocker.getId());
}
}
possibleBlockers.put(attacker.getId(), goodBlockers);
}
// effects: can't block alone
// too much blockers
if (blockersCount == 1) {
List<UUID> toBeRemoved = new ArrayList<>();
for (UUID blockerId : getBlockers()) {
@ -802,19 +818,27 @@ public class CombatGroup implements Serializable, Copyable<CombatGroup> {
for (UUID uuid : attackers) {
Permanent attacker = game.getPermanent(uuid);
if (attacker != null && this.blocked) {
// Check if there are enough blockers to have a legal block
// effects: can't be blocked except by xxx or more creatures
// too few blockers
if (attacker.getMinBlockedBy() > 1 && !blockers.isEmpty() && blockers.size() < attacker.getMinBlockedBy()) {
for (UUID blockerId : new ArrayList<>(blockers)) {
game.getCombat().removeBlocker(blockerId, game);
game.getCombat().removeBlocker(blockerId, game); // !
}
blockers.clear();
blockerOrder.clear();
if (!game.isSimulation()) {
game.informPlayers(attacker.getLogName() + " can't be blocked except by " + attacker.getMinBlockedBy() + " or more creatures. Blockers discarded.");
}
blockWasLegal = false;
// if there aren't any possible blocker configuration then it's legal due mtg rules
// warning, it's affect AI related logic like other block auto-fixes does, see https://github.com/magefree/mage/pull/13182
if (attacker.getMinBlockedBy() <= possibleBlockers.getOrDefault(attacker.getId(), Collections.emptySet()).size()) {
blockWasLegal = false;
}
}
// Check if there are too many blockers (maxBlockedBy = 0 means no restrictions)
// effects: can't be blocked by more than xxx creature
// too much blockers
if (attacker.getMaxBlockedBy() > 0 && attacker.getMaxBlockedBy() < blockers.size()) {
for (UUID blockerId : new ArrayList<>(blockers)) {
game.getCombat().removeBlocker(blockerId, game);
@ -827,6 +851,7 @@ public class CombatGroup implements Serializable, Copyable<CombatGroup> {
.append(attacker.getMaxBlockedBy() == 1 ? " creature." : " creatures.")
.append(" Blockers discarded.").toString());
}
blockWasLegal = false;
}
}