Просмотр исходного кода

refactor: Improved quest drops handling

Hooked quest drop handling into the main drop calculation mechanism. This allows for better quest drops management including support for grouped drops, precise calculation etc. in a way that is consistent with how other drops are handled.
Added a configurable rate for amount of quest drops, separate from the chance multiplier.
Created QuestDroplist with a suite of builders to generate droplists for quests in a fluent way.
Repurposed QuestItemHolder into QuestItemChanceHolder to hold item limit information.
Noe Caratini 3 лет назад
Родитель
Сommit
45e78d6183

+ 7 - 0
pom.xml

@@ -21,6 +21,7 @@
 		<!-- Test -->
 		<junit-jupiter.version>5.8.2</junit-jupiter.version>
 		<mockito.version>4.1.0</mockito.version>
+		<assertj-core.version>3.22.0</assertj-core.version>
 		<!-- Plugins -->
 		<maven-surefire-plugin.version>2.22.2</maven-surefire-plugin.version>
 		<maven-jar-plugin.version>3.2.0</maven-jar-plugin.version>
@@ -166,5 +167,11 @@
 			<version>${mockito.version}</version>
 			<scope>test</scope>
 		</dependency>
+		<dependency>
+			<groupId>org.assertj</groupId>
+			<artifactId>assertj-core</artifactId>
+			<version>${assertj-core.version}</version>
+			<scope>test</scope>
+		</dependency>
 	</dependencies>
 </project>

+ 5 - 2
src/main/java/com/l2jserver/gameserver/config/RatesConfiguration.java

