Dynamic shop loading based on chunk loading

This commit is contained in:
Eric 2020-03-18 17:30:28 +01:00
parent 9d8a38a2da
commit 2d832e587a
11 changed files with 412 additions and 106 deletions

View File

@ -21,7 +21,7 @@ import de.epiceric.shopchest.api.shop.ShopProduct;
public interface ShopManager {
/**
* Gets all shops
* Gets all currently loaded shops
*
* @return a collection of shops
*/
@ -31,7 +31,7 @@ public interface ShopManager {
* Gets the shop by its ID
*
* @param id the shop's ID
* @return the shop or an empty optional if there is no shop
* @return the shop or an empty optional if there is no shop loaded
* @since 1.13
*/
Optional<Shop> getShop(int id);
@ -40,13 +40,13 @@ public interface ShopManager {
* Gets the shop at the given location
*
* @param location the shop's chest location
* @return the shop or an empty optional if there is no shop
* @return the shop or an empty optional if there is no shop loaded
* @since 1.13
*/
Optional<Shop> getShop(Location location);
/**
* Gets all shops by the given player
* Gets all loaded shops by the given player
*
* @param vendor the player
* @return a collection of shops
@ -56,7 +56,7 @@ public interface ShopManager {
Collection<Shop> getShops(OfflinePlayer vendor);
/**
* Gets all shops in the given world
* Gets all loaded shops in the given world
*
* @param world the world
* @return a collection of shops
@ -114,7 +114,7 @@ public interface ShopManager {
void removeShop(Shop shop, Consumer<Void> callback, Consumer<Throwable> errorCallback);
/**
* Asynchronously reloads all shops from the database
* Removes all shops and reloads the shops in currently loaded chunks
* <p>
* This does not trigger the {@link ShopReloadEvent}.
*

View File

@ -1,38 +0,0 @@
package de.epiceric.shopchest.api.event;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
/**
* Called after all shops are initialized after the plugin is enabled
*
* @since 1.13
*/
public class ShopInitializedEvent extends Event {
private static final HandlerList handlers = new HandlerList();
private int amount;
public ShopInitializedEvent(int amount) {
this.amount = amount;
}
/**
* Gets the amount of shops that were initialized
*
* @return the amount of shops
* @since 1.13
*/
public int getAmount() {
return amount;
}
public static HandlerList getHandlerList() {
return handlers;
}
@Override
public HandlerList getHandlers() {
return handlers;
}
}

View File

@ -0,0 +1,43 @@
package de.epiceric.shopchest.api.event;
import java.util.Collection;
import java.util.Collections;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
import de.epiceric.shopchest.api.shop.Shop;
/**
* Called after shops are loaded from the database due to a chunk load
*
* @since 1.13
*/
public class ShopLoadedEvent extends Event {
private static final HandlerList handlers = new HandlerList();
private Collection<Shop> shops;
public ShopLoadedEvent(Collection<Shop> shops) {
this.shops = Collections.unmodifiableCollection(shops);
}
/**
* Gets the shops that have been loaded
*
* @return the shops
* @since 1.13
*/
public Collection<Shop> getShops() {
return shops;
}
public static HandlerList getHandlerList() {
return handlers;
}
@Override
public HandlerList getHandlers() {
return handlers;
}
}

View File

@ -94,7 +94,17 @@ public interface ShopPlayer {
int getShopLimit();
/**
* Gets the shops this player owns
* Gets the amount of shops the given player currently has
* <p>
* This number includes shops that are not loaded.
*
* @return the amount of shops
* @since 1.13
*/
int getShopAmount();
/**
* Gets the loaded shops this player owns
*
* @return a collection of shops
* @since 1.13

View File

@ -5,12 +5,17 @@ import java.io.InputStreamReader;
import java.lang.reflect.Field;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.bukkit.Chunk;
import org.bukkit.World;
import org.bukkit.command.Command;
import org.bukkit.command.CommandMap;
import org.bukkit.command.SimpleCommandMap;
@ -22,7 +27,7 @@ import de.epiceric.shopchest.api.ShopManager;
import de.epiceric.shopchest.api.command.ShopCommand;
import de.epiceric.shopchest.api.config.Config;
import de.epiceric.shopchest.api.database.DatabaseType;
import de.epiceric.shopchest.api.event.ShopInitializedEvent;
import de.epiceric.shopchest.api.event.ShopLoadedEvent;
import de.epiceric.shopchest.api.player.ShopPlayer;
import de.epiceric.shopchest.command.ShopCommandImpl;
import de.epiceric.shopchest.config.ConfigManager;
@ -170,13 +175,29 @@ public class ShopChestImpl extends ShopChest {
database = new MySQL(this);
}
((ShopManagerImpl) getShopManager()).loadShops(
((ShopManagerImpl) getShopManager()).loadShopAmounts(
shopAmounts -> {
Logger.info("Loaded shop amounts from the database");
},
error -> {
Logger.severe("Failed to load shops amounts from the database");
Logger.severe("Shop limits will not be working correctly");
Logger.severe(error);
}
);
List<Chunk> chunks = new ArrayList<>();
for (World world : getServer().getWorlds()) {
chunks.addAll(Arrays.asList(world.getLoadedChunks()));
}
((ShopManagerImpl) getShopManager()).loadShops(chunks.toArray(new Chunk[chunks.size()]),
shops -> {
getServer().getPluginManager().callEvent(new ShopInitializedEvent(shops.size()));
getServer().getPluginManager().callEvent(new ShopLoadedEvent(shops));
Logger.info("Loaded {0} shops from the database", shops.size());
},
error -> {
Logger.severe("Failed to load shops from database");
Logger.severe("Failed to load shops from the database");
Logger.severe(error);
getServer().getPluginManager().disablePlugin(this);
}
@ -259,12 +280,17 @@ public class ShopChestImpl extends ShopChest {
});
}
/* package-private */ Database getDatabase() {
/**
* Gets an instance of the plugin's database
*
* @return the database
*/
public Database getDatabase() {
return database;
}
/**
* Gets an instance to the config manager
* Gets an instance of the config manager
*
* @return the config manager
* @see ConfigManager#get(ShopChestImpl)

View File

@ -1,23 +1,32 @@
package de.epiceric.shopchest;
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.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.Map.Entry;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.OfflinePlayer;
import org.bukkit.World;
import org.bukkit.block.Chest;
import de.epiceric.shopchest.api.ShopManager;
import de.epiceric.shopchest.api.event.ShopLoadedEvent;
import de.epiceric.shopchest.api.player.ShopPlayer;
import de.epiceric.shopchest.api.shop.Shop;
import de.epiceric.shopchest.api.shop.ShopProduct;
import de.epiceric.shopchest.shop.ShopImpl;
import de.epiceric.shopchest.util.Counter;
public class ShopManagerImpl implements ShopManager {
private static ShopManagerImpl instance;
@ -32,6 +41,7 @@ public class ShopManagerImpl implements ShopManager {
private ShopChestImpl plugin;
private Map<String, Map<Location, Shop>> shopsInWorld = new HashMap<>();
private Map<UUID, Counter> shopAmounts = new HashMap<>();
private ShopManagerImpl(ShopChestImpl plugin) {
this.plugin = plugin;
@ -66,29 +76,40 @@ public class ShopManagerImpl implements ShopManager {
}
/**
* Loads and caches shops from the database
* Loads shops in the given chunks from the database
* <p>
* Cache will be cleared before new shops are cached.
* This will fire a {@link ShopLoadedEvent}.
*
* @param chunks a collection
*/
public void loadShops(Consumer<Collection<Shop>> callback, Consumer<Throwable> errorCallback) {
public void loadShops(Chunk[] chunks, Consumer<Collection<Shop>> callback, Consumer<Throwable> errorCallback) {
plugin.getDatabase().connect(
amount -> {
plugin.getDatabase().getShops(
plugin.getDatabase().getShops(chunks,
shops -> {
clearShops();
shops.stream().forEach(shop -> {
((ShopImpl) shop).create();
for (Iterator<Shop> it = shops.iterator(); it.hasNext();) {
Shop shop = it.next();
if (getShop(shop.getLocation()).isPresent()) {
// A shop is already loaded at the location, which should be the same.
it.remove();
continue;
}
String worldName = shop.getWorld().getName();
if (!shopsInWorld.containsKey(worldName)) {
shopsInWorld.put(worldName, new HashMap<>());
}
((ShopImpl) shop).create();
shopsInWorld.get(worldName).put(toBlockLocation(shop.getLocation()), shop);
((ShopImpl) shop).getOtherLocation().ifPresent(otherLoc ->
shopsInWorld.get(worldName).put(toBlockLocation(otherLoc), shop));;
});
callback.accept(shops);
shopsInWorld.get(worldName).put(toBlockLocation(otherLoc), shop));
}
callback.accept(Collections.unmodifiableCollection(shops));
plugin.getServer().getPluginManager().callEvent(new ShopLoadedEvent(shops));
},
errorCallback
);
@ -97,6 +118,31 @@ public class ShopManagerImpl implements ShopManager {
);
}
/**
* Loads all players' shop amounts from the database
*/
public void loadShopAmounts(Consumer<Map<UUID, Integer>> callback, Consumer<Throwable> errorCallback) {
plugin.getDatabase().getShopAmounts(
shopAmounts -> {
this.shopAmounts.clear();
shopAmounts.forEach((uuid, amount) -> this.shopAmounts.put(uuid, new Counter(amount)));
callback.accept(shopAmounts);
},
errorCallback
);
}
/**
* Gets the amount of shops a player has
*
* @param player the player
* @return the amount of shops
* @see ShopPlayer#getShopAmount()
*/
public int getShopAmount(OfflinePlayer player) {
return shopAmounts.getOrDefault(player.getUniqueId(), new Counter()).get();
}
/* API Implementation */
@Override
@ -152,6 +198,12 @@ public class ShopManagerImpl implements ShopManager {
((ShopImpl) shop).getOtherLocation().ifPresent(otherLoc ->
shopsInWorld.get(worldName).put(toBlockLocation(otherLoc), shop));
if (vendor != null) {
shopAmounts.compute(vendor.getUniqueId(), (uuid, counter) -> {
return counter == null ? new Counter(1) : counter.increment();
});
}
callback.accept(shop);
},
errorCallback
@ -168,8 +220,14 @@ public class ShopManagerImpl implements ShopManager {
((ShopImpl) shop).destroy();
shopsInWorld.get(shop.getWorld().getName()).remove(shop.getLocation());
getRemainingShopLocation(shop.getId(), shop.getWorld()).ifPresent(otherLoc -> {
shopsInWorld.get(shop.getWorld().getName()).remove(otherLoc);
getRemainingShopLocation(shop.getId(), shop.getWorld()).ifPresent(otherLoc ->
shopsInWorld.get(shop.getWorld().getName()).remove(otherLoc));
shop.getVendor().ifPresent(vendor -> {
shopAmounts.compute(vendor.getUniqueId(), (uuid, counter) -> {
return counter == null ? new Counter() : counter.decrement();
});
});
plugin.getDatabase().removeShop(shop, callback, errorCallback);
@ -177,6 +235,14 @@ public class ShopManagerImpl implements ShopManager {
@Override
public void reloadShops(Consumer<Integer> callback, Consumer<Throwable> errorCallback) {
List<Chunk> chunks = new ArrayList<>();
for (World world : plugin.getServer().getWorlds()) {
chunks.addAll(Arrays.asList(world.getLoadedChunks()));
}
loadShops(chunks.toArray(new Chunk[chunks.size()]),
shops -> callback.accept(shops.size()),
errorCallback
);
}
}

View File

@ -1,5 +1,6 @@
package de.epiceric.shopchest.database;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.OfflinePlayer;
import org.bukkit.World;
@ -29,8 +30,10 @@ import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
@ -41,6 +44,8 @@ public abstract class Database {
private final Set<String> notFoundWorlds = new HashSet<>();
private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
private boolean initialized;
String tableShops;
String tableLogs;
String tableLogouts;
@ -344,6 +349,7 @@ public abstract class Database {
ResultSet rs = s.executeQuery("SELECT COUNT(id) FROM " + tableShops);
if (rs.next()) {
int count = rs.getInt(1);
initialized = true;
callSyncResult(callback, count);
} else {
throw new SQLException("Count result set has no entries");
@ -378,6 +384,32 @@ public abstract class Database {
});
}
/**
* Get shop amounts for each player
*
* @param callback Callback that returns a map of each player's shop amount
*/
public void getShopAmounts(Consumer<Map<UUID, Integer>> callback, Consumer<Throwable> errorCallback) {
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
try (Connection con = dataSource.getConnection();
Statement s = con.createStatement()) {
ResultSet rs = s.executeQuery("SELECT vendor, COUNT(*) AS count FROM " + tableShops + " WHERE shoptype = 'NORMAL' GROUP BY vendor");
Map<UUID, Integer> result = new HashMap<>();
while (rs.next()) {
UUID uuid = UUID.fromString(rs.getString("vendor"));
result.put(uuid, rs.getInt("count"));
}
callSyncResult(callback, result);
} catch (SQLException e) {
callSyncError(errorCallback, e);
Logger.severe("Failed to get shop amounts from database");
Logger.severe(e);
}
});
}
/**
* Get all shops from the database
*
@ -387,54 +419,103 @@ public abstract class Database {
* collection of all shops (as
* {@code Collection<Shop>})
*/
public void getShops(Consumer<Collection<Shop>> callback, Consumer<Throwable> errorCallback) {
public void getShops(Chunk[] chunks, Consumer<Collection<Shop>> callback, Consumer<Throwable> errorCallback) {
// 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;
}
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
List<Shop> shops = new ArrayList<>();
try (Connection con = dataSource.getConnection();
PreparedStatement ps = con.prepareStatement("SELECT * FROM " + tableShops + "")) {
ResultSet rs = ps.executeQuery();
// Send a request for each chunk package
for (Chunk[] newChunks : splitChunks) {
while (rs.next()) {
int id = rs.getInt("id");
String worldName = rs.getString("world");
World world = plugin.getServer().getWorld(worldName);
if (world == null) {
if (!notFoundWorlds.contains(worldName)) {
Logger.warning("Could not find world with name \"{0}\"", worldName);
notFoundWorlds.add(worldName);
}
continue;
}
boolean admin = rs.getString("shoptype").equalsIgnoreCase("ADMIN");
OfflinePlayer vendor = admin ? null
: plugin.getServer().getOfflinePlayer(UUID.fromString(rs.getString("vendor")));
int x = rs.getInt("x");
int y = rs.getInt("y");
int z = rs.getInt("z");
Location location = new Location(world, x, y, z);
ItemStack itemStack = decodeItemStack(rs.getString("product"));
int amount = rs.getInt("amount");
ShopProduct product = new ShopProductImpl(itemStack, amount);
double buyPrice = rs.getDouble("buyprice");
double sellPrice = rs.getDouble("sellprice");
shops.add(new ShopImpl(id, vendor, product, location, buyPrice, sellPrice));
// 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);
}
callSyncResult(callback, Collections.unmodifiableCollection(shops));
} catch (SQLException e) {
callSyncError(errorCallback, e);
Logger.severe("Failed to get shops from database");
Logger.severe(e);
// 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();
PreparedStatement ps = con.prepareStatement(query)) {
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()) {
int id = rs.getInt("id");
String worldName = rs.getString("world");
World world = plugin.getServer().getWorld(worldName);
if (world == null) {
if (!notFoundWorlds.contains(worldName)) {
Logger.warning("Could not find world with name \"{0}\"", worldName);
notFoundWorlds.add(worldName);
}
continue;
}
boolean admin = rs.getString("shoptype").equalsIgnoreCase("ADMIN");
OfflinePlayer vendor = admin ? null
: plugin.getServer().getOfflinePlayer(UUID.fromString(rs.getString("vendor")));
int x = rs.getInt("x");
int y = rs.getInt("y");
int z = rs.getInt("z");
Location location = new Location(world, x, y, z);
ItemStack itemStack = decodeItemStack(rs.getString("product"));
int amount = rs.getInt("amount");
ShopProduct product = new ShopProductImpl(itemStack, amount);
double buyPrice = rs.getDouble("buyprice");
double sellPrice = rs.getDouble("sellprice");
shops.add(new ShopImpl(id, vendor, product, location, buyPrice, sellPrice));
}
} catch (SQLException e) {
callSyncError(errorCallback, e);
Logger.severe("Failed to get shops from database");
Logger.severe(e);
return;
}
}
callSyncResult(callback, Collections.unmodifiableCollection(shops));
});
}
@ -664,4 +745,11 @@ public abstract class Database {
dataSource = null;
}
}
/**
* Gets whether the database has been fully initialized
*/
public boolean isInitialized() {
return initialized;
}
}

View File

@ -0,0 +1,50 @@
package de.epiceric.shopchest.listener;
import java.util.HashSet;
import java.util.Set;
import org.bukkit.Chunk;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.world.ChunkLoadEvent;
import de.epiceric.shopchest.ShopChestImpl;
import de.epiceric.shopchest.ShopManagerImpl;
import de.epiceric.shopchest.api.ShopChest;
import de.epiceric.shopchest.util.Logger;
public class ChunkLoadListener implements Listener {
private ShopChest plugin;
private final Set<Chunk> newLoadedChunks = new HashSet<>();
public ChunkLoadListener(ShopChest plugin) {
this.plugin = plugin;
}
@EventHandler
public void onChunkLoad(ChunkLoadEvent e) {
if (!((ShopChestImpl) plugin).getDatabase().isInitialized()) {
return;
}
// 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()) {
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
int chunkCount = newLoadedChunks.size();
((ShopManagerImpl) plugin.getShopManager()).loadShops(newLoadedChunks.toArray(new Chunk[chunkCount]),
shops -> {},
error -> {
Logger.severe("Failed to load shops in newly loaded chunks");
Logger.severe(error);
}
);
newLoadedChunks.clear();
}, 10L);
}
newLoadedChunks.add(e.getChunk());
}
}

View File

@ -82,7 +82,7 @@ public class ShopCommandListener implements Listener {
}
// Check shop limit
if (player.getShops().size() >= player.getShopLimit()) {
if (player.getShopAmount() >= player.getShopLimit()) {
e.setCancelled(true);
player.sendMessage("§cYou don't have permission to create any more shops."); // TODO: i18n
return;
@ -99,7 +99,7 @@ public class ShopCommandListener implements Listener {
boolean allowDecimals = Config.SHOP_CREATION_ALLOW_DECIMAL_PRICES.get();
if (!allowDecimals && (!isInt(buyPrice) || !isInt(buyPrice))) {
e.setCancelled(true);
player.sendMessage("§cThe prices must not contain decimals.");
player.sendMessage("§cThe prices must not contain decimals."); // TODO: i18n
return;
}

View File

@ -8,6 +8,7 @@ import java.util.UUID;
import org.bukkit.entity.Player;
import de.epiceric.shopchest.ShopManagerImpl;
import de.epiceric.shopchest.api.ShopChest;
import de.epiceric.shopchest.api.config.Config;
import de.epiceric.shopchest.api.flag.Flag;
@ -76,6 +77,11 @@ public class ShopPlayerImpl implements ShopPlayer {
return Config.CORE_DEFAULT_SHOP_LIMIT.get(); // TODO: permissions based
}
@Override
public int getShopAmount() {
return ((ShopManagerImpl) plugin.getShopManager()).getShopAmount(getBukkitPlayer());
}
@Override
public Collection<Shop> getShops() {
return plugin.getShopManager().getShops(getBukkitPlayer());

View File

@ -0,0 +1,55 @@
package de.epiceric.shopchest.util;
/**
* Represents a counter for integers greather than or equal to zero.
*/
public final class Counter {
private int value;
/**
* Creates a counter with a starting value of zero
*/
public Counter() {
this(0);
}
/**
* Creates a counter with the given starting value
* @param value the starting value of this counter
*/
public Counter(int value) {
set(value);
}
/**
* Increments the counter by one and returns itself
*/
public final Counter increment() {
this.value++;
return this;
}
/**
* Decrements the counter by one if its value is greater than zero and returns itself
*/
public final Counter decrement() {
this.value = Math.max(0, this.value - 1);
return this;
}
/**
* Sets the counter's value to the given value or zero if the given value is negative
* @param value the value to set the counter to
*/
public final Counter set(int value) {
this.value = Math.max(0, value);
return this;
}
/**
* Returns the current value
*/
public final int get() {
return value;
}
}