forked from External/mage
Clash adjustments (#10616)
* adjust clash effect * Make clash not a singleton * Add unit test for Clash effect * fix test (skip init shuffling) * Fix CLASHED event flag logic and add to unit test * Additional test and comments * comments in GameEvent * param name typo
This commit is contained in:
parent
0d4c73b385
commit
ee29c38413
15 changed files with 187 additions and 67 deletions
|
|
@ -66,7 +66,7 @@ class BrokenAmbitionsEffect extends OneShotEffect {
|
|||
if (player == null) {
|
||||
return false;
|
||||
}
|
||||
if (ClashEffect.getInstance().apply(game, source)) {
|
||||
if (new ClashEffect().apply(game, source)) {
|
||||
player.millCards(4, source, game);
|
||||
}
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ class CaptivatingGlanceEffect extends OneShotEffect {
|
|||
if (controller != null
|
||||
&& captivatingGlance != null) {
|
||||
Permanent enchantedCreature = game.getPermanent(captivatingGlance.getAttachedTo());
|
||||
clashResult = ClashEffect.getInstance().apply(game, source);
|
||||
clashResult = new ClashEffect().apply(game, source);
|
||||
if (enchantedCreature != null) {
|
||||
if (clashResult) {
|
||||
ContinuousEffect effect = new GainControlTargetEffect(Duration.Custom, controller.getId());
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class FistfulOfForceEffect extends OneShotEffect {
|
|||
ContinuousEffect effect = new BoostTargetEffect(2,2,Duration.EndOfTurn);
|
||||
effect.setTargetPointer(new FixedTarget(creature.getId(), game));
|
||||
game.addEffect(effect, source);
|
||||
if (ClashEffect.getInstance().apply(game, source)) {
|
||||
if (new ClashEffect().apply(game, source)) {
|
||||
game.addEffect(effect.copy(), source);
|
||||
effect = new GainAbilityTargetEffect(TrampleAbility.getInstance(), Duration.EndOfTurn);
|
||||
effect.setTargetPointer(new FixedTarget(creature.getId(), game));
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class GiltLeafAmbushCreateTokenEffect extends OneShotEffect {
|
|||
if (controller != null) {
|
||||
CreateTokenEffect effect = new CreateTokenEffect(new ElfWarriorToken(), 2);
|
||||
effect.apply(game, source);
|
||||
if (ClashEffect.getInstance().apply(game, source)) {
|
||||
if (new ClashEffect().apply(game, source)) {
|
||||
for (UUID tokenId : effect.getLastAddedTokenIds()) {
|
||||
Permanent token = game.getPermanent(tokenId);
|
||||
if (token != null) {
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class HoardersGreedEffect extends OneShotEffect {
|
|||
do {
|
||||
controller.loseLife(2, game, source, false);
|
||||
controller.drawCards(2, source, game);
|
||||
} while (controller.canRespond() && ClashEffect.getInstance().apply(game, source));
|
||||
} while (controller.canRespond() && new ClashEffect().apply(game, source));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class LashOutEffect extends OneShotEffect {
|
|||
Permanent creature = game.getPermanent(getTargetPointer().getFirst(game, source));
|
||||
if (controller != null && creature != null) {
|
||||
creature.damage(3, source.getSourceId(), source, game, false, true);
|
||||
if (ClashEffect.getInstance().apply(game, source)) {
|
||||
if (new ClashEffect().apply(game, source)) {
|
||||
Player creaturesController = game.getPlayer(creature.getControllerId());
|
||||
if (creaturesController != null) {
|
||||
creaturesController.damage(3, source.getSourceId(), source, game);
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class PullingTeethEffect extends OneShotEffect {
|
|||
Player controller = game.getPlayer(source.getControllerId());
|
||||
if (controller != null) {
|
||||
int cardsToDiscard;
|
||||
if (ClashEffect.getInstance().apply(game, source)) {
|
||||
if (new ClashEffect().apply(game, source)) {
|
||||
cardsToDiscard = 2;
|
||||
} else {
|
||||
cardsToDiscard = 1;
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class ScatteringStrokeEffect extends OneShotEffect {
|
|||
Player controller = game.getPlayer(source.getControllerId());
|
||||
if (controller != null && spell != null) {
|
||||
game.getStack().counter(spell.getId(), source, game);
|
||||
if (ClashEffect.getInstance().apply(game, source)) {
|
||||
if (new ClashEffect().apply(game, source)) {
|
||||
Effect effect = new AddManaToManaPoolSourceControllerEffect(new Mana(0, 0, 0, 0, 0, 0, 0, spell.getManaValue()));
|
||||
AtTheBeginOfMainPhaseDelayedTriggeredAbility delayedAbility
|
||||
= new AtTheBeginOfMainPhaseDelayedTriggeredAbility(effect, true, TargetController.YOU, AtTheBeginOfMainPhaseDelayedTriggeredAbility.PhaseSelection.NEXT_MAIN);
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class WeedStrangleEffect extends OneShotEffect {
|
|||
Permanent creature = game.getPermanent(getTargetPointer().getFirst(game, source));
|
||||
if (controller != null && creature != null) {
|
||||
creature.destroy(source, game, false);
|
||||
if (ClashEffect.getInstance().apply(game, source)) {
|
||||
if (new ClashEffect().apply(game, source)) {
|
||||
controller.gainLife(creature.getToughness().getValue(), game, source);
|
||||
}
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class WhirlpoolWhelmEffect extends OneShotEffect {
|
|||
Permanent creature = game.getPermanent(getTargetPointer().getFirst(game, source));
|
||||
if (controller != null) {
|
||||
boolean topOfLibrary = false;
|
||||
if (ClashEffect.getInstance().apply(game, source)) {
|
||||
if (new ClashEffect().apply(game, source)) {
|
||||
topOfLibrary = controller.chooseUse(outcome, "Put " + creature.getLogName() + " to top of libraray instead?", source, game);
|
||||
}
|
||||
if (topOfLibrary) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
package org.mage.test.cards.abilities.keywords;
|
||||
|
||||
import mage.constants.PhaseStep;
|
||||
import mage.constants.Zone;
|
||||
import org.junit.Test;
|
||||
import org.mage.test.serverside.base.CardTestPlayerBase;
|
||||
|
||||
/**
|
||||
* @author xenohedron
|
||||
*/
|
||||
public class ClashEffectTest extends CardTestPlayerBase {
|
||||
|
||||
/**
|
||||
* 701.23. Clash
|
||||
* 701.23a. To clash, a player reveals the top card of their library.
|
||||
* That player may then put that card on the bottom of their library.
|
||||
* 701.23b. "Clash with an opponent" means "Choose an opponent. You and that opponent each clash."
|
||||
* 701.23c. Each clashing player reveals the top card of their library at the same time.
|
||||
* Then those players decide in APNAP order (see rule 101.4) where to put those cards, then those cards move at the same time.
|
||||
* 701.23d. A player wins a clash if that player revealed a card with a higher mana value than all other cards revealed in that clash.
|
||||
*/
|
||||
|
||||
private static final String rascal = "Paperfin Rascal"; // 2/2 for 2U, ETB clash, +1/+1 counter if won (so 3/3)
|
||||
|
||||
private void prepareClashing() {
|
||||
|
||||
setStrictChooseMode(true);
|
||||
|
||||
addCard(Zone.HAND, playerA, rascal);
|
||||
addCard(Zone.BATTLEFIELD, playerA, "Island", 3);
|
||||
addCard(Zone.BATTLEFIELD, playerB, "Sylvan Echoes" , 1); // Whenever you clash and win, you may draw a card.
|
||||
|
||||
// Default: both players have only lands with mana value 0
|
||||
// Add card with greater mana value to one player's library so that they win the clash
|
||||
removeAllCardsFromLibrary(playerA);
|
||||
removeAllCardsFromLibrary(playerB);
|
||||
addCard(Zone.LIBRARY, playerA, "Island", 4);
|
||||
addCard(Zone.LIBRARY, playerB, "Island", 4);
|
||||
skipInitShuffling();
|
||||
|
||||
}
|
||||
|
||||
private void performClashing() {
|
||||
|
||||
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, rascal);
|
||||
setChoice(playerA, "PlayerB"); // choose an opponent to clash with
|
||||
setChoice(playerA, false); // put revealed card to bottom of library
|
||||
setChoice(playerB, true); // put revealed card to top of library
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClashYouWin() {
|
||||
prepareClashing();
|
||||
addCard(Zone.LIBRARY, playerA, "Divination", 1);
|
||||
performClashing();
|
||||
setStopAt(1, PhaseStep.BEGIN_COMBAT);
|
||||
execute();
|
||||
assertPowerToughness(playerA, rascal, 3, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClashOppWin() {
|
||||
prepareClashing();
|
||||
addCard(Zone.LIBRARY, playerB, "Divination", 1);
|
||||
performClashing();
|
||||
setChoice(playerB, true); // Draw a card from Sylvan Echoes
|
||||
setStopAt(1, PhaseStep.BEGIN_COMBAT);
|
||||
execute();
|
||||
assertPowerToughness(playerA, rascal, 2, 2);
|
||||
assertHandCount(playerB, "Divination", 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClashNoWin() {
|
||||
prepareClashing();
|
||||
performClashing();
|
||||
setStopAt(1, PhaseStep.BEGIN_COMBAT);
|
||||
execute();
|
||||
assertPowerToughness(playerA, rascal, 2, 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHoardersGreed() {
|
||||
|
||||
setStrictChooseMode(true);
|
||||
|
||||
removeAllCardsFromLibrary(playerA);
|
||||
removeAllCardsFromLibrary(playerB);
|
||||
skipInitShuffling();
|
||||
addCard(Zone.LIBRARY, playerB, "Island", 5);
|
||||
addCard(Zone.LIBRARY, playerA, "Island", 5);
|
||||
addCard(Zone.LIBRARY, playerA, "Wastes", 1);
|
||||
addCard(Zone.LIBRARY, playerA, "Razorfield Thresher", 1);
|
||||
addCard(Zone.LIBRARY, playerA, "Phyrexian Hulk", 1);
|
||||
addCard(Zone.LIBRARY, playerA, "Stone Golem", 1);
|
||||
addCard(Zone.LIBRARY, playerA, "Gilded Sentinel", 1);
|
||||
addCard(Zone.LIBRARY, playerA, "Stonework Puma", 1);
|
||||
addCard(Zone.LIBRARY, playerA, "Field Creeper", 1);
|
||||
addCard(Zone.LIBRARY, playerA, "Metallic Sliver", 1);
|
||||
addCard(Zone.LIBRARY, playerA, "Memnite", 1);
|
||||
addCard(Zone.BATTLEFIELD, playerA, "Swamp", 4);
|
||||
addCard(Zone.HAND, playerA, "Hoarder's Greed", 1);
|
||||
|
||||
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Hoarder's Greed");
|
||||
// Draw two cards (Memnite and Metallic Sliver), lose 2 life, clash:
|
||||
setChoice(playerA, "PlayerB");
|
||||
setChoice(playerA, false); // Field Creeper to bottom of library
|
||||
setChoice(playerB, true);
|
||||
// Clash won. Draw two cards (Stonework Puma and Gilded Sentinel), lose 2 life, clash:
|
||||
setChoice(playerA, "PlayerB");
|
||||
setChoice(playerA, false); // Stone Golem to bottom of library
|
||||
setChoice(playerB, true);
|
||||
// Clash won. Draw two cards (Phyrexian Hulk and Razorfield Thresher), lose 2 life, clash:
|
||||
setChoice(playerA, "PlayerB");
|
||||
setChoice(playerA, true); // Wastes to top of library
|
||||
setChoice(playerB, true);
|
||||
// No winner in this clash.
|
||||
|
||||
setStopAt(1, PhaseStep.BEGIN_COMBAT);
|
||||
execute();
|
||||
|
||||
assertLife(playerA, 20 - (2 * 3));
|
||||
assertHandCount(playerA, "Memnite", 1);
|
||||
assertHandCount(playerA, "Metallic Sliver", 1);
|
||||
assertHandCount(playerA, "Field Creeper", 0);
|
||||
assertHandCount(playerA, "Stonework Puma", 1);
|
||||
assertHandCount(playerA, "Gilded Sentinel", 1);
|
||||
assertHandCount(playerA, "Stone Golem", 0);
|
||||
assertHandCount(playerA, "Phyrexian Hulk", 1);
|
||||
assertHandCount(playerA, "Razorfield Thresher", 1);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -2,8 +2,6 @@ package mage.abilities.effects.common;
|
|||
|
||||
import mage.MageObject;
|
||||
import mage.abilities.Ability;
|
||||
import mage.abilities.MageSingleton;
|
||||
import mage.abilities.effects.Effect;
|
||||
import mage.abilities.effects.OneShotEffect;
|
||||
import mage.cards.Card;
|
||||
import mage.cards.Cards;
|
||||
|
|
@ -13,11 +11,10 @@ import mage.constants.Zone;
|
|||
import mage.game.Game;
|
||||
import mage.game.events.GameEvent;
|
||||
import mage.players.Player;
|
||||
import mage.players.PlayerList;
|
||||
import mage.target.Target;
|
||||
import mage.target.common.TargetOpponent;
|
||||
|
||||
import java.io.ObjectStreamException;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 1. The controller of the spell or ability chooses an opponent. (This doesn't
|
||||
|
|
@ -54,24 +51,14 @@ import java.io.ObjectStreamException;
|
|||
*
|
||||
* @author LevelX2
|
||||
*/
|
||||
public class ClashEffect extends OneShotEffect implements MageSingleton {
|
||||
public class ClashEffect extends OneShotEffect {
|
||||
|
||||
private static final ClashEffect instance = new ClashEffect();
|
||||
|
||||
private Object readResolve() throws ObjectStreamException {
|
||||
return instance;
|
||||
}
|
||||
|
||||
private ClashEffect() {
|
||||
public ClashEffect() {
|
||||
super(Outcome.Benefit);
|
||||
this.staticText = "Clash with an opponent";
|
||||
}
|
||||
|
||||
public static ClashEffect getInstance() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ClashEffect(final ClashEffect effect) {
|
||||
protected ClashEffect(final ClashEffect effect) {
|
||||
super(effect);
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +81,6 @@ public class ClashEffect extends OneShotEffect implements MageSingleton {
|
|||
// choose opponent
|
||||
Target target = new TargetOpponent(true);
|
||||
target.setTargetName("an opponent to clash with");
|
||||
target.setNotTarget(true);
|
||||
if (!controller.choose(Outcome.Benefit, target, source, game)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -133,32 +119,26 @@ public class ClashEffect extends OneShotEffect implements MageSingleton {
|
|||
message.append(" no card");
|
||||
}
|
||||
message.append(" - ");
|
||||
if (!game.isSimulation()) {
|
||||
if (cmcController > cmcOpponent) {
|
||||
message.append(controller.getLogName()).append(" won the clash");
|
||||
game.informPlayer(controller, "You won the clash!");
|
||||
} else if (cmcController < cmcOpponent) {
|
||||
message.append(opponent.getLogName()).append(" won the clash");
|
||||
game.informPlayer(controller, opponent.getName() + " won the clash!");
|
||||
} else {
|
||||
message.append(" no winner ");
|
||||
}
|
||||
game.informPlayers(message.toString());
|
||||
if (cmcController > cmcOpponent) {
|
||||
message.append(controller.getLogName()).append(" won the clash");
|
||||
} else if (cmcController < cmcOpponent) {
|
||||
message.append(opponent.getLogName()).append(" won the clash");
|
||||
} else {
|
||||
message.append(" no winner ");
|
||||
}
|
||||
// decide to put the cards on top or on the buttom of library in turn order beginning with the active player in turn order
|
||||
PlayerList playerList = game.getPlayerList().copy();
|
||||
playerList.setCurrent(game.getActivePlayerId());
|
||||
Player nextPlayer;
|
||||
do {
|
||||
Player current = playerList.getCurrent(game);
|
||||
if (cardController != null && current.getId().equals(controller.getId())) {
|
||||
topController = current.chooseUse(Outcome.Detriment, "Put " + cardController.getLogName() + " back on top of your library? (otherwise it goes to bottom)", source, game);
|
||||
game.informPlayers(message.toString());
|
||||
// decide to put the cards on top or on the bottom of library in turn order beginning with the active player in turn order
|
||||
for (UUID playerId : game.getState().getPlayersInRange(source.getControllerId(), game)) {
|
||||
Player player = game.getPlayer(playerId);
|
||||
if (player == null) {
|
||||
continue;
|
||||
}
|
||||
if (cardOpponent != null && current.getId().equals(opponent.getId())) {
|
||||
topOpponent = current.chooseUse(Outcome.Detriment, "Put " + cardOpponent.getLogName() + " back on top of your library? (otherwise it goes to bottom)", source, game);
|
||||
if (cardController != null && player.getId().equals(controller.getId())) {
|
||||
topController = player.chooseUse(Outcome.Detriment, "Put " + cardController.getLogName() + " back on top of your library? (otherwise it goes to bottom)", source, game);
|
||||
} else if (cardOpponent != null && player.getId().equals(opponent.getId())) {
|
||||
topOpponent = player.chooseUse(Outcome.Detriment, "Put " + cardOpponent.getLogName() + " back on top of your library? (otherwise it goes to bottom)", source, game);
|
||||
}
|
||||
nextPlayer = playerList.getNext(game, false);
|
||||
} while (nextPlayer != null && !nextPlayer.getId().equals(game.getActivePlayerId()));
|
||||
}
|
||||
// put the cards back to library
|
||||
if (cardController != null) {
|
||||
controller.moveCardToLibraryWithInfo(cardController, source, game, Zone.LIBRARY, topController, true);
|
||||
|
|
@ -166,14 +146,14 @@ public class ClashEffect extends OneShotEffect implements MageSingleton {
|
|||
if (cardOpponent != null) {
|
||||
opponent.moveCardToLibraryWithInfo(cardOpponent, source, game, Zone.LIBRARY, topOpponent, true);
|
||||
}
|
||||
// fire CLASHED event with info about who won
|
||||
game.fireEvent(new GameEvent(
|
||||
GameEvent.EventType.CLASHED, controller.getId(), source,
|
||||
opponent.getId(), 0, cmcController > cmcOpponent
|
||||
));
|
||||
// fire CLASHED events with info about winner (flag is true if playerId won; other player is targetId)
|
||||
game.fireEvent(new GameEvent(
|
||||
GameEvent.EventType.CLASHED, opponent.getId(), source,
|
||||
controller.getId(), 0, cmcOpponent > cmcController
|
||||
controller.getId(), 0, cmcController > cmcOpponent
|
||||
));
|
||||
game.fireEvent(new GameEvent(
|
||||
GameEvent.EventType.CLASHED, controller.getId(), source,
|
||||
opponent.getId(), 0, cmcOpponent > cmcController
|
||||
));
|
||||
|
||||
// set opponent to DoIfClashWonEffect
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ public class DoIfClashWonEffect extends OneShotEffect {
|
|||
}
|
||||
|
||||
if (chooseUseText == null || player.chooseUse(executingEffect.getOutcome(), message, source, game)) {
|
||||
if (ClashEffect.getInstance().apply(game, source)) {
|
||||
if (new ClashEffect().apply(game, source)) {
|
||||
if (setTargetPointerToClashedOpponent) {
|
||||
Object opponent = getValue("clashOpponent");
|
||||
if (opponent instanceof Player) {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,11 @@ public class GameEvent implements Serializable {
|
|||
DISCARDED_CARD,
|
||||
DISCARDED_CARDS,
|
||||
CYCLE_CARD, CYCLED_CARD, CYCLE_DRAW,
|
||||
/* CLASHED (one event fired for each player involved)
|
||||
playerId the id of the clashing player
|
||||
flag true = playerId won the clash
|
||||
targetId the id of the other player in the clash
|
||||
*/
|
||||
CLASH, CLASHED,
|
||||
DAMAGE_PLAYER,
|
||||
MILL_CARDS,
|
||||
|
|
@ -107,9 +112,9 @@ public class GameEvent implements Serializable {
|
|||
/* DAMAGED_PLAYER
|
||||
targetId the id of the damaged player
|
||||
sourceId sourceId of the ability which caused the damage
|
||||
playerId the id of the damged player
|
||||
playerId the id of the damaged player
|
||||
amount amount of damage
|
||||
flag true = comabat damage - other damage = false
|
||||
flag true = combat damage - other damage = false
|
||||
*/
|
||||
DAMAGED_PLAYER,
|
||||
|
||||
|
|
@ -121,7 +126,7 @@ public class GameEvent implements Serializable {
|
|||
/* DAMAGE_CAUSES_LIFE_LOSS,
|
||||
targetId the id of the damaged player
|
||||
sourceId sourceId of the ability which caused the damage, can be null for default events like combat
|
||||
playerId the id of the damged player
|
||||
playerId the id of the damaged player
|
||||
amount amount of damage
|
||||
flag is it combat damage
|
||||
*/
|
||||
|
|
@ -134,7 +139,7 @@ public class GameEvent implements Serializable {
|
|||
sourceId sourceId of the ability which caused the lose
|
||||
playerId the id of the player loosing life
|
||||
amount amount of life loss
|
||||
flag true = from comabat damage - other from non combat damage
|
||||
flag true = from combat damage - other from non combat damage
|
||||
*/
|
||||
PLAY_LAND, LAND_PLAYED,
|
||||
CREATURE_CHAMPIONED,
|
||||
|
|
@ -333,7 +338,7 @@ public class GameEvent implements Serializable {
|
|||
TAP,
|
||||
/* TAPPED,
|
||||
targetId tapped permanent
|
||||
sourceId id of the abilitity's source (can be null for standard tap actions like combat)
|
||||
sourceId id of the ability's source (can be null for standard tap actions like combat)
|
||||
playerId controller of the tapped permanent
|
||||
amount not used for this event
|
||||
flag is it tapped for combat
|
||||
|
|
@ -441,7 +446,7 @@ public class GameEvent implements Serializable {
|
|||
/* LOST_CONTROL
|
||||
targetId id of the creature that lost control
|
||||
sourceId null
|
||||
playerId player that controlles the creature before
|
||||
playerId player that controls the creature before
|
||||
amount not used for this event
|
||||
flag not used for this event
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -567,11 +567,11 @@ public interface Player extends MageItem, Copyable<Player> {
|
|||
|
||||
void revealCards(Ability source, Cards cards, Game game);
|
||||
|
||||
void revealCards(String titelSuffix, Cards cards, Game game);
|
||||
void revealCards(String titleSuffix, Cards cards, Game game);
|
||||
|
||||
void revealCards(Ability source, String titelSuffix, Cards cards, Game game);
|
||||
void revealCards(Ability source, String titleSuffix, Cards cards, Game game);
|
||||
|
||||
void revealCards(String titelSuffix, Cards cards, Game game, boolean postToLog);
|
||||
void revealCards(String titleSuffix, Cards cards, Game game, boolean postToLog);
|
||||
|
||||
/**
|
||||
* Adds the cards to the reveal window and adds the source object's id name
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue