mirror of
https://github.com/Card-Forge/forge.git
synced 2025-11-18 19:58:00 +00:00
Merge pull request #2157 from asvitkine/sim_imprv
Simulated AI: Fix land pruning logic and skip invalid targets.
This commit is contained in:
@@ -198,14 +198,11 @@ public class GameSimulator {
|
|||||||
final SpellAbility playingSa = sa;
|
final SpellAbility playingSa = sa;
|
||||||
|
|
||||||
simGame.copyLastState();
|
simGame.copyLastState();
|
||||||
boolean success = ComputerUtil.handlePlayingSpellAbility(aiPlayer, sa, simGame, new Runnable() {
|
boolean success = ComputerUtil.handlePlayingSpellAbility(aiPlayer, sa, simGame, () -> {
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
if (interceptor != null) {
|
if (interceptor != null) {
|
||||||
interceptor.announceX(playingSa);
|
interceptor.announceX(playingSa);
|
||||||
interceptor.chooseTargets(playingSa, GameSimulator.this);
|
interceptor.chooseTargets(playingSa, GameSimulator.this);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return new Score(Integer.MIN_VALUE);
|
return new Score(Integer.MIN_VALUE);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package forge.ai.simulation;
|
package forge.ai.simulation;
|
||||||
|
|
||||||
|
import forge.ai.ComputerUtilAbility;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
@@ -16,7 +17,7 @@ import forge.game.spellability.AbilitySub;
|
|||||||
import forge.game.spellability.SpellAbility;
|
import forge.game.spellability.SpellAbility;
|
||||||
|
|
||||||
public class SpellAbilityChoicesIterator {
|
public class SpellAbilityChoicesIterator {
|
||||||
private SimulationController controller;
|
private final SimulationController controller;
|
||||||
|
|
||||||
private Iterator<int[]> modeIterator;
|
private Iterator<int[]> modeIterator;
|
||||||
private int[] selectedModes;
|
private int[] selectedModes;
|
||||||
@@ -34,11 +35,13 @@ public class SpellAbilityChoicesIterator {
|
|||||||
Card selectedChoice;
|
Card selectedChoice;
|
||||||
Score bestScoreForChoice = new Score(Integer.MIN_VALUE);
|
Score bestScoreForChoice = new Score(Integer.MIN_VALUE);
|
||||||
}
|
}
|
||||||
private ArrayList<ChoicePoint> choicePoints = new ArrayList<>();
|
private final ArrayList<ChoicePoint> choicePoints = new ArrayList<>();
|
||||||
private int incrementedCpIndex = 0;
|
private int incrementedCpIndex = 0;
|
||||||
private int cpIndex = -1;
|
private int cpIndex = -1;
|
||||||
|
|
||||||
private int evalDepth;
|
private int evalDepth;
|
||||||
|
// Maps from filtered mode indexes to original ones.
|
||||||
|
private List<Integer> modesMap;
|
||||||
|
|
||||||
public SpellAbilityChoicesIterator(SimulationController controller) {
|
public SpellAbilityChoicesIterator(SimulationController controller) {
|
||||||
this.controller = controller;
|
this.controller = controller;
|
||||||
@@ -46,17 +49,28 @@ public class SpellAbilityChoicesIterator {
|
|||||||
|
|
||||||
public List<AbilitySub> chooseModesForAbility(List<AbilitySub> choices, int min, int num, boolean allowRepeat) {
|
public List<AbilitySub> chooseModesForAbility(List<AbilitySub> choices, int min, int num, boolean allowRepeat) {
|
||||||
if (modeIterator == null) {
|
if (modeIterator == null) {
|
||||||
// TODO: Need to skip modes that are invalid (e.g. targets don't exist)!
|
// Skip modes that don't have legal targets.
|
||||||
|
modesMap = new ArrayList<>();
|
||||||
|
int origIndex = -1;
|
||||||
|
for (AbilitySub sub : choices) {
|
||||||
|
origIndex++;
|
||||||
|
if (!ComputerUtilAbility.isFullyTargetable(sub)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
modesMap.add(origIndex);
|
||||||
|
}
|
||||||
// TODO: Do we need to do something special to support cards that have extra costs
|
// TODO: Do we need to do something special to support cards that have extra costs
|
||||||
// when choosing more modes, like Blessed Alliance?
|
// when choosing more modes, like Blessed Alliance?
|
||||||
if (!allowRepeat) {
|
if (modesMap.isEmpty()) {
|
||||||
modeIterator = CombinatoricsUtils.combinationsIterator(choices.size(), num);
|
return null;
|
||||||
|
} else if (!allowRepeat) {
|
||||||
|
modeIterator = CombinatoricsUtils.combinationsIterator(modesMap.size(), num);
|
||||||
} else {
|
} else {
|
||||||
// Note: When allowRepeat is true, it does result in many possibilities being tried.
|
// Note: When allowRepeat is true, it does result in many possibilities being tried.
|
||||||
// We should ideally prune some of those at a higher level.
|
// We should ideally prune some of those at a higher level.
|
||||||
modeIterator = new AllowRepeatModesIterator(choices.size(), min, num);
|
modeIterator = new AllowRepeatModesIterator(modesMap.size(), min, num);
|
||||||
}
|
}
|
||||||
selectedModes = modeIterator.next();
|
selectedModes = remapModes(modeIterator.next());
|
||||||
advancedToNextMode = true;
|
advancedToNextMode = true;
|
||||||
}
|
}
|
||||||
// Note: If modeIterator already existed, selectedModes would have been updated in advance().
|
// Note: If modeIterator already existed, selectedModes would have been updated in advance().
|
||||||
@@ -78,6 +92,13 @@ public class SpellAbilityChoicesIterator {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int[] remapModes(int[] modes) {
|
||||||
|
for (int i = 0; i < modes.length; i++) {
|
||||||
|
modes[i] = modesMap.get(modes[i]);
|
||||||
|
}
|
||||||
|
return modes;
|
||||||
|
}
|
||||||
|
|
||||||
public Card chooseCard(CardCollection fetchList) {
|
public Card chooseCard(CardCollection fetchList) {
|
||||||
cpIndex++;
|
cpIndex++;
|
||||||
if (cpIndex >= choicePoints.size()) {
|
if (cpIndex >= choicePoints.size()) {
|
||||||
@@ -86,8 +107,7 @@ public class SpellAbilityChoicesIterator {
|
|||||||
ChoicePoint cp = choicePoints.get(cpIndex);
|
ChoicePoint cp = choicePoints.get(cpIndex);
|
||||||
// Prune duplicates.
|
// Prune duplicates.
|
||||||
HashSet<String> uniqueCards = new HashSet<>();
|
HashSet<String> uniqueCards = new HashSet<>();
|
||||||
for (int i = 0; i < fetchList.size(); i++) {
|
for (Card card : fetchList) {
|
||||||
Card card = fetchList.get(i);
|
|
||||||
if (uniqueCards.add(card.getName()) && uniqueCards.size() == cp.nextChoice + 1) {
|
if (uniqueCards.add(card.getName()) && uniqueCards.size() == cp.nextChoice + 1) {
|
||||||
cp.selectedChoice = card;
|
cp.selectedChoice = card;
|
||||||
}
|
}
|
||||||
@@ -137,14 +157,9 @@ public class SpellAbilityChoicesIterator {
|
|||||||
evalDepth++;
|
evalDepth++;
|
||||||
pushTarget = false;
|
pushTarget = false;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public int[] getSelectModes() {
|
|
||||||
return selectedModes;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean advance(Score lastScore) {
|
public boolean advance(Score lastScore) {
|
||||||
cpIndex = -1;
|
cpIndex = -1;
|
||||||
for (ChoicePoint cp : choicePoints) {
|
for (ChoicePoint cp : choicePoints) {
|
||||||
@@ -195,7 +210,7 @@ public class SpellAbilityChoicesIterator {
|
|||||||
doneEvaluating(bestScoreForMode);
|
doneEvaluating(bestScoreForMode);
|
||||||
bestScoreForMode = new Score(Integer.MIN_VALUE);
|
bestScoreForMode = new Score(Integer.MIN_VALUE);
|
||||||
if (modeIterator.hasNext()) {
|
if (modeIterator.hasNext()) {
|
||||||
selectedModes = modeIterator.next();
|
selectedModes = remapModes(modeIterator.next());
|
||||||
advancedToNextMode = true;
|
advancedToNextMode = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -232,8 +247,8 @@ public class SpellAbilityChoicesIterator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static class AllowRepeatModesIterator implements Iterator<int[]> {
|
private static class AllowRepeatModesIterator implements Iterator<int[]> {
|
||||||
private int numChoices;
|
private final int numChoices;
|
||||||
private int max;
|
private final int max;
|
||||||
private int[] indexes;
|
private int[] indexes;
|
||||||
|
|
||||||
public AllowRepeatModesIterator(int numChoices, int min, int max) {
|
public AllowRepeatModesIterator(int numChoices, int min, int max) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public class SpellAbilityPicker {
|
|||||||
private SpellAbilityChoicesIterator interceptor;
|
private SpellAbilityChoicesIterator interceptor;
|
||||||
|
|
||||||
private Plan plan;
|
private Plan plan;
|
||||||
|
private int numSimulations;
|
||||||
|
|
||||||
public SpellAbilityPicker(Game game, Player player) {
|
public SpellAbilityPicker(Game game, Player player) {
|
||||||
this.game = game;
|
this.game = game;
|
||||||
@@ -66,19 +67,7 @@ public class SpellAbilityPicker {
|
|||||||
private List<SpellAbility> getCandidateSpellsAndAbilities() {
|
private List<SpellAbility> getCandidateSpellsAndAbilities() {
|
||||||
CardCollection cards = ComputerUtilAbility.getAvailableCards(game, player);
|
CardCollection cards = ComputerUtilAbility.getAvailableCards(game, player);
|
||||||
List<SpellAbility> all = ComputerUtilAbility.getSpellAbilities(cards, player);
|
List<SpellAbility> all = ComputerUtilAbility.getSpellAbilities(cards, player);
|
||||||
CardCollection landsToPlay = ComputerUtilAbility.getAvailableLandsToPlay(game, player);
|
|
||||||
if (landsToPlay != null) {
|
|
||||||
HashMap<String, Card> landsDeDupe = new HashMap<>();
|
HashMap<String, Card> landsDeDupe = new HashMap<>();
|
||||||
for (Card land : landsToPlay) {
|
|
||||||
Card previousLand = landsDeDupe.get(land.getName());
|
|
||||||
// Skip identical lands.
|
|
||||||
if (previousLand != null && previousLand.getZone() == land.getZone() && previousLand.getOwner() == land.getOwner()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
landsDeDupe.put(land.getName(), land);
|
|
||||||
all.add(new LandAbility(land));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
List<SpellAbility> candidateSAs = ComputerUtilAbility.getOriginalAndAltCostAbilities(all, player);
|
List<SpellAbility> candidateSAs = ComputerUtilAbility.getOriginalAndAltCostAbilities(all, player);
|
||||||
int writeIndex = 0;
|
int writeIndex = 0;
|
||||||
for (int i = 0; i < candidateSAs.size(); i++) {
|
for (int i = 0; i < candidateSAs.size(); i++) {
|
||||||
@@ -86,6 +75,16 @@ public class SpellAbilityPicker {
|
|||||||
if (sa.isManaAbility()) {
|
if (sa.isManaAbility()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Skip identical lands.
|
||||||
|
if (sa instanceof LandAbility) {
|
||||||
|
Card land = sa.getHostCard();
|
||||||
|
Card previousLand = landsDeDupe.get(sa.getHostCard().getName());
|
||||||
|
if (previousLand != null && previousLand.getZone() == land.getZone() &&
|
||||||
|
previousLand.getOwner() == land.getOwner()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
landsDeDupe.put(land.getName(), land);
|
||||||
|
}
|
||||||
sa.setActivatingPlayer(player, true);
|
sa.setActivatingPlayer(player, true);
|
||||||
|
|
||||||
AiPlayDecision opinion = canPlayAndPayForSim(sa);
|
AiPlayDecision opinion = canPlayAndPayForSim(sa);
|
||||||
@@ -353,7 +352,9 @@ public class SpellAbilityPicker {
|
|||||||
if (!ComputerUtilCost.canPayCost(sa, player, sa.isTrigger())) {
|
if (!ComputerUtilCost.canPayCost(sa, player, sa.isTrigger())) {
|
||||||
return AiPlayDecision.CantAfford;
|
return AiPlayDecision.CantAfford;
|
||||||
}
|
}
|
||||||
|
if (!ComputerUtilAbility.isFullyTargetable(sa)) {
|
||||||
|
return AiPlayDecision.TargetingFailed;
|
||||||
|
}
|
||||||
if (shouldWaitForLater(sa)) {
|
if (shouldWaitForLater(sa)) {
|
||||||
return AiPlayDecision.AnotherTime;
|
return AiPlayDecision.AnotherTime;
|
||||||
}
|
}
|
||||||
@@ -375,10 +376,13 @@ public class SpellAbilityPicker {
|
|||||||
final SpellAbilityChoicesIterator choicesIterator = new SpellAbilityChoicesIterator(controller);
|
final SpellAbilityChoicesIterator choicesIterator = new SpellAbilityChoicesIterator(controller);
|
||||||
Score lastScore;
|
Score lastScore;
|
||||||
do {
|
do {
|
||||||
|
// TODO: MyRandom should be an instance on the game object, so that we could do
|
||||||
|
// simulations in parallel without messing up global state.
|
||||||
MyRandom.setRandom(new Random(randomSeedToUse));
|
MyRandom.setRandom(new Random(randomSeedToUse));
|
||||||
GameSimulator simulator = new GameSimulator(controller, game, player, phase);
|
GameSimulator simulator = new GameSimulator(controller, game, player, phase);
|
||||||
simulator.setInterceptor(choicesIterator);
|
simulator.setInterceptor(choicesIterator);
|
||||||
lastScore = simulator.simulateSpellAbility(sa);
|
lastScore = simulator.simulateSpellAbility(sa);
|
||||||
|
numSimulations++;
|
||||||
if (lastScore.value > bestScore.value) {
|
if (lastScore.value > bestScore.value) {
|
||||||
bestScore = lastScore;
|
bestScore = lastScore;
|
||||||
}
|
}
|
||||||
@@ -462,4 +466,8 @@ public class SpellAbilityPicker {
|
|||||||
}
|
}
|
||||||
return ComputerUtil.chooseSacrificeType(player, type, ability, ability.getTargetCard(), effect, amount, exclude);
|
return ComputerUtil.chooseSacrificeType(player, type, ability, ability.getTargetCard(), effect, amount, exclude);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getNumSimulations() {
|
||||||
|
return numSimulations;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1254,10 +1254,16 @@ public abstract class SpellAbility extends CardTraitBase implements ISpellAbilit
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final TargetRestrictions tr = getTargetRestrictions();
|
final SpellAbility rootAbility = this.getRootAbility();
|
||||||
|
// 115.5. A spell or ability on the stack is an illegal target for itself.
|
||||||
|
// (This covers the spell case.)
|
||||||
|
if (rootAbility.isSpell() && rootAbility.getHostCard() == entity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Restriction related to this ability
|
// Restriction related to this ability
|
||||||
if (usesTargeting()) {
|
if (usesTargeting()) {
|
||||||
|
final TargetRestrictions tr = getTargetRestrictions();
|
||||||
if (tr.isUniqueTargets() && getUniqueTargets().contains(entity))
|
if (tr.isUniqueTargets() && getUniqueTargets().contains(entity))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|||||||
@@ -483,4 +483,126 @@ public class SpellAbilityPickerSimulationTest extends SimulationTest {
|
|||||||
AssertJUnit.assertEquals("Chaos Warp", sa.getHostCard().getName());
|
AssertJUnit.assertEquals("Chaos Warp", sa.getHostCard().getName());
|
||||||
AssertJUnit.assertEquals(expectedTarget, sa.getTargetCard());
|
AssertJUnit.assertEquals(expectedTarget, sa.getTargetCard());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNoSimulationsWhenNoTargets() {
|
||||||
|
Game game = initAndCreateGame();
|
||||||
|
Player p = game.getPlayers().get(1);
|
||||||
|
|
||||||
|
addCard("Island", p);
|
||||||
|
addCard("Island", p);
|
||||||
|
addCardToZone("Counterspell", p, ZoneType.Hand);
|
||||||
|
addCardToZone("Unsummon", p, ZoneType.Hand);
|
||||||
|
|
||||||
|
game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p);
|
||||||
|
game.getAction().checkStateEffects(true);
|
||||||
|
|
||||||
|
SpellAbilityPicker picker = new SpellAbilityPicker(game, p);
|
||||||
|
SpellAbility sa = picker.chooseSpellAbilityToPlay(null);
|
||||||
|
AssertJUnit.assertNull(sa);
|
||||||
|
AssertJUnit.assertEquals(0, picker.getNumSimulations());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testLandDropPruning() {
|
||||||
|
Game game = initAndCreateGame();
|
||||||
|
Player p = game.getPlayers().get(1);
|
||||||
|
|
||||||
|
addCardToZone("Island", p, ZoneType.Hand);
|
||||||
|
addCardToZone("Island", p, ZoneType.Hand);
|
||||||
|
addCardToZone("Island", p, ZoneType.Hand);
|
||||||
|
|
||||||
|
game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p);
|
||||||
|
game.getAction().checkStateEffects(true);
|
||||||
|
|
||||||
|
SpellAbilityPicker picker = new SpellAbilityPicker(game, p);
|
||||||
|
SpellAbility sa = picker.chooseSpellAbilityToPlay(null);
|
||||||
|
AssertJUnit.assertNotNull(sa);
|
||||||
|
// Only one land drop should be simulated, since the cards are identical.
|
||||||
|
AssertJUnit.assertEquals(1, picker.getNumSimulations());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSpellCantTargetSelf() {
|
||||||
|
Game game = initAndCreateGame();
|
||||||
|
Player p = game.getPlayers().get(1);
|
||||||
|
Player opponent = game.getPlayers().get(0);
|
||||||
|
|
||||||
|
addCardToZone("Unsubstantiate", p, ZoneType.Hand);
|
||||||
|
addCard("Forest", p);
|
||||||
|
addCard("Island", p);
|
||||||
|
Card expectedTarget = addCard("Flying Men", opponent);
|
||||||
|
|
||||||
|
game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p);
|
||||||
|
game.getAction().checkStateEffects(true);
|
||||||
|
|
||||||
|
SpellAbilityPicker picker = new SpellAbilityPicker(game, p);
|
||||||
|
SpellAbility sa = picker.chooseSpellAbilityToPlay(null);
|
||||||
|
AssertJUnit.assertNotNull(sa);
|
||||||
|
AssertJUnit.assertEquals(expectedTarget, sa.getTargetCard());
|
||||||
|
// Only a single simulation expected (no target self).
|
||||||
|
AssertJUnit.assertEquals(1, picker.getNumSimulations());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testModalSpellCantTargetSelf() {
|
||||||
|
Game game = initAndCreateGame();
|
||||||
|
Player p = game.getPlayers().get(1);
|
||||||
|
Player opponent = game.getPlayers().get(0);
|
||||||
|
|
||||||
|
addCardToZone("Decisive Denial", p, ZoneType.Hand);
|
||||||
|
addCard("Forest", p);
|
||||||
|
addCard("Island", p);
|
||||||
|
addCard("Runeclaw Bear", p);
|
||||||
|
addCard("Flying Men", opponent);
|
||||||
|
|
||||||
|
game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p);
|
||||||
|
game.getAction().checkStateEffects(true);
|
||||||
|
|
||||||
|
SpellAbilityPicker picker = new SpellAbilityPicker(game, p);
|
||||||
|
SpellAbility sa = picker.chooseSpellAbilityToPlay(null);
|
||||||
|
AssertJUnit.assertNotNull(sa);
|
||||||
|
// Expected: Runeclaw Bear fights Flying Men
|
||||||
|
// Only a single simulation expected (no target self).
|
||||||
|
AssertJUnit.assertEquals(1, picker.getNumSimulations());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testModalSpellNoTargetsForModeWithSubAbility() {
|
||||||
|
Game game = initAndCreateGame();
|
||||||
|
Player p = game.getPlayers().get(1);
|
||||||
|
|
||||||
|
addCardToZone("Temur Charm", p, ZoneType.Hand);
|
||||||
|
addCard("Forest", p);
|
||||||
|
addCard("Island", p);
|
||||||
|
addCard("Mountain", p);
|
||||||
|
|
||||||
|
game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p);
|
||||||
|
game.getAction().checkStateEffects(true);
|
||||||
|
|
||||||
|
SpellAbilityPicker picker = new SpellAbilityPicker(game, p);
|
||||||
|
picker.chooseSpellAbilityToPlay(null);
|
||||||
|
// Only mode "Creatures with power 3 or less can’t block this turn" should be simulated.
|
||||||
|
AssertJUnit.assertEquals(1, picker.getNumSimulations());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testModalSpellNoTargetsForAnyModes() {
|
||||||
|
Game game = initAndCreateGame();
|
||||||
|
Player p = game.getPlayers().get(1);
|
||||||
|
|
||||||
|
addCardToZone("Drown in the Loch", p, ZoneType.Hand);
|
||||||
|
addCard("Swamp", p);
|
||||||
|
addCard("Island", p);
|
||||||
|
|
||||||
|
game.getPhaseHandler().devModeSet(PhaseType.MAIN2, p);
|
||||||
|
game.getAction().checkStateEffects(true);
|
||||||
|
|
||||||
|
SpellAbilityPicker picker = new SpellAbilityPicker(game, p);
|
||||||
|
picker.chooseSpellAbilityToPlay(null);
|
||||||
|
// TODO: Ideally, this would be 0 simulations, but we currently only determine there are no
|
||||||
|
// valid modes in SpellAbilityChoicesIterator, which runs already when we're simulating.
|
||||||
|
// Still, this test case exercises the code path and ensures we don't crash in this case.
|
||||||
|
AssertJUnit.assertEquals(1, picker.getNumSimulations());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user