/*
* Copyright (C) 2022-2023 NotEnoughUpdates contributors
*
* This file is part of NotEnoughUpdates.
*
* NotEnoughUpdates is free software: you can redistribute it
* and/or modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation, either
* version 3 of the License, or (at your option) any later version.
*
* NotEnoughUpdates is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with NotEnoughUpdates. If not, see .
*/
package io.github.moulberry.notenoughupdates.overlays;
import com.google.gson.JsonObject;
import io.github.moulberry.notenoughupdates.NEUManager;
import io.github.moulberry.notenoughupdates.NotEnoughUpdates;
import io.github.moulberry.notenoughupdates.core.config.Position;
import io.github.moulberry.notenoughupdates.core.util.lerp.LerpUtils;
import io.github.moulberry.notenoughupdates.util.ItemResolutionQuery;
import io.github.moulberry.notenoughupdates.util.SidebarUtil;
import io.github.moulberry.notenoughupdates.util.Utils;
import io.github.moulberry.notenoughupdates.util.XPInformation;
import io.github.moulberry.notenoughupdates.util.hypixelapi.HypixelItemAPI;
import lombok.val;
import lombok.var;
import net.minecraft.client.Minecraft;
import net.minecraft.item.ItemStack;
import net.minecraft.nbt.NBTTagCompound;
import net.minecraft.util.EnumChatFormatting;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.function.Supplier;
import java.util.regex.Pattern;
public class FarmingSkillOverlay extends TextOverlay {
private static final NumberFormat format = NumberFormat.getIntegerInstance();
private final HashMap lineMap = new HashMap<>();
private long lastUpdate = -1;
private long counterLast = -1;
private long counter = -1;
private int cultivatingLast = -1;
private int cultivating = -1;
private int cultivatingTier = -1;
private String cultivatingTierAmount = "1";
private int foraging = 0;
private double coins = -1;
private String lastItemHeld = "null";
private int jacobPredictionLast = -1;
private int jacobPrediction = -1;
private boolean inJacobContest = false;
private XPInformation.SkillInfo skillInfo = null;
private XPInformation.SkillInfo skillInfoLast = null;
private float lastTotalXp = -1;
private boolean isFarming = false;
private final LinkedList xpGainQueue = new LinkedList<>();
private float xpGainHourLast = -1;
private float xpGainHour = -1;
private int xpGainTimer = 0;
public static final int CPS_WINDOW_SIZE = 302;
/**
* Stores the values of the crop counter as a sliding window.
* Values can be accessed using the {@link #cropsPerSecondCursor}.
*/
private long[] cropsPerSecondValues = new long[CPS_WINDOW_SIZE];
/**
* The theoretical call interval of {@link #update()} is 1 second,
* but in reality it can deviate by one tick, or 50ms,
* which means we have to save time stamps of the values in order to prevent up to 5% (50ms) incorrectness.
*/
private long[] cropsPerSecondTimeStamps = new long[CPS_WINDOW_SIZE];
private int cropsPerSecondCursor = -1;
private float cropsPerSecondLast = 0;
private float cropsPerSecond = 0;
private float cpsResetTimer = 1;
private String skillType = "Farming";
private static final Pattern CONTEST_AMOUNT_PATTERN = Pattern.compile(
" (Collected|(BRONZE|SILVER|GOLD|PLATINUM|DIAMOND) with) (?[\\d,]+)");
public FarmingSkillOverlay(
Position position,
Supplier> dummyStrings,
Supplier styleSupplier
) {
super(position, dummyStrings, styleSupplier);
}
private float interpolate(float now, float last) {
float interp = now;
if (last >= 0 && last != now) {
float factor = (System.currentTimeMillis() - lastUpdate) / 1000f;
factor = LerpUtils.clampZeroOne(factor);
interp = last + (now - last) * factor;
}
return interp;
}
/**
* @return x mod y with the sign of the divisor, y, instead of the dividend, x.
* Moreover -1 % 2 is mathematically both -1 and 1. Java chose -1, we want 1.
*/
private int mod(int x, int y) {
int mod = x % y;
if (mod < 0)
mod += y;
return mod;
}
private void resetCropsPerSecond() {
cropsPerSecondTimeStamps = new long[CPS_WINDOW_SIZE];
cropsPerSecondValues = new long[CPS_WINDOW_SIZE];
cropsPerSecond = 0;
cropsPerSecondLast = 0;
}
private double getCoinsBz(String enchCropName, int numItemsForEnch) {
JsonObject crop = NotEnoughUpdates.INSTANCE.manager.auctionManager.getBazaarInfo(enchCropName);
if (crop != null && crop.has("curr_sell")) {
return crop.get("curr_sell").getAsFloat() / numItemsForEnch;
}
return 0;
}
private void gatherJacobData() {
inJacobContest = false;
if (isJacobTime()) {
int timeLeftInContest = (20 * 60) - ((int) ((System.currentTimeMillis() % 3600000 - 900000) / 1000));
int cropsFarmed = -1;
for (String line : SidebarUtil.readSidebarLines()) {
val matcher = CONTEST_AMOUNT_PATTERN.matcher(line);
if (matcher.matches()) {
String amount = matcher.group("amount");
// account for when the scoreboard line is too long and last digit or two are cut off
int lastComma = amount.lastIndexOf(',');
int extraZeros = lastComma != -1 ? 4 + lastComma - amount.length() : 0;
try {
inJacobContest = true;
cropsFarmed = Integer.parseInt(amount.replace(",", "")) * (int) Math.pow(10, extraZeros);
} catch (NumberFormatException e) {
e.printStackTrace();
}
}
jacobPrediction = (int) (cropsFarmed + (cropsPerSecond * timeLeftInContest));
}
} else {
jacobPrediction = -1;
jacobPredictionLast = -1;
}
}
/**
* @return if there is an active Jacob's contest
*/
private static boolean isJacobTime() {
long now = System.currentTimeMillis();
return now % 3600000 >= 900000 && now % 3600000 <= 2100000;
}
@Override
public boolean isEnabled() {
return NotEnoughUpdates.INSTANCE.config.skillOverlays.farmingOverlay;
}
private enum CropType {
WHEAT("THEORETICAL_HOE_WHEAT", "ENCHANTED_HAY_BLOCK", 1296),
NETHER_WART("THEORETICAL_HOE_WARTS", "ENCHANTED_NETHER_STALK", 160),
SUGAR_CANE("THEORETICAL_HOE_CANE", "ENCHANTED_SUGAR", 160),
CARROT("THEORETICAL_HOE_CARROT", "ENCHANTED_CARROT", 160),
POTATO("THEORETICAL_HOE_POTATO", "ENCHANTED_POTATO", 160),
COCOA_BEANS("COCO_CHOPPER", "ENCHANTED_COCOA", 160),
PUMPKIN("PUMPKIN_DICER", "ENCHANTED_PUMPKIN", 160),
MELON("MELON_DICER", "ENCHANTED_MELON", 160),
CACTUS("CACTUS_KNIFE", "ENCHANTED_CACTUS_GREEN", 160),
;
private final String toolName;
private final String item;
private final int enchSize;
CropType(String toolName, String item, int enchSize) {
this.toolName = toolName;
this.item = item;
this.enchSize = enchSize;
}
}
@Override
public void update() {
if (!isEnabled()) {
counter = -1;
overlayStrings = null;
resetCropsPerSecond();
return;
}
lastUpdate = System.currentTimeMillis();
counterLast = counter;
cultivatingLast = cultivating;
xpGainHourLast = xpGainHour;
counter = -1;
if (Minecraft.getMinecraft().thePlayer == null) return;
ItemStack stack = Minecraft.getMinecraft().thePlayer.getHeldItem();
updateCounter(stack);
updateCropsPerSecond();
String internalName =
new ItemResolutionQuery(NotEnoughUpdates.INSTANCE.manager).withItemStack(stack).resolveInternalName();
if (internalName != null) {
updateSkillType(internalName);
}
updateSkillInfo();
if (counter != -1) {
overlayStrings = new ArrayList<>();
} else {
overlayStrings = null;
}
gatherJacobData();
}
private void updateSkillType(String internalName) {
//Set default skillType to Farming and get BZ price config value
skillType = "Farming";
foraging = 0;
boolean useBZPrice = NotEnoughUpdates.INSTANCE.config.skillOverlays.useBZPrice;
if (internalName.equals("TREECAPITATOR_AXE") || internalName.equalsIgnoreCase("JUNGLE_AXE")) {
//WOOD
skillType = "Foraging";
foraging = 1;
coins = 2;
} else if (internalName.equals("FUNGI_CUTTER")) {
//MUSHROOM
if (useBZPrice) {
coins = (getCoinsBz("ENCHANTED_RED_MUSHROOM", 160) +
getCoinsBz("ENCHANTED_BROWN_MUSHROOM", 160)) / 2;
} else {
Double red = HypixelItemAPI.getNPCSellPrice("ENCHANTED_RED_MUSHROOM");
Double brown = HypixelItemAPI.getNPCSellPrice("ENCHANTED_BROWN_MUSHROOM");
if (red == null || brown == null) {
coins = 0;
} else {
coins = (red * 160 + brown * 160) / 2;
}
}
} else {
// EVERYTHING ELSE
coins = 0;
for (CropType crop : CropType.values()) {
if (internalName.startsWith(crop.toolName)) {
Double npcSellPrice = HypixelItemAPI.getNPCSellPrice(crop.item);
if (npcSellPrice == null) {
npcSellPrice = 0.0;
}
coins = useBZPrice ? getCoinsBz(crop.item, crop.enchSize) : npcSellPrice / crop.enchSize;
}
}
}
}
private void updateSkillInfo() {
skillInfoLast = skillInfo;
var s = NotEnoughUpdates.INSTANCE.config.skillOverlays.farmingText;
skillInfo = XPInformation.getInstance().getSkillInfo(
skillType,
s.contains(2) ||
s.contains(3) ||
s.contains(4) ||
s.contains(5) ||
s.contains(7)
);
if (skillInfo != null) {
float totalXp = (float) skillInfo.totalXp;
if (lastTotalXp > 0) {
float delta = totalXp - lastTotalXp;
if (delta > 0 && delta < 1000) {
xpGainTimer = NotEnoughUpdates.INSTANCE.config.skillOverlays.farmingPauseTimer;
xpGainQueue.add(0, delta);
while (xpGainQueue.size() > 30) {
xpGainQueue.removeLast();
}
float totalGain = 0;
for (float f : xpGainQueue) totalGain += f;
xpGainHour = totalGain * (60 * 60) / xpGainQueue.size();
isFarming = true;
} else if (xpGainTimer > 0) {
xpGainTimer--;
xpGainQueue.add(0, 0f);
while (xpGainQueue.size() > 30) {
xpGainQueue.removeLast();
}
float totalGain = 0;
for (float f : xpGainQueue) totalGain += f;
xpGainHour = totalGain * (60 * 60) / xpGainQueue.size();
isFarming = true;
} else if (delta <= 0) {
isFarming = false;
}
}
lastTotalXp = totalXp;
}
}
private void updateCounter(ItemStack stack) {
if (stack != null && stack.hasTagCompound()) {
NBTTagCompound tag = stack.getTagCompound();
if (tag.hasKey("ExtraAttributes", 10)) {
NBTTagCompound ea = tag.getCompoundTag("ExtraAttributes");
if (ea.hasKey("mined_crops", 99)) {
counter = ea.getLong("mined_crops");
cultivating = ea.getInteger("farmed_cultivating");
} else if (ea.hasKey("farmed_cultivating", 99)) {
counter = ea.getLong("farmed_cultivating");
cultivating = ea.getInteger("farmed_cultivating");
}
}
}
if (cultivating < 1000) {
cultivatingTier = 1;
cultivatingTierAmount = "1,000";
} else if (cultivating < 5000) {
cultivatingTier = 2;
cultivatingTierAmount = "5,000";
} else if (cultivating < 25000) {
cultivatingTier = 3;
cultivatingTierAmount = "25,000";
} else if (cultivating < 100000) {
cultivatingTier = 4;
cultivatingTierAmount = "100,000";
} else if (cultivating < 300000) {
cultivatingTier = 5;
cultivatingTierAmount = "300,000";
} else if (cultivating < 1500000) {
cultivatingTier = 6;
cultivatingTierAmount = "1,500,000";
} else if (cultivating < 5000000) {
cultivatingTier = 7;
cultivatingTierAmount = "5,000,000";
} else if (cultivating < 20000000) {
cultivatingTier = 8;
cultivatingTierAmount = "20,000,000";
} else if (cultivating < 100000000) {
cultivatingTier = 9;
cultivatingTierAmount = "100,000,000";
} else {
cultivatingTier = 10;
cultivatingTierAmount = "Maxed";
}
}
/**
* Finds the average crops farmed during the configured time frame or since the player has started farming.
*/
private void updateCropsPerSecond() {
//update values in arrays
cropsPerSecondTimeStamps[++cropsPerSecondCursor % CPS_WINDOW_SIZE] =
System.currentTimeMillis();
cropsPerSecondValues[cropsPerSecondCursor % CPS_WINDOW_SIZE] = counter;
//calculate
long current = cropsPerSecondValues[cropsPerSecondCursor % CPS_WINDOW_SIZE];
int timeFrame = Math.min(
NotEnoughUpdates.INSTANCE.config.skillOverlays.farmingCropsPerSecondTimeFrame,
CPS_WINDOW_SIZE
);
//The searchIndex serves to find the start of the player farming.
//This makes it so that even if the timeframe is set high,
// the initial average will be the average since the player starts farming instead of the full timeframe.
int searchIndex = mod(cropsPerSecondCursor - timeFrame, CPS_WINDOW_SIZE);
while (cropsPerSecondValues[searchIndex] == cropsPerSecondValues[mod(searchIndex - 1, CPS_WINDOW_SIZE)] &&
mod(searchIndex, CPS_WINDOW_SIZE) != mod(cropsPerSecondCursor, CPS_WINDOW_SIZE)) {
searchIndex++;
searchIndex %= CPS_WINDOW_SIZE;
}
float newCropsPerSecond = current - cropsPerSecondValues[searchIndex];
float timePassed =
cropsPerSecondTimeStamps[cropsPerSecondCursor % CPS_WINDOW_SIZE] - cropsPerSecondTimeStamps[searchIndex];
timePassed /= 1000f;
newCropsPerSecond /= timePassed;
if (Float.isNaN(newCropsPerSecond)) newCropsPerSecond = 0;
cropsPerSecondLast = cropsPerSecond;
cropsPerSecond = newCropsPerSecond;
//reset logic
if (counter == counterLast) {
cpsResetTimer++;
} else {
//starts at 1 because by the time the increment takes place, a second has already passed.
cpsResetTimer = 1;
}
String currentItemHeld = NEUManager.getUUIDForItem(Minecraft.getMinecraft().thePlayer.getHeldItem()) == null
? "null" : NEUManager.getUUIDForItem(Minecraft.getMinecraft().thePlayer.getHeldItem());
if (!lastItemHeld.equals(currentItemHeld)) {
lastItemHeld = currentItemHeld;
resetCropsPerSecond();
} else if (cpsResetTimer > NotEnoughUpdates.INSTANCE.config.skillOverlays.farmingResetCPS) {
resetCropsPerSecond();
}
}
@Override
public void updateFrequent() {
super.updateFrequent();
if (counter < 0) {
overlayStrings = null;
} else {
lineMap.clear();
overlayStrings = new ArrayList<>();
if (cultivating != counter) {
renderCounter();
}
if (counter >= 0) {
renderCropsPerSecond();
if (coins > 0) {
renderCoins();
}
}
renderCultivating();
renderJacob();
renderLevelAndXP();
renderYawPitch();
for (int strIndex : NotEnoughUpdates.INSTANCE.config.skillOverlays.farmingText) {
if (lineMap.get(strIndex) != null) {
overlayStrings.add(lineMap.get(strIndex));
}
}
if (overlayStrings != null && overlayStrings.isEmpty()) overlayStrings = null;
}
}
private void renderCounter() {
int counterInterp = (int) interpolate(counter, counterLast);
lineMap.put(0, EnumChatFormatting.AQUA + "Counter: " + EnumChatFormatting.YELLOW + format.format(counterInterp));
}
private void renderCoins() {
float coinsMultiplier = 0;
String unit = null;
switch (NotEnoughUpdates.INSTANCE.config.skillOverlays.farmingCoinRateUnit) {
case 0:
coinsMultiplier = 1;
unit = "/s";
break;
case 1:
coinsMultiplier = 60;
unit = "/m";
break;
case 2:
coinsMultiplier = 3600;
unit = "/h";
break;
}
if (cropsPerSecondLast == cropsPerSecond && cropsPerSecond <= 0) {
lineMap.put(10, EnumChatFormatting.AQUA + "Coins" + unit + ": " + EnumChatFormatting.YELLOW + "N/A");
} else {
float cropsPerSecond = cropsPerSecondLast != 0
? interpolate(this.cropsPerSecond, cropsPerSecondLast)
: this.cropsPerSecond;
float cropsPerUnit = cropsPerSecond * coinsMultiplier;
lineMap.put(10, EnumChatFormatting.AQUA + "Coins" + unit + ": " + EnumChatFormatting.YELLOW +
String.format("%,.0f", cropsPerUnit * coins));
}
}
private void renderCultivating() {
if (cultivatingTier <= 9 && cultivating > 0) {
int counterInterp = (int) interpolate(cultivating, cultivatingLast);
lineMap.put(
9,
EnumChatFormatting.AQUA + "Cultivating: " + EnumChatFormatting.YELLOW + format.format(counterInterp) + "/" +
cultivatingTierAmount
);
}
if (cultivatingTier == 10) {
int counterInterp = (int) interpolate(cultivating, cultivatingLast);
lineMap.put(
9,
EnumChatFormatting.AQUA + "Cultivating: " + EnumChatFormatting.YELLOW + format.format(counterInterp)
);
}
}
private void renderJacob() {
if (isJacobTime() && inJacobContest) {
if (jacobPredictionLast == jacobPrediction && jacobPrediction <= 0) {
lineMap.put(11, EnumChatFormatting.AQUA + "Contest Estimate: " + EnumChatFormatting.YELLOW + "N/A");
} else {
float predInterp = interpolate(jacobPrediction, jacobPredictionLast);
lineMap.put(
11,
EnumChatFormatting.AQUA + "Contest Estimate: " + EnumChatFormatting.YELLOW +
String.format("%,.0f", predInterp)
);
}
}
}
private void renderLevelAndXP() {
float xpInterp = xpGainHour;
if (xpGainHourLast == xpGainHour && xpGainHour <= 0) {
lineMap.put(5, EnumChatFormatting.AQUA + "XP/h: " + EnumChatFormatting.YELLOW + "N/A");
} else {
xpInterp = interpolate(xpGainHour, xpGainHourLast);
lineMap.put(5, EnumChatFormatting.AQUA + "XP/h: " + EnumChatFormatting.YELLOW +
format.format(xpInterp) + (isFarming ? "" : EnumChatFormatting.RED + " (PAUSED)"));
}
if (skillInfo != null && skillInfo.level < 60) {
StringBuilder levelStr = new StringBuilder(EnumChatFormatting.AQUA + skillType + ": ");
levelStr.append(EnumChatFormatting.YELLOW)
.append(skillInfo.level)
.append(EnumChatFormatting.GRAY)
.append(" [");
float progress = (float) (skillInfo.currentXp / skillInfo.currentXpMax);
if (skillInfoLast != null && skillInfo.currentXpMax == skillInfoLast.currentXpMax) {
progress = interpolate(progress, (float) (skillInfoLast.currentXp / skillInfoLast.currentXpMax));
}
float lines = 25;
for (int i = 0; i < lines; i++) {
if (i / lines < progress) {
levelStr.append(EnumChatFormatting.YELLOW);
} else {
levelStr.append(EnumChatFormatting.DARK_GRAY);
}
levelStr.append('|');
}
levelStr.append(EnumChatFormatting.GRAY)
.append("] ")
.append(EnumChatFormatting.YELLOW)
.append((int) (progress * 100))
.append("%");
int current = (int) skillInfo.currentXp;
if (skillInfoLast != null && skillInfo.currentXpMax == skillInfoLast.currentXpMax) {
current = (int) interpolate(current, (float) skillInfoLast.currentXp);
}
int remaining = (int) (skillInfo.currentXpMax - skillInfo.currentXp);
if (skillInfoLast != null && skillInfo.currentXpMax == skillInfoLast.currentXpMax) {
remaining = (int) interpolate(remaining, (int) (skillInfoLast.currentXpMax - skillInfoLast.currentXp));
}
lineMap.put(2, levelStr.toString());
lineMap.put(3, EnumChatFormatting.AQUA + "Current XP: " + EnumChatFormatting.YELLOW + format.format(current));
if (remaining < 0) {
lineMap.put(4, EnumChatFormatting.AQUA + "Remaining XP: " + EnumChatFormatting.YELLOW + "MAXED!");
lineMap.put(7, EnumChatFormatting.AQUA + "ETA: " + EnumChatFormatting.YELLOW + "MAXED!");
} else {
lineMap.put(
4,
EnumChatFormatting.AQUA + "Remaining XP: " + EnumChatFormatting.YELLOW + format.format(remaining)
);
if (xpGainHour < 1000) {
lineMap.put(7, EnumChatFormatting.AQUA + "ETA: " + EnumChatFormatting.YELLOW + "N/A");
} else {
lineMap.put(
7,
EnumChatFormatting.AQUA + "ETA: " + EnumChatFormatting.YELLOW +
Utils.prettyTime((long) (remaining) * 1000 * 60 * 60 / (long) xpInterp)
);
}
}
}
if (skillInfo != null && skillInfo.level == 60) {
int current = (int) skillInfo.currentXp;
if (skillInfoLast != null && skillInfo.currentXpMax == skillInfoLast.currentXpMax) {
current = (int) interpolate(current, (float) skillInfoLast.currentXp);
}
if (foraging == 0) {
lineMap.put(
2,
EnumChatFormatting.AQUA + "Farming: " + EnumChatFormatting.YELLOW + "60 " + EnumChatFormatting.RED +
"(Maxed)"
);
} else {
lineMap.put(
2,
EnumChatFormatting.AQUA + "Foraging: " + EnumChatFormatting.YELLOW + "50 " + EnumChatFormatting.RED +
"(Maxed)"
);
}
lineMap.put(3, EnumChatFormatting.AQUA + "Current XP: " + EnumChatFormatting.YELLOW + format.format(current));
}
}
private void renderYawPitch() {
float yaw = Minecraft.getMinecraft().thePlayer.rotationYawHead;
float pitch = Minecraft.getMinecraft().thePlayer.rotationPitch;
yaw %= 360;
if (yaw < 0) yaw += 360;
if (yaw > 180) yaw -= 360;
pitch %= 360;
if (pitch < 0) pitch += 360;
if (pitch > 180) pitch -= 360;
lineMap.put(6, EnumChatFormatting.AQUA + "Yaw: " + EnumChatFormatting.YELLOW +
String.format("%.2f°", yaw));
lineMap.put(8, EnumChatFormatting.AQUA + "Pitch: " + EnumChatFormatting.YELLOW +
String.format("%.2f°", pitch));
}
private void renderCropsPerSecond() {
float cropsMultiplier = 0;
String unit = null;
switch (NotEnoughUpdates.INSTANCE.config.skillOverlays.farmingCropRateUnit) {
case 0:
cropsMultiplier = 1;
unit = "/s";
break;
case 1:
cropsMultiplier = 60;
unit = "/m";
break;
case 2:
cropsMultiplier = 3600;
unit = "/h";
break;
}
if (cropsPerSecondLast == cropsPerSecond && cropsPerSecond <= 0) {
lineMap.put(
1,
EnumChatFormatting.AQUA +
(foraging == 1 ? "Logs" + unit + ": " : "Crops" + unit + ": ") +
EnumChatFormatting.YELLOW + "N/A"
);
} else {
//Don't interpolate at the start
float cropsPerSecond = cropsPerSecondLast != 0
? interpolate(this.cropsPerSecond, cropsPerSecondLast)
: this.cropsPerSecond;
float cropsPerUnit = cropsPerSecond * cropsMultiplier;
lineMap.put(
1,
EnumChatFormatting.AQUA + (foraging == 1 ? "Logs" + unit + ": " : "Crops" + unit + ": ") +
EnumChatFormatting.YELLOW +
String.format("%,.0f", cropsPerUnit)
);
}
}
}