Load/unload shops on chunk load/unload

This breaks shop limits, only loaded shops are counted at the moment
This commit is contained in:
Eric 2020-01-21 20:00:08 +01:00
parent f15fdc781f
commit c595b574ec
8 changed files with 339 additions and 120 deletions

View File

@ -43,6 +43,8 @@ import me.wiefferink.areashop.AreaShop;
import net.milkbowl.vault.economy.Economy; import net.milkbowl.vault.economy.Economy;
import org.bstats.bukkit.Metrics; import org.bstats.bukkit.Metrics;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.World;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin; import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.RegisteredServiceProvider; import org.bukkit.plugin.RegisteredServiceProvider;
@ -65,6 +67,7 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
public class ShopChest extends JavaPlugin { public class ShopChest extends JavaPlugin {
@ -437,13 +440,26 @@ public class ShopChest extends JavaPlugin {
* Initializes the shops * Initializes the shops
*/ */
private void initializeShops() { private void initializeShops() {
debug("Initializing Shops..."); getShopDatabase().connect(new Callback<Integer>(this) {
shopUtils.reloadShops(false, true, new Callback<Integer>(this) {
@Override @Override
public void onResult(Integer result) { public void onResult(Integer result) {
Bukkit.getServer().getPluginManager().callEvent(new ShopInitializedEvent(result)); Chunk[] loadedChunks = getServer().getWorlds().stream().map(World::getLoadedChunks)
getLogger().info("Initialized " + result + " Shops"); .flatMap(Stream::of).toArray(Chunk[]::new);
debug("Initialized " + result + " Shops");
shopUtils.loadShops(loadedChunks, new Callback<Integer>(ShopChest.this) {
@Override
public void onResult(Integer result) {
getServer().getPluginManager().callEvent(new ShopInitializedEvent(result));
getLogger().info("Loaded " + result + " shops in already loaded chunks");
debug("Loaded " + result + " shops in already loaded chunks");
}
@Override
public void onError(Throwable throwable) {
getLogger().severe("Failed to load shops in already loaded chunks");
if (throwable != null) getLogger().severe(throwable.getMessage());
}
});
} }
@Override @Override

View File

@ -26,8 +26,10 @@ import de.epiceric.shopchest.utils.ClickType.CreateClickType;
import de.epiceric.shopchest.utils.ClickType.SelectClickType; import de.epiceric.shopchest.utils.ClickType.SelectClickType;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.GameMode; import org.bukkit.GameMode;
import org.bukkit.OfflinePlayer; import org.bukkit.OfflinePlayer;
import org.bukkit.World;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
@ -36,7 +38,9 @@ import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.stream.Stream;
class ShopCommandExecutor implements CommandExecutor { class ShopCommandExecutor implements CommandExecutor {
@ -191,7 +195,25 @@ class ShopCommandExecutor implements CommandExecutor {
return; return;
} }
shopUtils.reloadShops(true, true, new Callback<Integer>(plugin) { // Reload configurations
plugin.getShopChestConfig().reload(false, true, true);
plugin.getHologramFormat().reload();
plugin.getUpdater().restart();
// Remove all shops
Iterator<Shop> iter = shopUtils.getShops().iterator();
while (iter.hasNext()) {
shopUtils.removeShop(iter.next(), false);
}
Chunk[] loadedChunks = Bukkit.getWorlds().stream().map(World::getLoadedChunks)
.flatMap(Stream::of).toArray(Chunk[]::new);
// Reconnect to the database and re-load shops in loaded chunks
plugin.getShopDatabase().connect(new Callback<Integer>(plugin) {
@Override
public void onResult(Integer result) {
shopUtils.loadShops(loadedChunks, new Callback<Integer>(plugin) {
@Override @Override
public void onResult(Integer result) { public void onResult(Integer result) {
sender.sendMessage(LanguageUtils.getMessage(Message.RELOADED_SHOPS, sender.sendMessage(LanguageUtils.getMessage(Message.RELOADED_SHOPS,
@ -199,6 +221,16 @@ class ShopCommandExecutor implements CommandExecutor {
plugin.debug(sender.getName() + " has reloaded " + result + " shops"); plugin.debug(sender.getName() + " has reloaded " + result + " shops");
} }
@Override
public void onError(Throwable throwable) {
sender.sendMessage(LanguageUtils.getMessage(Message.ERROR_OCCURRED,
new Replacement(Placeholder.ERROR, "Failed to load shops from database")));
plugin.getLogger().severe("Failed to load shops");
if (throwable != null) plugin.getLogger().severe(throwable.getMessage());
}
});
}
@Override @Override
public void onError(Throwable throwable) { public void onError(Throwable throwable) {
// Database connection probably failed => disable plugin to prevent more errors // Database connection probably failed => disable plugin to prevent more errors

View File

@ -3,6 +3,11 @@ package de.epiceric.shopchest.event;
import org.bukkit.event.Event; import org.bukkit.event.Event;
import org.bukkit.event.HandlerList; import org.bukkit.event.HandlerList;
/**
* @deprecated Use {@link ShopsLoadedEvent} instead since shops are loaded
* dynamically based on chunk loading
*/
@Deprecated
public class ShopInitializedEvent extends Event { public class ShopInitializedEvent extends Event {
private static final HandlerList handlers = new HandlerList(); private static final HandlerList handlers = new HandlerList();

View File

@ -0,0 +1,33 @@
package de.epiceric.shopchest.event;
import java.util.Collection;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
import de.epiceric.shopchest.shop.Shop;
/**
* Called when shops have been loaded and added to the server
*/
public class ShopsLoadedEvent extends Event {
private static final HandlerList handlers = new HandlerList();
private Collection<Shop> shops;
public ShopsLoadedEvent(Collection<Shop> shops) {
this.shops = shops;
}
public Collection<Shop> getShops() {
return shops;
}
public static HandlerList getHandlerList() {
return handlers;
}
@Override
public HandlerList getHandlers() {
return handlers;
}
}

View File

@ -0,0 +1,33 @@
package de.epiceric.shopchest.event;
import java.util.Collection;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
import de.epiceric.shopchest.shop.Shop;
/**
* Called when shops have been unloaded and removed from the server
*/
public class ShopsUnloadedEvent extends Event {
private static final HandlerList handlers = new HandlerList();
private Collection<Shop> shops;
public ShopsUnloadedEvent(Collection<Shop> shops) {
this.shops = shops;
}
public Collection<Shop> getShops() {
return shops;
}
public static HandlerList getHandlerList() {
return handlers;
}
@Override
public HandlerList getHandlers() {
return handlers;
}
}

View File

@ -4,6 +4,10 @@ import de.epiceric.shopchest.ShopChest;
import de.epiceric.shopchest.shop.Shop; import de.epiceric.shopchest.shop.Shop;
import de.epiceric.shopchest.utils.Callback; import de.epiceric.shopchest.utils.Callback;
import java.util.HashSet;
import java.util.Set;
import org.bukkit.Chunk;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
@ -12,12 +16,14 @@ import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.world.WorldLoadEvent; import org.bukkit.event.world.ChunkLoadEvent;
import org.bukkit.event.world.ChunkUnloadEvent;
import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.scheduler.BukkitRunnable;
public class ShopUpdateListener implements Listener { public class ShopUpdateListener implements Listener {
private ShopChest plugin; private final ShopChest plugin;
private final Set<Chunk> newLoadedChunks = new HashSet<>();
public ShopUpdateListener(ShopChest plugin) { public ShopUpdateListener(ShopChest plugin) {
this.plugin = plugin; this.plugin = plugin;
@ -83,23 +89,55 @@ public class ShopUpdateListener implements Listener {
} }
@EventHandler @EventHandler
public void onWorldLoad(WorldLoadEvent e) { public void onChunkLoad(ChunkLoadEvent e) {
final String worldName = e.getWorld().getName(); if (!plugin.getShopDatabase().isInitialized()) {
return;
}
plugin.getShopUtils().reloadShops(false, false, new Callback<Integer>(plugin) { // Wait 10 ticks after first event is triggered, so that multiple
// chunk loads can be handled at the same time without having to
// send a database request for each chunk.
if (newLoadedChunks.isEmpty()) {
new BukkitRunnable(){
@Override
public void run() {
int chunkCount = newLoadedChunks.size();
plugin.getShopUtils().loadShops(newLoadedChunks.toArray(new Chunk[chunkCount]), new Callback<Integer>(plugin) {
@Override @Override
public void onResult(Integer result) { public void onResult(Integer result) {
plugin.getLogger().info(String.format("Reloaded %d shops because a new world '%s' was loaded", result, worldName)); if (result == 0) {
plugin.debug(String.format("Reloaded %d shops because a new world '%s' was loaded", result, worldName)); return;
}
plugin.debug("Loaded " + result + " shops in " + chunkCount + " chunks");
} }
@Override @Override
public void onError(Throwable throwable) { public void onError(Throwable throwable) {
// Database connection probably failed => disable plugin to prevent more errors // Database connection probably failed => disable plugin to prevent more errors
plugin.getLogger().severe("No database access. Disabling ShopChest"); plugin.getLogger().severe("Failed to load shops in newly loaded chunks");
if (throwable != null) plugin.getLogger().severe(throwable.getMessage()); plugin.debug("Failed to load shops in newly loaded chunks");
plugin.getServer().getPluginManager().disablePlugin(plugin); if (throwable != null) plugin.debug(throwable);
} }
}); });
newLoadedChunks.clear();
}
}.runTaskLater(plugin, 10L);
}
newLoadedChunks.add(e.getChunk());
}
@EventHandler
public void onChunkUnload(ChunkUnloadEvent e) {
if (!plugin.getShopDatabase().isInitialized()) {
return;
}
int num = plugin.getShopUtils().unloadShops(e.getChunk());
if (num > 0) {
String chunkStr = "[" + e.getChunk().getX() + "; " + e.getChunk().getZ() + "]";
plugin.debug("Unloaded " + num + " shops in chunk " + chunkStr);
}
} }
} }

View File

@ -4,13 +4,13 @@ import de.epiceric.shopchest.ShopChest;
import de.epiceric.shopchest.config.Config; import de.epiceric.shopchest.config.Config;
import de.epiceric.shopchest.event.ShopBuySellEvent; import de.epiceric.shopchest.event.ShopBuySellEvent;
import de.epiceric.shopchest.event.ShopBuySellEvent.Type; import de.epiceric.shopchest.event.ShopBuySellEvent.Type;
import de.epiceric.shopchest.exceptions.WorldNotFoundException;
import de.epiceric.shopchest.shop.Shop; import de.epiceric.shopchest.shop.Shop;
import de.epiceric.shopchest.shop.ShopProduct; import de.epiceric.shopchest.shop.ShopProduct;
import de.epiceric.shopchest.shop.Shop.ShopType; import de.epiceric.shopchest.shop.Shop.ShopType;
import de.epiceric.shopchest.utils.Callback; import de.epiceric.shopchest.utils.Callback;
import de.epiceric.shopchest.utils.Utils; import de.epiceric.shopchest.utils.Utils;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.OfflinePlayer; import org.bukkit.OfflinePlayer;
import org.bukkit.World; import org.bukkit.World;
@ -28,16 +28,20 @@ import java.text.SimpleDateFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import com.zaxxer.hikari.HikariDataSource; import com.zaxxer.hikari.HikariDataSource;
public abstract class Database { public abstract class Database {
private final Set<String> notFoundWorlds = new HashSet<>();
private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
private boolean initialized;
String tableShops; String tableShops;
String tableLogs; String tableLogs;
String tableLogouts; String tableLogouts;
@ -305,6 +309,7 @@ public abstract class Database {
ResultSet rs = s.executeQuery("SELECT COUNT(id) FROM " + tableShops); ResultSet rs = s.executeQuery("SELECT COUNT(id) FROM " + tableShops);
if (rs.next()) { if (rs.next()) {
int count = rs.getInt(1); int count = rs.getInt(1);
initialized = true;
plugin.debug("Initialized database with " + count + " entries"); plugin.debug("Initialized database with " + count + " entries");
@ -362,46 +367,78 @@ public abstract class Database {
} }
/** /**
* Get all shops from the database * Get all shops from the database that are located in the given chunks
* *
* @param showConsoleMessages Whether console messages (errors or warnings) * @param chunks Shops in these chunks are retrieved
* should be shown * @param callback Callback that returns an immutable collection of shops if succeeded
* @param callback Callback that - if succeeded - returns a read-only
* collection of all shops (as
* {@code Collection<Shop>})
*/ */
public void getShops(final boolean showConsoleMessages, final Callback<Collection<Shop>> callback) { public void getShopsInChunks(final Chunk[] chunks, final Callback<Collection<Shop>> callback) {
new BukkitRunnable() { // Split chunks into packages containing each {splitSize} chunks at max
int splitSize = 80;
int parts = (int) Math.ceil(chunks.length / (double) splitSize);
Chunk[][] splitChunks = new Chunk[parts][];
for (int i = 0; i < parts; i++) {
int size = i < parts - 1 ? splitSize : chunks.length % splitSize;
Chunk[] tmp = new Chunk[size];
System.arraycopy(chunks, i * splitSize, tmp, 0, size);
splitChunks[i] = tmp;
}
new BukkitRunnable(){
@Override @Override
public void run() { public void run() {
ArrayList<Shop> shops = new ArrayList<>(); List<Shop> shops = new ArrayList<>();
// Send a request for each chunk package
for (Chunk[] newChunks : splitChunks) {
// Map chunks by world
Map<String, Set<Chunk>> chunksByWorld = new HashMap<>();
for (Chunk chunk : newChunks) {
String world = chunk.getWorld().getName();
Set<Chunk> chunksForWorld = chunksByWorld.getOrDefault(world, new HashSet<>());
chunksForWorld.add(chunk);
chunksByWorld.put(world, chunksForWorld);
}
// Create query dynamically
String query = "SELECT * FROM " + tableShops + " WHERE ";
for (String world : chunksByWorld.keySet()) {
query += "(world = ? AND (";
int chunkNum = chunksByWorld.get(world).size();
for (int i = 0; i < chunkNum; i++) {
query += "((x BETWEEN ? AND ?) AND (z BETWEEN ? AND ?)) OR ";
}
query += "1=0)) OR ";
}
query += "1=0";
try (Connection con = dataSource.getConnection(); try (Connection con = dataSource.getConnection();
PreparedStatement ps = con.prepareStatement("SELECT * FROM " + tableShops + "")) { PreparedStatement ps = con.prepareStatement(query)) {
ResultSet rs = ps.executeQuery(); int index = 0;
for (String world : chunksByWorld.keySet()) {
ps.setString(++index, world);
for (Chunk chunk : chunksByWorld.get(world)) {
int minX = chunk.getX() * 16;
int minZ = chunk.getZ() * 16;
ps.setInt(++index, minX);
ps.setInt(++index, minX + 15);
ps.setInt(++index, minZ);
ps.setInt(++index, minZ + 15);
}
}
ResultSet rs = ps.executeQuery();
while (rs.next()) { while (rs.next()) {
int id = rs.getInt("id"); int id = rs.getInt("id");
plugin.debug("Getting Shop... (#" + id + ")"); plugin.debug("Getting Shop... (#" + id + ")");
String worldName = rs.getString("world");
World world = Bukkit.getWorld(worldName);
if (world == null) {
WorldNotFoundException ex = new WorldNotFoundException(worldName);
if (showConsoleMessages && !notFoundWorlds.contains(worldName)) {
plugin.getLogger().warning(ex.getMessage());
notFoundWorlds.add(worldName);
}
plugin.debug("Failed to get shop (#" + id + ")");
plugin.debug(ex);
continue;
}
int x = rs.getInt("x"); int x = rs.getInt("x");
int y = rs.getInt("y"); int y = rs.getInt("y");
int z = rs.getInt("z"); int z = rs.getInt("z");
World world = plugin.getServer().getWorld(rs.getString("world"));
Location location = new Location(world, x, y, z); Location location = new Location(world, x, y, z);
OfflinePlayer vendor = Bukkit.getOfflinePlayer(UUID.fromString(rs.getString("vendor"))); OfflinePlayer vendor = Bukkit.getOfflinePlayer(UUID.fromString(rs.getString("vendor")));
ItemStack itemStack = Utils.decode(rs.getString("product")); ItemStack itemStack = Utils.decode(rs.getString("product"));
@ -415,10 +452,6 @@ public abstract class Database {
shops.add(new Shop(id, plugin, vendor, product, location, buyPrice, sellPrice, shopType)); shops.add(new Shop(id, plugin, vendor, product, location, buyPrice, sellPrice, shopType));
} }
if (callback != null) {
callback.callSyncResult(Collections.unmodifiableCollection(shops));
}
} catch (SQLException ex) { } catch (SQLException ex) {
if (callback != null) { if (callback != null) {
callback.callSyncError(ex); callback.callSyncError(ex);
@ -427,9 +460,15 @@ public abstract class Database {
plugin.getLogger().severe("Failed to get shops from database"); plugin.getLogger().severe("Failed to get shops from database");
plugin.debug("Failed to get shops"); plugin.debug("Failed to get shops");
plugin.debug(ex); plugin.debug(ex);
return;
}
} }
if (callback != null) {
callback.callSyncResult(Collections.unmodifiableCollection(shops));
} }
};
}.runTaskAsynchronously(plugin); }.runTaskAsynchronously(plugin);
} }
@ -730,6 +769,13 @@ public abstract class Database {
} }
} }
/**
* Returns whether a connection to the database has been established
*/
public boolean isInitialized() {
return initialized;
}
public enum DatabaseType { public enum DatabaseType {
SQLite, MySQL SQLite, MySQL
} }

View File

@ -2,8 +2,12 @@ package de.epiceric.shopchest.utils;
import de.epiceric.shopchest.ShopChest; import de.epiceric.shopchest.ShopChest;
import de.epiceric.shopchest.config.Config; import de.epiceric.shopchest.config.Config;
import de.epiceric.shopchest.event.ShopsLoadedEvent;
import de.epiceric.shopchest.event.ShopsUnloadedEvent;
import de.epiceric.shopchest.shop.Shop; import de.epiceric.shopchest.shop.Shop;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.OfflinePlayer; import org.bukkit.OfflinePlayer;
import org.bukkit.block.Chest; import org.bukkit.block.Chest;
@ -246,6 +250,7 @@ public class ShopUtils {
* @return The amount of a shops a player has (if {@link Config#excludeAdminShops} is true, admin shops won't be counted) * @return The amount of a shops a player has (if {@link Config#excludeAdminShops} is true, admin shops won't be counted)
*/ */
public int getShopAmount(OfflinePlayer p) { public int getShopAmount(OfflinePlayer p) {
// FIXME: currently only showing loaded shops
float shopCount = 0; float shopCount = 0;
for (Shop shop : getShops()) { for (Shop shop : getShops()) {
@ -265,52 +270,63 @@ public class ShopUtils {
} }
/** /**
* Reload the shops * Gets all shops in the given chunk from the database and adds them to the server
* @param reloadConfig Whether the configuration should also be reloaded * @param chunk The chunk to load shops from
* @param showConsoleMessages Whether messages about the language file should be shown in the console * @param callback Callback that returns the amount of shops added if succeeded
* @param callback Callback that - if succeeded - returns the amount of shops that were reloaded (as {@code int}) * @see ShopUtils#loadShops(Chunk[], Callback)
*/ */
public void reloadShops(boolean reloadConfig, final boolean showConsoleMessages, final Callback<Integer> callback) { public void loadShops(final Chunk chunk, final Callback<Integer> callback) {
plugin.debug("Reloading shops..."); loadShops(new Chunk[] {chunk}, callback);
if (reloadConfig) {
plugin.getShopChestConfig().reload(false, true, showConsoleMessages);
plugin.getHologramFormat().reload();
plugin.getUpdater().restart();
} }
plugin.getShopDatabase().connect(new Callback<Integer>(plugin) { /**
@Override * Gets all shops in the given chunks from the database and adds them to the server
public void onResult(Integer result) { * @param chunk The chunks to load shops from
for (Shop shop : getShopsCopy()) { * @param callback Callback that returns the amount of shops added if succeeded
removeShop(shop, false); * @see ShopUtils#loadShops(Chunk Callback)
plugin.debug("Removed shop (#" + shop.getID() + ")"); */
} public void loadShops(final Chunk[] chunks, final Callback<Integer> callback) {
plugin.getShopDatabase().getShopsInChunks(chunks, new Callback<Collection<Shop>>(plugin) {
plugin.getShopDatabase().getShops(showConsoleMessages, new Callback<Collection<Shop>>(plugin) {
@Override @Override
public void onResult(Collection<Shop> result) { public void onResult(Collection<Shop> result) {
for (Shop shop : result) { for (Shop shop : result) {
if (shop.create(showConsoleMessages)) { if (shop.create(true)) {
addShop(shop, false); addShop(shop, false);
} }
} }
if (callback != null) callback.callSyncResult(result.size()); if (callback != null) callback.onResult(result.size());
Bukkit.getPluginManager().callEvent(new ShopsLoadedEvent(result));
} }
@Override @Override
public void onError(Throwable throwable) { public void onError(Throwable throwable) {
if (callback != null) callback.callSyncError(throwable); if (callback != null) callback.onError(throwable);
} }
}); });
} }
@Override /**
public void onError(Throwable throwable) { * Removes all shops from the given chunk from the server
if (callback != null) callback.callSyncError(throwable); * @param chunk The chunk containing the shops to unload
* @return The amount of shops that were unloaded
*/
public int unloadShops(final Chunk chunk) {
Set<Shop> unloadedShops = new HashSet<>();
Iterator<Shop> iter = getShops().iterator();
while(iter.hasNext()) {
Shop shop = iter.next();
if (shop.getLocation().getChunk().equals(chunk)) {
removeShop(shop, false);
unloadedShops.add(shop);
plugin.debug("Unloaded shop (#" + shop.getID() + ")");
} }
}); }
Bukkit.getPluginManager().callEvent(new ShopsUnloadedEvent(Collections.unmodifiableCollection(unloadedShops)));
return unloadedShops.size();
} }
/** /**