Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion bundle/src/main/java/net/okocraft/box/bundle/Builtin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions bundle/src/main/resources/ja.properties
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,15 @@ box.stick.command.customstick.success=<gray>手に持っているアイテムを
box.stick.command.customstick.is-stick=<red>手に持っているアイテムはすでに Box Stick として使用できます。
box.stick.command.customstick.is-air=<red>手に何も持っていません。
box.stick.command.customstick.help=<aqua>/boxadmin customstick<dark_gray> - <gray>手持ちのアイテムを Box Stick にする
box.stats.mode.display-name=アイテム統計
box.stats.gui.loading=<gray>読み込み中...
box.stats.gui.click-to-refresh=<gray>クリックして再読み込み
box.stats.gui.item.display-name=<reset><item><reset> <aqua>#<rank>
box.stats.gui.item.stock=<gray>在庫数: <aqua><amount><gray>
box.stats.gui.item.stock-percentage=<dark_gray> サーバー内在庫の<percentage>%
box.stats.gui.item.stock-in-server=<gray>サーバー: <aqua><amount><gray>
box.stats.gui.item.stock-percentage-in-server=<dark_gray> すべてのアイテムの<percentage>%
box.stats.gui.global.display-name=<gold>全体統計
box.stats.gui.global.stock=<gray>総在庫数: <aqua><amount>
box.stats.command.box.iteminfo.stock-percentage=<gray>アイテム統計\: サーバー内在庫の<aqua><percentage>%<gray> (<aqua>#<rank><gray>)
box.stats.command.box.iteminfo.stock-in-server=<gray>サーバー内在庫\: <aqua><amount><gray> (すべてのアイテムの<aqua><percentage>%<gray>)
11 changes: 11 additions & 0 deletions features/stats/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Integer, Long> 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);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Integer, StockStatistics> 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));
}
}
}
}
}
Loading