@@ -110,8 +110,11 @@ public interface RatesConfiguration extends Reloadable {
 	@Key("RateHellboundTrustDecrease")
 	Float getRateHellboundTrustDecrease();
 	
-	@Key("RateQuestDrop")
-	Float getRateQuestDrop();
+	@Key("QuestDropChanceMultiplier")
+	Float getQuestDropChanceMultiplier();
+
+	@Key("QuestDropAmountMultiplier")
+	Float getQuestDropAmountMultiplier();
 	
 	@Key("RateQuestRewardXP")
 	Float getRateQuestRewardXP();

+ 1 - 1
src/main/java/com/l2jserver/gameserver/model/drops/DropListScope.java

@@ -37,7 +37,7 @@ public enum DropListScope implements IDropItemFactory, IGroupedDropItemFactory {
 	STATIC(
 		(itemId, min, max, chance) -> new GeneralDropItem(itemId, min, max, chance, IAmountMultiplierStrategy.STATIC, IChanceMultiplierStrategy.STATIC, IPreciseDeterminationStrategy.ALWAYS, IKillerChanceModifierStrategy.NO_RULES),
 		chance -> new GroupedGeneralDropItem(chance, IGroupedItemDropCalculationStrategy.DEFAULT_STRATEGY, IKillerChanceModifierStrategy.NO_RULES, IPreciseDeterminationStrategy.ALWAYS)),
-	QUEST((itemId, min, max, chance) -> new GeneralDropItem(itemId, min, max, chance, IAmountMultiplierStrategy.STATIC, IChanceMultiplierStrategy.QUEST, IPreciseDeterminationStrategy.ALWAYS, IKillerChanceModifierStrategy.NO_RULES), STATIC);
+	QUEST((itemId, min, max, chance) -> new GeneralDropItem(itemId, min, max, chance, IAmountMultiplierStrategy.QUEST, IChanceMultiplierStrategy.QUEST, IPreciseDeterminationStrategy.ALWAYS, IKillerChanceModifierStrategy.NO_RULES), STATIC);
 	
 	private final IDropItemFactory _factory;
 	private final IGroupedDropItemFactory _groupFactory;

+ 1 - 0
src/main/java/com/l2jserver/gameserver/model/drops/strategy/IAmountMultiplierStrategy.java

@@ -33,6 +33,7 @@ public interface IAmountMultiplierStrategy {
 	IAmountMultiplierStrategy DROP = DEFAULT_STRATEGY(rates().getDeathDropAmountMultiplier());
 	IAmountMultiplierStrategy SPOIL = DEFAULT_STRATEGY(rates().getCorpseDropAmountMultiplier());
 	IAmountMultiplierStrategy STATIC = (item, victim) -> 1;
+	IAmountMultiplierStrategy QUEST = DEFAULT_STRATEGY(rates().getQuestDropAmountMultiplier());
 	
 	static IAmountMultiplierStrategy DEFAULT_STRATEGY(final double defaultMultiplier) {
 		return (item, victim) -> {

+ 1 - 1
src/main/java/com/l2jserver/gameserver/model/drops/strategy/IChanceMultiplierStrategy.java

@@ -42,7 +42,7 @@ public interface IChanceMultiplierStrategy {
 			championmult = customs().getChampionRewardsChance();
 		}
 		
-		return (customs().championEnable() && (victim != null) && victim.isChampion()) ? (rates().getRateQuestDrop() * championmult) : rates().getRateQuestDrop();
+		return (customs().championEnable() && (victim != null) && victim.isChampion()) ? (rates().getQuestDropChanceMultiplier() * championmult) : rates().getQuestDropChanceMultiplier();
 	};
 	
 	static IChanceMultiplierStrategy DEFAULT_STRATEGY(final double defaultMultiplier) {

+ 102 - 75
src/main/java/com/l2jserver/gameserver/model/events/AbstractScript.java

@@ -18,30 +18,6 @@
  */
 package com.l2jserver.gameserver.model.events;
 
-import static com.l2jserver.gameserver.config.Configuration.character;
-import static com.l2jserver.gameserver.config.Configuration.customs;
-import static com.l2jserver.gameserver.config.Configuration.rates;
-
-import java.lang.annotation.Annotation;
-import java.lang.reflect.Method;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.function.Consumer;
-import java.util.function.Function;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
 import com.l2jserver.commons.util.Rnd;
 import com.l2jserver.commons.util.Util;
 import com.l2jserver.gameserver.GameTimeController;
@@ -128,6 +104,7 @@ import com.l2jserver.gameserver.model.events.listeners.RunnableEventListener;
 import com.l2jserver.gameserver.model.events.returns.AbstractEventReturn;
 import com.l2jserver.gameserver.model.events.returns.TerminateReturn;
 import com.l2jserver.gameserver.model.holders.ItemHolder;
+import com.l2jserver.gameserver.model.holders.QuestItemChanceHolder;
 import com.l2jserver.gameserver.model.holders.SkillHolder;
 import com.l2jserver.gameserver.model.interfaces.INamable;
 import com.l2jserver.gameserver.model.interfaces.IPositionable;
@@ -137,6 +114,8 @@ import com.l2jserver.gameserver.model.items.L2EtcItem;
 import com.l2jserver.gameserver.model.items.L2Item;
 import com.l2jserver.gameserver.model.items.instance.L2ItemInstance;
 import com.l2jserver.gameserver.model.olympiad.Olympiad;
+import com.l2jserver.gameserver.model.quest.QuestDroplist;
+import com.l2jserver.gameserver.model.quest.QuestDroplist.QuestDropInfo;
 import com.l2jserver.gameserver.model.skills.Skill;
 import com.l2jserver.gameserver.model.zone.L2ZoneType;
 import com.l2jserver.gameserver.network.NpcStringId;
@@ -149,6 +128,30 @@ import com.l2jserver.gameserver.network.serverpackets.SystemMessage;
 import com.l2jserver.gameserver.scripting.ScriptManager;
 import com.l2jserver.gameserver.util.MinionList;
 
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.l2jserver.gameserver.config.Configuration.character;
+import static com.l2jserver.gameserver.config.Configuration.rates;
+import static com.l2jserver.gameserver.model.quest.QuestDroplist.singleDropItem;
+
 /**
  * Abstract script.
  * @author KenM
@@ -1715,6 +1718,15 @@ public abstract class AbstractScript implements INamable {
 		}
 		return hasQuestItems(player, item.getId());
 	}
+
+	protected static boolean hasItemsAtLimit(L2PcInstance player, QuestItemChanceHolder... items) {
+		if (items == null) {
+			return false;
+		}
+
+		return Arrays.stream(items)
+				.allMatch(item -> getQuestItemsCount(player, item.getId()) >= item.getLimit());
+	}
 	
 	/**
 	 * Check if the player has all the specified items in his inventory and, if necessary, if their count is also as required.
@@ -1971,83 +1983,98 @@ public abstract class AbstractScript implements INamable {
 		
 		sendItemGetMessage(player, item, count);
 	}
-	
+
 	/**
-	 * Give the specified player a set amount of items if he is lucky enough.<br>
-	 * Not recommended to use this for non-stacking items.
-	 * @param player the player to give the item(s) to
-	 * @param itemId the ID of the item to give
-	 * @param amountToGive the amount of items to give
-	 * @param limit the maximum amount of items the player can have. Won't give more if this limit is reached. 0 - no limit.
-	 * @param dropChance the drop chance as a decimal digit from 0 to 1
-	 * @param playSound if true, plays ItemSound.quest_itemget when items are given and ItemSound.quest_middle when the limit is reached
-	 * @return {@code true} if limit > 0 and the limit was reached or if limit <= 0 and items were given; {@code false} in all other cases
+	 * @see AbstractScript#giveItemRandomly(L2PcInstance player, L2Npc npc, L2PcInstance killer, IDropItem dropItem, long limit, boolean playSound)
 	 */
+	@Deprecated
 	public static boolean giveItemRandomly(L2PcInstance player, int itemId, long amountToGive, long limit, double dropChance, boolean playSound) {
-		return giveItemRandomly(player, null, itemId, amountToGive, amountToGive, limit, dropChance, playSound);
+		return giveItemRandomly(player, null, player, singleDropItem(itemId, amountToGive, amountToGive, dropChance * 100), limit, playSound);
 	}
-	
+
 	/**
-	 * Give the specified player a set amount of items if he is lucky enough.<br>
-	 * Not recommended to use this for non-stacking items.
-	 * @param player the player to give the item(s) to
-	 * @param npc the NPC that "dropped" the item (can be null)
-	 * @param itemId the ID of the item to give
-	 * @param amountToGive the amount of items to give
-	 * @param limit the maximum amount of items the player can have. Won't give more if this limit is reached. 0 - no limit.
-	 * @param dropChance the drop chance as a decimal digit from 0 to 1
-	 * @param playSound if true, plays ItemSound.quest_itemget when items are given and ItemSound.quest_middle when the limit is reached
-	 * @return {@code true} if limit > 0 and the limit was reached or if limit <= 0 and items were given; {@code false} in all other cases
+	 * @see AbstractScript#giveItemRandomly(L2PcInstance player, L2Npc npc, L2PcInstance killer, IDropItem dropItem, long limit, boolean playSound)
 	 */
+	@Deprecated
 	public static boolean giveItemRandomly(L2PcInstance player, L2Npc npc, int itemId, long amountToGive, long limit, double dropChance, boolean playSound) {
-		return giveItemRandomly(player, npc, itemId, amountToGive, amountToGive, limit, dropChance, playSound);
+		return giveItemRandomly(player, npc, player, singleDropItem(itemId, amountToGive, amountToGive, dropChance * 100), limit, playSound);
 	}
-	
+
+	/**
+	 * @see AbstractScript#giveItemRandomly(L2PcInstance player, L2Npc npc, L2PcInstance killer, IDropItem dropItem, long limit, boolean playSound)
+	 */
+	@Deprecated
+	public static boolean giveItemRandomly(L2PcInstance player, L2Npc npc, int itemId, long minAmount, long maxAmount, long limit, double dropChance, boolean playSound) {
+		return giveItemRandomly(player, npc, player, singleDropItem(itemId, minAmount, maxAmount, dropChance * 100), limit, playSound);
+	}
+
+	/**
+	 * For one-off use when no {@link QuestDroplist} has been created.
+	 * @see AbstractScript#giveItemRandomly(L2PcInstance player, L2Npc npc, L2PcInstance killer, IDropItem dropItem, long limit, boolean playSound)
+	 */
+	public static boolean giveItemRandomly(L2PcInstance player, L2Npc npc, int itemId, boolean playSound) {
+		return giveItemRandomly(player, npc, player, singleDropItem(itemId, 1, 1, 100.0), 0, playSound);
+	}
+
+	/**
+	 * For one-off use when no {@link QuestDroplist} has been created.
+	 * @see AbstractScript#giveItemRandomly(L2PcInstance player, L2Npc npc, L2PcInstance killer, IDropItem dropItem, long limit, boolean playSound)
+	 */
+	public static boolean giveItemRandomly(L2PcInstance player, L2Npc npc, QuestItemChanceHolder questItem, boolean playSound) {
+		return giveItemRandomly(player, npc, player, singleDropItem(questItem), questItem.getLimit(), playSound);
+	}
+
+	/**
+	 * For use with {@link QuestDroplist} elements.
+	 * @see AbstractScript#giveItemRandomly(L2PcInstance player, L2Npc npc, L2PcInstance killer, IDropItem dropItem, long limit, boolean playSound)
+	 */
+	public static boolean giveItemRandomly(L2PcInstance player, L2Npc npc, QuestDropInfo dropInfo, boolean playSound) {
+		if (dropInfo == null) {
+			return false;
+		}
+		return giveItemRandomly(player, npc, player, dropInfo.drop(), dropInfo.getLimit(), playSound);
+	}
+
+	/**
+	 * @see AbstractScript#giveItemRandomly(L2PcInstance player, L2Npc npc, L2PcInstance killer, IDropItem dropItem, long limit, boolean playSound)
+	 */
+	public static boolean giveItemRandomly(L2PcInstance player, L2Npc npc, IDropItem dropItem, long limit, boolean playSound) {
+		return giveItemRandomly(player, npc, player, dropItem, limit, playSound);
+	}
+
 	/**
 	 * Give the specified player a random amount of items if he is lucky enough.<br>
 	 * Not recommended to use this for non-stacking items.
 	 * @param player the player to give the item(s) to
 	 * @param npc the NPC that "dropped" the item (can be null)
-	 * @param itemId the ID of the item to give
-	 * @param minAmount the minimum amount of items to give
-	 * @param maxAmount the maximum amount of items to give (will give a random amount between min/maxAmount multiplied by quest rates)
+	 * @param killer the player who killed the NPC
+	 * @param dropItem the item or item group to drop
 	 * @param limit the maximum amount of items the player can have. Won't give more if this limit is reached. 0 - no limit.
-	 * @param dropChance the drop chance as a decimal digit from 0 to 1
 	 * @param playSound if true, plays ItemSound.quest_itemget when items are given and ItemSound.quest_middle when the limit is reached
 	 * @return {@code true} if limit > 0 and the limit was reached or if limit <= 0 and items were given; {@code false} in all other cases
 	 */
-	public static boolean giveItemRandomly(L2PcInstance player, L2Npc npc, int itemId, long minAmount, long maxAmount, long limit, double dropChance, boolean playSound) {
-		final long currentCount = getQuestItemsCount(player, itemId);
-		
+	public static boolean giveItemRandomly(L2PcInstance player, L2Npc npc, L2PcInstance killer, IDropItem dropItem, long limit, boolean playSound) {
+		if (dropItem == null) {
+			return false;
+		}
+
+		ItemHolder drop = dropItem.calculateDrops(npc, killer).get(0);
+
+		final long currentCount = getQuestItemsCount(player, drop.getId());
+
 		if ((limit > 0) && (currentCount >= limit)) {
 			return true;
 		}
-		
-		minAmount *= rates().getRateQuestDrop();
-		maxAmount *= rates().getRateQuestDrop();
-		dropChance *= rates().getRateQuestDrop(); // TODO separate configs for rate and amount
-		if ((npc != null) && customs().championEnable() && npc.isChampion()) {
-			if ((itemId == Inventory.ADENA_ID) || (itemId == Inventory.ANCIENT_ADENA_ID)) {
-				dropChance *= customs().getChampionAdenasRewardsChance();
-				minAmount *= customs().getChampionAdenasRewardsAmount();
-				maxAmount *= customs().getChampionAdenasRewardsAmount();
-			} else {
-				dropChance *= customs().getChampionRewardsChance();
-				minAmount *= customs().getChampionRewardsAmount();
-				maxAmount *= customs().getChampionRewardsAmount();
-			}
-		}
-		
-		long amountToGive = ((minAmount == maxAmount) ? minAmount : Rnd.get(minAmount, maxAmount));
-		final double random = Rnd.nextDouble();
+
+		long amountToGive = drop.getCount();
 		// Inventory slot check (almost useless for non-stacking items)
-		if ((dropChance >= random) && (amountToGive > 0) && player.getInventory().validateCapacityByItemId(itemId)) {
+		if (amountToGive > 0 && player.getInventory().validateCapacityByItemId(drop.getId())) {
 			if ((limit > 0) && ((currentCount + amountToGive) > limit)) {
 				amountToGive = limit - currentCount;
 			}
 			
 			// Give the item to player
-			L2ItemInstance item = player.addItem("Quest", itemId, amountToGive, npc, true);
+			L2ItemInstance item = player.addItem("Quest", drop.getId(), amountToGive, npc, true);
 			if (item != null) {
 				// limit reached (if there is no limit, this block doesn't execute)
 				if ((currentCount + amountToGive) == limit) {

+ 1 - 1
src/main/java/com/l2jserver/gameserver/model/holders/ItemChanceHolder.java

@@ -20,7 +20,7 @@ package com.l2jserver.gameserver.model.holders;
 
 /**
  * A DTO for items; contains item ID, count and chance.<br>
- * Complemented by {@link QuestItemHolder}.
+ * Complemented by {@link QuestItemChanceHolder}.
  * @author xban1x
  */
 public class ItemChanceHolder extends ItemHolder {

+ 1 - 1
src/main/java/com/l2jserver/gameserver/model/holders/ItemHolder.java

@@ -22,7 +22,7 @@ import com.l2jserver.gameserver.model.interfaces.IIdentifiable;
 
 /**
  * A simple DTO for items; contains item ID and count.<br>
- * Extended by {@link ItemChanceHolder}, {@link QuestItemHolder}, {@link UniqueItemHolder}.
+ * Extended by {@link ItemChanceHolder}, {@link QuestItemChanceHolder}, {@link UniqueItemHolder}.
  * @author UnAfraid
  */
 public class ItemHolder implements IIdentifiable {

+ 62 - 50
src/main/java/com/l2jserver/gameserver/model/holders/QuestItemHolder.java → src/main/java/com/l2jserver/gameserver/model/holders/QuestItemChanceHolder.java

@@ -1,50 +1,62 @@
-/*
- * Copyright © 2004-2021 L2J Server
- * 
- * This file is part of L2J Server.
- * 
- * L2J Server is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- * 
- * L2J Server 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
- * General Public License for more details.
- * 
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-package com.l2jserver.gameserver.model.holders;
-
-/**
- * A DTO for items; contains item ID, count and chance.<br>
- * Complemented by {@link ItemChanceHolder}.
- * @author xban1x
- */
-public class QuestItemHolder extends ItemHolder {
-	private final int _chance;
-	
-	public QuestItemHolder(int id, int chance) {
-		this(id, chance, 1);
-	}
-	
-	public QuestItemHolder(int id, int chance, long count) {
-		super(id, count);
-		_chance = chance;
-	}
-	
-	/**
-	 * Gets the chance.
-	 * @return the drop chance of the item contained in this object
-	 */
-	public int getChance() {
-		return _chance;
-	}
-	
-	@Override
-	public String toString() {
-		return "[" + getClass().getSimpleName() + "] ID: " + getId() + ", count: " + getCount() + ", chance: " + _chance;
-	}
-}
+/*
+ * Copyright © 2004-2021 L2J Server
+ * 
+ * This file is part of L2J Server.
+ * 
+ * L2J Server is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * 
+ * L2J Server 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
+ * General Public License for more details.
+ * 
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.l2jserver.gameserver.model.holders;
+
+/**
+ * A DTO for quest items; contains limit info.<br>
+ * @author xban1x
+ * @author Noé Caratini aka Kita
+ */
+public class QuestItemChanceHolder extends ItemChanceHolder {
+	private final long limit;
+
+	public QuestItemChanceHolder(int id) {
+		this(id, 100, 1, 0);
+	}
+
+	public QuestItemChanceHolder(int id, long limit) {
+		this(id, 100, 1, limit);
+	}
+
+	public QuestItemChanceHolder(int id, long count, long limit) {
+		this(id, 100, count, limit);
+	}
+	
+	public QuestItemChanceHolder(int id, double chance) {
+		this(id, chance, 1, 0);
+	}
+
+	public QuestItemChanceHolder(int id, double chance, long limit) {
+		this(id, chance, 1, limit);
+	}
+	
+	public QuestItemChanceHolder(int id, double chance, long count, long limit) {
+		super(id, chance, count);
+		this.limit = limit;
+	}
+
+	public long getLimit() {
+		return limit;
+	}
+
+	@Override
+	public String toString() {
+		return "[" + getClass().getSimpleName() + "] ID: " + getId() + ", count: " + getCount() + ", chance: " + getChance() + ", limit: " + getLimit();
+	}
+}

+ 308 - 0
src/main/java/com/l2jserver/gameserver/model/quest/QuestDroplist.java

@@ -0,0 +1,308 @@
+/*
+ * Copyright © 2004-2021 L2J Server
+ *
+ * This file is part of L2J Server.
+ *
+ * L2J Server is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * L2J Server 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
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.l2jserver.gameserver.model.quest;
+
+import com.l2jserver.gameserver.model.actor.L2Npc;
+import com.l2jserver.gameserver.model.drops.DropListScope;
+import com.l2jserver.gameserver.model.drops.GeneralDropItem;
+import com.l2jserver.gameserver.model.drops.GroupedGeneralDropItem;
+import com.l2jserver.gameserver.model.drops.IDropItem;
+import com.l2jserver.gameserver.model.holders.ItemChanceHolder;
+import com.l2jserver.gameserver.model.holders.ItemHolder;
+import com.l2jserver.gameserver.model.holders.QuestItemChanceHolder;
+import com.l2jserver.gameserver.model.quest.QuestDroplist.QuestDropListBuilder.GroupedDropBuilder;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * @author Noé Caratini aka Kita
+ */
+public class QuestDroplist {
+    private final Map<Integer, List<QuestDropInfo>> dropsByNpcId = new HashMap<>();
+
+    private QuestDroplist(QuestDropListBuilder builder) {
+        dropsByNpcId.putAll(builder.dropList);
+    }
+
+    public QuestDropInfo get(int npcId) {
+        if (!dropsByNpcId.containsKey(npcId)) {
+            return null;
+        }
+
+        return dropsByNpcId.get(npcId).get(0);
+    }
+
+    public QuestDropInfo get(L2Npc npc) {
+        return get(npc.getId());
+    }
+
+    public QuestDropInfo get(int npcId, int itemId) {
+        if (!dropsByNpcId.containsKey(npcId)) {
+            return null;
+        }
+
+        return dropsByNpcId.get(npcId).stream()
+                .filter(dropInfo -> dropInfo.item().getId() == itemId)
+                .findFirst()
+                .orElse(null);
+    }
+
+    public QuestDropInfo get(int npcId, ItemHolder item) {
+        return get(npcId, item.getId());
+    }
+
+    public QuestDropInfo get(L2Npc npc, ItemHolder item) {
+        return get(npc.getId(), item.getId());
+    }
+
+    public Set<Integer> getNpcIds() {
+        return dropsByNpcId.keySet();
+    }
+
+    public static IDropItem singleDropItem(ItemChanceHolder itemHolder) {
+        return singleDropItem(itemHolder.getId(), itemHolder.getCount(), itemHolder.getCount(), itemHolder.getChance());
+    }
+
+    public static IDropItem singleDropItem(ItemChanceHolder itemHolder, double chance) {
+        return singleDropItem(itemHolder.getId(), itemHolder.getCount(), itemHolder.getCount(), chance);
+    }
+
+    public static IDropItem singleDropItem(ItemChanceHolder itemHolder, long amount) {
+        return singleDropItem(itemHolder.getId(), amount, amount, itemHolder.getChance());
+    }
+
+    public static IDropItem singleDropItem(int itemId, long amount) {
+        return singleDropItem(itemId, amount, amount, 100.0);
+    }
+
+    public static IDropItem singleDropItem(int itemId, double chance) {
+        return singleDropItem(itemId, 1, 1, chance);
+    }
+
+    public static IDropItem singleDropItem(int itemId, long minAmount, long maxAmount, double chance) {
+        return DropListScope.QUEST.newDropItem(itemId, minAmount, maxAmount, chance);
+    }
+
+    public static IDropItem groupedDropItem(double chance, ItemChanceHolder... itemHolders) {
+        return groupedDropItem(chance, List.of(itemHolders));
+    }
+
+    public static IDropItem groupedDropItem(double chance, List<? extends ItemChanceHolder> itemHolders) {
+        GroupedGeneralDropItem group = DropListScope.QUEST.newGroupedDropItem(chance);
+        List<GeneralDropItem> dropItems = itemHolders.stream()
+                .map(QuestDroplist::singleDropItem)
+                .filter(GeneralDropItem.class::isInstance)
+                .map(GeneralDropItem.class::cast)
+                .toList();
+        group.setItems(dropItems);
+        return group;
+    }
+
+    public static QuestDropListBuilder builder() {
+        return new QuestDropListBuilder();
+    }
+
+    public static class QuestDropListBuilder {
+        private final Map<Integer, List<QuestDropInfo>> dropList = new HashMap<>();
+
+        public QuestDropListBuilder addSingleDrop(int npcId, QuestItemChanceHolder questItem, long minAmount, long maxAmount, double chance) {
+            List<QuestDropInfo> dropsForMob = dropList.computeIfAbsent(npcId, ArrayList::new);
+            dropsForMob.add(new QuestDropInfo(questItem, singleDropItem(questItem.getId(), minAmount, maxAmount, chance)));
+            return this;
+        }
+
+        public QuestDropListBuilder addSingleDrop(int npcId, QuestItemChanceHolder questItem) {
+            return addSingleDrop(npcId, questItem, questItem.getCount(), questItem.getCount(), questItem.getChance());
+        }
+
+        public QuestDropListBuilder addSingleDrop(int npcId, QuestItemChanceHolder questItem, long amount, double chance) {
+            return addSingleDrop(npcId,
+                    new QuestItemChanceHolder(questItem.getId(), chance, amount, questItem.getLimit()));
+        }
+
+        public QuestDropListBuilder addSingleDrop(int npcId, QuestItemChanceHolder questItem, long amount) {
+            return addSingleDrop(npcId,
+                    new QuestItemChanceHolder(questItem.getId(), questItem.getChance(), amount, questItem.getLimit()));
+        }
+
+        public QuestDropListBuilder addSingleDrop(int npcId, QuestItemChanceHolder questItem, double chance) {
+            return addSingleDrop(npcId,
+                    new QuestItemChanceHolder(questItem.getId(), chance, questItem.getCount(), questItem.getLimit()));
+        }
+
+        public QuestDropListBuilder addSingleDrop(int npcId, int itemId) {
+            return addSingleDrop(npcId,
+                    new QuestItemChanceHolder(itemId, 100.0, 1, 0));
+        }
+
+        public QuestDropListBuilder addSingleDrop(int npcId, int itemId, long amount) {
+            return addSingleDrop(npcId,
+                    new QuestItemChanceHolder(itemId, 100.0, amount, 0));
+        }
+
+        public QuestDropListBuilder addSingleDrop(int npcId, int itemId, double chance) {
+            return addSingleDrop(npcId,
+                    new QuestItemChanceHolder(itemId, chance, 1, 0));
+        }
+
+        public QuestDropListBuilder addSingleDrop(int npcId, int itemId, long amount, double chance) {
+            return addSingleDrop(npcId,
+                    new QuestItemChanceHolder(itemId, chance, amount, 0));
+        }
+
+        public SingleDropBuilder bulkAddSingleDrop(QuestItemChanceHolder questItem) {
+            return new SingleDropBuilder(this, questItem);
+        }
+
+        public SingleDropBuilder bulkAddSingleDrop(QuestItemChanceHolder questItem, double chance) {
+            return bulkAddSingleDrop(new QuestItemChanceHolder(questItem.getId(), chance, questItem.getCount(), questItem.getLimit()));
+        }
+
+        public SingleDropBuilder bulkAddSingleDrop(int itemId, double chance) {
+            return bulkAddSingleDrop(new QuestItemChanceHolder(itemId, chance, 1, 0));
+        }
+
+        private QuestDropListBuilder addGroupedDrop(int npcId, QuestDropInfo dropInfo) {
+            List<QuestDropInfo> dropsForMob = dropList.computeIfAbsent(npcId, ArrayList::new);
+            dropsForMob.add(dropInfo);
+            return this;
+        }
+
+        public GroupedDropBuilder addGroupedDrop(int npcId, double chanceForGroup) {
+            return new GroupedDropBuilder(this, npcId, chanceForGroup);
+        }
+
+        public GroupedDropForSingleItemBuilder addGroupedDropForSingleItem(int npcId, QuestItemChanceHolder questItem, double chanceForGroup) {
+            return new GroupedDropForSingleItemBuilder(this, npcId, questItem, chanceForGroup);
+        }
+
+        public QuestDroplist build() {
+            return new QuestDroplist(this);
+        }
+
+        public static class SingleDropBuilder {
+            private final QuestDropListBuilder parentBuilder;
+            private final QuestItemChanceHolder item;
+
+            private final Set<Integer> npcIds = new HashSet<>();
+
+            public SingleDropBuilder(QuestDropListBuilder parentBuilder, QuestItemChanceHolder item) {
+                this.parentBuilder = parentBuilder;
+                this.item = item;
+            }
+
+            public SingleDropBuilder withNpcs(Set<Integer> npcIds) {
+                this.npcIds.addAll(npcIds);
+                return this;
+            }
+
+            public SingleDropBuilder withNpcs(int... npcIds) {
+                return withNpcs(Arrays.stream(npcIds).boxed().collect(Collectors.toSet()));
+            }
+
+            public QuestDropListBuilder build() {
+                npcIds.forEach(npcId -> parentBuilder.addSingleDrop(npcId, item));
+                return parentBuilder;
+            }
+        }
+
+        public static class GroupedDropBuilder {
+            private final QuestDropListBuilder parentBuilder;
+            private final int npcId;
+
+            private final double chance;
+            protected final List<QuestItemChanceHolder> items = new ArrayList<>();
+
+            private GroupedDropBuilder(QuestDropListBuilder parentBuilder, int npcId, double chanceForGroup) {
+                this.parentBuilder = parentBuilder;
+                this.npcId = npcId;
+                this.chance = chanceForGroup;
+            }
+
+            public GroupedDropBuilder withDropItem(QuestItemChanceHolder questItem) {
+                items.add(questItem);
+                return this;
+            }
+
+            public GroupedDropBuilder withDropItem(QuestItemChanceHolder questItem, long amount) {
+                return withDropItem(new QuestItemChanceHolder(questItem.getId(), questItem.getChance(), amount, questItem.getLimit()));
+            }
+
+            public GroupedDropBuilder withDropItem(QuestItemChanceHolder questItem, double chanceWithinGroup) {
+                return withDropItem(new QuestItemChanceHolder(questItem.getId(), chanceWithinGroup, questItem.getCount(), questItem.getLimit()));
+            }
+
+            public GroupedDropBuilder withDropItem(QuestItemChanceHolder questItem, long amount, double chanceWithinGroup) {
+                return withDropItem(new QuestItemChanceHolder(questItem.getId(), chanceWithinGroup, amount, questItem.getLimit()));
+            }
+
+            public GroupedDropBuilder withDropItem(int itemId, double chanceWithinGroup) {
+                return withDropItem(new QuestItemChanceHolder(itemId, chanceWithinGroup, 1, 0));
+            }
+
+            public GroupedDropBuilder withDropItem(int itemId, long amount, double chanceWithinGroup) {
+                return withDropItem(new QuestItemChanceHolder(itemId, chanceWithinGroup, amount, 0));
+            }
+
+            public QuestDropListBuilder build() {
+                return parentBuilder.addGroupedDrop(npcId, new QuestDropInfo(items.get(0), groupedDropItem(chance, items)));
+            }
+        }
+    }
+
+    public static class GroupedDropForSingleItemBuilder extends GroupedDropBuilder {
+        private final QuestItemChanceHolder questItem;
+
+        private GroupedDropForSingleItemBuilder(QuestDropListBuilder parentBuilder, int npcId, QuestItemChanceHolder questItem, double chanceForGroup) {
+            super(parentBuilder, npcId, chanceForGroup);
+            this.questItem = questItem;
+        }
+
+        public GroupedDropForSingleItemBuilder withAmount(long amount, double chanceWithinGroup) {
+            this.withDropItem(questItem, amount, chanceWithinGroup);
+            return this;
+        }
+
+        public QuestDropListBuilder orElse(long amount) {
+            double sumOfChances = items.stream()
+                    .mapToDouble(ItemChanceHolder::getChance)
+                    .sum();
+
+            this.withDropItem(questItem, amount, 100.0 - sumOfChances);
+            return super.build();
+        }
+
+        public QuestDropListBuilder build() {
+            return super.build();
+        }
+    }
+
+    public record QuestDropInfo(QuestItemChanceHolder item, IDropItem drop) {
+        public long getLimit() {
+            return item.getLimit();
+        }
+    }
+}

+ 8 - 5
src/main/java/com/l2jserver/gameserver/model/quest/QuestState.java

@@ -657,15 +657,18 @@ public final class QuestState {
 	public void giveItems(int itemId, long count, byte attributeId, int attributeLevel) {
 		AbstractScript.giveItems(_player, itemId, count, attributeId, attributeLevel);
 	}
-	
+
+	@Deprecated
 	public boolean giveItemRandomly(int itemId, long amount, long limit, double dropChance, boolean playSound) {
-		return AbstractScript.giveItemRandomly(_player, null, itemId, amount, amount, limit, dropChance, playSound);
+		return AbstractScript.giveItemRandomly(_player, null, itemId, amount, limit, dropChance, playSound);
 	}
-	
+
+	@Deprecated
 	public boolean giveItemRandomly(L2Npc npc, int itemId, long amount, long limit, double dropChance, boolean playSound) {
-		return AbstractScript.giveItemRandomly(_player, npc, itemId, amount, amount, limit, dropChance, playSound);
+		return AbstractScript.giveItemRandomly(_player, npc, itemId, amount, limit, dropChance, playSound);
 	}
-	
+
+	@Deprecated
 	public boolean giveItemRandomly(L2Npc npc, int itemId, long minAmount, long maxAmount, long limit, double dropChance, boolean playSound) {
 		return AbstractScript.giveItemRandomly(_player, npc, itemId, minAmount, maxAmount, limit, dropChance, playSound);
 	}

+ 137 - 135
src/main/resources/config/rates.properties

@@ -1,135 +1,137 @@
-# ---------------------------------------------------------------------------
-# Rate Settings
-# ---------------------------------------------------------------------------
-# The defaults are set to be retail-like. If you modify any of these settings your server will deviate from being retail-like.
-# Warning: 
-# Please take extreme caution when changing anything. Also please understand what you are changing before you do so on a live server.
-
-# ---------------------------------------------------------------------------
-# Item Rates
-# ---------------------------------------------------------------------------
-# Warning: Remember if you increase both chance and amount you will have higher rates than expected
-# Example: if amount multiplier is 5 and chance multiplier is 5 you will end up with 5*5 = 25 drop rates so be careful!
-
-
-# Multiplies the amount of items dropped from monster on ground when it dies.
-DeathDropAmountMultiplier = 1
-# Multiplies the amount of items looted from monster when a skill like Sweeper(Spoil) is used.
-CorpseDropAmountMultiplier = 1
-# Multiplies the amount of items dropped from monster on ground when it dies.
-HerbDropAmountMultiplier = 1
-RaidDropAmountMultiplier = 1
-
-# Multiplies the chance of items that can be dropped from monster on ground when it dies.
-DeathDropChanceMultiplier = 1
-# Multiplies the chance of items that can be looted from monster when a skill like Sweeper(Spoil) is used.
-CorpseDropChanceMultiplier = 1
-# Multiplies the chance of items that can be dropped from monster on ground when it dies.
-HerbDropChanceMultiplier = 1
-RaidDropChanceMultiplier = 1
-
-# List of items affected by custom drop rate by id, used now for Adena rate too.
-# Usage: itemId1,multiplier1;itemId2,multiplier2;...
-# Note: Make sure the lists do NOT CONTAIN trailing spaces or spaces between the numbers!
-# Example for Raid boss 1x jewelry: 6656,1;6657,1;6658,1;6659,1;6660,1;6661,1;6662,1;8191,1;10170,1;10314,1;
-# Default: 57,1
-DropAmountMultiplierByItemId = 57,1
-DropChanceMultiplierByItemId = 57,1
-
-
-# ---------------------------------------------------------------------------
-# Standard Settings (Retail value = 1)
-# ---------------------------------------------------------------------------
-
-
-# Experience multiplier
-RateXp = 1
-# Skill points multiplier
-RateSp = 1
-# Experience multiplier (Party)
-RatePartyXp = 1
-# Skill points multiplier (Party)
-RatePartySp = 1
-RateDropManor = 1
-# Karma decreasing rate
-# Default: 1
-RateKarmaLost = 1
-RateKarmaExpLost = 1
-RateSiegeGuardsPrice = 1
-
-# Modify the rate of reward of all extractable items and skills.
-# Default: 1.
-RateExtractable = 1.
-
-# Hellbound trust increase/decrease multipliers
-RateHellboundTrustIncrease = 1
-RateHellboundTrustDecrease = 1
-
-# Quest Multipliers
-# Warning: Many quests need to be rewritten 
-# for this setting to work properly.
-
-# Quest item drop multiplier
-RateQuestDrop = 1
-
-# Exp/SP reward multipliers
-RateQuestRewardXP = 1
-RateQuestRewardSP = 1
-
-# Adena reward multiplier
-RateQuestRewardAdena = 1
-
-# Use additional item multipliers?
-# Default: False
-UseQuestRewardMultipliers = False
-
-# Default reward multiplier
-# When UseRewardMultipliers=False - default multiplier is used for any reward
-# When UseRewardMultipliers=True  - default multiplier is used for all items not affected by additional multipliers
-# Default: 1
-RateQuestReward = 1
-
-# Additional quest-reward multipliers based on item type
-RateQuestRewardPotion = 1
-RateQuestRewardScroll = 1
-RateQuestRewardRecipe = 1
-RateQuestRewardMaterial = 1
-
-# ---------------------------------------------------------------------------
-# Player Drops (values are set in PERCENTS)
-# ---------------------------------------------------------------------------
-
-PlayerDropLimit = 0
-# in %
-PlayerRateDrop = 0
-# in %
-PlayerRateDropItem = 0
-# in %
-PlayerRateDropEquip = 0
-# in %
-PlayerRateDropEquipWeapon = 0
-
-# Default: 10
-KarmaDropLimit = 10
-
-# Default: 40
-KarmaRateDrop = 40
-
-# Default: 50
-KarmaRateDropItem = 50
-
-# Default: 40
-KarmaRateDropEquip = 40
-
-# Default: 10
-KarmaRateDropEquipWeapon = 10
-
-
-# ---------------------------------------------------------------------------
-# Pets (Default value = 1)
-# ---------------------------------------------------------------------------
-
-PetXpRate = 1
-PetFoodRate = 1
-SinEaterXpRate = 1
-
+# ---------------------------------------------------------------------------
+# Rate Settings
+# ---------------------------------------------------------------------------
+# The defaults are set to be retail-like. If you modify any of these settings your server will deviate from being retail-like.
+# Warning: 
+# Please take extreme caution when changing anything. Also please understand what you are changing before you do so on a live server.
+
+# ---------------------------------------------------------------------------
+# Item Rates
+# ---------------------------------------------------------------------------
+# Warning: Remember if you increase both chance and amount you will have higher rates than expected
+# Example: if amount multiplier is 5 and chance multiplier is 5 you will end up with 5*5 = 25 drop rates so be careful!
+
+
+# Multiplies the amount of items dropped from monster on ground when it dies.
+DeathDropAmountMultiplier = 1
+# Multiplies the amount of items looted from monster when a skill like Sweeper(Spoil) is used.
+CorpseDropAmountMultiplier = 1
+# Multiplies the amount of items dropped from monster on ground when it dies.
+HerbDropAmountMultiplier = 1
+RaidDropAmountMultiplier = 1
+
+# Multiplies the chance of items that can be dropped from monster on ground when it dies.
+DeathDropChanceMultiplier = 1
+# Multiplies the chance of items that can be looted from monster when a skill like Sweeper(Spoil) is used.
+CorpseDropChanceMultiplier = 1
+# Multiplies the chance of items that can be dropped from monster on ground when it dies.
+HerbDropChanceMultiplier = 1
+RaidDropChanceMultiplier = 1
+
+# List of items affected by custom drop rate by id, used now for Adena rate too.
+# Usage: itemId1,multiplier1;itemId2,multiplier2;...
+# Note: Make sure the lists do NOT CONTAIN trailing spaces or spaces between the numbers!
+# Example for Raid boss 1x jewelry: 6656,1;6657,1;6658,1;6659,1;6660,1;6661,1;6662,1;8191,1;10170,1;10314,1;
+# Default: 57,1
+DropAmountMultiplierByItemId = 57,1
+DropChanceMultiplierByItemId = 57,1
+
+
+# ---------------------------------------------------------------------------
+# Standard Settings (Retail value = 1)
+# ---------------------------------------------------------------------------
+
+
+# Experience multiplier
+RateXp = 1
+# Skill points multiplier
+RateSp = 1
+# Experience multiplier (Party)
+RatePartyXp = 1
+# Skill points multiplier (Party)
+RatePartySp = 1
+RateDropManor = 1
+# Karma decreasing rate
+# Default: 1
+RateKarmaLost = 1
+RateKarmaExpLost = 1
+RateSiegeGuardsPrice = 1
+
+# Modify the rate of reward of all extractable items and skills.
+# Default: 1.
+RateExtractable = 1.
+
+# Hellbound trust increase/decrease multipliers
+RateHellboundTrustIncrease = 1
+RateHellboundTrustDecrease = 1
+
+# Quest Multipliers
+# Warning: Many quests need to be rewritten 
+# for this setting to work properly.
+
+# Quest item drop chance multiplier
+QuestDropChanceMultiplier = 1
+# Quest item drop amount multiplier
+QuestDropAmountMultiplier = 1
+
+# Exp/SP reward multipliers
+RateQuestRewardXP = 1
+RateQuestRewardSP = 1
+
+# Adena reward multiplier
+RateQuestRewardAdena = 1
+
+# Use additional item multipliers?
+# Default: False
+UseQuestRewardMultipliers = False
+
+# Default reward multiplier
+# When UseRewardMultipliers=False - default multiplier is used for any reward
+# When UseRewardMultipliers=True  - default multiplier is used for all items not affected by additional multipliers
+# Default: 1
+RateQuestReward = 1
+
+# Additional quest-reward multipliers based on item type
+RateQuestRewardPotion = 1
+RateQuestRewardScroll = 1
+RateQuestRewardRecipe = 1
+RateQuestRewardMaterial = 1
+
+# ---------------------------------------------------------------------------
+# Player Drops (values are set in PERCENTS)
+# ---------------------------------------------------------------------------
+
+PlayerDropLimit = 0
+# in %
+PlayerRateDrop = 0
+# in %
+PlayerRateDropItem = 0
+# in %
+PlayerRateDropEquip = 0
+# in %
+PlayerRateDropEquipWeapon = 0
+
+# Default: 10
+KarmaDropLimit = 10
+
+# Default: 40
+KarmaRateDrop = 40
+
+# Default: 50
+KarmaRateDropItem = 50
+
+# Default: 40
+KarmaRateDropEquip = 40
+
+# Default: 10
+KarmaRateDropEquipWeapon = 10
+
+
+# ---------------------------------------------------------------------------
+# Pets (Default value = 1)
+# ---------------------------------------------------------------------------
+
+PetXpRate = 1
+PetFoodRate = 1
+SinEaterXpRate = 1
+

+ 48 - 0
src/test/java/com/l2jserver/gameserver/model/events/AbstractScriptTest.java

@@ -0,0 +1,48 @@
+package com.l2jserver.gameserver.model.events;
+
+import com.l2jserver.gameserver.model.actor.instance.L2PcInstance;
+import com.l2jserver.gameserver.model.holders.QuestItemChanceHolder;
+import com.l2jserver.gameserver.model.itemcontainer.PcInventory;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class AbstractScriptTest {
+
+    @Mock
+    private L2PcInstance player;
+
+    @Mock
+    private PcInventory inventory;
+
+    @Test
+    public void shouldReturnTrueIfQuestItemsAtLimit() {
+        QuestItemChanceHolder questItem = new QuestItemChanceHolder(1, 10L);
+
+        when(player.getInventory()).thenReturn(inventory);
+        when(inventory.getInventoryItemCount(eq(1), anyInt())).thenReturn(10L);
+
+        boolean result = AbstractScript.hasItemsAtLimit(player, questItem);
+
+        assertThat(result).isTrue();
+    }
+
+    @Test
+    public void shouldReturnFalseIfQuestItemsNotAtLimit() {
+        QuestItemChanceHolder questItem = new QuestItemChanceHolder(1, 10L);
+
+        when(player.getInventory()).thenReturn(inventory);
+        when(inventory.getInventoryItemCount(eq(1), anyInt())).thenReturn(5L);
+
+        boolean result = AbstractScript.hasItemsAtLimit(player, questItem);
+
+        assertThat(result).isFalse();
+    }
+}

+ 629 - 0
src/test/java/com/l2jserver/gameserver/model/quest/QuestDroplistTest.java

@@ -0,0 +1,629 @@
+package com.l2jserver.gameserver.model.quest;
+
+import com.l2jserver.gameserver.model.actor.L2Npc;
+import com.l2jserver.gameserver.model.drops.GeneralDropItem;
+import com.l2jserver.gameserver.model.drops.GroupedGeneralDropItem;
+import com.l2jserver.gameserver.model.drops.IDropItem;
+import com.l2jserver.gameserver.model.holders.QuestItemChanceHolder;
+import com.l2jserver.gameserver.model.quest.QuestDroplist.QuestDropInfo;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Set;
+import java.util.stream.IntStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class QuestDroplistTest {
+    private static final QuestItemChanceHolder QUEST_ITEM_1 = new QuestItemChanceHolder(1, 25.0, 2L, 70L);
+    private static final QuestItemChanceHolder QUEST_ITEM_2 = new QuestItemChanceHolder(2, 50.0);
+    private static final QuestItemChanceHolder QUEST_ITEM_3 = new QuestItemChanceHolder(3, 50.0, 2L, 0L);
+
+    @Mock
+    private L2Npc npc;
+
+    @Test
+    public void shouldBuildDroplistAndRetrieveInfo() {
+        when(npc.getId()).thenReturn(1);
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(1);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.item()).isEqualTo(QUEST_ITEM_1);
+        assertThat(dropInfo.getLimit()).isEqualTo(QUEST_ITEM_1.getLimit());
+
+        QuestDropInfo dropInfo2 = dropList.get(npc);
+        assertThat(dropInfo2).isNotNull();
+        assertThat(dropInfo2.item()).isEqualTo(QUEST_ITEM_1);
+        assertThat(dropInfo2.getLimit()).isEqualTo(QUEST_ITEM_1.getLimit());
+
+        assertThat(dropList.getNpcIds()).containsExactly(1);
+    }
+
+    @Test
+    public void shouldAddSingleDropWithAmount() {
+        long amount = 5;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addSingleDrop(2, QUEST_ITEM_1, amount)
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.item()).isNotEqualTo(QUEST_ITEM_1);
+        assertThat(dropInfo.item().getId()).isEqualTo(QUEST_ITEM_1.getId());
+        assertThat(dropInfo.item().getCount()).isEqualTo(amount);
+        assertThat(dropInfo.item().getChance()).isEqualTo(QUEST_ITEM_1.getChance());
+        assertThat(dropInfo.item().getLimit()).isEqualTo(QUEST_ITEM_1.getLimit());
+    }
+
+    @Test
+    public void shouldAddSingleDropWithChance() {
+        double chance = 75.0;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addSingleDrop(2, QUEST_ITEM_1, chance)
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.item()).isNotEqualTo(QUEST_ITEM_1);
+        assertThat(dropInfo.item().getId()).isEqualTo(QUEST_ITEM_1.getId());
+        assertThat(dropInfo.item().getCount()).isEqualTo(QUEST_ITEM_1.getCount());
+        assertThat(dropInfo.item().getChance()).isEqualTo(chance);
+        assertThat(dropInfo.item().getLimit()).isEqualTo(QUEST_ITEM_1.getLimit());
+    }
+
+    @Test
+    public void shouldAddSingleDropWithAmountAndChance() {
+        long amount = 5;
+        double chance = 75.0;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addSingleDrop(2, QUEST_ITEM_1, amount, chance)
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.item()).isNotEqualTo(QUEST_ITEM_1);
+        assertThat(dropInfo.item().getId()).isEqualTo(QUEST_ITEM_1.getId());
+        assertThat(dropInfo.item().getCount()).isEqualTo(amount);
+        assertThat(dropInfo.item().getChance()).isEqualTo(chance);
+        assertThat(dropInfo.item().getLimit()).isEqualTo(QUEST_ITEM_1.getLimit());
+    }
+
+    @Test
+    public void shouldAddSingleDropWithItemId() {
+        int itemId = 2;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addSingleDrop(2, itemId)
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.item().getId()).isEqualTo(itemId);
+        assertThat(dropInfo.item().getCount()).isEqualTo(1);
+        assertThat(dropInfo.item().getChance()).isEqualTo(100.0);
+        assertThat(dropInfo.item().getLimit()).isEqualTo(0);
+    }
+
+    @Test
+    public void shouldAddSingleDropWithItemIdAndAmount() {
+        int itemId = 2;
+        long amount = 5;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addSingleDrop(2, itemId, amount)
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.item().getId()).isEqualTo(itemId);
+        assertThat(dropInfo.item().getCount()).isEqualTo(amount);
+        assertThat(dropInfo.item().getChance()).isEqualTo(100.0);
+        assertThat(dropInfo.item().getLimit()).isEqualTo(0);
+    }
+
+    @Test
+    public void shouldAddSingleDropWithItemIdAndChance() {
+        int itemId = 2;
+        double chance = 50.0;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addSingleDrop(2, itemId, chance)
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.item().getId()).isEqualTo(itemId);
+        assertThat(dropInfo.item().getCount()).isEqualTo(1);
+        assertThat(dropInfo.item().getChance()).isEqualTo(chance);
+        assertThat(dropInfo.item().getLimit()).isEqualTo(0);
+    }
+
+    @Test
+    public void shouldAddSingleDropWithItemIdAndAmountAndChance() {
+        int itemId = 2;
+        long amount = 5;
+        double chance = 50.0;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addSingleDrop(2, itemId, amount, chance)
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.item().getId()).isEqualTo(itemId);
+        assertThat(dropInfo.item().getCount()).isEqualTo(amount);
+        assertThat(dropInfo.item().getChance()).isEqualTo(chance);
+        assertThat(dropInfo.item().getLimit()).isEqualTo(0);
+    }
+
+    @Test
+    public void shouldBulkAddSingleDrop() {
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .bulkAddSingleDrop(QUEST_ITEM_1)
+                    .withNpcs(Set.of(2, 3, 4))
+                    .build()
+                .bulkAddSingleDrop(QUEST_ITEM_1)
+                    .withNpcs(5, 6, 7)
+                    .build()
+                .build();
+
+        IntStream.range(2, 8).forEach(npcId -> {
+            QuestDropInfo dropInfo = dropList.get(npcId);
+            assertThat(dropInfo).isNotNull();
+            assertThat(dropInfo.item()).isEqualTo(QUEST_ITEM_1);
+        });
+    }
+
+    @Test
+    public void shouldBulkAddSingleDropWithChance() {
+        double chance = 10.0;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .bulkAddSingleDrop(QUEST_ITEM_1, chance)
+                    .withNpcs(Set.of(2, 3, 4))
+                    .build()
+                .bulkAddSingleDrop(QUEST_ITEM_1, chance)
+                    .withNpcs(5, 6, 7)
+                    .build()
+                .build();
+
+        IntStream.range(2, 8).forEach(npcId -> {
+            QuestDropInfo dropInfo = dropList.get(npcId);
+            assertThat(dropInfo).isNotNull();
+            assertThat(dropInfo.item()).isNotEqualTo(QUEST_ITEM_1);
+            assertThat(dropInfo.item().getId()).isEqualTo(QUEST_ITEM_1.getId());
+            assertThat(dropInfo.item().getCount()).isEqualTo(QUEST_ITEM_1.getCount());
+            assertThat(dropInfo.item().getChance()).isEqualTo(chance);
+            assertThat(dropInfo.item().getLimit()).isEqualTo(QUEST_ITEM_1.getLimit());
+        });
+    }
+
+    @Test
+    public void shouldBulkAddSingleDropWithItemIdAndChance() {
+        double chance = 10.0;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .bulkAddSingleDrop(QUEST_ITEM_1.getId(), chance)
+                    .withNpcs(Set.of(2, 3, 4))
+                    .build()
+                .bulkAddSingleDrop(QUEST_ITEM_1.getId(), chance)
+                    .withNpcs(5, 6, 7)
+                    .build()
+                .build();
+
+        IntStream.range(2, 8).forEach(npcId -> {
+            QuestDropInfo dropInfo = dropList.get(npcId);
+            assertThat(dropInfo).isNotNull();
+            assertThat(dropInfo.item()).isNotEqualTo(QUEST_ITEM_1);
+            assertThat(dropInfo.item().getId()).isEqualTo(QUEST_ITEM_1.getId());
+            assertThat(dropInfo.item().getCount()).isEqualTo(1);
+            assertThat(dropInfo.item().getChance()).isEqualTo(chance);
+            assertThat(dropInfo.item().getLimit()).isEqualTo(0);
+        });
+    }
+
+    @Test
+    public void shouldAddGroupedDrop() {
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addGroupedDrop(2, 100.0)
+                    .withDropItem(QUEST_ITEM_2)
+                    .withDropItem(QUEST_ITEM_3)
+                    .build()
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.item()).isEqualTo(QUEST_ITEM_2);
+
+        assertThat(dropInfo.drop()).isInstanceOf(GroupedGeneralDropItem.class);
+        GroupedGeneralDropItem group = (GroupedGeneralDropItem) dropInfo.drop();
+        assertThat(group.getItems()).hasSize(2);
+        assertThat(group.getItems()).anySatisfy(dropItem -> {
+            assertThat(dropItem.getItemId()).isEqualTo(QUEST_ITEM_2.getId());
+            assertThat(dropItem.getChance()).isEqualTo(QUEST_ITEM_2.getChance());
+            assertThat(dropItem.getMin()).isEqualTo(QUEST_ITEM_2.getCount());
+            assertThat(dropItem.getMax()).isEqualTo(QUEST_ITEM_2.getCount());
+        });
+        assertThat(group.getItems()).anySatisfy(dropItem -> {
+            assertThat(dropItem.getItemId()).isEqualTo(QUEST_ITEM_3.getId());
+            assertThat(dropItem.getChance()).isEqualTo(QUEST_ITEM_3.getChance());
+            assertThat(dropItem.getMin()).isEqualTo(QUEST_ITEM_3.getCount());
+            assertThat(dropItem.getMax()).isEqualTo(QUEST_ITEM_3.getCount());
+        });
+    }
+
+    @Test
+    public void shouldAddGroupedDropWithAmount() {
+        long amount = 5;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addGroupedDrop(2, 100.0)
+                    .withDropItem(QUEST_ITEM_2, amount)
+                    .build()
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.getLimit()).isEqualTo(QUEST_ITEM_2.getLimit());
+
+        assertThat(dropInfo.drop()).isInstanceOf(GroupedGeneralDropItem.class);
+        GroupedGeneralDropItem group = (GroupedGeneralDropItem) dropInfo.drop();
+        assertThat(group.getItems()).hasSize(1);
+        assertThat(group.getItems()).satisfiesExactly(dropItem -> {
+            assertThat(dropItem.getItemId()).isEqualTo(QUEST_ITEM_2.getId());
+            assertThat(dropItem.getChance()).isEqualTo(QUEST_ITEM_2.getChance());
+            assertThat(dropItem.getMin()).isEqualTo(amount);
+            assertThat(dropItem.getMax()).isEqualTo(amount);
+        });
+    }
+
+    @Test
+    public void shouldAddGroupedDropWithChance() {
+        double chance = 80.0;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addGroupedDrop(2, 100.0)
+                    .withDropItem(QUEST_ITEM_2, chance)
+                    .build()
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.getLimit()).isEqualTo(QUEST_ITEM_2.getLimit());
+
+        assertThat(dropInfo.drop()).isInstanceOf(GroupedGeneralDropItem.class);
+        GroupedGeneralDropItem group = (GroupedGeneralDropItem) dropInfo.drop();
+        assertThat(group.getItems()).hasSize(1);
+        assertThat(group.getItems()).satisfiesExactly(dropItem -> {
+            assertThat(dropItem.getItemId()).isEqualTo(QUEST_ITEM_2.getId());
+            assertThat(dropItem.getChance()).isEqualTo(chance);
+            assertThat(dropItem.getMin()).isEqualTo(QUEST_ITEM_2.getCount());
+            assertThat(dropItem.getMax()).isEqualTo(QUEST_ITEM_2.getCount());
+        });
+    }
+
+    @Test
+    public void shouldAddGroupedDropWithItemIdAndChance() {
+        double chance = 80.0;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addGroupedDrop(2, 100.0)
+                    .withDropItem(QUEST_ITEM_2.getId(), chance)
+                    .build()
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.getLimit()).isEqualTo(0);
+
+        assertThat(dropInfo.drop()).isInstanceOf(GroupedGeneralDropItem.class);
+        GroupedGeneralDropItem group = (GroupedGeneralDropItem) dropInfo.drop();
+        assertThat(group.getItems()).hasSize(1);
+        assertThat(group.getItems()).satisfiesExactly(dropItem -> {
+            assertThat(dropItem.getItemId()).isEqualTo(QUEST_ITEM_2.getId());
+            assertThat(dropItem.getChance()).isEqualTo(chance);
+            assertThat(dropItem.getMin()).isEqualTo(1);
+            assertThat(dropItem.getMax()).isEqualTo(1);
+        });
+    }
+
+    @Test
+    public void shouldAddGroupedDropWithItemIdAmountAndChance() {
+        long amount = 5;
+        double chance = 80.0;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addGroupedDrop(2, 100.0)
+                    .withDropItem(QUEST_ITEM_2.getId(), amount, chance)
+                    .build()
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.getLimit()).isEqualTo(0);
+
+        assertThat(dropInfo.drop()).isInstanceOf(GroupedGeneralDropItem.class);
+        GroupedGeneralDropItem group = (GroupedGeneralDropItem) dropInfo.drop();
+        assertThat(group.getItems()).hasSize(1);
+        assertThat(group.getItems()).satisfiesExactly(dropItem -> {
+            assertThat(dropItem.getItemId()).isEqualTo(QUEST_ITEM_2.getId());
+            assertThat(dropItem.getChance()).isEqualTo(chance);
+            assertThat(dropItem.getMin()).isEqualTo(amount);
+            assertThat(dropItem.getMax()).isEqualTo(amount);
+        });
+    }
+
+    @Test
+    public void shouldAddGroupedDropWithAmountAndChance() {
+        long amount = 5;
+        double chance = 80.0;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addGroupedDrop(2, 100.0)
+                    .withDropItem(QUEST_ITEM_2, amount, chance)
+                    .build()
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.item().getId()).isEqualTo(QUEST_ITEM_2.getId());
+
+        assertThat(dropInfo.drop()).isInstanceOf(GroupedGeneralDropItem.class);
+        GroupedGeneralDropItem group = (GroupedGeneralDropItem) dropInfo.drop();
+        assertThat(group.getItems()).hasSize(1);
+        assertThat(group.getItems()).satisfiesExactly(dropItem -> {
+            assertThat(dropItem.getItemId()).isEqualTo(QUEST_ITEM_2.getId());
+            assertThat(dropItem.getChance()).isEqualTo(chance);
+            assertThat(dropItem.getMin()).isEqualTo(amount);
+            assertThat(dropItem.getMax()).isEqualTo(amount);
+        });
+    }
+
+    @Test
+    public void shouldAddGroupedDropForSingleItem() {
+        long amount1 = 3;
+        long amount2 = 5;
+        double chance1 = 20.0;
+        double chance2 = 80.0;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addGroupedDropForSingleItem(2, QUEST_ITEM_2, 100.0)
+                    .withAmount(amount1, chance1)
+                    .withAmount(amount2, chance2).build()
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.drop()).isInstanceOf(GroupedGeneralDropItem.class);
+
+        GroupedGeneralDropItem group = (GroupedGeneralDropItem) dropInfo.drop();
+        assertThat(group.getItems()).hasSize(2);
+        assertThat(group.getItems()).anySatisfy(dropItem -> {
+            assertThat(dropItem.getItemId()).isEqualTo(QUEST_ITEM_2.getId());
+            assertThat(dropItem.getChance()).isEqualTo(chance1);
+            assertThat(dropItem.getMin()).isEqualTo(amount1);
+            assertThat(dropItem.getMax()).isEqualTo(amount1);
+        });
+        assertThat(group.getItems()).anySatisfy(dropItem -> {
+            assertThat(dropItem.getItemId()).isEqualTo(QUEST_ITEM_2.getId());
+            assertThat(dropItem.getChance()).isEqualTo(chance2);
+            assertThat(dropItem.getMin()).isEqualTo(amount2);
+            assertThat(dropItem.getMax()).isEqualTo(amount2);
+        });
+    }
+
+    @Test
+    public void shouldAddGroupedDropForSingleItemUsingOrElse() {
+        long amount1 = 3;
+        long amount2 = 5;
+        double chance1 = 20.0;
+        double chance2 = 80.0;
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addGroupedDropForSingleItem(2, QUEST_ITEM_2, 100.0)
+                    .withAmount(amount1, chance1)
+                    .orElse(amount2)
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.drop()).isInstanceOf(GroupedGeneralDropItem.class);
+
+        GroupedGeneralDropItem group = (GroupedGeneralDropItem) dropInfo.drop();
+        assertThat(group.getItems()).hasSize(2);
+        assertThat(group.getItems()).anySatisfy(dropItem -> {
+            assertThat(dropItem.getItemId()).isEqualTo(QUEST_ITEM_2.getId());
+            assertThat(dropItem.getChance()).isEqualTo(chance1);
+            assertThat(dropItem.getMin()).isEqualTo(amount1);
+            assertThat(dropItem.getMax()).isEqualTo(amount1);
+        });
+        assertThat(group.getItems()).anySatisfy(dropItem -> {
+            assertThat(dropItem.getItemId()).isEqualTo(QUEST_ITEM_2.getId());
+            assertThat(dropItem.getChance()).isEqualTo(chance2);
+            assertThat(dropItem.getMin()).isEqualTo(amount2);
+            assertThat(dropItem.getMax()).isEqualTo(amount2);
+        });
+    }
+
+    @Test
+    public void shouldGenerateSingleDropItem() {
+        IDropItem dropItem = QuestDroplist.singleDropItem(QUEST_ITEM_1);
+
+        assertThat(dropItem).isInstanceOf(GeneralDropItem.class);
+
+        GeneralDropItem generalDropItem = (GeneralDropItem) dropItem;
+        assertThat(generalDropItem.getItemId()).isEqualTo(QUEST_ITEM_1.getId());
+        assertThat(generalDropItem.getChance()).isEqualTo(QUEST_ITEM_1.getChance());
+        assertThat(generalDropItem.getMin()).isEqualTo(QUEST_ITEM_1.getCount());
+        assertThat(generalDropItem.getMax()).isEqualTo(QUEST_ITEM_1.getCount());
+    }
+
+    @Test
+    public void shouldGenerateSingleDropItemWithChance() {
+        double chance = 75.0;
+
+        IDropItem dropItem = QuestDroplist.singleDropItem(QUEST_ITEM_1, chance);
+
+        assertThat(dropItem).isInstanceOf(GeneralDropItem.class);
+
+        GeneralDropItem generalDropItem = (GeneralDropItem) dropItem;
+        assertThat(generalDropItem.getItemId()).isEqualTo(QUEST_ITEM_1.getId());
+        assertThat(generalDropItem.getChance()).isEqualTo(chance);
+        assertThat(generalDropItem.getMin()).isEqualTo(QUEST_ITEM_1.getCount());
+        assertThat(generalDropItem.getMax()).isEqualTo(QUEST_ITEM_1.getCount());
+    }
+
+    @Test
+    public void shouldGenerateSingleDropItemWithAmount() {
+        long amount = 10;
+
+        IDropItem dropItem = QuestDroplist.singleDropItem(QUEST_ITEM_1, amount);
+
+        assertThat(dropItem).isInstanceOf(GeneralDropItem.class);
+
+        GeneralDropItem generalDropItem = (GeneralDropItem) dropItem;
+        assertThat(generalDropItem.getItemId()).isEqualTo(QUEST_ITEM_1.getId());
+        assertThat(generalDropItem.getChance()).isEqualTo(QUEST_ITEM_1.getChance());
+        assertThat(generalDropItem.getMin()).isEqualTo(amount);
+        assertThat(generalDropItem.getMax()).isEqualTo(amount);
+    }
+
+    @Test
+    public void shouldGenerateSingleDropItemWithItemIdAndAmount() {
+        long amount = 10;
+
+        IDropItem dropItem = QuestDroplist.singleDropItem(QUEST_ITEM_1.getId(), amount);
+
+        assertThat(dropItem).isInstanceOf(GeneralDropItem.class);
+
+        GeneralDropItem generalDropItem = (GeneralDropItem) dropItem;
+        assertThat(generalDropItem.getItemId()).isEqualTo(QUEST_ITEM_1.getId());
+        assertThat(generalDropItem.getChance()).isEqualTo(100.0);
+        assertThat(generalDropItem.getMin()).isEqualTo(amount);
+        assertThat(generalDropItem.getMax()).isEqualTo(amount);
+    }
+
+    @Test
+    public void shouldGenerateSingleDropItemWithItemIdAndChance() {
+        double chance = 75.0;
+
+        IDropItem dropItem = QuestDroplist.singleDropItem(QUEST_ITEM_1.getId(), chance);
+
+        assertThat(dropItem).isInstanceOf(GeneralDropItem.class);
+
+        GeneralDropItem generalDropItem = (GeneralDropItem) dropItem;
+        assertThat(generalDropItem.getItemId()).isEqualTo(QUEST_ITEM_1.getId());
+        assertThat(generalDropItem.getChance()).isEqualTo(chance);
+        assertThat(generalDropItem.getMin()).isEqualTo(1);
+        assertThat(generalDropItem.getMax()).isEqualTo(1);
+    }
+
+    @Test
+    public void shouldGenerateSingleDropItemWithItemIdMinMaxAndChance() {
+        int itemId = 2;
+        long min = 1;
+        long max = 5;
+        double chance = 75.0;
+
+        IDropItem dropItem = QuestDroplist.singleDropItem(itemId, min, max, chance);
+
+        assertThat(dropItem).isInstanceOf(GeneralDropItem.class);
+
+        GeneralDropItem generalDropItem = (GeneralDropItem) dropItem;
+        assertThat(generalDropItem.getItemId()).isEqualTo(itemId);
+        assertThat(generalDropItem.getChance()).isEqualTo(chance);
+        assertThat(generalDropItem.getMin()).isEqualTo(min);
+        assertThat(generalDropItem.getMax()).isEqualTo(max);
+    }
+
+    @Test
+    public void shouldGenerateGroupedDropItem() {
+        double chance = 75.0;
+        IDropItem group = QuestDroplist.groupedDropItem(chance, QUEST_ITEM_2, QUEST_ITEM_3);
+
+        assertThat(group).isInstanceOf(GroupedGeneralDropItem.class);
+
+        GroupedGeneralDropItem groupDropItem = (GroupedGeneralDropItem) group;
+        assertThat(groupDropItem.getChance()).isEqualTo(chance);
+        assertThat(groupDropItem.getItems()).anySatisfy(item -> {
+            assertThat(item.getItemId()).isEqualTo(QUEST_ITEM_2.getId());
+            assertThat(item.getChance()).isEqualTo(QUEST_ITEM_2.getChance());
+            assertThat(item.getMin()).isEqualTo(QUEST_ITEM_2.getCount());
+            assertThat(item.getMax()).isEqualTo(QUEST_ITEM_2.getCount());
+        });
+        assertThat(groupDropItem.getItems()).anySatisfy(item -> {
+            assertThat(item.getItemId()).isEqualTo(QUEST_ITEM_3.getId());
+            assertThat(item.getChance()).isEqualTo(QUEST_ITEM_3.getChance());
+            assertThat(item.getMin()).isEqualTo(QUEST_ITEM_3.getCount());
+            assertThat(item.getMax()).isEqualTo(QUEST_ITEM_3.getCount());
+        });
+    }
+
+    @Test
+    public void shouldAddDropToExistingNpcAndRetrieveInfo() {
+        when(npc.getId()).thenReturn(1);
+
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addSingleDrop(1, QUEST_ITEM_2)
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(1, QUEST_ITEM_1.getId());
+        assertThat(dropInfo).isNotNull();
+        assertThat(dropInfo.item()).isEqualTo(QUEST_ITEM_1);
+
+        QuestDropInfo dropInfo2 = dropList.get(1, QUEST_ITEM_2);
+        assertThat(dropInfo2).isNotNull();
+        assertThat(dropInfo2.item()).isEqualTo(QUEST_ITEM_2);
+
+        QuestDropInfo dropInfo3 = dropList.get(npc, QUEST_ITEM_2);
+        assertThat(dropInfo3).isNotNull();
+        assertThat(dropInfo3.item()).isEqualTo(QUEST_ITEM_2);
+    }
+
+    @Test
+    public void shouldReturnNullForInvalidKeys() {
+        QuestDroplist dropList = QuestDroplist.builder()
+                .addSingleDrop(1, QUEST_ITEM_1)
+                .addSingleDrop(1, QUEST_ITEM_2)
+                .build();
+
+        QuestDropInfo dropInfo = dropList.get(2);
+        assertThat(dropInfo).isNull();
+
+        QuestDropInfo dropInfo2 = dropList.get(2, QUEST_ITEM_3.getId());
+        assertThat(dropInfo2).isNull();
+
+        QuestDropInfo dropInfo3 = dropList.get(1, QUEST_ITEM_3.getId());
+        assertThat(dropInfo3).isNull();
+    }
+}