Add Smoothed London Mulligan option (#10965)

* Add Smoothed London Mulligan (similar to but weaker than MTGA's)

* Make SmoothedLondonMulligan extend LondonMulligan instead of copying code

* modified to be have no effect within +1/-1 of the expected lands
fixed tests by always putting nonchosen hand on the bottom

* Inherit the primary mulligan logic as well, add comments

* Make drawHand public and part of Mulligan, use it on opening 7
use Card::isLand instead of reimplementing it, remove unused imports
Use standard spacing

* Better account for half-land MDFCs

* Don't count TDFCs as half-lands

* Remove "crossover_point" calculation to make algorithm clearer

* Genericize the tests, undo changed access that's no longer needed, unbox bool

* Use standard case in function naming

* Add Override

* Add mulligan type to TableView info, add tourneyMatchOptions local variable
This commit is contained in:
ssk97 2023-08-27 12:08:27 -07:00 committed by GitHub
parent a7c77a8895
commit c50e913398
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 153 additions and 41 deletions

View file

@ -7,7 +7,9 @@ import mage.game.Seat;
import mage.game.Table;
import mage.game.draft.Draft;
import mage.game.draft.DraftOptions;
import mage.game.match.MatchOptions;
import mage.game.match.MatchPlayer;
import mage.game.mulligan.MulliganType;
import mage.game.tournament.TournamentPlayer;
import java.io.Serializable;
@ -99,6 +101,9 @@ public class TableView implements Serializable {
addInfo.append("Wins:").append(table.getMatch().getWinsNeeded());
addInfo.append(" Time: ").append(table.getMatch().getOptions().getMatchTimeLimit().toString());
addInfo.append(" Buffer: ").append(table.getMatch().getOptions().getMatchBufferTime().toString());
if (table.getMatch().getOptions().getMulliganType() != MulliganType.GAME_DEFAULT){
addInfo.append(" Mulligan: \"").append(table.getMatch().getOptions().getMulliganType().toString()).append("\"");
}
if (table.getMatch().getFreeMulligans() > 0) {
addInfo.append(" FM: ").append(table.getMatch().getFreeMulligans());
}
@ -150,10 +155,14 @@ public class TableView implements Serializable {
if (TableState.WAITING.equals(table.getState())) {
stateText.append(" (").append(table.getTournament().getPlayers().size()).append('/').append(table.getNumberOfSeats()).append(')');
}
infoText.append(" Time: ").append(table.getTournament().getOptions().getMatchOptions().getMatchTimeLimit().toString());
infoText.append(" Buffer: ").append(table.getTournament().getOptions().getMatchOptions().getMatchBufferTime().toString());
if (table.getTournament().getOptions().getMatchOptions().getFreeMulligans() > 0) {
infoText.append(" FM: ").append(table.getTournament().getOptions().getMatchOptions().getFreeMulligans());
MatchOptions tourneyMatchOptions = table.getTournament().getOptions().getMatchOptions();
infoText.append(" Time: ").append(tourneyMatchOptions.getMatchTimeLimit().toString());
infoText.append(" Buffer: ").append(tourneyMatchOptions.getMatchBufferTime().toString());
if (tourneyMatchOptions.getMulliganType() != MulliganType.GAME_DEFAULT){
infoText.append(" Mulligan: \"").append(tourneyMatchOptions.getMulliganType().toString()).append("\"");
}
if (tourneyMatchOptions.getFreeMulligans() > 0) {
infoText.append(" FM: ").append(tourneyMatchOptions.getFreeMulligans());
}
if (table.getTournament().getTournamentType().isLimited()) {
infoText.append(" Constr.: ").append(table.getTournament().getOptions().getLimitedOptions().getConstructionTime() / 60).append(" Min.");
@ -162,10 +171,10 @@ public class TableView implements Serializable {
DraftOptions draftOptions = (DraftOptions) table.getTournament().getOptions().getLimitedOptions();
infoText.append(" Pick time: ").append(draftOptions.getTiming().getShortName());
}
if (table.getTournament().getOptions().getMatchOptions().isRollbackTurnsAllowed()) {
if (tourneyMatchOptions.isRollbackTurnsAllowed()) {
infoText.append(" RB");
}
if (table.getTournament().getOptions().getMatchOptions().isPlaneChase()) {
if (tourneyMatchOptions.isPlaneChase()) {
infoText.append(" PC");
}
if (table.getTournament().getOptions().isWatchingAllowed()) {

View file

@ -10,10 +10,15 @@ import java.util.stream.Collectors;
import static org.junit.Assert.assertEquals;
public class LondonMulliganTest extends MulliganTestBase {
protected MulliganType getMullType() {
return MulliganType.LONDON;
}
protected int getCardsPerMull() {
return 7;
}
@Test
public void testLondonMulligan_NoMulligan() {
MulliganScenarioTest scenario = new MulliganScenarioTest(MulliganType.LONDON, 0);
MulliganScenarioTest scenario = new MulliganScenarioTest(getMullType(), 0);
Set<UUID> hand1 = new HashSet<>();
scenario.mulligan(() -> {
scenario.assertSizes(7, 33);
@ -28,7 +33,7 @@ public class LondonMulliganTest extends MulliganTestBase {
@Test
public void testLondonMulligan_OneMulligan() {
MulliganScenarioTest scenario = new MulliganScenarioTest(MulliganType.LONDON, 0);
MulliganScenarioTest scenario = new MulliganScenarioTest(getMullType(), 0);
Set<UUID> hand1 = new HashSet<>();
Set<UUID> hand2 = new HashSet<>();
List<UUID> discarded = new ArrayList<>();
@ -41,14 +46,14 @@ public class LondonMulliganTest extends MulliganTestBase {
scenario.discardBottom(count -> {
scenario.assertSizes(7, 33);
assertEquals(1, count);
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(26, 7)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull(), 7)));
scenario.getHand().stream().limit(count).forEach(discarded::add);
remainingHand.addAll(Sets.difference(scenario.getHand(), new HashSet<>(discarded)));
return discarded;
});
scenario.mulligan(() -> {
scenario.assertSizes(6, 34);
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(26, 7)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull(), 7)));
assertEquals(remainingHand, scenario.getHand());
hand2.addAll(scenario.getHand());
return false;
@ -56,7 +61,7 @@ public class LondonMulliganTest extends MulliganTestBase {
scenario.run(() -> {
scenario.assertSizes(6, 34);
assertEquals(remainingHand, new HashSet<>(scenario.getHand()));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(26, 7)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull(), 7)));
assertEquals(hand2, scenario.getHand());
assertEquals(discarded, scenario.getNBottomOfLibrary(1));
});
@ -64,7 +69,7 @@ public class LondonMulliganTest extends MulliganTestBase {
@Test
public void testLondonMulligan_TwoMulligan() {
MulliganScenarioTest scenario = new MulliganScenarioTest(MulliganType.LONDON, 0);
MulliganScenarioTest scenario = new MulliganScenarioTest(getMullType(), 0);
Set<UUID> hand1 = new HashSet<>();
Set<UUID> hand2 = new HashSet<>();
Set<UUID> hand3 = new HashSet<>();
@ -78,23 +83,23 @@ public class LondonMulliganTest extends MulliganTestBase {
scenario.discardBottom(count -> {
scenario.assertSizes(7, 33);
assertEquals(1, count);
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(26, 7)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull(), 7)));
scenario.getHand().stream().limit(count).forEach(discarded::add);
remainingHand.addAll(Sets.difference(scenario.getHand(), new HashSet<>(discarded)));
return discarded;
});
scenario.mulligan(() -> {
scenario.assertSizes(6, 34);
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(26, 7)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull(), 7)));
hand2.addAll(scenario.getHand());
return true;
});
scenario.discardBottom(count -> {
scenario.assertSizes(7, 33);
assertEquals(1, count);
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(19, 7)));
assertEquals(discarded, scenario.getLibraryRangeSize(26, 1));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(27, 6)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull()*2, 7)));
assertEquals(discarded, scenario.getLibraryRangeSize(33-getCardsPerMull(), 1));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(34-getCardsPerMull(), 6)));
discarded.clear();
remainingHand.clear();
scenario.getHand().stream().limit(count).forEach(discarded::add);
@ -104,9 +109,9 @@ public class LondonMulliganTest extends MulliganTestBase {
scenario.discardBottom(count -> {
scenario.assertSizes(6, 34);
assertEquals(1, count);
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(19, 7)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull()*2, 7)));
assertEquals(discarded, scenario.getNBottomOfLibrary(1));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(27, 6)));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(34-getCardsPerMull(), 6)));
discarded.clear();
remainingHand.clear();
scenario.getHand().stream().limit(count).forEach(discarded::add);
@ -115,8 +120,8 @@ public class LondonMulliganTest extends MulliganTestBase {
});
scenario.mulligan(() -> {
scenario.assertSizes(5, 35);
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(19, 7)));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(27, 6)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull()*2, 7)));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(34-getCardsPerMull(), 6)));
assertEquals(discarded, scenario.getNBottomOfLibrary(1));
hand3.addAll(scenario.getHand());
return false;
@ -124,8 +129,8 @@ public class LondonMulliganTest extends MulliganTestBase {
scenario.run(() -> {
scenario.assertSizes(5, 35);
assertEquals(remainingHand, new HashSet<>(scenario.getHand()));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(19, 7)));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(27, 6)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull()*2, 7)));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(34-getCardsPerMull(), 6)));
assertEquals(hand3, scenario.getHand());
assertEquals(discarded, scenario.getNBottomOfLibrary(1));
});
@ -133,7 +138,7 @@ public class LondonMulliganTest extends MulliganTestBase {
@Test
public void testLondonMulligan_FreeMulligan_NoMulligan() {
MulliganScenarioTest scenario = new MulliganScenarioTest(MulliganType.LONDON, 1);
MulliganScenarioTest scenario = new MulliganScenarioTest(getMullType(), 1);
Set<UUID> hand1 = new HashSet<>();
scenario.mulligan(() -> {
scenario.assertSizes(7, 33);
@ -148,7 +153,7 @@ public class LondonMulliganTest extends MulliganTestBase {
@Test
public void testLondonMulligan_FreeMulligan_OneMulligan() {
MulliganScenarioTest scenario = new MulliganScenarioTest(MulliganType.LONDON, 1);
MulliganScenarioTest scenario = new MulliganScenarioTest(getMullType(), 1);
Set<UUID> hand1 = new HashSet<>();
Set<UUID> hand2 = new HashSet<>();
scenario.mulligan(() -> {
@ -159,19 +164,19 @@ public class LondonMulliganTest extends MulliganTestBase {
scenario.mulligan(() -> {
scenario.assertSizes(7, 33);
hand2.addAll(scenario.getHand());
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(26, 7)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull(), 7)));
return false;
});
scenario.run(() -> {
scenario.assertSizes(7, 33);
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(26, 7)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull(), 7)));
assertEquals(hand2, new HashSet<>(scenario.getHand()));
});
}
@Test
public void testLondonMulligan_FreeMulligan_TwoMulligan() {
MulliganScenarioTest scenario = new MulliganScenarioTest(MulliganType.LONDON, 1);
MulliganScenarioTest scenario = new MulliganScenarioTest(getMullType(), 1);
Set<UUID> hand1 = new HashSet<>();
Set<UUID> hand2 = new HashSet<>();
Set<UUID> hand3 = new HashSet<>();
@ -184,31 +189,31 @@ public class LondonMulliganTest extends MulliganTestBase {
});
scenario.mulligan(() -> {
scenario.assertSizes(7, 33);
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(26, 7)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull(), 7)));
hand2.addAll(scenario.getHand());
return true;
});
scenario.discardBottom(count -> {
scenario.assertSizes(7, 33);
assertEquals(1, count);
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(19, 7)));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(26, 7)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull()*2, 7)));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull(), 7)));
scenario.getHand().stream().limit(count).forEach(discarded::add);
remainingHand.addAll(Sets.difference(scenario.getHand(), new HashSet<>(discarded)));
return discarded;
});
scenario.mulligan(() -> {
scenario.assertSizes(6, 34);
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(19, 7)));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(26, 7)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull()*2, 7)));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull(), 7)));
assertEquals(discarded, scenario.getNBottomOfLibrary(1));
hand3.addAll(scenario.getHand());
return false;
});
scenario.run(() -> {
scenario.assertSizes(6, 34);
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(19, 7)));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(26, 7)));
assertEquals(hand1, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull()*2, 7)));
assertEquals(hand2, new HashSet<>(scenario.getLibraryRangeSize(33-getCardsPerMull(), 7)));
assertEquals(hand3, scenario.getHand());
assertEquals(remainingHand, new HashSet<>(scenario.getHand()));
assertEquals(discarded, scenario.getNBottomOfLibrary(1));
@ -217,7 +222,7 @@ public class LondonMulliganTest extends MulliganTestBase {
@Test
public void testLondonMulligan_AlwaysMulligan() {
MulliganScenarioTest scenario = new MulliganScenarioTest(MulliganType.LONDON, 0);
MulliganScenarioTest scenario = new MulliganScenarioTest(getMullType(), 0);
scenario.mulligan(() -> {
scenario.assertSizes(7, 33);
return true;

View file

@ -3,6 +3,7 @@ package org.mage.test.mulligan;
import mage.cards.CardSetInfo;
import mage.cards.basiclands.Forest;
import mage.cards.decks.Deck;
import mage.cards.s.Squire;
import mage.constants.RangeOfInfluence;
import mage.game.Game;
import mage.game.GameOptions;
@ -141,7 +142,10 @@ public class MulliganTestBase {
public static Deck generateDeck(UUID playerId, int count) {
Deck deck = new Deck();
Stream.generate(() -> new Forest(playerId, new CardSetInfo("Forest", "TEST", "1", LAND)))
.limit(count)
.limit(count/2+(count & 1)) //If odd number of cards, add one extra forest
.forEach(deck.getCards()::add);
Stream.generate(() -> new Squire(playerId, new CardSetInfo("Squire", "TEST", "2", LAND)))
.limit(count/2)
.forEach(deck.getCards()::add);
return deck;
}

View file

@ -0,0 +1,13 @@
package org.mage.test.mulligan;
import mage.game.mulligan.MulliganType;
public class SmoothedLondonMulliganTest extends LondonMulliganTest {
@Override
protected MulliganType getMullType() {
return MulliganType.SMOOTHED_LONDON;
}
@Override
protected int getCardsPerMull() {
return 14;
}
}

View file

@ -1263,7 +1263,7 @@ public abstract class GameImpl implements Game {
player.initLife(this.getStartingLife());
}
if (!gameOptions.testMode) {
player.drawCards(startingHandSize, null, this);
mulligan.drawHand(startingHandSize, player, this);
}
}

View file

@ -107,7 +107,7 @@ public class LondonMulligan extends Mulligan {
newHandSize +
(newHandSize == 1 ? " card" : " cards"));
}
player.drawCards(numCards, null, game);
drawHand(numCards, player, game);
while (player.canRespond() && player.getHand().size() > newHandSize) {
Target target = new TargetCardInHand(new FilterCard("card (" + (player.getHand().size() - newHandSize) + " more) to put on the bottom of your library"));

View file

@ -91,4 +91,7 @@ public abstract class Mulligan implements Serializable {
return freeMulligans;
}
public void drawHand(int numCards, Player player, Game game){
player.drawCards(numCards, null, game);
}
}

View file

@ -8,6 +8,7 @@ public enum MulliganType {
VANCOUVER("Vancouver"),
PARIS("Paris"),
LONDON("London"),
SMOOTHED_LONDON("Smoothed London"),
CANADIAN_HIGHLANDER("Canadian Highlander");
private final String displayName;
@ -24,6 +25,8 @@ public enum MulliganType {
return new CanadianHighlanderMulligan(freeMulligans);
case VANCOUVER:
return new VancouverMulligan(freeMulligans);
case SMOOTHED_LONDON:
return new SmoothedLondonMulligan(freeMulligans);
default:
case LONDON:
return new LondonMulligan(freeMulligans);

View file

@ -57,7 +57,7 @@ public class ParisMulligan extends Mulligan {
.append(deduction == 0 ? " for free and draws " : " down to ")
.append((numCards - deduction))
.append(numCards - deduction == 1 ? " card" : " cards").toString());
player.drawCards(numCards - deduction, null, game);
drawHand(numCards - deduction, player, game);
}
@Override

View file

@ -0,0 +1,75 @@
package mage.game.mulligan;
import mage.cards.Card;
import mage.cards.CardsImpl;
import mage.cards.ModalDoubleFacedCard;
import mage.game.Game;
import mage.players.Player;
import mage.util.RandomUtil;
import java.util.*;
public class SmoothedLondonMulligan extends LondonMulligan {
public SmoothedLondonMulligan(int freeMulligans) {
super(freeMulligans);
}
SmoothedLondonMulligan(final SmoothedLondonMulligan mulligan) {
super(mulligan);
}
private static double countLands(Collection<Card> cards, boolean library){
double land_count = 0;
for (Card card : cards){
if (card.isLand()) {
land_count += 1;
} else if (card instanceof ModalDoubleFacedCard && ((ModalDoubleFacedCard)card).getRightHalfCard().isLand()){
if (library) { //count MDFCs with a nonland front and a land back as:
land_count += 0.5;//half a land in a library
} else if (RandomUtil.nextBoolean()){
land_count += 1; //randomly as a land or nonland in a hand
// This avoids the bias problem where adjusting the deck land ratio to be (integer vs X.5)/7
// can greatly affect the chance of drawing an MDFC
}
}
}
return land_count;
}
@Override
public void drawHand(int numCards, Player player, Game game){
List<Card> library = player.getLibrary().getCards(game);
if (library.size() >= numCards*2 && numCards > 1) {
double land_ratio = countLands(library, true) / (double) library.size();
Set<Card> hand1 = player.getLibrary().getTopCards(game, numCards);
Set<Card> hand2 = player.getLibrary().getTopCards(game, numCards * 2);
hand2.removeAll(hand1);
double hand1_ratio = countLands(hand1, false) / (double) numCards;
double hand2_ratio = countLands(hand2, false) / (double) numCards;
//distance = max(0,abs(land_ratio-hand_ratio)-0.15)+random()*0.3
//Where land_ratio is (deck lands/deck size) and hand_ratio is (hand lands/hand size)
//Keeps whichever hand's distance is smaller. Note that a 1-land difference is 1/7 = 0.143
//So -0.15 means that there's no change in relative probabilities if within +1/-1 of the expected amount
double hand1_distance = Math.max(0,Math.abs(land_ratio - hand1_ratio)-0.15)+RandomUtil.nextDouble()*0.3;
double hand2_distance = Math.max(0,Math.abs(land_ratio - hand2_ratio)-0.15)+RandomUtil.nextDouble()*0.3;
//game.debugMessage("1: "+hand1_ratio+", 2 = "+hand2_ratio+", expected = "+land_ratio);
//game.debugMessage("hand1: "+hand1_distance+", hand2: "+hand2_distance);
if (hand1_distance < hand2_distance) {
player.drawCards(numCards, null, game);
player.putCardsOnBottomOfLibrary(new CardsImpl(hand2), game, null, false);
//These are immediately shuffled away, but needed for consistent testing
} else {
player.putCardsOnBottomOfLibrary(new CardsImpl(hand1), game, null, false);
player.drawCards(numCards, null, game);
}
player.shuffleLibrary(null, game);
} else { //not enough cards in library or hand, just do a normal draw instead
player.drawCards(numCards, null, game);
}
}
@Override
public SmoothedLondonMulligan copy() {
return new SmoothedLondonMulligan(this);
}
}