diff --git a/bundle/src/main/java/net/okocraft/box/bundle/Builtin.java b/bundle/src/main/java/net/okocraft/box/bundle/Builtin.java index 83cdb4d5bf..384f892448 100644 --- a/bundle/src/main/java/net/okocraft/box/bundle/Builtin.java +++ b/bundle/src/main/java/net/okocraft/box/bundle/Builtin.java @@ -9,6 +9,7 @@ import net.okocraft.box.feature.craft.CraftFeature; import net.okocraft.box.feature.gui.GuiFeature; import net.okocraft.box.feature.notifier.NotifierFeature; +import net.okocraft.box.feature.stats.StatsFeature; import net.okocraft.box.feature.stick.StickFeature; import net.okocraft.box.storage.api.registry.StorageRegistry; import net.okocraft.box.storage.implementation.database.database.mysql.MySQLDatabase; @@ -36,7 +37,8 @@ public static void features(@NotNull BoxBootstrapContext context) { .addFeature(AutoStoreFeature::new) .addFeature(CraftFeature::new) .addFeature(StickFeature::new) - .addFeature(NotifierFeature::new); + .addFeature(NotifierFeature::new) + .addFeature(StatsFeature::new); } public static void storages(@NotNull StorageRegistry registry) { diff --git a/bundle/src/main/resources/ja.properties b/bundle/src/main/resources/ja.properties index 07dc56e3cd..81679354e0 100644 --- a/bundle/src/main/resources/ja.properties +++ b/bundle/src/main/resources/ja.properties @@ -212,3 +212,15 @@ box.stick.command.customstick.success=手に持っているアイテムを box.stick.command.customstick.is-stick=手に持っているアイテムはすでに Box Stick として使用できます。 box.stick.command.customstick.is-air=手に何も持っていません。 box.stick.command.customstick.help=/boxadmin customstick - 手持ちのアイテムを Box Stick にする +box.stats.mode.display-name=アイテム統計 +box.stats.gui.loading=読み込み中... +box.stats.gui.click-to-refresh=クリックして再読み込み +box.stats.gui.item.display-name= # +box.stats.gui.item.stock=在庫数: +box.stats.gui.item.stock-percentage= サーバー内在庫の% +box.stats.gui.item.stock-in-server=サーバー: +box.stats.gui.item.stock-percentage-in-server= すべてのアイテムの% +box.stats.gui.global.display-name=全体統計 +box.stats.gui.global.stock=総在庫数: +box.stats.command.box.iteminfo.stock-percentage=アイテム統計\: サーバー内在庫の% (#) +box.stats.command.box.iteminfo.stock-in-server=サーバー内在庫\: (すべてのアイテムの%) diff --git a/features/stats/build.gradle.kts b/features/stats/build.gradle.kts new file mode 100644 index 0000000000..d02eadad05 --- /dev/null +++ b/features/stats/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.mavenPublication) +} + +dependencies { + compileOnly(projects.boxApi) + compileOnly(projects.boxGuiFeature) + compileOnly(projects.boxStorageApi) + compileOnly(projects.boxStorageDatabase) + testImplementation(projects.boxTestSharedClasses) +} diff --git a/features/stats/src/main/java/net/okocraft/box/feature/stats/StatsFeature.java b/features/stats/src/main/java/net/okocraft/box/feature/stats/StatsFeature.java new file mode 100644 index 0000000000..888bd95068 --- /dev/null +++ b/features/stats/src/main/java/net/okocraft/box/feature/stats/StatsFeature.java @@ -0,0 +1,97 @@ +package net.okocraft.box.feature.stats; + +import net.kyori.adventure.key.Key; +import net.okocraft.box.api.BoxAPI; +import net.okocraft.box.api.event.player.PlayerCollectItemInfoEvent; +import net.okocraft.box.api.feature.AbstractBoxFeature; +import net.okocraft.box.api.feature.FeatureContext; +import net.okocraft.box.api.util.BoxLogger; +import net.okocraft.box.feature.gui.api.mode.ClickModeRegistry; +import net.okocraft.box.feature.stats.database.operator.StatisticsOperators; +import net.okocraft.box.feature.stats.gui.StockStatisticsMode; +import net.okocraft.box.feature.stats.listener.BoxEventHandlers; +import net.okocraft.box.feature.stats.provider.ItemStatisticsProvider; +import net.okocraft.box.feature.stats.provider.LanguageProvider; +import net.okocraft.box.feature.stats.provider.StockStatisticsProvider; +import net.okocraft.box.feature.stats.task.StatisticsUpdateTask; +import net.okocraft.box.storage.api.holder.StorageHolder; +import net.okocraft.box.storage.implementation.database.DatabaseStorage; +import net.okocraft.box.storage.implementation.database.database.Database; +import org.jetbrains.annotations.NotNull; + +import java.sql.SQLException; +import java.time.Duration; + +public class StatsFeature extends AbstractBoxFeature { + + private static final Key LISTENER_KEY = Key.key("box", "stats"); + + private final LanguageProvider languageProvider; + + private StockStatisticsProvider stockStatisticsProvider; + private ItemStatisticsProvider itemStatisticsProvider; + private StockStatisticsMode stockStatisticsMode; + private StatisticsUpdateTask updateTask; + + public StatsFeature(@NotNull FeatureContext.Registration context) { + super("stats"); + this.languageProvider = new LanguageProvider(context.defaultMessageCollector()); + } + + @Override + public void enable(@NotNull FeatureContext.Enabling context) { + if (!StorageHolder.isInitialized() || !(StorageHolder.getStorage() instanceof DatabaseStorage storage)) { + BoxLogger.logger().warn("Stats feature is disabled because storage is not loaded yet or is not a database storage."); + return; + } + + Database database = storage.getDatabase(); + StatisticsOperators operators = StatisticsOperators.create(database); + + try { + operators.initTables(database); + } catch (SQLException e) { + BoxLogger.logger().error("Failed to initialize the statistics tables", e); + return; + } + + this.stockStatisticsProvider = new StockStatisticsProvider(database, operators); + this.itemStatisticsProvider = new ItemStatisticsProvider(database, operators); + + try { + this.itemStatisticsProvider.refresh(); + } catch (Exception e) { + BoxLogger.logger().error("Failed to refresh item statistics", e); + return; + } + + this.stockStatisticsMode = new StockStatisticsMode(this.languageProvider, this.stockStatisticsProvider, this.itemStatisticsProvider); + ClickModeRegistry.register(this.stockStatisticsMode); + + this.updateTask = new StatisticsUpdateTask(this.stockStatisticsProvider, this.itemStatisticsProvider); + BoxAPI.api().getScheduler().scheduleRepeatingAsyncTask(this.updateTask, Duration.ofMinutes(1), this.updateTask::isRunning); + + BoxAPI.api().getListenerSubscriber().subscribe(PlayerCollectItemInfoEvent.class, LISTENER_KEY, event -> BoxEventHandlers.onPlayerCollectItemInfoCollect(event, this.languageProvider, this.stockStatisticsProvider, this.itemStatisticsProvider)); + } + + @Override + public void disable(FeatureContext.@NotNull Disabling context) { + BoxAPI.api().getListenerSubscriber().unsubscribeByKey(LISTENER_KEY); + + if (this.updateTask != null) { + this.updateTask.stop(); + } + + if (this.stockStatisticsMode != null) { + ClickModeRegistry.unregister(this.stockStatisticsMode); + } + + if (this.stockStatisticsProvider != null) { + this.stockStatisticsProvider.clearAllCache(); + } + + if (this.itemStatisticsProvider != null) { + this.itemStatisticsProvider.clearCache(); + } + } +} diff --git a/features/stats/src/main/java/net/okocraft/box/feature/stats/database/operator/ItemStatisticsOperator.java b/features/stats/src/main/java/net/okocraft/box/feature/stats/database/operator/ItemStatisticsOperator.java new file mode 100644 index 0000000000..72ebc371d7 --- /dev/null +++ b/features/stats/src/main/java/net/okocraft/box/feature/stats/database/operator/ItemStatisticsOperator.java @@ -0,0 +1,36 @@ +package net.okocraft.box.feature.stats.database.operator; + +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.function.BiConsumer; + +public class ItemStatisticsOperator { + + private final String selectTotalAmountByItemIdQuery; + + public ItemStatisticsOperator(String stockTableName) { + this.selectTotalAmountByItemIdQuery = """ + SELECT + item_id, + SUM(amount) as total_amount + FROM %1$s + GROUP BY item_id + """.formatted(stockTableName); + } + + public void selectTotalAmountByItemId(@NotNull Connection connection, @NotNull BiConsumer consumer) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(this.selectTotalAmountByItemIdQuery)) { + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + int itemId = resultSet.getInt("item_id"); + long totalAmount = resultSet.getLong("total_amount"); + consumer.accept(itemId, totalAmount); + } + } + } + } +} diff --git a/features/stats/src/main/java/net/okocraft/box/feature/stats/database/operator/StatisticsOperators.java b/features/stats/src/main/java/net/okocraft/box/feature/stats/database/operator/StatisticsOperators.java new file mode 100644 index 0000000000..d770702302 --- /dev/null +++ b/features/stats/src/main/java/net/okocraft/box/feature/stats/database/operator/StatisticsOperators.java @@ -0,0 +1,27 @@ +package net.okocraft.box.feature.stats.database.operator; + +import net.okocraft.box.storage.implementation.database.database.Database; + +import java.sql.Connection; +import java.sql.SQLException; + +public record StatisticsOperators( + StockStatisticsTableOperator stockStatisticsTableOperator, + ItemStatisticsOperator itemStatisticsTableOperator +) { + + public static StatisticsOperators create(Database database) { + return new StatisticsOperators( + new StockStatisticsTableOperator(database.tablePrefix(), database.operators().stockTable().tableName(), database.operators().stockHolderTable().tableName()), + new ItemStatisticsOperator(database.operators().stockTable().tableName()) + ); + } + + public void initTables(Database database) throws SQLException { + try (Connection connection = database.getConnection()) { + this.stockStatisticsTableOperator.initTable(connection); + this.stockStatisticsTableOperator.deleteNonExistingStockRecords(connection); + this.stockStatisticsTableOperator.updateTableRecords(connection); + } + } +} diff --git a/features/stats/src/main/java/net/okocraft/box/feature/stats/database/operator/StockStatisticsTableOperator.java b/features/stats/src/main/java/net/okocraft/box/feature/stats/database/operator/StockStatisticsTableOperator.java new file mode 100644 index 0000000000..1c6832281a --- /dev/null +++ b/features/stats/src/main/java/net/okocraft/box/feature/stats/database/operator/StockStatisticsTableOperator.java @@ -0,0 +1,140 @@ +package net.okocraft.box.feature.stats.database.operator; + +import it.unimi.dsi.fastutil.ints.IntCollection; +import net.okocraft.box.feature.stats.model.StockStatistics; +import net.okocraft.box.storage.implementation.database.operator.UUIDConverters; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.UUID; +import java.util.function.BiConsumer; + +public class StockStatisticsTableOperator { + + private static final String BULK_UPSERT_QUERY = """ + INSERT INTO %1$s (stock_id, item_id, amount, rank, percentage) + WITH global_stock_data AS ( + SELECT + stock_id, + item_id, + amount, + RANK() OVER stock_amount_by_item_id as rank, + SUM(amount) OVER stock_amount_by_item_id as total_amount + FROM %2$s + WINDOW stock_amount_by_item_id AS (PARTITION BY item_id ORDER BY amount DESC) + ) + SELECT + stock_id, + item_id, + amount, + rank, + ROUND( + CASE + WHEN total_amount = 0 THEN 0 + ELSE amount * 100.0 / total_amount + END, + 2 + ) as percentage + FROM global_stock_data + {WHERE_CLAUSE} + ON CONFLICT(stock_id, item_id) DO UPDATE SET + amount = EXCLUDED.amount, + rank = EXCLUDED.rank, + percentage = EXCLUDED.percentage + """; + + private final String createTableQuery; + private final String createItemIdIndexQuery; + private final String bulkUpsertQuery; + private final String bulkUpsertByStockIdQuery; + private final String deleteNonExistingStockRecordsQuery; + private final String selectRecordsByUuid; + + public StockStatisticsTableOperator(String tablePrefix, String stockTableName, String stockHolderTableName) { + String tableName = tablePrefix + "stock_statistics"; + + this.createTableQuery = """ + CREATE TABLE IF NOT EXISTS %1$s ( + stock_id INT NOT NULL, + item_id INT NOT NULL, + amount INT NOT NULL, + rank INT NOT NULL, + percentage FLOAT NOT NULL, + PRIMARY KEY (`stock_id`, `item_id`) + ) + """.formatted(tableName); + this.createItemIdIndexQuery = "CREATE INDEX IF NOT EXISTS idx_%1$s_item_id_rank ON %1$s (item_id, rank)".formatted(tableName); + this.bulkUpsertQuery = BULK_UPSERT_QUERY.formatted(tableName, stockTableName).replace("{WHERE_CLAUSE}", "WHERE TRUE"); + this.bulkUpsertByStockIdQuery = BULK_UPSERT_QUERY.formatted(tableName, stockTableName); + this.deleteNonExistingStockRecordsQuery = """ + DELETE FROM %1$s + WHERE NOT EXISTS ( + SELECT 1 + FROM %2$s + WHERE %1$s.stock_id = %2$s.stock_id AND %1$s.item_id = %2$s.item_id + ) + """.formatted(tableName, stockTableName); + this.selectRecordsByUuid = """ + SELECT %1$s.item_id as item_id, %1$s.amount as amount, %1$s.rank as rank, %1$s.percentage as percentage + FROM %1$s + INNER JOIN %2$s ON %1$s.stock_id = %2$s.stock_id AND %1$s.item_id = %2$s.item_id + INNER JOIN %3$s ON %2$s.stock_id = %3$s.id + WHERE %3$s.uuid = ? + """.formatted(tableName, stockTableName, stockHolderTableName); + } + + public void initTable(@NotNull Connection connection) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute(this.createTableQuery); + statement.execute(this.createItemIdIndexQuery); + } + } + + public void updateTableRecords(@NotNull Connection connection) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate(this.bulkUpsertQuery); + } + } + + public void updateTableRecordsByStockIds(@NotNull Connection connection, IntCollection stockIds) throws SQLException { + StringBuilder whereClause = new StringBuilder("WHERE stock_id IN ("); + boolean first = true; + for (int stockId : stockIds) { + if (!first) { + whereClause.append(", "); + } + whereClause.append(stockId); + first = false; + } + whereClause.append(")"); + + try (Statement statement = connection.createStatement()) { + statement.executeUpdate(this.bulkUpsertByStockIdQuery.replace("{WHERE_CLAUSE}", whereClause.toString())); + } + } + + public void deleteNonExistingStockRecords(@NotNull Connection connection) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate(this.deleteNonExistingStockRecordsQuery); + } + } + + public void selectRecordsByUuid(@NotNull Connection connection, @NotNull UUID uuid, @NotNull BiConsumer consumer) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(this.selectRecordsByUuid)) { + statement.setBytes(1, UUIDConverters.toBytes(uuid)); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + int itemId = resultSet.getInt("item_id"); + int amount = resultSet.getInt("amount"); + int rank = resultSet.getInt("rank"); + float percentage = resultSet.getFloat("percentage"); + consumer.accept(itemId, new StockStatistics(amount, rank, percentage)); + } + } + } + } +} diff --git a/features/stats/src/main/java/net/okocraft/box/feature/stats/gui/StockStatisticsMode.java b/features/stats/src/main/java/net/okocraft/box/feature/stats/gui/StockStatisticsMode.java new file mode 100644 index 0000000000..3bc8e88100 --- /dev/null +++ b/features/stats/src/main/java/net/okocraft/box/feature/stats/gui/StockStatisticsMode.java @@ -0,0 +1,179 @@ +package net.okocraft.box.feature.stats.gui; + +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import net.kyori.adventure.text.Component; +import net.okocraft.box.api.BoxAPI; +import net.okocraft.box.api.model.item.BoxItem; +import net.okocraft.box.api.model.stock.StockHolder; +import net.okocraft.box.api.util.BoxLogger; +import net.okocraft.box.feature.gui.api.button.Button; +import net.okocraft.box.feature.gui.api.button.ClickResult; +import net.okocraft.box.feature.gui.api.mode.AbstractStorageMode; +import net.okocraft.box.feature.gui.api.session.PlayerSession; +import net.okocraft.box.feature.gui.api.session.TypedKey; +import net.okocraft.box.feature.gui.api.util.ItemEditor; +import net.okocraft.box.feature.stats.model.StockStatistics; +import net.okocraft.box.feature.stats.provider.ItemStatisticsProvider; +import net.okocraft.box.feature.stats.provider.LanguageProvider; +import net.okocraft.box.feature.stats.provider.StockStatisticsProvider; +import org.bukkit.Material; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public class StockStatisticsMode extends AbstractStorageMode { + + private static final TypedKey IS_LOADING = TypedKey.of(Boolean.class, "stock-statistics:is-loading"); + + private static boolean isLoading(@NotNull PlayerSession session) { + Boolean isLoading = session.getData(IS_LOADING); + return isLoading != null && isLoading; + } + + private static void setLoading(@NotNull PlayerSession session) { + session.putData(IS_LOADING, true); + } + + public static void setNotLoading(@NotNull PlayerSession session) { + session.removeData(IS_LOADING); + } + + private final LanguageProvider languageProvider; + private final StockStatisticsProvider stockStatisticsProvider; + private final ItemStatisticsProvider itemStatisticsProvider; + + public StockStatisticsMode(LanguageProvider languageProvider, StockStatisticsProvider stockStatisticsProvider, ItemStatisticsProvider itemStatisticsProvider) { + this.languageProvider = languageProvider; + this.stockStatisticsProvider = stockStatisticsProvider; + this.itemStatisticsProvider = itemStatisticsProvider; + } + + @Override + public @NotNull Material getIconMaterial() { + return Material.KNOWLEDGE_BOOK; + } + + @Override + public @NotNull Component getDisplayName(@NotNull PlayerSession session) { + return this.languageProvider.modeDisplayName().asComponent(); + } + + @Override + public @NotNull ItemStack createItemIcon(@NotNull PlayerSession session, @NotNull BoxItem item) { + ItemEditor editor = ItemEditor.create(); + + editor.displayName(item.getOriginal().effectiveName()); + editor.loreEmptyLine(); + + StockHolder stockHolder = session.getSourceStockHolder(); + + Int2ObjectMap statisticsByItemId = this.stockStatisticsProvider.getIfLoaded(stockHolder.getUUID()); + if (statisticsByItemId == null) { + return editor.loreLine(this.languageProvider.guiLoading()) + .loreLine(this.languageProvider.guiClickToRefresh()) + .loreEmptyLine() + .applyTo(session.getViewer(), item.getClonedItem()); + } + + StockStatistics statistics = statisticsByItemId.getOrDefault(item.getInternalId(), StockStatistics.EMPTY); + if (statistics.rank() != 0) { + editor.displayName(this.languageProvider.guiItemDisplayNameWithRank().apply(item.getOriginal().effectiveName(), statistics.rank())); + } + + editor.loreLine(this.languageProvider.guiItemStock().apply((long) stockHolder.getAmount(item))); + editor.loreLine(this.languageProvider.guiItemStockPercentage().apply(statistics.percentage())); + + long itemTotalAmount = this.itemStatisticsProvider.getTotalAmountByItemId(item.getInternalId()); + float itemPercentage = this.itemStatisticsProvider.calculateItemPercentage(item.getInternalId()); + editor.loreLine(this.languageProvider.guiItemStockInServer().apply(itemTotalAmount)); + editor.loreLine(this.languageProvider.guiItemStockPercentageInServer().apply(itemPercentage)); + + editor.loreEmptyLine(); + + return editor.applyTo(session.getViewer(), item.getClonedItem()); + } + + @Override + public @NotNull ClickResult onSelect(@NotNull PlayerSession session) { + boolean isLoaded = this.stockStatisticsProvider.getIfLoaded(session.getSourceStockHolder().getUUID()) != null; + if (isLoaded) { + return ClickResult.UPDATE_ICONS; + } + + // To wait for loading using WaitingTask, always load the data even if isLoading is true + return this.scheduleLoading(session); + } + + @Override + public @NotNull ClickResult onClick(@NotNull PlayerSession session, @NotNull BoxItem item, @NotNull ClickType clickType) { + boolean isLoaded = this.stockStatisticsProvider.getIfLoaded(session.getSourceStockHolder().getUUID()) != null; + if (isLoaded) { + // When the statistics are already loaded, update a clicked button only + return ClickResult.UPDATE_BUTTON; + } else if (isLoading(session)) { + return ClickResult.NO_UPDATE_NEEDED; + } + + return this.scheduleLoading(session); + } + + @Override + public boolean hasAdditionalButton() { + return true; + } + + @Override + public @NotNull Button createAdditionalButton(@NotNull PlayerSession session, int slot) { + return new Button() { + @Override + public int getSlot() { + return slot; + } + + @Override + public @NotNull ItemStack createIcon(@NotNull PlayerSession session) { + ItemEditor editor = ItemEditor.create(); + + editor.displayName(StockStatisticsMode.this.languageProvider.guiGlobalDisplayName()); + editor.loreEmptyLine(); + + long globalStock = StockStatisticsMode.this.itemStatisticsProvider.getTotalAmount(); + editor.loreLine(StockStatisticsMode.this.languageProvider.guiGlobalStock().apply(globalStock)); + + editor.loreEmptyLine(); + + return editor.createItem(session.getViewer(), Material.NETHER_STAR); + } + + @Override + public @NotNull ClickResult onClick(@NotNull PlayerSession session, @NotNull ClickType clickType) { + return ClickResult.NO_UPDATE_NEEDED; + } + }; + } + + @Override + public boolean canUse(@NotNull PlayerSession session) { + return session.getViewer().hasPermission("box.stats"); + } + + private ClickResult.WaitingTask scheduleLoading(@NotNull PlayerSession session) { + setLoading(session); + + ClickResult.WaitingTask waitingTask = ClickResult.waitingTask(); + BoxAPI.api().getScheduler().runAsyncTask(() -> { + UUID uuid = session.getSourceStockHolder().getUUID(); + try { + this.stockStatisticsProvider.load(uuid); + setNotLoading(session); // If an exception occurs during loading, the menu will continue to display “Loading...” and will not attempt to reload within the session + } catch (Exception e) { + BoxLogger.logger().error("Failed to load stock statistics for {}", uuid, e); + } + + waitingTask.complete(ClickResult.UPDATE_ICONS); + }); + return waitingTask; + } +} diff --git a/features/stats/src/main/java/net/okocraft/box/feature/stats/listener/BoxEventHandlers.java b/features/stats/src/main/java/net/okocraft/box/feature/stats/listener/BoxEventHandlers.java new file mode 100644 index 0000000000..965d01d23e --- /dev/null +++ b/features/stats/src/main/java/net/okocraft/box/feature/stats/listener/BoxEventHandlers.java @@ -0,0 +1,43 @@ +package net.okocraft.box.feature.stats.listener; + +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import net.okocraft.box.api.event.player.PlayerCollectItemInfoEvent; +import net.okocraft.box.api.model.stock.StockHolder; +import net.okocraft.box.api.player.BoxPlayer; +import net.okocraft.box.api.util.BoxLogger; +import net.okocraft.box.feature.stats.model.StockStatistics; +import net.okocraft.box.feature.stats.provider.ItemStatisticsProvider; +import net.okocraft.box.feature.stats.provider.LanguageProvider; +import net.okocraft.box.feature.stats.provider.StockStatisticsProvider; +import org.jetbrains.annotations.NotNullByDefault; + +@NotNullByDefault +public final class BoxEventHandlers { + + public static void onPlayerCollectItemInfoCollect(PlayerCollectItemInfoEvent event, LanguageProvider languageProvider, StockStatisticsProvider stockStatisticsProvider, ItemStatisticsProvider itemStatisticsProvider) { + BoxPlayer player = event.getBoxPlayer(); + + if (!player.getPlayer().hasPermission("box.stats")) { + return; + } + + StockHolder stockHolder = player.getCurrentStockHolder(); + Int2ObjectMap statisticsByItemId; + try { + statisticsByItemId = stockStatisticsProvider.getOrLoad(stockHolder.getUUID()); + } catch (Exception e) { + BoxLogger.logger().error("Failed to load stock statistics for {}", stockHolder.getUUID(), e); + return; + } + + int itemId = event.getItem().getInternalId(); + StockStatistics statistics = statisticsByItemId.get(itemId); + if (statistics.rank() != 0) { + event.addInfo(languageProvider.commandBoxItemInfoStockPercentage().apply(statistics.percentage(), statistics.rank())); + } + + long itemTotalAmount = itemStatisticsProvider.getTotalAmountByItemId(itemId); + float itemPercentage = itemStatisticsProvider.calculateItemPercentage(itemId); + event.addInfo(languageProvider.commandBoxItemInfoStockInServer().apply(itemTotalAmount, itemPercentage)); + } +} diff --git a/features/stats/src/main/java/net/okocraft/box/feature/stats/model/StockStatistics.java b/features/stats/src/main/java/net/okocraft/box/feature/stats/model/StockStatistics.java new file mode 100644 index 0000000000..da656dcc8a --- /dev/null +++ b/features/stats/src/main/java/net/okocraft/box/feature/stats/model/StockStatistics.java @@ -0,0 +1,7 @@ +package net.okocraft.box.feature.stats.model; + +public record StockStatistics(int amount, int rank, float percentage) { + + public static final StockStatistics EMPTY = new StockStatistics(0, 0, 0.0f); + +} diff --git a/features/stats/src/main/java/net/okocraft/box/feature/stats/provider/ItemStatisticsProvider.java b/features/stats/src/main/java/net/okocraft/box/feature/stats/provider/ItemStatisticsProvider.java new file mode 100644 index 0000000000..a8d8d6f5dd --- /dev/null +++ b/features/stats/src/main/java/net/okocraft/box/feature/stats/provider/ItemStatisticsProvider.java @@ -0,0 +1,65 @@ +package net.okocraft.box.feature.stats.provider; + +import it.unimi.dsi.fastutil.ints.Int2LongMap; +import it.unimi.dsi.fastutil.ints.Int2LongOpenHashMap; +import net.okocraft.box.feature.stats.database.operator.ItemStatisticsOperator; +import net.okocraft.box.feature.stats.database.operator.StatisticsOperators; +import net.okocraft.box.storage.implementation.database.database.Database; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.sql.Connection; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +@NotNullByDefault +public class ItemStatisticsProvider { + + private final AtomicReference<@Nullable Int2LongMap> totalAmountByItemId = new AtomicReference<>(); + private final AtomicLong totalAmount = new AtomicLong(); + private final Database database; + private final ItemStatisticsOperator operator; + + public ItemStatisticsProvider(Database database, StatisticsOperators operators) { + this.database = database; + this.operator = operators.itemStatisticsTableOperator(); + } + + public long getTotalAmountByItemId(int itemId) { + Int2LongMap totalAmountByItemId = this.totalAmountByItemId.get(); + if (totalAmountByItemId == null) { + return 0; + } + return totalAmountByItemId.getOrDefault(itemId, 0); + } + + public long getTotalAmount() { + return this.totalAmount.get(); + } + + public float calculateItemPercentage(int itemId) { + long globalTotalAmount = this.getTotalAmount(); + long itemTotalAmount = this.getTotalAmountByItemId(itemId); + return globalTotalAmount == 0 ? 0 : (float) itemTotalAmount / (float) globalTotalAmount * 100.0F; + } + + public void refresh() throws Exception { + Int2LongMap totalAmountByItemId = new Int2LongOpenHashMap(); + AtomicLong newTotalAmount = new AtomicLong(); + + try (Connection connection = this.database.getConnection()) { + this.operator.selectTotalAmountByItemId(connection, (itemId, amount) -> { + totalAmountByItemId.put(itemId, amount.intValue()); + newTotalAmount.addAndGet(amount); + }); + } + + this.totalAmountByItemId.set(totalAmountByItemId); + this.totalAmount.set(newTotalAmount.get()); + } + + public void clearCache() { + this.totalAmountByItemId.set(null); + this.totalAmount.set(0); + } +} diff --git a/features/stats/src/main/java/net/okocraft/box/feature/stats/provider/LanguageProvider.java b/features/stats/src/main/java/net/okocraft/box/feature/stats/provider/LanguageProvider.java new file mode 100644 index 0000000000..f328c1eb98 --- /dev/null +++ b/features/stats/src/main/java/net/okocraft/box/feature/stats/provider/LanguageProvider.java @@ -0,0 +1,93 @@ +package net.okocraft.box.feature.stats.provider; + +import dev.siroshun.mcmsgdef.MessageKey; +import dev.siroshun.mcmsgdef.Placeholder; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ComponentLike; +import net.kyori.adventure.text.minimessage.translation.Argument; +import net.okocraft.box.api.message.DefaultMessageCollector; +import org.jetbrains.annotations.NotNullByDefault; + +@NotNullByDefault +public class LanguageProvider { + + private static final Placeholder AMOUNT_PLACEHOLDER = amount -> Argument.string("amount", String.format("%,d", amount)); + private static final Placeholder PERCENTAGE_PLACEHOLDER = percentage -> Argument.string("percentage", String.format("%.2f", percentage)); + private static final Placeholder RANK_PLACEHOLDER = rank -> Argument.numeric("rank", rank); + + private final MessageKey modeDisplayName; + private final MessageKey guiLoading; + private final MessageKey guiClickToRefresh; + private final MessageKey.Arg2 guiItemDisplayNameWithRank; + private final MessageKey.Arg1 guiItemStock; + private final MessageKey.Arg1 guiItemStockPercentage; + private final MessageKey.Arg1 guiItemStockInServer; + private final MessageKey.Arg1 guiItemStockPercentageInServer; + private final MessageKey guiGlobalDisplayName; + private final MessageKey.Arg1 guiGlobalStock; + private final MessageKey.Arg2 commandBoxItemInfoStockPercentage; + private final MessageKey.Arg2 commandBoxItemInfoStockInServer; + + public LanguageProvider(DefaultMessageCollector collector) { + this.modeDisplayName = MessageKey.key(collector.add("box.stats.mode.display-name", "Item Statistics")); + this.guiLoading = MessageKey.key(collector.add("box.stats.gui.loading", "Loading...")); + this.guiClickToRefresh = MessageKey.key(collector.add("box.stats.gui.click-to-refresh", "Click to refresh")); + this.guiItemDisplayNameWithRank = MessageKey.arg2(collector.add("box.stats.gui.item.display-name", " #"), item -> Argument.component("item", item), RANK_PLACEHOLDER); + this.guiItemStock = MessageKey.arg1(collector.add("box.stats.gui.item.stock", "Stock: "), AMOUNT_PLACEHOLDER); + this.guiItemStockPercentage = MessageKey.arg1(collector.add("box.stats.gui.item.stock-percentage", " % of all item stock"), PERCENTAGE_PLACEHOLDER); + this.guiItemStockInServer = MessageKey.arg1(collector.add("box.stats.gui.item.stock-in-server", "Server: "), AMOUNT_PLACEHOLDER); + this.guiItemStockPercentageInServer = MessageKey.arg1(collector.add("box.stats.gui.item.stock-percentage-in-server", " % of all stock"), PERCENTAGE_PLACEHOLDER); + this.guiGlobalDisplayName = MessageKey.key(collector.add("box.stats.gui.global.display-name", "Global Statistics")); + this.guiGlobalStock = MessageKey.arg1(collector.add("box.stats.gui.global.stock", "Total Stock: "), AMOUNT_PLACEHOLDER); + this.commandBoxItemInfoStockPercentage = MessageKey.arg2(collector.add("box.stats.command.box.iteminfo.stock-percentage", "Stock Statistics: % of all item stock (#)"), PERCENTAGE_PLACEHOLDER, RANK_PLACEHOLDER); + this.commandBoxItemInfoStockInServer = MessageKey.arg2(collector.add("box.stats.command.box.iteminfo.stock-in-server", "Server Stock: (% of all stock)"), AMOUNT_PLACEHOLDER, PERCENTAGE_PLACEHOLDER); + } + + public ComponentLike modeDisplayName() { + return this.modeDisplayName; + } + + public ComponentLike guiLoading() { + return this.guiLoading; + } + + public ComponentLike guiClickToRefresh() { + return this.guiClickToRefresh; + } + + public MessageKey.Arg2 guiItemDisplayNameWithRank() { + return this.guiItemDisplayNameWithRank; + } + + public MessageKey.Arg1 guiItemStock() { + return this.guiItemStock; + } + + public MessageKey.Arg1 guiItemStockPercentage() { + return this.guiItemStockPercentage; + } + + public MessageKey.Arg1 guiItemStockInServer() { + return this.guiItemStockInServer; + } + + public MessageKey.Arg1 guiItemStockPercentageInServer() { + return this.guiItemStockPercentageInServer; + } + + public MessageKey guiGlobalDisplayName() { + return this.guiGlobalDisplayName; + } + + public MessageKey.Arg1 guiGlobalStock() { + return this.guiGlobalStock; + } + + public MessageKey.Arg2 commandBoxItemInfoStockPercentage() { + return this.commandBoxItemInfoStockPercentage; + } + + public MessageKey.Arg2 commandBoxItemInfoStockInServer() { + return this.commandBoxItemInfoStockInServer; + } +} diff --git a/features/stats/src/main/java/net/okocraft/box/feature/stats/provider/StockStatisticsProvider.java b/features/stats/src/main/java/net/okocraft/box/feature/stats/provider/StockStatisticsProvider.java new file mode 100644 index 0000000000..753304d5a1 --- /dev/null +++ b/features/stats/src/main/java/net/okocraft/box/feature/stats/provider/StockStatisticsProvider.java @@ -0,0 +1,74 @@ +package net.okocraft.box.feature.stats.provider; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectMaps; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import net.okocraft.box.feature.stats.database.operator.StatisticsOperators; +import net.okocraft.box.feature.stats.database.operator.StockStatisticsTableOperator; +import net.okocraft.box.feature.stats.model.StockStatistics; +import net.okocraft.box.storage.implementation.database.database.Database; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.util.Set; +import java.util.UUID; + +@NotNullByDefault +public class StockStatisticsProvider { + + private final Cache> cache = CacheBuilder.newBuilder().expireAfterAccess(Duration.ofMinutes(15)).build(); + private final Database database; + private final StockStatisticsTableOperator operator; + + public StockStatisticsProvider(Database database, StatisticsOperators operators) { + this.database = database; + this.operator = operators.stockStatisticsTableOperator(); + } + + @SuppressWarnings("deprecation") + public Int2ObjectMap load(UUID uuid) throws Exception { + this.cache.invalidate(uuid); + + Int2ObjectMap statisticsByItemId = new Int2ObjectOpenHashMap<>(); + try (Connection connection = this.database.getConnection()) { + this.operator.selectRecordsByUuid(connection, uuid, statisticsByItemId::put); + } + + Int2ObjectMap unmodifiable = Int2ObjectMaps.unmodifiable(statisticsByItemId); + this.cache.put(uuid, unmodifiable); + + return unmodifiable; + } + + public @Nullable Int2ObjectMap getIfLoaded(UUID uuid) { + return this.cache.getIfPresent(uuid); + } + + public Int2ObjectMap getOrLoad(UUID uuid) throws Exception { + Int2ObjectMap statisticsByItemId = this.getIfLoaded(uuid); + if (statisticsByItemId != null) { + return statisticsByItemId; + } + return this.load(uuid); + } + + public void refresh(Set uuids) throws SQLException { + this.cache.invalidateAll(uuids); + + try (Connection connection = this.database.getConnection()) { + Object2IntMap idMap = this.database.operators().stockHolderTable().getAllStockHolderIdByUUID(connection); + this.operator.updateTableRecordsByStockIds(connection, IntArrayList.toList(uuids.stream().filter(idMap::containsKey).mapToInt(idMap::getInt))); + } + } + + public void clearAllCache() { + this.cache.invalidateAll(); + } +} diff --git a/features/stats/src/main/java/net/okocraft/box/feature/stats/task/StatisticsUpdateTask.java b/features/stats/src/main/java/net/okocraft/box/feature/stats/task/StatisticsUpdateTask.java new file mode 100644 index 0000000000..d670ac29ab --- /dev/null +++ b/features/stats/src/main/java/net/okocraft/box/feature/stats/task/StatisticsUpdateTask.java @@ -0,0 +1,77 @@ +package net.okocraft.box.feature.stats.task; + +import net.okocraft.box.api.BoxAPI; +import net.okocraft.box.api.player.BoxPlayerMap; +import net.okocraft.box.api.util.BoxLogger; +import net.okocraft.box.feature.stats.provider.ItemStatisticsProvider; +import net.okocraft.box.feature.stats.provider.StockStatisticsProvider; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNullByDefault; + +import java.sql.SQLException; +import java.time.Instant; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +@NotNullByDefault +public class StatisticsUpdateTask implements Runnable { + + private final StockStatisticsProvider stockStatisticsProvider; + private final ItemStatisticsProvider itemStatisticsProvider; + private final AtomicBoolean stopped = new AtomicBoolean(false); + + public StatisticsUpdateTask(StockStatisticsProvider stockStatisticsProvider, ItemStatisticsProvider itemStatisticsProvider) { + this.stockStatisticsProvider = stockStatisticsProvider; + this.itemStatisticsProvider = itemStatisticsProvider; + } + + @Override + public void run() { + Instant start = Instant.now(); + + try { + this.itemStatisticsProvider.refresh(); + } catch (Exception e) { + BoxLogger.logger().error("Failed to refresh item statistics", e); + } + + long tookForItemStatistics = start.until(Instant.now()).toMillis(); + start = Instant.now(); + + Collection players = Bukkit.getOnlinePlayers(); + Set stockUuidsToUpdate = HashSet.newHashSet(players.size()); + BoxPlayerMap playerMap = BoxAPI.api().getBoxPlayerMap(); + + for (Player player : players) { + if (playerMap.isLoaded(player)) { + stockUuidsToUpdate.add(playerMap.get(player).getCurrentStockHolder().getUUID()); + } + } + + if (!stockUuidsToUpdate.isEmpty()) { + try { + this.stockStatisticsProvider.refresh(stockUuidsToUpdate); + } catch (SQLException e) { + BoxLogger.logger().error("Failed to update statistics", e); + } + } + + long tookForStockStatistics = start.until(Instant.now()).toMillis(); + long total = tookForItemStatistics + tookForStockStatistics; + if (3000 < total) { + BoxLogger.logger().warn("Statistics update took {}ms ({}ms for item statistics, {}ms for stock statistics)", total, tookForItemStatistics, tookForStockStatistics); + } + } + + public boolean isRunning() { + return !this.stopped.get(); + } + + public void stop() { + this.stopped.set(true); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 382c96eac6..b4ae357b7c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,6 +46,7 @@ sequenceOf( "craft", "gui", "notifier", + "stats", "stick" ).forEach { include("$boxPrefix-$it-$featureSuffix")