mirror of
https://github.com/magefree/mage.git
synced 2025-12-20 02:30:08 -08:00
feature: implement Rooms (#13786)
- Adds Room + Room half card types - Adds Room unlock ability - Adds Room special functionality (locking halves, unlocking, changing names, changing mana values) - Adjusts name predicate handling for Room name handling (Rooms have between 0 and 2 names on the battlefield depending on their state. They have 1 name on the stack as a normal split card does). Allows cards to match these names properly - Adds Room game events (Unlock, Fully Unlock) and unlock triggers - Updates Split card constructor to allow a single type line as with Rooms - Adds empty name constant for fully unlocked rooms - Updates Permanent to include the door unlock states (that all permanents have) that are relevant when a permanent is or becomes a Room. - Updates ZonesHandler to properly move Room card parts onto and off of the battlefield. - Updated Eerie ability to function properly with Rooms - Implemented Bottomless Pool // Locker Room - Implemented Surgical Suite // Hospital Room - Added Room card tests
This commit is contained in:
parent
cb900eb799
commit
f7be842008
22 changed files with 1857 additions and 32 deletions
|
|
@ -9,7 +9,6 @@ import mage.game.events.GameEvent;
|
|||
import mage.game.permanent.Permanent;
|
||||
|
||||
/**
|
||||
* TODO: This only triggers off of enchantments entering as the room mechanic hasn't been implemented yet
|
||||
*
|
||||
* @author TheElk801
|
||||
*/
|
||||
|
|
@ -41,15 +40,23 @@ public class EerieAbility extends TriggeredAbilityImpl {
|
|||
|
||||
@Override
|
||||
public boolean checkEventType(GameEvent event, Game game) {
|
||||
return event.getType() == GameEvent.EventType.ENTERS_THE_BATTLEFIELD;
|
||||
return event.getType() == GameEvent.EventType.ENTERS_THE_BATTLEFIELD
|
||||
|| event.getType() == GameEvent.EventType.ROOM_FULLY_UNLOCKED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean checkTrigger(GameEvent event, Game game) {
|
||||
if (!isControlledBy(event.getPlayerId())) {
|
||||
return false;
|
||||
if (event.getType() == GameEvent.EventType.ENTERS_THE_BATTLEFIELD) {
|
||||
if (!isControlledBy(event.getPlayerId())) {
|
||||
return false;
|
||||
}
|
||||
Permanent permanent = game.getPermanent(event.getTargetId());
|
||||
return permanent != null && permanent.isEnchantment(game);
|
||||
}
|
||||
Permanent permanent = game.getPermanent(event.getTargetId());
|
||||
return permanent != null && permanent.isEnchantment(game);
|
||||
|
||||
if (event.getType() == GameEvent.EventType.ROOM_FULLY_UNLOCKED) {
|
||||
return isControlledBy(event.getPlayerId());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
Mage/src/main/java/mage/abilities/common/RoomUnlockAbility.java
Normal file
106
Mage/src/main/java/mage/abilities/common/RoomUnlockAbility.java
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package mage.abilities.common;
|
||||
|
||||
import mage.abilities.Ability;
|
||||
import mage.abilities.SpecialAction;
|
||||
import mage.abilities.condition.common.RoomHalfLockedCondition;
|
||||
import mage.abilities.costs.mana.ManaCosts;
|
||||
import mage.abilities.effects.OneShotEffect;
|
||||
import mage.constants.Outcome;
|
||||
import mage.constants.TimingRule;
|
||||
import mage.constants.Zone;
|
||||
import mage.game.Game;
|
||||
import mage.game.permanent.Permanent;
|
||||
|
||||
/**
|
||||
* @author oscscull
|
||||
* Special action for Room cards to unlock a locked half by paying its
|
||||
* mana
|
||||
* cost.
|
||||
* This ability is only present if the corresponding half is currently
|
||||
* locked.
|
||||
*/
|
||||
public class RoomUnlockAbility extends SpecialAction {
|
||||
|
||||
private final boolean isLeftHalf;
|
||||
|
||||
public RoomUnlockAbility(ManaCosts costs, boolean isLeftHalf) {
|
||||
super(Zone.BATTLEFIELD, null);
|
||||
this.addCost(costs);
|
||||
|
||||
this.isLeftHalf = isLeftHalf;
|
||||
this.timing = TimingRule.SORCERY;
|
||||
|
||||
// only works if the relevant half is *locked*
|
||||
if (isLeftHalf) {
|
||||
this.setCondition(RoomHalfLockedCondition.LEFT);
|
||||
} else {
|
||||
this.setCondition(RoomHalfLockedCondition.RIGHT);
|
||||
}
|
||||
|
||||
// Adds the effect to pay + unlock the half
|
||||
this.addEffect(new RoomUnlockHalfEffect(isLeftHalf));
|
||||
}
|
||||
|
||||
protected RoomUnlockAbility(final RoomUnlockAbility ability) {
|
||||
super(ability);
|
||||
this.isLeftHalf = ability.isLeftHalf;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RoomUnlockAbility copy() {
|
||||
return new RoomUnlockAbility(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRule() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(getManaCostsToPay().getText()).append(": ");
|
||||
sb.append("Unlock the ");
|
||||
sb.append(isLeftHalf ? "left" : "right").append(" half.");
|
||||
sb.append(" <i>(Activate only as a sorcery, and only if the ");
|
||||
sb.append(isLeftHalf ? "left" : "right").append(" half is locked.)</i>");
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows you to pay to unlock the door
|
||||
*/
|
||||
class RoomUnlockHalfEffect extends OneShotEffect {
|
||||
|
||||
private final boolean isLeftHalf;
|
||||
|
||||
public RoomUnlockHalfEffect(boolean isLeftHalf) {
|
||||
super(Outcome.Neutral);
|
||||
this.isLeftHalf = isLeftHalf;
|
||||
staticText = "unlock the " + (isLeftHalf ? "left" : "right") + " half";
|
||||
}
|
||||
|
||||
private RoomUnlockHalfEffect(final RoomUnlockHalfEffect effect) {
|
||||
super(effect);
|
||||
this.isLeftHalf = effect.isLeftHalf;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RoomUnlockHalfEffect copy() {
|
||||
return new RoomUnlockHalfEffect(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean apply(Game game, Ability source) {
|
||||
Permanent permanent = source.getSourcePermanentIfItStillExists(game);
|
||||
|
||||
if (permanent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isLeftHalf && permanent.isLeftDoorUnlocked()) {
|
||||
return false;
|
||||
}
|
||||
if (!isLeftHalf && permanent.isRightDoorUnlocked()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return permanent.unlockDoor(game, source, isLeftHalf);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package mage.abilities.common;
|
||||
|
||||
import mage.abilities.TriggeredAbilityImpl;
|
||||
import mage.abilities.effects.Effect;
|
||||
import mage.constants.Zone;
|
||||
import mage.game.Game;
|
||||
import mage.game.events.GameEvent;
|
||||
|
||||
/**
|
||||
* Triggered ability for "when you unlock this door" effects
|
||||
*
|
||||
* @author oscscull
|
||||
*/
|
||||
public class UnlockThisDoorTriggeredAbility extends TriggeredAbilityImpl {
|
||||
|
||||
private final boolean isLeftHalf;
|
||||
|
||||
public UnlockThisDoorTriggeredAbility(Effect effect, boolean optional, boolean isLeftHalf) {
|
||||
super(Zone.BATTLEFIELD, effect, optional);
|
||||
this.isLeftHalf = isLeftHalf;
|
||||
this.setTriggerPhrase("When you unlock this door, ");
|
||||
}
|
||||
|
||||
private UnlockThisDoorTriggeredAbility(final UnlockThisDoorTriggeredAbility ability) {
|
||||
super(ability);
|
||||
this.isLeftHalf = ability.isLeftHalf;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean checkEventType(GameEvent event, Game game) {
|
||||
return event.getType() == GameEvent.EventType.DOOR_UNLOCKED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean checkTrigger(GameEvent event, Game game) {
|
||||
return event.getTargetId().equals(getSourceId()) && event.getFlag() == isLeftHalf;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UnlockThisDoorTriggeredAbility copy() {
|
||||
return new UnlockThisDoorTriggeredAbility(this);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package mage.abilities.condition.common;
|
||||
|
||||
import mage.abilities.Ability;
|
||||
import mage.abilities.condition.Condition;
|
||||
import mage.game.Game;
|
||||
import mage.game.permanent.Permanent;
|
||||
|
||||
/**
|
||||
* @author oscscull
|
||||
* Checks if a Permanent's specified half is LOCKED (i.e., NOT unlocked).
|
||||
*/
|
||||
public enum RoomHalfLockedCondition implements Condition {
|
||||
|
||||
LEFT(true),
|
||||
RIGHT(false);
|
||||
|
||||
private final boolean checkLeft;
|
||||
|
||||
RoomHalfLockedCondition(boolean checkLeft) {
|
||||
this.checkLeft = checkLeft;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean apply(Game game, Ability source) {
|
||||
Permanent permanent = source.getSourcePermanentIfItStillExists(game);
|
||||
|
||||
if (permanent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Return true if the specified half is NOT unlocked
|
||||
return checkLeft ? !permanent.isLeftDoorUnlocked() : !permanent.isRightDoorUnlocked();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package mage.abilities.effects.common;
|
||||
|
||||
import mage.MageObject;
|
||||
import mage.Mana;
|
||||
import mage.abilities.Ability;
|
||||
import mage.abilities.costs.mana.ManaCosts;
|
||||
import mage.abilities.costs.mana.ManaCostsImpl;
|
||||
import mage.abilities.effects.ContinuousEffectImpl;
|
||||
import mage.cards.Card;
|
||||
import mage.cards.SplitCard;
|
||||
import mage.constants.Duration;
|
||||
import mage.constants.Layer;
|
||||
import mage.constants.Outcome;
|
||||
import mage.constants.SubLayer;
|
||||
import mage.game.Game;
|
||||
import mage.game.permanent.Permanent;
|
||||
import mage.game.permanent.PermanentCard;
|
||||
|
||||
/**
|
||||
* @author oscscull
|
||||
* Continuous effect that sets the name and mana value of a Room permanent based
|
||||
* on its unlocked halves.
|
||||
*
|
||||
* Functions as a characteristic-defining ability.
|
||||
* 709.5. Some split cards are permanent cards with a single shared type line.
|
||||
* A shared type line on such an object represents two static abilities that
|
||||
* function on the battlefield.
|
||||
* These are "As long as this permanent doesn't have the 'left half unlocked'
|
||||
* designation, it doesn't have the name, mana cost, or rules text of this
|
||||
* object's left half"
|
||||
* and "As long as this permanent doesn't have the 'right half unlocked'
|
||||
* designation, it doesn't have the name, mana cost, or rules text of this
|
||||
* object's right half."
|
||||
* These abilities, as well as which half of that permanent a characteristic is
|
||||
* in, are part of that object's copiable values.
|
||||
*/
|
||||
public class RoomCharacteristicsEffect extends ContinuousEffectImpl {
|
||||
|
||||
public RoomCharacteristicsEffect() {
|
||||
super(Duration.WhileOnBattlefield, Layer.PTChangingEffects_7, SubLayer.CharacteristicDefining_7a,
|
||||
Outcome.Neutral);
|
||||
staticText = "";
|
||||
}
|
||||
|
||||
private RoomCharacteristicsEffect(final RoomCharacteristicsEffect effect) {
|
||||
super(effect);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RoomCharacteristicsEffect copy() {
|
||||
return new RoomCharacteristicsEffect(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean apply(Game game, Ability source) {
|
||||
Permanent permanent = source.getSourcePermanentIfItStillExists(game);
|
||||
|
||||
if (permanent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Card roomCardBlueprint;
|
||||
|
||||
// Handle copies
|
||||
if (permanent.isCopy()) {
|
||||
MageObject copiedObject = permanent.getCopyFrom();
|
||||
if (copiedObject instanceof PermanentCard) {
|
||||
roomCardBlueprint = ((PermanentCard) copiedObject).getCard();
|
||||
} else if (copiedObject instanceof Card) {
|
||||
roomCardBlueprint = (Card) copiedObject;
|
||||
} else {
|
||||
roomCardBlueprint = permanent.getMainCard();
|
||||
}
|
||||
} else {
|
||||
roomCardBlueprint = permanent.getMainCard();
|
||||
}
|
||||
|
||||
if (!(roomCardBlueprint instanceof SplitCard)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SplitCard roomCard = (SplitCard) roomCardBlueprint;
|
||||
|
||||
// Set the name based on unlocked halves
|
||||
String newName = "";
|
||||
|
||||
boolean isLeftUnlocked = permanent.isLeftDoorUnlocked();
|
||||
if (isLeftUnlocked && roomCard.getLeftHalfCard() != null) {
|
||||
newName += roomCard.getLeftHalfCard().getName();
|
||||
}
|
||||
|
||||
boolean isRightUnlocked = permanent.isRightDoorUnlocked();
|
||||
if (isRightUnlocked && roomCard.getRightHalfCard() != null) {
|
||||
if (!newName.isEmpty()) {
|
||||
newName += " // "; // Split card name separator
|
||||
}
|
||||
newName += roomCard.getRightHalfCard().getName();
|
||||
}
|
||||
|
||||
permanent.setName(newName);
|
||||
|
||||
// Set the mana value based on unlocked halves
|
||||
// Create a new Mana object to accumulate the costs
|
||||
Mana totalManaCost = new Mana();
|
||||
|
||||
// Add the mana from the left half's cost to our total Mana object
|
||||
if (isLeftUnlocked) {
|
||||
ManaCosts leftHalfManaCost = null;
|
||||
if (roomCard.getLeftHalfCard() != null && roomCard.getLeftHalfCard().getSpellAbility() != null) {
|
||||
leftHalfManaCost = roomCard.getLeftHalfCard().getSpellAbility().getManaCosts();
|
||||
}
|
||||
if (leftHalfManaCost != null) {
|
||||
totalManaCost.add(leftHalfManaCost.getMana());
|
||||
}
|
||||
}
|
||||
|
||||
// Add the mana from the right half's cost to our total Mana object
|
||||
if (isRightUnlocked) {
|
||||
ManaCosts rightHalfManaCost = null;
|
||||
if (roomCard.getRightHalfCard() != null && roomCard.getRightHalfCard().getSpellAbility() != null) {
|
||||
rightHalfManaCost = roomCard.getRightHalfCard().getSpellAbility().getManaCosts();
|
||||
}
|
||||
if (rightHalfManaCost != null) {
|
||||
totalManaCost.add(rightHalfManaCost.getMana());
|
||||
}
|
||||
}
|
||||
|
||||
String newManaCostString = totalManaCost.toString();
|
||||
ManaCostsImpl newManaCosts;
|
||||
|
||||
// If both halves are locked or total 0, it's 0mv.
|
||||
if (newManaCostString.isEmpty() || totalManaCost.count() == 0) {
|
||||
newManaCosts = new ManaCostsImpl<>("");
|
||||
} else {
|
||||
newManaCosts = new ManaCostsImpl<>(newManaCostString);
|
||||
}
|
||||
|
||||
permanent.setManaCost(newManaCosts);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
215
Mage/src/main/java/mage/cards/RoomCard.java
Normal file
215
Mage/src/main/java/mage/cards/RoomCard.java
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
package mage.cards;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import mage.abilities.Abilities;
|
||||
import mage.abilities.Ability;
|
||||
import mage.abilities.common.EntersBattlefieldAbility;
|
||||
import mage.abilities.common.RoomUnlockAbility;
|
||||
import mage.abilities.common.SimpleStaticAbility;
|
||||
import mage.abilities.common.UnlockThisDoorTriggeredAbility;
|
||||
import mage.abilities.condition.common.RoomHalfLockedCondition;
|
||||
import mage.abilities.costs.mana.ManaCosts;
|
||||
import mage.abilities.decorator.ConditionalContinuousEffect;
|
||||
import mage.abilities.effects.OneShotEffect;
|
||||
import mage.abilities.effects.common.RoomCharacteristicsEffect;
|
||||
import mage.constants.CardType;
|
||||
import mage.constants.Duration;
|
||||
import mage.constants.Outcome;
|
||||
import mage.constants.SpellAbilityType;
|
||||
import mage.constants.Zone;
|
||||
import mage.game.Game;
|
||||
import mage.game.permanent.Permanent;
|
||||
import mage.game.permanent.PermanentToken;
|
||||
import mage.abilities.effects.common.continuous.LoseAbilitySourceEffect;
|
||||
|
||||
/**
|
||||
* @author oscscull
|
||||
*/
|
||||
public abstract class RoomCard extends SplitCard {
|
||||
private SpellAbilityType lastCastHalf = null;
|
||||
|
||||
protected RoomCard(UUID ownerId, CardSetInfo setInfo, CardType[] types, String costsLeft,
|
||||
String costsRight, SpellAbilityType spellAbilityType) {
|
||||
super(ownerId, setInfo, costsLeft, costsRight, spellAbilityType, types);
|
||||
|
||||
String[] names = setInfo.getName().split(" // ");
|
||||
|
||||
leftHalfCard = new RoomCardHalfImpl(
|
||||
this.getOwnerId(), new CardSetInfo(names[0], setInfo.getExpansionSetCode(), setInfo.getCardNumber(),
|
||||
setInfo.getRarity(), setInfo.getGraphicInfo()),
|
||||
types, costsLeft, this, SpellAbilityType.SPLIT_LEFT);
|
||||
rightHalfCard = new RoomCardHalfImpl(
|
||||
this.getOwnerId(), new CardSetInfo(names[1], setInfo.getExpansionSetCode(), setInfo.getCardNumber(),
|
||||
setInfo.getRarity(), setInfo.getGraphicInfo()),
|
||||
types, costsRight, this, SpellAbilityType.SPLIT_RIGHT);
|
||||
}
|
||||
|
||||
protected RoomCard(RoomCard card) {
|
||||
super(card);
|
||||
this.lastCastHalf = card.lastCastHalf;
|
||||
}
|
||||
|
||||
public SpellAbilityType getLastCastHalf() {
|
||||
return lastCastHalf;
|
||||
}
|
||||
|
||||
public void setLastCastHalf(SpellAbilityType lastCastHalf) {
|
||||
this.lastCastHalf = lastCastHalf;
|
||||
}
|
||||
|
||||
protected void addRoomAbilities(Ability leftAbility, Ability rightAbility) {
|
||||
getLeftHalfCard().addAbility(leftAbility);
|
||||
getRightHalfCard().addAbility(rightAbility);
|
||||
this.addAbility(leftAbility.copy());
|
||||
this.addAbility(rightAbility.copy());
|
||||
|
||||
// Add the one-shot effect to unlock a door on cast -> ETB
|
||||
Ability entersAbility = new EntersBattlefieldAbility(new RoomEnterUnlockEffect());
|
||||
entersAbility.setRuleVisible(false);
|
||||
this.addAbility(entersAbility);
|
||||
|
||||
// Remove locked door abilities - keeping unlock triggers (or they won't trigger
|
||||
// when unlocked)
|
||||
if (leftAbility != null && !(leftAbility instanceof UnlockThisDoorTriggeredAbility)) {
|
||||
Ability ability = new SimpleStaticAbility(Zone.BATTLEFIELD, new ConditionalContinuousEffect(
|
||||
new LoseAbilitySourceEffect(leftAbility, Duration.WhileOnBattlefield),
|
||||
RoomHalfLockedCondition.LEFT, "")).setRuleVisible(false);
|
||||
this.addAbility(ability);
|
||||
}
|
||||
|
||||
if (rightAbility != null && !(rightAbility instanceof UnlockThisDoorTriggeredAbility)) {
|
||||
Ability ability = new SimpleStaticAbility(Zone.BATTLEFIELD, new ConditionalContinuousEffect(
|
||||
new LoseAbilitySourceEffect(rightAbility, Duration.WhileOnBattlefield),
|
||||
RoomHalfLockedCondition.RIGHT, "")).setRuleVisible(false);
|
||||
this.addAbility(ability);
|
||||
}
|
||||
|
||||
// Add the Special Action to unlock doors.
|
||||
// These will ONLY be active if the corresponding half is LOCKED!
|
||||
if (leftAbility != null) {
|
||||
ManaCosts leftHalfManaCost = null;
|
||||
if (this.getLeftHalfCard() != null && this.getLeftHalfCard().getSpellAbility() != null) {
|
||||
leftHalfManaCost = this.getLeftHalfCard().getSpellAbility().getManaCosts();
|
||||
}
|
||||
RoomUnlockAbility leftUnlockAbility = new RoomUnlockAbility(leftHalfManaCost, true);
|
||||
this.addAbility(leftUnlockAbility.setRuleAtTheTop(true));
|
||||
}
|
||||
|
||||
if (rightAbility != null) {
|
||||
ManaCosts rightHalfManaCost = null;
|
||||
if (this.getRightHalfCard() != null && this.getRightHalfCard().getSpellAbility() != null) {
|
||||
rightHalfManaCost = this.getRightHalfCard().getSpellAbility().getManaCosts();
|
||||
}
|
||||
RoomUnlockAbility rightUnlockAbility = new RoomUnlockAbility(rightHalfManaCost, false);
|
||||
this.addAbility(rightUnlockAbility.setRuleAtTheTop(true));
|
||||
}
|
||||
|
||||
this.addAbility(new RoomAbility());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Abilities<Ability> getAbilities() {
|
||||
return this.abilities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Abilities<Ability> getAbilities(Game game) {
|
||||
return this.abilities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setZone(Zone zone, Game game) {
|
||||
super.setZone(zone, game);
|
||||
|
||||
if (zone == Zone.BATTLEFIELD) {
|
||||
game.setZone(getLeftHalfCard().getId(), Zone.OUTSIDE);
|
||||
game.setZone(getRightHalfCard().getId(), Zone.OUTSIDE);
|
||||
return;
|
||||
}
|
||||
|
||||
game.setZone(getLeftHalfCard().getId(), zone);
|
||||
game.setZone(getRightHalfCard().getId(), zone);
|
||||
}
|
||||
}
|
||||
|
||||
class RoomEnterUnlockEffect extends OneShotEffect {
|
||||
public RoomEnterUnlockEffect() {
|
||||
super(Outcome.Neutral);
|
||||
staticText = "";
|
||||
}
|
||||
|
||||
private RoomEnterUnlockEffect(final RoomEnterUnlockEffect effect) {
|
||||
super(effect);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RoomEnterUnlockEffect copy() {
|
||||
return new RoomEnterUnlockEffect(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean apply(Game game, Ability source) {
|
||||
Permanent permanent = source.getSourcePermanentIfItStillExists(game);
|
||||
|
||||
if (permanent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (permanent.wasRoomUnlockedOnCast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
permanent.unlockRoomOnCast(game);
|
||||
RoomCard roomCard = null;
|
||||
// Get the parent card to access the lastCastHalf variable
|
||||
if (permanent instanceof PermanentToken) {
|
||||
Card mainCard = permanent.getMainCard();
|
||||
if (mainCard instanceof RoomCard) {
|
||||
roomCard = (RoomCard) mainCard;
|
||||
}
|
||||
} else {
|
||||
Card card = game.getCard(permanent.getId());
|
||||
if (card instanceof RoomCard) {
|
||||
roomCard = (RoomCard) card;
|
||||
}
|
||||
}
|
||||
if (roomCard == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
SpellAbilityType lastCastHalf = roomCard.getLastCastHalf();
|
||||
|
||||
if (lastCastHalf == SpellAbilityType.SPLIT_LEFT || lastCastHalf == SpellAbilityType.SPLIT_RIGHT) {
|
||||
roomCard.setLastCastHalf(null);
|
||||
return permanent.unlockDoor(game, source, lastCastHalf == SpellAbilityType.SPLIT_LEFT);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For the overall Room card flavor text and mana value effect.
|
||||
class RoomAbility extends SimpleStaticAbility {
|
||||
public RoomAbility() {
|
||||
super(Zone.ALL, null);
|
||||
this.setRuleVisible(true);
|
||||
this.setRuleAtTheTop(true);
|
||||
this.addEffect(new RoomCharacteristicsEffect());
|
||||
}
|
||||
|
||||
protected RoomAbility(final RoomAbility ability) {
|
||||
super(ability);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRule() {
|
||||
return "<i>(You may cast either half. That door unlocks on the battlefield. " +
|
||||
"As a sorcery, you may pay the mana cost of a locked door to unlock it.)</i>";
|
||||
}
|
||||
|
||||
@Override
|
||||
public RoomAbility copy() {
|
||||
return new RoomAbility(this);
|
||||
}
|
||||
}
|
||||
9
Mage/src/main/java/mage/cards/RoomCardHalf.java
Normal file
9
Mage/src/main/java/mage/cards/RoomCardHalf.java
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package mage.cards;
|
||||
|
||||
/**
|
||||
* @author oscscull
|
||||
*/
|
||||
public interface RoomCardHalf extends SplitCardHalf {
|
||||
@Override
|
||||
RoomCardHalf copy();
|
||||
}
|
||||
68
Mage/src/main/java/mage/cards/RoomCardHalfImpl.java
Normal file
68
Mage/src/main/java/mage/cards/RoomCardHalfImpl.java
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package mage.cards;
|
||||
|
||||
import mage.abilities.SpellAbility;
|
||||
import mage.constants.CardType;
|
||||
import mage.constants.SpellAbilityType;
|
||||
import mage.constants.SubType;
|
||||
import mage.constants.Zone;
|
||||
import mage.game.Game;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @author oscscull
|
||||
*/
|
||||
public class RoomCardHalfImpl extends SplitCardHalfImpl implements RoomCardHalf {
|
||||
|
||||
public RoomCardHalfImpl(UUID ownerId, CardSetInfo setInfo, CardType[] cardTypes, String costs,
|
||||
RoomCard splitCardParent, SpellAbilityType spellAbilityType) {
|
||||
super(ownerId, setInfo, cardTypes, costs, splitCardParent, spellAbilityType);
|
||||
this.addSubType(SubType.ROOM);
|
||||
}
|
||||
|
||||
protected RoomCardHalfImpl(final RoomCardHalfImpl card) {
|
||||
super(card);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RoomCardHalfImpl copy() {
|
||||
return new RoomCardHalfImpl(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean cast(Game game, Zone fromZone, SpellAbility ability, UUID controllerId) {
|
||||
SpellAbilityType abilityType = ability.getSpellAbilityType();
|
||||
RoomCard parentCard = (RoomCard) this.getParentCard();
|
||||
|
||||
if (parentCard != null) {
|
||||
if (abilityType == SpellAbilityType.SPLIT_LEFT) {
|
||||
parentCard.setLastCastHalf(SpellAbilityType.SPLIT_LEFT);
|
||||
} else if (abilityType == SpellAbilityType.SPLIT_RIGHT) {
|
||||
parentCard.setLastCastHalf(SpellAbilityType.SPLIT_RIGHT);
|
||||
} else {
|
||||
parentCard.setLastCastHalf(null);
|
||||
}
|
||||
}
|
||||
|
||||
return super.cast(game, fromZone, ability, controllerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A room half is used for the spell half on the stack, similar to a normal split card.
|
||||
* On the stack, it has only one name, mana cost, etc.
|
||||
* However, in the hand and on the battlefield, it is the full card, which is the parent of the half.
|
||||
* This code helps to ensure that the parent, and not the halves, are the only part of the card active on the battlefield.
|
||||
* This is important for example when that half has a triggered ability etc that otherwise might trigger twice (once for the parent, once for the half)
|
||||
* - in the case that the half was an object on the battlefield. In all other cases, they should all move together.
|
||||
*/
|
||||
@Override
|
||||
public void setZone(Zone zone, Game game) {
|
||||
if (zone == Zone.BATTLEFIELD) {
|
||||
game.setZone(splitCardParent.getId(), zone);
|
||||
game.setZone(splitCardParent.getLeftHalfCard().getId(), Zone.OUTSIDE);
|
||||
game.setZone(splitCardParent.getRightHalfCard().getId(), Zone.OUTSIDE);
|
||||
return;
|
||||
}
|
||||
super.setZone(zone, game);
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,15 @@ public abstract class SplitCard extends CardImpl implements CardWithHalves {
|
|||
rightHalfCard = new SplitCardHalfImpl(this.getOwnerId(), new CardSetInfo(names[1], setInfo.getExpansionSetCode(), setInfo.getCardNumber(), setInfo.getRarity(), setInfo.getGraphicInfo()), typesRight, costsRight, this, SpellAbilityType.SPLIT_RIGHT);
|
||||
}
|
||||
|
||||
// Params reordered as we need the same arguments as the parent constructor, with slightly different behaviour.
|
||||
// Currently only used for rooms, because they are the only current split card with a shared type line.
|
||||
protected SplitCard(UUID ownerId, CardSetInfo setInfo, String costsLeft, String costsRight, SpellAbilityType spellAbilityType, CardType[] singleTypeLine) {
|
||||
super(ownerId, setInfo, singleTypeLine, costsLeft + costsRight, spellAbilityType);
|
||||
String[] names = setInfo.getName().split(" // ");
|
||||
leftHalfCard = new SplitCardHalfImpl(this.getOwnerId(), new CardSetInfo(names[0], setInfo.getExpansionSetCode(), setInfo.getCardNumber(), setInfo.getRarity(), setInfo.getGraphicInfo()), singleTypeLine, costsLeft, this, SpellAbilityType.SPLIT_LEFT);
|
||||
rightHalfCard = new SplitCardHalfImpl(this.getOwnerId(), new CardSetInfo(names[1], setInfo.getExpansionSetCode(), setInfo.getCardNumber(), setInfo.getRarity(), setInfo.getGraphicInfo()), singleTypeLine, costsRight, this, SpellAbilityType.SPLIT_RIGHT);
|
||||
}
|
||||
|
||||
protected SplitCard(SplitCard card) {
|
||||
super(card);
|
||||
// make sure all parts created and parent ref added
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ public enum EmptyNames {
|
|||
// TODO: replace all getName().equals to haveSameNames and haveEmptyName
|
||||
FACE_DOWN_CREATURE("", "[face_down_creature]"), // "Face down creature"
|
||||
FACE_DOWN_TOKEN("", "[face_down_token]"), // "Face down token"
|
||||
FACE_DOWN_CARD("", "[face_down_card]"); // "Face down card"
|
||||
FACE_DOWN_CARD("", "[face_down_card]"), // "Face down card"
|
||||
FULLY_LOCKED_ROOM("", "[fully_locked_room]"); // "Fully locked room"
|
||||
|
||||
public static final String EMPTY_NAME_IN_LOGS = "face down object";
|
||||
|
||||
|
|
@ -40,7 +41,8 @@ public enum EmptyNames {
|
|||
public static boolean isEmptyName(String objectName) {
|
||||
return objectName.equals(FACE_DOWN_CREATURE.getObjectName())
|
||||
|| objectName.equals(FACE_DOWN_TOKEN.getObjectName())
|
||||
|| objectName.equals(FACE_DOWN_CARD.getObjectName());
|
||||
|| objectName.equals(FACE_DOWN_CARD.getObjectName())
|
||||
|| objectName.equals(FULLY_LOCKED_ROOM.getObjectName());
|
||||
}
|
||||
|
||||
public static String replaceTestCommandByObjectName(String searchCommand) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package mage.filter.predicate.mageobject;
|
||||
|
||||
import mage.MageObject;
|
||||
import mage.cards.CardWithHalves;
|
||||
import mage.cards.SplitCard;
|
||||
import mage.constants.SpellAbilityType;
|
||||
import mage.filter.predicate.Predicate;
|
||||
|
|
@ -42,6 +41,7 @@ public class NamePredicate implements Predicate<MageObject> {
|
|||
if (name == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If a player names a card, the player may name either half of a split card, but not both.
|
||||
// A split card has the chosen name if one of its two names matches the chosen name.
|
||||
// This is NOT the same for double faced cards, where only the front side matches
|
||||
|
|
@ -51,28 +51,54 @@ public class NamePredicate implements Predicate<MageObject> {
|
|||
// including the one that you countered, because those cards have only their front-face characteristics
|
||||
// (including name) in the graveyard, hand, and library. (2021-04-16)
|
||||
|
||||
String[] searchNames = extractNames(name);
|
||||
|
||||
if (input instanceof SplitCard) {
|
||||
return CardUtil.haveSameNames(name, ((CardWithHalves) input).getLeftHalfCard().getName(), this.ignoreMtgRuleForEmptyNames) ||
|
||||
CardUtil.haveSameNames(name, ((CardWithHalves) input).getRightHalfCard().getName(), this.ignoreMtgRuleForEmptyNames) ||
|
||||
CardUtil.haveSameNames(name, input.getName(), this.ignoreMtgRuleForEmptyNames);
|
||||
} else if (input instanceof Spell && ((Spell) input).getSpellAbility().getSpellAbilityType() == SpellAbilityType.SPLIT_FUSED) {
|
||||
SplitCard splitCard = (SplitCard) input;
|
||||
// Check against left half, right half, and full card name
|
||||
return matchesAnyName(searchNames, new String[] {
|
||||
splitCard.getLeftHalfCard().getName(),
|
||||
splitCard.getRightHalfCard().getName(),
|
||||
splitCard.getName()
|
||||
});
|
||||
} else if (input instanceof Spell
|
||||
&& ((Spell) input).getSpellAbility().getSpellAbilityType() == SpellAbilityType.SPLIT_FUSED) {
|
||||
SplitCard card = (SplitCard) ((Spell) input).getCard();
|
||||
return CardUtil.haveSameNames(name, card.getLeftHalfCard().getName(), this.ignoreMtgRuleForEmptyNames) ||
|
||||
CardUtil.haveSameNames(name, card.getRightHalfCard().getName(), this.ignoreMtgRuleForEmptyNames) ||
|
||||
CardUtil.haveSameNames(name, card.getName(), this.ignoreMtgRuleForEmptyNames);
|
||||
// Check against left half, right half, and full card name
|
||||
return matchesAnyName(searchNames, new String[] {
|
||||
card.getLeftHalfCard().getName(),
|
||||
card.getRightHalfCard().getName(),
|
||||
card.getName()
|
||||
});
|
||||
} else if (input instanceof Spell && ((Spell) input).isFaceDown(game)) {
|
||||
// face down spells don't have names, so it's not equal, see https://github.com/magefree/mage/issues/6569
|
||||
return false;
|
||||
} else {
|
||||
if (name.contains(" // ")) {
|
||||
String leftName = name.substring(0, name.indexOf(" // "));
|
||||
String rightName = name.substring(name.indexOf(" // ") + 4);
|
||||
return CardUtil.haveSameNames(leftName, input.getName(), this.ignoreMtgRuleForEmptyNames) ||
|
||||
CardUtil.haveSameNames(rightName, input.getName(), this.ignoreMtgRuleForEmptyNames);
|
||||
} else {
|
||||
return CardUtil.haveSameNames(name, input.getName(), this.ignoreMtgRuleForEmptyNames);
|
||||
// For regular cards, extract names from input and compare
|
||||
String[] inputNames = extractNames(input.getName());
|
||||
return matchesAnyName(searchNames, inputNames);
|
||||
}
|
||||
}
|
||||
|
||||
private String[] extractNames(String nameString) {
|
||||
if (nameString.contains(" // ")) {
|
||||
String leftName = nameString.substring(0, nameString.indexOf(" // "));
|
||||
String rightName = nameString.substring(nameString.indexOf(" // ") + 4);
|
||||
return new String[] { leftName, rightName };
|
||||
} else {
|
||||
return new String[] { nameString };
|
||||
}
|
||||
}
|
||||
|
||||
private boolean matchesAnyName(String[] searchNames, String[] targetNames) {
|
||||
for (String searchName : searchNames) {
|
||||
for (String targetName : targetNames) {
|
||||
if (CardUtil.haveSameNames(searchName, targetName, this.ignoreMtgRuleForEmptyNames)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -191,6 +191,28 @@ public final class ZonesHandler {
|
|||
cardsToUpdate.get(toZone).add(mdfCard.getRightHalfCard());
|
||||
break;
|
||||
}
|
||||
} else if (targetCard instanceof RoomCard || targetCard instanceof RoomCardHalf) {
|
||||
// Room cards must be moved as single object
|
||||
RoomCard roomCard = (RoomCard) targetCard.getMainCard();
|
||||
cardsToMove = new CardsImpl(roomCard);
|
||||
cardsToUpdate.get(toZone).add(roomCard);
|
||||
switch (toZone) {
|
||||
case STACK:
|
||||
case BATTLEFIELD:
|
||||
// We don't want room halves to ever be on the battlefield
|
||||
cardsToUpdate.get(Zone.OUTSIDE).add(roomCard.getLeftHalfCard());
|
||||
cardsToUpdate.get(Zone.OUTSIDE).add(roomCard.getRightHalfCard());
|
||||
break;
|
||||
default:
|
||||
// move all parts
|
||||
cardsToUpdate.get(toZone).add(roomCard.getLeftHalfCard());
|
||||
cardsToUpdate.get(toZone).add(roomCard.getRightHalfCard());
|
||||
// If we aren't casting onto the stack or etb'ing, we need to clear this state
|
||||
// (countered, memory lapsed etc)
|
||||
// This prevents the state persisting for a put into play effect later
|
||||
roomCard.setLastCastHalf(null);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
cardsToMove = new CardsImpl(targetCard);
|
||||
cardsToUpdate.get(toZone).addAll(cardsToMove);
|
||||
|
|
@ -269,8 +291,12 @@ public final class ZonesHandler {
|
|||
}
|
||||
|
||||
// update zone in main
|
||||
game.setZone(event.getTargetId(), event.getToZone());
|
||||
|
||||
if (targetCard instanceof RoomCardHalf && (toZone == Zone.BATTLEFIELD)) {
|
||||
game.setZone(event.getTargetId(), Zone.OUTSIDE);
|
||||
} else {
|
||||
game.setZone(event.getTargetId(), event.getToZone());
|
||||
}
|
||||
|
||||
// update zone in other parts (meld cards, mdf half cards)
|
||||
cardsToUpdate.entrySet().forEach(entry -> {
|
||||
for (Card card : entry.getValue().getCards(game)) {
|
||||
|
|
@ -378,7 +404,11 @@ public final class ZonesHandler {
|
|||
Permanent permanent;
|
||||
if (card instanceof MeldCard) {
|
||||
permanent = new PermanentMeld(card, event.getPlayerId(), game);
|
||||
} else if (card instanceof ModalDoubleFacedCard) {
|
||||
} else if (card instanceof RoomCardHalf) {
|
||||
// Only the main room card can etb
|
||||
permanent = new PermanentCard(card.getMainCard(), event.getPlayerId(), game);
|
||||
}
|
||||
else if (card instanceof ModalDoubleFacedCard) {
|
||||
// main mdf card must be processed before that call (e.g. only halves can be moved to battlefield)
|
||||
throw new IllegalStateException("Unexpected trying of move mdf card to battlefield instead half");
|
||||
} else if (card instanceof Permanent) {
|
||||
|
|
@ -503,4 +533,4 @@ public final class ZonesHandler {
|
|||
return card;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -699,6 +699,19 @@ public class GameEvent implements Serializable {
|
|||
AIRBENDED,
|
||||
FIREBENDED,
|
||||
WATERBENDED,
|
||||
/* A room permanent has a door unlocked.
|
||||
targetId the room permanent
|
||||
sourceId the unlock ability
|
||||
playerId the room permanent's controller
|
||||
flag true = left door unlocked false = right door unlocked
|
||||
*/
|
||||
DOOR_UNLOCKED,
|
||||
/* A room permanent has a door unlocked.
|
||||
targetId the room permanent
|
||||
sourceId the unlock ability
|
||||
playerId the room permanent's controller
|
||||
*/
|
||||
ROOM_FULLY_UNLOCKED,
|
||||
// custom events - must store some unique data to track
|
||||
CUSTOM_EVENT;
|
||||
|
||||
|
|
|
|||
|
|
@ -474,6 +474,16 @@ public interface Permanent extends Card, Controllable {
|
|||
|
||||
void setHarnessed(boolean value);
|
||||
|
||||
boolean wasRoomUnlockedOnCast();
|
||||
|
||||
boolean isLeftDoorUnlocked();
|
||||
|
||||
boolean isRightDoorUnlocked();
|
||||
|
||||
boolean unlockRoomOnCast(Game game);
|
||||
|
||||
boolean unlockDoor(Game game, Ability source, boolean isLeftDoor);
|
||||
|
||||
@Override
|
||||
Permanent copy();
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ public class PermanentCard extends PermanentImpl {
|
|||
goodForBattlefield = false;
|
||||
} else if (card instanceof SplitCard) {
|
||||
// fused spells allowed (it uses main card)
|
||||
if (card.getSpellAbility() != null && !card.getSpellAbility().getSpellAbilityType().equals(SpellAbilityType.SPLIT_FUSED)) {
|
||||
// room spells allowed (it uses main card)
|
||||
if (card.getSpellAbility() != null && !card.getSpellAbility().getSpellAbilityType().equals(SpellAbilityType.SPLIT_FUSED) && !(card instanceof RoomCard)) {
|
||||
goodForBattlefield = false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
|
|||
protected boolean deathtouched;
|
||||
protected boolean solved = false;
|
||||
|
||||
protected boolean roomWasUnlockedOnCast = false;
|
||||
protected boolean leftHalfUnlocked = false;
|
||||
protected boolean rightHalfUnlocked = false;
|
||||
protected Map<String, List<UUID>> connectedCards = new HashMap<>();
|
||||
protected Set<MageObjectReference> dealtDamageByThisTurn;
|
||||
protected UUID attachedTo;
|
||||
|
|
@ -191,6 +194,9 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
|
|||
|
||||
this.morphed = permanent.morphed;
|
||||
this.disguised = permanent.disguised;
|
||||
this.leftHalfUnlocked = permanent.leftHalfUnlocked;
|
||||
this.rightHalfUnlocked = permanent.rightHalfUnlocked;
|
||||
this.roomWasUnlockedOnCast = permanent.roomWasUnlockedOnCast;
|
||||
this.manifested = permanent.manifested;
|
||||
this.cloaked = permanent.cloaked;
|
||||
this.createOrder = permanent.createOrder;
|
||||
|
|
@ -2086,4 +2092,65 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
|
|||
|
||||
return ZonesHandler.moveCard(zcInfo, game, source);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wasRoomUnlockedOnCast() {
|
||||
return roomWasUnlockedOnCast;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isLeftDoorUnlocked() {
|
||||
return leftHalfUnlocked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRightDoorUnlocked() {
|
||||
return rightHalfUnlocked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean unlockRoomOnCast(Game game) {
|
||||
if (this.roomWasUnlockedOnCast) {
|
||||
return false;
|
||||
}
|
||||
this.roomWasUnlockedOnCast = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean unlockDoor(Game game, Ability source, boolean isLeftDoor) {
|
||||
// Check if already unlocked
|
||||
boolean thisDoorUnlocked = isLeftDoor ? leftHalfUnlocked : rightHalfUnlocked;
|
||||
if (thisDoorUnlocked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Log the unlock
|
||||
Player controller = game.getPlayer(source.getControllerId());
|
||||
if (controller != null) {
|
||||
String doorSide = isLeftDoor ? "left" : "right";
|
||||
game.informPlayers(controller.getLogName() + " unlocked the " + doorSide + " door of "
|
||||
+ getLogName() + CardUtil.getSourceLogName(game, source));
|
||||
}
|
||||
|
||||
// Update unlock state
|
||||
if (isLeftDoor) {
|
||||
leftHalfUnlocked = true;
|
||||
} else {
|
||||
rightHalfUnlocked = true;
|
||||
}
|
||||
|
||||
// Fire door unlock event
|
||||
GameEvent event = new GameEvent(GameEvent.EventType.DOOR_UNLOCKED, getId(), source, source.getControllerId());
|
||||
event.setFlag(isLeftDoor);
|
||||
game.fireEvent(event);
|
||||
|
||||
// Check if room is now fully unlocked
|
||||
boolean otherDoorUnlocked = isLeftDoor ? rightHalfUnlocked : leftHalfUnlocked;
|
||||
if (otherDoorUnlocked) {
|
||||
game.fireEvent(new GameEvent(EventType.ROOM_FULLY_UNLOCKED, getId(), source, source.getControllerId()));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -138,7 +138,13 @@ public class PermanentToken extends PermanentImpl {
|
|||
|
||||
@Override
|
||||
public Card getMainCard() {
|
||||
// token don't have game card, so return itself
|
||||
// Check if we have a copy source card (for tokens created from copied spells)
|
||||
Card copySourceCard = token.getCopySourceCard();
|
||||
if (copySourceCard != null) {
|
||||
return copySourceCard;
|
||||
}
|
||||
|
||||
// Fallback to current behavior
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue