diff --git a/forge-ai/src/main/java/forge/ai/AiController.java b/forge-ai/src/main/java/forge/ai/AiController.java index 9b888cbbee43..406f9ba0a129 100644 --- a/forge-ai/src/main/java/forge/ai/AiController.java +++ b/forge-ai/src/main/java/forge/ai/AiController.java @@ -30,7 +30,6 @@ import forge.card.CardType; import forge.card.ColorSet; import forge.card.MagicColor; -import forge.card.mana.ManaAtom; import forge.card.mana.ManaCost; import forge.deck.Deck; import forge.deck.DeckSection; @@ -618,23 +617,6 @@ private Card chooseBestLandToPlay(CardCollection landList) { final Set basics = Sets.newHashSet(); // what colors are available? - int[] counts = new int[6]; // in WUBRGC order - - for (Card c : player.getCardsIn(ZoneType.Battlefield)) { - for (SpellAbility m: c.getManaAbilities()) { - m.setActivatingPlayer(c.getController()); - for (AbilityManaPart mp : m.getAllManaParts()) { - for (String part : mp.mana(m).split(" ")) { - // TODO handle any - int index = ManaAtom.getIndexFromName(part); - if (index != -1) { - counts[index] += 1; - } - } - } - } - } - // what types can I go get? for (final String name : MagicColor.Constant.BASIC_LANDS) { if (landList.stream().anyMatch(c -> c.getType().hasSubtype(name)) && @@ -657,27 +639,9 @@ private Card chooseBestLandToPlay(CardCollection landList) { } // TODO handle fetchlands and what they can fetch for - // determine new color pips - int[] card_counts = new int[6]; // in WUBRGC order - for (SpellAbility m: card.getManaAbilities()) { - m.setActivatingPlayer(card.getController()); - for (AbilityManaPart mp : m.getAllManaParts()) { - for (String part : mp.mana(m).split(" ")) { - // TODO handle any - int index = ManaAtom.getIndexFromName(part); - if (index != -1) { - card_counts[index] += 1; - } - } - } - } - - // use 1 / x+1 for diminishing returns - // TODO use max pips of each color in the deck from deck statistics to weight this - for (int i = 0; i < card_counts.length; i++) { - int diff = (card_counts[i] * 50) / (counts[i] + 1); - score += diff; - } + // depth in colours we are thin on, with diminishing returns, plus what this + // land would unblock outright - the two say different things, so they add + score += ComputerUtilCard.getColorFixingValue(player, card); // TODO utility lands only if we have enough to pay their costs // TODO Tron lands and other lands that care about land counts diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java index 5545667c3884..8391f16b9097 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCard.java @@ -1,6 +1,7 @@ package forge.ai; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Comparator; import java.util.IdentityHashMap; @@ -30,6 +31,7 @@ import forge.card.MagicColor; import forge.card.MagicColor.Constant; import forge.card.mana.ManaCost; +import forge.card.mana.ManaCostShard; import forge.deck.CardPool; import forge.deck.Deck; import forge.deck.DeckSection; @@ -211,6 +213,91 @@ public static Card getBestEnchantmentAI(final List list, final SpellAbilit return cardStream.max(Comparator.comparing(Card::getCMC)).orElse(null); } + /** Score per source fixed, on the scale AiController's land scoring already uses. */ + public static final int COLOR_FIXING_WEIGHT = 50; + + /** How many missing sources these extra ones supply across the player's hand and board. */ + private static int countSourcesFixed(final Player benefits, final int[] have, final int[] with) { + if (Arrays.equals(have, with)) { + return 0; + } + + int fixed = 0; + for (Card c : benefits.getCardsIn(ZoneType.Hand)) { + fixed += countSourcesFixed(c.getManaCost(), have, with); + } + for (Card c : benefits.getCardsIn(ZoneType.Battlefield)) { + // only what can be activated from here, not the permanent's own casting cost + for (SpellAbility ab : c.getNonManaAbilities()) { + if (!ab.isActivatedAbility() || ab.getPayCosts() == null + || ab.getPayCosts().getCostMana() == null) { + continue; + } + fixed += countSourcesFixed(ab.getPayCosts().getCostMana().getMana(), have, with); + } + } + return fixed; + } + + /** What this land is worth to the player's mana: what it makes payable, plus spare depth. */ + public static int getColorFixingValue(final Player benefits, final Card candidate) { + if (benefits == null || candidate == null) { + return 0; + } + // one pass over the battlefield; the candidate only ever adds to what is already there + final int[] have = ComputerUtilCost.getManaSourceCounts(benefits); + final int[] with = have.clone(); + ComputerUtilCost.addManaSources(candidate, with); + return COLOR_FIXING_WEIGHT * countSourcesFixed(benefits, have, with) + evaluateSpareSources(have, with); + } + + /** Value of a spare source of a color nothing needs yet, falling off as sources accumulate. */ + private static int evaluateSpareSources(final int[] have, final int[] with) { + int value = 0; + for (int i = 0; i < have.length; i++) { + if (with[i] > have[i]) { + // 1/(x+1) for diminishing returns, matching the land scoring this replaces + value += ((with[i] - have[i]) * COLOR_FIXING_WEIGHT) / (have[i] + 1); + } + } + return value; + } + + /** How many more colored sources this cost still wants; zero once it is castable. */ + private static int countMissingSources(final ManaCost cost, final int[] counts) { + if (cost == null || cost.isNoCost()) { + return 0; + } + byte mask = 0; + for (MagicColor.Color color : MagicColor.Color.values()) { + if (counts[color.ordinal()] > 0) { + mask |= color.getColorMask(); + } + } + int[] need = new int[counts.length]; + int missing = 0; + for (ManaCostShard shard : cost) { + if (shard.isPhyrexian()) { + continue; + } + if (shard.isMonoColor() && !shard.isOr2Generic()) { + // a plain colored pip wants a source of its own + need[MagicColor.Color.fromByte(shard.getColorMask()).ordinal()]++; + } else if (!shard.canBePaidWithManaOfColor(mask)) { + missing++; // hybrid and generic keep the looser check + } + } + for (int i = 0; i < need.length; i++) { + missing += Math.max(0, need[i] - counts[i]); + } + return missing; + } + + /** How many of one cost's missing sources these extra ones supply, never negative. */ + private static int countSourcesFixed(final ManaCost cost, final int[] have, final int[] with) { + return Math.max(0, countMissingSources(cost, have) - countMissingSources(cost, with)); + } + /** *

* getBestLandAI. @@ -220,6 +307,10 @@ public static Card getBestEnchantmentAI(final List list, final SpellAbilit * @return a {@link forge.game.card.Card} object. */ public static Card getBestLandAI(final Iterable list) { + return getBestLandAI(null, list); + } + + public static Card getBestLandAI(final Player benefits, final Iterable list) { final List land = CardLists.filter(list, CardPredicates.LANDS); if (land.isEmpty()) { return null; @@ -249,7 +340,9 @@ public static Card getBestLandAI(final Iterable list) { } } - return Aggregates.random(nbLand); + // a colour we are short of outranks raw land value; ties fall through to it + return Aggregates.itemWithMax(nbLand, + c -> getColorFixingValue(benefits, c) + landEvaluator.apply(c)); } // if no non-basic lands, target the least represented basic land type diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java b/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java index 392f42d03b6e..1401db4148fc 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilCost.java @@ -5,6 +5,7 @@ import java.util.Set; import java.util.function.Predicate; +import forge.card.MagicColor; import forge.game.GameObject; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; @@ -647,16 +648,41 @@ public static Set getAvailableManaColors(Player ai, List additiona } for (Card c : cardsToConsider) { - for (SpellAbility sa : c.getManaAbilities()) { - if (sa.getManaPart() != null) { - colorsAvailable.add(sa.getManaPart().getOrigProduced()); - } + // the raw Produced$ is a script string, and every caller runs this through + // ColorSet.fromNames, which drops anything that is not a colour name - so an "Any" + // source used to contribute nothing at all + colorsAvailable.addAll(c.getProducibleColors()); + if (colorsAvailable.size() == MagicColor.Constant.COLORS_AND_COLORLESS.size()) { + break; // nothing left for a further source to add } } return colorsAvailable; } + /** + * How many sources of each color the player's board could produce, indexed by + * {@link MagicColor.Color#ordinal()}. Counted per card rather than per ability, since a land + * with two mana abilities still only taps once. + */ + public static int[] getManaSourceCounts(Player ai) { + int[] counts = new int[MagicColor.Color.values().length]; + for (Card c : ai.getCardsIn(ZoneType.Battlefield)) { + addManaSources(c, counts); + } + return counts; + } + + /** Adds what one card could produce to counts from {@link #getManaSourceCounts}. */ + public static void addManaSources(Card c, int[] counts) { + final Set producible = c.getProducibleColors(); + for (MagicColor.Color color : MagicColor.Color.values()) { + if (producible.contains(color.getName())) { + counts[color.ordinal()] += 1; + } + } + } + public static boolean isFreeCastAllowedByPermanent(Player player, String altCost) { Game game = player.getGame(); for (Card cardInPlay : game.getCardsIn(ZoneType.Battlefield)) { diff --git a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java index c5e628117815..ba740605db99 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java @@ -537,8 +537,31 @@ private static AiAbilityDecision hiddenTriggerAI(final Player ai, final SpellAbi * a List object. * @return a {@link forge.game.card.Card} object. */ - private static Card basicManaFixing(final Player ai, List list) { // Search for a Basic Land - final CardCollectionView combined = CardCollection.combine(ai.getCardsIn(ZoneType.Battlefield), ai.getCardsIn(ZoneType.Hand)); + private static Card basicManaFixing(final Player decider, final Player owner, List list) { // Search for a Basic Land + // the land ends up with its owner, so the colours that matter are theirs, not the + // chooser's. No card in the pool currently chooses for another player here, so rather + // than guess at that case it is left on the existing behaviour untouched. + if (!decider.isOpponentOf(owner)) { + // narrow to the lands worth the most, then let the basic-type spread and the + // dual-land preference below break the tie as they already did + final List best = new ArrayList<>(); + int most = 0; // only worth narrowing for lands that improve the mana at all + for (Card c : list) { + final int value = ComputerUtilCard.getColorFixingValue(owner, c); + if (value > most) { + most = value; + best.clear(); + } + if (value == most && most > 0) { + best.add(c); + } + } + if (!best.isEmpty()) { + list = best; + } + } + + final CardCollectionView combined = CardCollection.combine(owner.getCardsIn(ZoneType.Battlefield), owner.getCardsIn(ZoneType.Hand)); final List basics = new ArrayList<>(); // what types can I go get? @@ -1572,7 +1595,7 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List } else if (origin.contains(ZoneType.Library) && (type.contains("Basic") || areAllBasics(type))) { if (keycardFound != null) return keycardFound; - c = basicManaFixing(decider, fetchList); + c = basicManaFixing(decider, player, fetchList); } else if (ZoneType.Hand.equals(destination) && CardLists.getNotType(fetchList, "Creature").isEmpty()) { if (keycardFound != null) return keycardFound; @@ -1600,12 +1623,12 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List CardCollectionView hand = decider.getCardsIn(ZoneType.Hand); if (!hand.anyMatch(CardPredicates.LANDS) && CardLists.count(decider.getCardsIn(ZoneType.Battlefield), CardPredicates.LANDS) < 4 && !hand.anyMatch(crd -> ComputerUtilMana.hasEnoughManaSourcesToCast(crd.getFirstSpellAbility(), decider))) { - c = basicManaFixing(decider, fetchList); + c = basicManaFixing(decider, player, fetchList); } if (c == null) { if (fetchList.allMatch(CardPredicates.LANDS)) { // we're only choosing from lands, so get the best land - c = ComputerUtilCard.getBestLandAI(fetchList); + c = ComputerUtilCard.getBestLandAI(player, fetchList); } else { fetchList = CardLists.getNotType(fetchList, "Land"); // Prefer to pull a creature, generally more useful for AI. @@ -2090,7 +2113,7 @@ private static Card considerRamp(Player ai, SpellAbility sa, CardCollection choi // If we are below the threshold, look for a land in the available choices and prefer it if (totalManaSources < threshold) { - Card manaFixing = basicManaFixing(ai, choices); + Card manaFixing = basicManaFixing(ai, ai, choices); if (manaFixing != null) { return manaFixing; } diff --git a/forge-ai/src/main/java/forge/ai/ability/ControlGainAi.java b/forge-ai/src/main/java/forge/ai/ability/ControlGainAi.java index 822e10c8ca35..1c29cabe3bcc 100644 --- a/forge-ai/src/main/java/forge/ai/ability/ControlGainAi.java +++ b/forge-ai/src/main/java/forge/ai/ability/ControlGainAi.java @@ -211,7 +211,7 @@ protected AiAbilityDecision canPlay(final Player ai, final SpellAbility sa) { } else if (artifacts > 0) { t = ComputerUtilCard.getBestArtifactAI(list); } else if (lands > 0) { - t = ComputerUtilCard.getBestLandAI(list); + t = ComputerUtilCard.getBestLandAI(ai, list); } else if (enchantments > 0) { t = ComputerUtilCard.getBestEnchantmentAI(list, sa, false); } else { diff --git a/forge-game/src/main/java/forge/game/card/Card.java b/forge-game/src/main/java/forge/game/card/Card.java index 5249b18cd2aa..5298ce007e7c 100644 --- a/forge-game/src/main/java/forge/game/card/Card.java +++ b/forge-game/src/main/java/forge/game/card/Card.java @@ -3351,19 +3351,32 @@ public final boolean canProduceColorMana(final Set colors) { return false; } - public final boolean canProduceSameManaTypeWith(final Card c) { - if (getManaAbilities().isEmpty()) { - return false; - } + /** Every color this card could produce, walking its mana abilities once. */ + public final Set getProducibleColors() { Set colors = new HashSet<>(); - for (final SpellAbility ab : c.getManaAbilities()) { + for (final SpellAbility ab : getManaAbilities()) { + // without an activating player canProduce falls into a much more expensive path, so + // fill it in - but only when missing, so a payment simulation keeps the payer it set + if (ab.getActivatingPlayer() == null) { + ab.setActivatingPlayer(getController()); + } if (ab.getApi() == ApiType.ManaReflected) { colors.addAll(CardUtil.getReflectableManaColors(ab)); } else { colors = CardUtil.canProduce(6, ab, colors); } + if (colors.size() == MagicColor.Constant.COLORS_AND_COLORLESS.size()) { + break; // nothing left for a further ability to add + } + } + return colors; + } + + public final boolean canProduceSameManaTypeWith(final Card c) { + if (getManaAbilities().isEmpty()) { + return false; } - return canProduceColorMana(colors); + return canProduceColorMana(c.getProducibleColors()); } public final int getMaxManaProduced() { diff --git a/forge-gui-desktop/src/test/java/forge/ai/ability/LandColorNeedAiTest.java b/forge-gui-desktop/src/test/java/forge/ai/ability/LandColorNeedAiTest.java new file mode 100644 index 000000000000..3545a5c8cba7 --- /dev/null +++ b/forge-gui-desktop/src/test/java/forge/ai/ability/LandColorNeedAiTest.java @@ -0,0 +1,205 @@ +package forge.ai.ability; + +import org.testng.annotations.Test; + +import com.google.common.collect.Lists; + +import java.util.List; + +import forge.ai.AITest; +import forge.ai.ComputerUtilCard; +import forge.ai.ComputerUtilCost; +import forge.card.ColorSet; +import forge.game.Game; +import forge.game.card.Card; +import forge.game.player.Player; +import forge.game.zone.ZoneType; + +import static org.testng.AssertJUnit.assertEquals; +import static org.testng.AssertJUnit.assertFalse; +import static org.testng.AssertJUnit.assertTrue; + +/** + * Land searches used to pick by list order, so a fetchland that had settled on the right colour + * still chose its second colour arbitrarily. Both the play and the search path now rank candidates + * by what the colours they add would let us pay for, plus depth in the colours we are thin on. + */ +public class LandColorNeedAiTest extends AITest { + + /** two duals sharing the searched-for type, differing in the colour beside it */ + private Card[] twoDuals(Player owner) { + return new Card[] { + addCardToZone("Tundra", owner, ZoneType.Library), // Plains Island + addCardToZone("Underground Sea", owner, ZoneType.Library) // Island Swamp + }; + } + + @Test + public void scoresTheColorTheHandIsWaitingOn() { + Game game = initAndCreateGame(); + Player ai = game.getPlayers().get(1); + + addCards("Island", 3, ai); + // castable the moment we can make black, and not before + addCardToZone("Sign in Blood", ai, ZoneType.Hand); + addCardToZone("Ravenous Chupacabra", ai, ZoneType.Hand); + game.getAction().checkStateEffects(true); + + Card[] duals = twoDuals(ai); + int white = ComputerUtilCard.getColorFixingValue(ai, duals[0]); + int black = ComputerUtilCard.getColorFixingValue(ai, duals[1]); + assertTrue("black unblocks the hand, white does not", black > white); + + // and that is what ranking picks: the two duals are worth the same as lands, so the + // colour we are waiting on is the only thing separating them + assertEquals("Underground Sea", + ComputerUtilCard.getBestLandAI(ai, Lists.newArrayList(duals)).getName()); + + // whether our sources happen to be tapped says nothing about which colours the board can + // make, so holding the land drop until main 2 must not change the answer + for (Card c : ai.getCardsIn(ZoneType.Battlefield)) { + c.setTapped(true); + } + game.getAction().checkStateEffects(true); + assertEquals("tapping out must not change the measure", + white, ComputerUtilCard.getColorFixingValue(ai, duals[0])); + assertEquals(black, ComputerUtilCard.getColorFixingValue(ai, duals[1])); + } + + @Test + public void scoresTheOtherColorWhenTheHandChanges() { + Game game = initAndCreateGame(); + Player ai = game.getPlayers().get(1); + + addCards("Island", 3, ai); + addCardToZone("Wrath of God", ai, ZoneType.Hand); + addCardToZone("Swords to Plowshares", ai, ZoneType.Hand); + game.getAction().checkStateEffects(true); + + Card[] duals = twoDuals(ai); + assertTrue("now it is white we are waiting on", + ComputerUtilCard.getColorFixingValue(ai, duals[0]) + > ComputerUtilCard.getColorFixingValue(ai, duals[1])); + } + + /** a permanent has already been paid for, so it must not make its own colours look needed */ + @Test + public void aResolvedPermanentDoesNotLookLikeDemand() { + Game game = initAndCreateGame(); + Player ai = game.getPlayers().get(1); + + addCards("Island", 3, ai); + // already resolved, so neither its casting cost nor its Adventure half is waiting on a + // colour - a permanent must not make its own colours look needed + addCard("Bonecrusher Giant", ai); + game.getAction().checkStateEffects(true); + + // nothing is in hand, so a Mountain is worth depth on one new colour and nothing else. + // If the Giant's casting cost or its Adventure half counted, this would be far higher. + assertEquals("only depth on the new colour, no demand from the Giant", + ComputerUtilCard.COLOR_FIXING_WEIGHT, + ComputerUtilCard.getColorFixingValue(ai, addCardToZone("Mountain", ai, ZoneType.Library))); + + // and with no player to ask, ranking falls through to land value rather than a coin flip + assertEquals("Raffine's Tower", ComputerUtilCard.getBestLandAI(null, Lists.newArrayList( + addCardToZone("Tundra", ai, ZoneType.Library), + addCardToZone("Raffine's Tower", ai, ZoneType.Library))).getName()); + } + + /** + * A colour mask cannot tell one source of a colour from two, so it thinks {@code BB} is + * payable off a single Swamp. Counting pips is what makes the second source worth having. + */ + @Test + public void aSecondSourceCountsForDoublePips() { + Game game = initAndCreateGame(); + Player ai = game.getPlayers().get(1); + + // one of each, so depth alone cannot tell the two candidates apart and only the pip + // counting can - a colour mask would call BB payable off the single Swamp + addCards("Island", 1, ai); + addCards("Swamp", 1, ai); + addCardToZone("Sign in Blood", ai, ZoneType.Hand); // BB, so it wants a second black + game.getAction().checkStateEffects(true); + + assertTrue("a second black source is progress towards BB, a second blue source is not", + ComputerUtilCard.getColorFixingValue(ai, addCardToZone("Swamp", ai, ZoneType.Library)) + > ComputerUtilCard.getColorFixingValue(ai, addCardToZone("Island", ai, ZoneType.Library))); + } + + /** with nothing waiting on a colour, depth still prefers the colours we are thin on */ + @Test + public void depthPrefersTheColorWeAreThinnestOn() { + Game game = initAndCreateGame(); + Player ai = game.getPlayers().get(1); + + addCards("Island", 3, ai); + addCards("Plains", 1, ai); + game.getAction().checkStateEffects(true); // empty hand: nothing to unblock at all + + int firstSwamp = ComputerUtilCard.getColorFixingValue(ai, addCardToZone("Swamp", ai, ZoneType.Library)); + int secondPlains = ComputerUtilCard.getColorFixingValue(ai, addCardToZone("Plains", ai, ZoneType.Library)); + int fourthIsland = ComputerUtilCard.getColorFixingValue(ai, addCardToZone("Island", ai, ZoneType.Library)); + + assertTrue("a colour we have none of beats a second source", firstSwamp > secondPlains); + assertTrue("and a second source still beats a fourth", secondPlains > fourthIsland); + assertTrue("but a fourth is not worthless", fourthIsland > 0); + } + + /** + * Every caller runs getAvailableManaColors through ColorSet.fromNames, which keeps only colour + * names - so a source whose script says {@code Produced$ Any} used to contribute nothing, and + * a board of nothing but City of Brass read as unable to cast anything coloured. + */ + @Test + public void anyColorSourcesOfferEveryColor() { + assertTrue("an any-colour source offers white", canPayWhiteOff("City of Brass")); + assertTrue(canPayWhiteOff("Mana Confluence")); + // and it still says no when the colour really is absent + assertFalse("a blue source does not offer white", canPayWhiteOff("Island")); + } + + private boolean canPayWhiteOff(String landName) { + Game game = initAndCreateGame(); + Player ai = game.getPlayers().get(1); + for (int i = 0; i < 3; i++) { + addCard(landName, ai).setSickness(false); + } + Card swords = addCardToZone("Swords to Plowshares", ai, ZoneType.Hand); + game.getAction().checkStateEffects(true); + + ColorSet available = ColorSet.fromNames( + ComputerUtilCost.getAvailableManaColors(ai, (List) null)); + return swords.getManaCost().canBePaidWithAvailable(available.getColor()); + } + + /** + * The same decision through a real fetchland, which is how this is reached in a game: + * Flooded Strand searches for a Plains or an Island, and every dual carrying one of those + * types is a legal target. + */ + @Test + public void aFetchlandBringsBackTheColorWeAreShortOf() { + Game game = initAndCreateGame(); + Player ai = game.getPlayers().get(1); + + Card strand = addCard("Flooded Strand", ai); + strand.setSickness(false); + addCards("Island", 3, ai); + // only castable once we can make black + addCardToZone("Sign in Blood", ai, ZoneType.Hand); + addCardToZone("Ravenous Chupacabra", ai, ZoneType.Hand); + // both carry Island, so both are legal fetches; only one gives us black + addCardToZone("Tundra", ai, ZoneType.Library); + addCardToZone("Underground Sea", ai, ZoneType.Library); + fillLibrary(ai, 10); + game.getAction().checkStateEffects(true); + + moveToMain2(game, ai); + playUntilStackClear(game); + + assertEquals("fetched the land that unblocks our hand", + 1, countCardsWithName(game, "Underground Sea")); + assertEquals(0, countCardsWithName(game, "Tundra")); + } +}