From 7d8f5eb8adfefdc5bc89f61be3ef7a52e5600bd4 Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Wed, 22 Jul 2026 06:03:22 +0800 Subject: [PATCH 1/7] Fix GUI drag & econ config source --- src/main/java/org/cubexmc/metro/Metro.java | 2 +- .../java/org/cubexmc/metro/config/ConfigFacade.kt | 2 +- src/main/java/org/cubexmc/metro/gui/GuiListener.java | 11 ++++++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/cubexmc/metro/Metro.java b/src/main/java/org/cubexmc/metro/Metro.java index 55c7c3a..e666744 100644 --- a/src/main/java/org/cubexmc/metro/Metro.java +++ b/src/main/java/org/cubexmc/metro/Metro.java @@ -123,7 +123,7 @@ protected void enablePlugin() throws Exception { getLogger().info("Vault economy not found or disabled."); } this.ticketService = new org.cubexmc.metro.service.TicketService(this::getVaultIntegration, - () -> getConfig().getBoolean("economy.enabled", true)); + () -> configFacade.isEconomyEnabled()); this.priceService = new org.cubexmc.metro.service.PriceService(); this.lineStatusService = new org.cubexmc.metro.service.LineStatusService(this, lineManager); diff --git a/src/main/java/org/cubexmc/metro/config/ConfigFacade.kt b/src/main/java/org/cubexmc/metro/config/ConfigFacade.kt index 77abe53..8d40126 100644 --- a/src/main/java/org/cubexmc/metro/config/ConfigFacade.kt +++ b/src/main/java/org/cubexmc/metro/config/ConfigFacade.kt @@ -117,7 +117,7 @@ class ConfigFacade(private val plugin: Metro) { private var safeModePassengerRailBreakProtection = false private var safeModeMinCruiseSpeed = 0.0 private var safeModeStallRecoveryTicks = 0L - private var economyEnabled = false + private var economyEnabled = true private var selectionTool: Material = Material.GOLDEN_SHOVEL private var selectionToolName = "" diff --git a/src/main/java/org/cubexmc/metro/gui/GuiListener.java b/src/main/java/org/cubexmc/metro/gui/GuiListener.java index d2723b3..c3ea246 100644 --- a/src/main/java/org/cubexmc/metro/gui/GuiListener.java +++ b/src/main/java/org/cubexmc/metro/gui/GuiListener.java @@ -94,9 +94,14 @@ public void onInventoryClick(InventoryClickEvent event) { @EventHandler public void onInventoryDrag(InventoryDragEvent event) { - // 防止在 GUI 中拖拽物品 - if (event.getInventory().getHolder() instanceof GuiHolder) { - event.setCancelled(true); + if (event.getView().getTopInventory().getHolder() instanceof GuiHolder) { + int topSize = event.getView().getTopInventory().getSize(); + for (int slot : event.getRawSlots()) { + if (slot < topSize) { + event.setCancelled(true); + return; + } + } } } } From 531a55763064e895b40b8c815c9dc02bed9ed58f Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Wed, 22 Jul 2026 06:16:59 +0800 Subject: [PATCH 2/7] Migrate workflows from Maven to Gradle Update CI and release GitHub Actions to use Gradle instead of Maven. Changes in .github/workflows/ci.yml and release.yml: switch cache to "gradle", add gradle/actions/setup-gradle@v4 step, replace mvn commands with "gradle clean build shadowJar", and update artifact paths from target/metro-*.jar to build/libs/metro-*.jar (including release upload). --- .github/workflows/ci.yml | 9 ++++++--- .github/workflows/release.yml | 11 +++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f180a0..b62557c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,17 @@ jobs: with: distribution: temurin java-version: "17" - cache: maven + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 - name: Verify quality gates and build artifact - run: mvn -B clean verify package + run: gradle clean build shadowJar - name: Upload metro jar uses: actions/upload-artifact@v4 with: name: metro-jar - path: target/metro-*.jar + path: build/libs/metro-*.jar diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f11794..231be10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,20 +26,23 @@ jobs: with: distribution: temurin java-version: "17" - cache: maven + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 - name: Verify quality gates and build artifact - run: mvn -B clean verify package + run: gradle clean build shadowJar - name: Upload artifact uses: actions/upload-artifact@v4 with: name: metro-jar - path: target/metro-*.jar + path: build/libs/metro-*.jar - name: Create GitHub release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event.inputs.tag || github.ref_name }} name: ${{ github.event.inputs.release_name || github.ref_name }} - files: target/metro-*.jar + files: build/libs/metro-*.jar From f09cdb65731d3b88dff4f7aebecee8fe1985e8c1 Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Wed, 22 Jul 2026 06:38:04 +0800 Subject: [PATCH 3/7] Migrate CI and release workflows to Maven Replace Gradle steps with Maven in .github/workflows/ci.yml and .github/workflows/release.yml: switch cache from "gradle" to "maven", remove the setup-gradle action, run `mvn -B clean verify package` instead of Gradle tasks, update artifact paths from `build/libs/metro-*.jar` to `target/metro-*.jar`, and adjust the release action to use the Maven-built artifact. This aligns CI and release pipelines with a Maven build setup. --- .github/workflows/ci.yml | 9 +++------ .github/workflows/release.yml | 11 ++++------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b62557c..0f180a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,17 +18,14 @@ jobs: with: distribution: temurin java-version: "17" - cache: gradle - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + cache: maven - name: Verify quality gates and build artifact - run: gradle clean build shadowJar + run: mvn -B clean verify package - name: Upload metro jar uses: actions/upload-artifact@v4 with: name: metro-jar - path: build/libs/metro-*.jar + path: target/metro-*.jar diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 231be10..2f11794 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,23 +26,20 @@ jobs: with: distribution: temurin java-version: "17" - cache: gradle - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + cache: maven - name: Verify quality gates and build artifact - run: gradle clean build shadowJar + run: mvn -B clean verify package - name: Upload artifact uses: actions/upload-artifact@v4 with: name: metro-jar - path: build/libs/metro-*.jar + path: target/metro-*.jar - name: Create GitHub release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event.inputs.tag || github.ref_name }} name: ${{ github.event.inputs.release_name || github.ref_name }} - files: build/libs/metro-*.jar + files: target/metro-*.jar From ae72cdb4b4b0cc54474176a9c81edff4f67c040d Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Wed, 22 Jul 2026 11:05:58 +0800 Subject: [PATCH 4/7] Prevent mid-ride dismount; adjust fare settlement Disallow players from dismounting while the train is moving by cancelling VehicleExitEvent when not at a stop and sending a localized message; clear scoreboard only after a valid dismount. Add `cannot_dismount` localization entries for multiple languages. In TrainMovementTask, call settleDistanceFare(stop) before early-return so distance fare is always settled even if stopPointLocation is null, and update session.entryStopId when consuming distance so INTERVAL mode calculates the next segment from the current stop. Modified VehicleListener, TrainMovementTask.kt and several lang/*.yml files. --- .../org/cubexmc/metro/listener/VehicleListener.java | 13 ++++++++++--- .../org/cubexmc/metro/train/TrainMovementTask.kt | 8 ++++++-- src/main/resources/lang/de_DE.yml | 3 ++- src/main/resources/lang/en_US.yml | 1 + src/main/resources/lang/es_ES.yml | 3 ++- src/main/resources/lang/nl_NL.yml | 3 ++- src/main/resources/lang/tr_TR.yml | 1 + src/main/resources/lang/zh_CN.yml | 3 ++- src/main/resources/lang/zh_TW.yml | 1 + 9 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/cubexmc/metro/listener/VehicleListener.java b/src/main/java/org/cubexmc/metro/listener/VehicleListener.java index e1d3a8c..b9240f4 100644 --- a/src/main/java/org/cubexmc/metro/listener/VehicleListener.java +++ b/src/main/java/org/cubexmc/metro/listener/VehicleListener.java @@ -61,12 +61,19 @@ public void onVehicleExit(VehicleExitEvent event) { return; } - // 玩家下车,清除其界面显示 - plugin.getScoreboardManager().clearPlayerDisplay(player); - // 获取当前位置 Location location = minecart.getLocation(); + // 检查位置是否在停靠区上,如果不在则禁止下车 + if (!isAtStop(location)) { + event.setCancelled(true); + player.sendMessage(plugin.getLanguageManager().getMessage("vehicle.cannot_dismount")); + return; + } + + // 玩家下车,清除其界面显示 + plugin.getScoreboardManager().clearPlayerDisplay(player); + // 检查位置是否在停靠区上 if (!isAtStop(location)) { // 如果不在停靠区上,立即移除矿车 diff --git a/src/main/java/org/cubexmc/metro/train/TrainMovementTask.kt b/src/main/java/org/cubexmc/metro/train/TrainMovementTask.kt index 16a01c7..966f79e 100644 --- a/src/main/java/org/cubexmc/metro/train/TrainMovementTask.kt +++ b/src/main/java/org/cubexmc/metro/train/TrainMovementTask.kt @@ -199,6 +199,9 @@ class TrainMovementTask @JvmOverloads constructor( movementAssistController.stop() session.plugin.bedrockCompatibility.onTrainArrival(session.passenger, minecart) + // 先结算里程费用,确保即使 stopPointLocation 为 null 也能正常扣费 + settleDistanceFare(stop) + val baseLocation = stop.stopPointLocation ?: return val snapLocation = baseLocation.clone() snapLocation.x = snapLocation.blockX + 0.5 @@ -208,8 +211,6 @@ class TrainMovementTask @JvmOverloads constructor( session.plugin.routeRecorder.sample(line.id, minecart, snapLocation) } - settleDistanceFare(stop) - val previousState = stateMachine.transitionTo(TrainState.STOPPED_AT_STATION, null) if (previousState == TrainState.MOVING_IN_STATION) { handleArrivalAtStation() @@ -258,6 +259,9 @@ class TrainMovementTask @JvmOverloads constructor( } session.addDistance(-distance) + + // 更新 entryStopId 为当前站,确保 INTERVAL 模式下下一段区间从当前站开始计算 + session.entryStopId = stop.id } private fun transitionToMovingInStation(targetStop: Stop) { diff --git a/src/main/resources/lang/de_DE.yml b/src/main/resources/lang/de_DE.yml index 9c349e6..9c61dfd 100644 --- a/src/main/resources/lang/de_DE.yml +++ b/src/main/resources/lang/de_DE.yml @@ -571,7 +571,8 @@ interact: vehicle: terminal_arrived: "Endstation erreicht: " auto_depart: "Automatische Abfahrt: von nach " - passenger_exit: "Spieler ist ausgestiegen, fährt nicht automatisch ab" + passenger_exit: "Spieler ist ausgestiegen, fährt nicht automatisch ab" + cannot_dismount: "Du kannst nicht aussteigen, während der Zug fährt!" chat: enter_new_name: "Bitte neuen Namen eingeben. Tippe 'cancel' zum Abbrechen." diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index 3e146b6..b93237e 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -573,6 +573,7 @@ vehicle: terminal_arrived: "Arrived at terminal station: " auto_depart: "Auto departure: From to " passenger_exit: "Player has alighted, will not auto depart" + cannot_dismount: "You cannot dismount while the train is moving!" chat: enter_new_name: "Please enter the new name. Type 'cancel' to cancel." diff --git a/src/main/resources/lang/es_ES.yml b/src/main/resources/lang/es_ES.yml index 6bc2ee8..00f1b05 100644 --- a/src/main/resources/lang/es_ES.yml +++ b/src/main/resources/lang/es_ES.yml @@ -572,7 +572,8 @@ interact: vehicle: terminal_arrived: "Estación terminal alcanzada: " auto_depart: "Salida automática: de a " - passenger_exit: "El jugador se ha bajado, no saldrá automáticamente" + passenger_exit: "El jugador se ha bajado, no saldrá automáticamente" + cannot_dismount: "¡No puedes bajarte mientras el tren está en movimiento!" chat: enter_new_name: "Por favor ingresa el nuevo nombre. Escribe 'cancel' para abortar." diff --git a/src/main/resources/lang/nl_NL.yml b/src/main/resources/lang/nl_NL.yml index 3eab7b1..6b29f19 100644 --- a/src/main/resources/lang/nl_NL.yml +++ b/src/main/resources/lang/nl_NL.yml @@ -572,7 +572,8 @@ interact: vehicle: terminal_arrived: "Eindstation bereikt: " auto_depart: "Automatisch vertrek: van naar " - passenger_exit: "Speler is uitgestapt, vertrekt niet automatisch" + passenger_exit: "Speler is uitgestapt, vertrekt niet automatisch" + cannot_dismount: "Je kunt niet uitstappen terwijl de trein rijdt!" chat: enter_new_name: "Voer een nieuwe naam in. Typ 'cancel' om te annuleren." diff --git a/src/main/resources/lang/tr_TR.yml b/src/main/resources/lang/tr_TR.yml index 213ec11..73becf9 100644 --- a/src/main/resources/lang/tr_TR.yml +++ b/src/main/resources/lang/tr_TR.yml @@ -573,6 +573,7 @@ vehicle: terminal_arrived: "Son durağa varıldı: " auto_depart: "Otomatik kalkış: durağından durağına" passenger_exit: "Oyuncu indi, otomatik kalkış yapılmayacak" + cannot_dismount: "Tren hareket halindeyken inemezsiniz!" chat: enter_new_name: "Lütfen yeni adı girin. İptal etmek için 'cancel' yazın." diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index 1d5baf3..ea76f5d 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -439,7 +439,8 @@ interact: vehicle: terminal_arrived: "已到达终点站: " auto_depart: "自动发车: 从 前往 " - passenger_exit: "玩家已下车,不会自动发车" + passenger_exit: "玩家已下车,不会自动发车" + cannot_dismount: "列车行驶中,禁止下车!" ownership: server: "服务器" diff --git a/src/main/resources/lang/zh_TW.yml b/src/main/resources/lang/zh_TW.yml index 26ac63e..6c5277b 100644 --- a/src/main/resources/lang/zh_TW.yml +++ b/src/main/resources/lang/zh_TW.yml @@ -440,6 +440,7 @@ vehicle: terminal_arrived: "已抵達終點站: " auto_depart: "自動發車: 從 前往 " passenger_exit: "玩家已下車,將不會自動發車" + cannot_dismount: "列車行駛中,禁止下車!" ownership: server: "伺服器" From 77035e5129b012baf7d5da9852938d72590c4bb6 Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Wed, 22 Jul 2026 11:09:50 +0800 Subject: [PATCH 5/7] Enforce maxPrice cap and apply time discount Train fare calculation: add totalVariableCharged to TrainSession and enforce a rule.maxPrice cap across the trip when computing variable fares. Compute raw variable price, limit it by remaining maxPrice (minus basePrice and already charged variable amount), apply an in-game time-based discount to produce the final charged amount, and charge/format the finalPrice to the passenger. Update totalVariableCharged using the pre-discount variable amount so the maxPrice cap behaves correctly. (Changes in TrainMovementTask.kt and TrainSession.kt) --- .../cubexmc/metro/train/TrainMovementTask.kt | 31 ++++++++++++++++--- .../org/cubexmc/metro/train/TrainSession.kt | 2 ++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/cubexmc/metro/train/TrainMovementTask.kt b/src/main/java/org/cubexmc/metro/train/TrainMovementTask.kt index 966f79e..7c3ed73 100644 --- a/src/main/java/org/cubexmc/metro/train/TrainMovementTask.kt +++ b/src/main/java/org/cubexmc/metro/train/TrainMovementTask.kt @@ -232,7 +232,7 @@ class TrainMovementTask @JvmOverloads constructor( return } - val variablePrice = when (rule.getMode()) { + val rawVariablePrice = when (rule.getMode()) { PriceRule.PricingMode.DISTANCE -> distance * rule.getPerBlockRate() PriceRule.PricingMode.INTERVAL -> { val intervals = session.plugin.priceService.countStopIntervals(line, session.entryStopId, stop.id) @@ -242,16 +242,39 @@ class TrainMovementTask @JvmOverloads constructor( else -> return } - if (variablePrice > 0) { - val status = session.plugin.ticketService.chargePrice(passenger, line, variablePrice) + // 应用 maxPrice 上限:整个行程的总费用不得超过 maxPrice + val variablePrice = if (rawVariablePrice > 0) { + val maxPrice = rule.getMaxPrice() + if (maxPrice > 0.0) { + val remaining = maxPrice - rule.getBasePrice() - session.totalVariableCharged + if (remaining <= 0) 0.0 else minOf(rawVariablePrice, remaining) + } else { + rawVariablePrice + } + } else { + 0.0 + } + + // 应用时间折扣 + val gameTime = passenger.world?.time ?: 6000L + val discount = rule.getActiveDiscountMultiplier(gameTime) + val finalPrice = if (variablePrice > 0 && discount < 1.0) { + variablePrice * discount + } else { + variablePrice + } + + if (finalPrice > 0) { + val status = session.plugin.ticketService.chargePrice(passenger, line, finalPrice) if (status == TicketService.TicketChargeStatus.CHARGED) { + session.totalVariableCharged += variablePrice // 按上限前价格记录,确保 maxPrice 正确生效 passenger.sendMessage( session.plugin.languageManager.getMessage( "economy.paid_distance", LanguageManager.put( LanguageManager.args(), "price", - session.plugin.ticketService.format(variablePrice), + session.plugin.ticketService.format(finalPrice), ), ), ) diff --git a/src/main/java/org/cubexmc/metro/train/TrainSession.kt b/src/main/java/org/cubexmc/metro/train/TrainSession.kt index 313693b..92d0564 100644 --- a/src/main/java/org/cubexmc/metro/train/TrainSession.kt +++ b/src/main/java/org/cubexmc/metro/train/TrainSession.kt @@ -26,6 +26,8 @@ class TrainSession( var entryStopId: String? = currentStopId var distanceTraveled: Double = 0.0 private set + /** 整个行程中已收取的可变费用总额(不含 basePrice),用于 maxPrice 上限检查 */ + var totalVariableCharged: Double = 0.0 fun addDistance(blocks: Double): Double { distanceTraveled += blocks From f62788cffdbcea9f6f2412a3674c709d5f565899 Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Wed, 22 Jul 2026 11:14:59 +0800 Subject: [PATCH 6/7] Use direct config lookup; default economy disabled Construct TicketService using getConfig().getBoolean("economy.enabled", true) instead of ConfigFacade.isEconomyEnabled(), and change ConfigFacade's default economyEnabled from true to false. Affects Metro.java and ConfigFacade.kt. Ensures ticket service reads the current config value and makes economy opt-in by default to avoid accidental enablement. --- src/main/java/org/cubexmc/metro/Metro.java | 2 +- src/main/java/org/cubexmc/metro/config/ConfigFacade.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/cubexmc/metro/Metro.java b/src/main/java/org/cubexmc/metro/Metro.java index e666744..55c7c3a 100644 --- a/src/main/java/org/cubexmc/metro/Metro.java +++ b/src/main/java/org/cubexmc/metro/Metro.java @@ -123,7 +123,7 @@ protected void enablePlugin() throws Exception { getLogger().info("Vault economy not found or disabled."); } this.ticketService = new org.cubexmc.metro.service.TicketService(this::getVaultIntegration, - () -> configFacade.isEconomyEnabled()); + () -> getConfig().getBoolean("economy.enabled", true)); this.priceService = new org.cubexmc.metro.service.PriceService(); this.lineStatusService = new org.cubexmc.metro.service.LineStatusService(this, lineManager); diff --git a/src/main/java/org/cubexmc/metro/config/ConfigFacade.kt b/src/main/java/org/cubexmc/metro/config/ConfigFacade.kt index 8d40126..77abe53 100644 --- a/src/main/java/org/cubexmc/metro/config/ConfigFacade.kt +++ b/src/main/java/org/cubexmc/metro/config/ConfigFacade.kt @@ -117,7 +117,7 @@ class ConfigFacade(private val plugin: Metro) { private var safeModePassengerRailBreakProtection = false private var safeModeMinCruiseSpeed = 0.0 private var safeModeStallRecoveryTicks = 0L - private var economyEnabled = true + private var economyEnabled = false private var selectionTool: Material = Material.GOLDEN_SHOVEL private var selectionToolName = "" From 92e5702bf3449de9015f11ebed7cd1482c1b3dac Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Wed, 22 Jul 2026 11:42:07 +0800 Subject: [PATCH 7/7] Improve Vault handling, GUI checks and refunds Add dynamic Vault provider refresh and event listeners (VaultIntegration.kt) so the plugin detects economy services at runtime and on service register/unregister. Register and refresh Vault on plugin command load (MetroMainCommand) and use configFacade.isEconomyEnabled() in Metro. Fix GUI inventory handling to use InventoryView.getTopInventory() and correct slot bounds checks (GuiListener). Make TicketService robust to owner deposit failures by refunding the passenger and returning TRANSACTION_FAILED. Update and add unit tests for VaultIntegration, GuiListener, and TicketService to cover the new behavior. Co-Authored-By: Shu Hu --- src/main/java/org/cubexmc/metro/Metro.java | 2 +- .../command/newcmd/MetroMainCommand.java | 3 + .../org/cubexmc/metro/gui/GuiListener.java | 6 +- .../metro/integration/VaultIntegration.kt | 42 ++++- .../cubexmc/metro/service/TicketService.kt | 12 +- .../cubexmc/metro/gui/GuiListenerTest.java | 55 +++++-- .../integration/VaultIntegrationTest.java | 144 ++++++++++++++++++ .../metro/service/TicketServiceTest.java | 78 ++++++++++ 8 files changed, 314 insertions(+), 28 deletions(-) create mode 100644 src/test/java/org/cubexmc/metro/integration/VaultIntegrationTest.java diff --git a/src/main/java/org/cubexmc/metro/Metro.java b/src/main/java/org/cubexmc/metro/Metro.java index 55c7c3a..e666744 100644 --- a/src/main/java/org/cubexmc/metro/Metro.java +++ b/src/main/java/org/cubexmc/metro/Metro.java @@ -123,7 +123,7 @@ protected void enablePlugin() throws Exception { getLogger().info("Vault economy not found or disabled."); } this.ticketService = new org.cubexmc.metro.service.TicketService(this::getVaultIntegration, - () -> getConfig().getBoolean("economy.enabled", true)); + () -> configFacade.isEconomyEnabled()); this.priceService = new org.cubexmc.metro.service.PriceService(); this.lineStatusService = new org.cubexmc.metro.service.LineStatusService(this, lineManager); diff --git a/src/main/java/org/cubexmc/metro/command/newcmd/MetroMainCommand.java b/src/main/java/org/cubexmc/metro/command/newcmd/MetroMainCommand.java index 383ae6f..05f0a4e 100644 --- a/src/main/java/org/cubexmc/metro/command/newcmd/MetroMainCommand.java +++ b/src/main/java/org/cubexmc/metro/command/newcmd/MetroMainCommand.java @@ -89,6 +89,9 @@ public void reload(CommandSender sender) { plugin.getPortalManager().load(); } plugin.getLanguageManager().loadLanguages(); + if (plugin.getVaultIntegration() != null) { + plugin.getVaultIntegration().refreshProvider(); + } plugin.refreshMapIntegrations(); diff --git a/src/main/java/org/cubexmc/metro/gui/GuiListener.java b/src/main/java/org/cubexmc/metro/gui/GuiListener.java index c3ea246..7cfd3d3 100644 --- a/src/main/java/org/cubexmc/metro/gui/GuiListener.java +++ b/src/main/java/org/cubexmc/metro/gui/GuiListener.java @@ -46,10 +46,10 @@ public GuiListener(Metro plugin) { @EventHandler public void onInventoryClick(InventoryClickEvent event) { - Inventory inv = event.getInventory(); + Inventory topInv = event.getView().getTopInventory(); // 检查是否是我们的 GUI - if (!(inv.getHolder() instanceof GuiHolder holder)) { + if (!(topInv.getHolder() instanceof GuiHolder holder)) { return; } @@ -64,7 +64,7 @@ public void onInventoryClick(InventoryClickEvent event) { int slot = event.getRawSlot(); // 忽略点击 GUI 外部 - if (slot < 0 || slot >= inv.getSize()) { + if (slot < 0 || slot >= topInv.getSize()) { return; } diff --git a/src/main/java/org/cubexmc/metro/integration/VaultIntegration.kt b/src/main/java/org/cubexmc/metro/integration/VaultIntegration.kt index 0173592..fbc0eec 100644 --- a/src/main/java/org/cubexmc/metro/integration/VaultIntegration.kt +++ b/src/main/java/org/cubexmc/metro/integration/VaultIntegration.kt @@ -4,21 +4,47 @@ import java.util.UUID import net.milkbowl.vault.economy.Economy import org.bukkit.Bukkit import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.plugin.ServiceRegisterEvent +import org.bukkit.plugin.ServiceUnregisterEvent import org.cubexmc.metro.Metro -class VaultIntegration(private val plugin: Metro) { +class VaultIntegration(private val plugin: Metro) : Listener { var economy: Economy? = null private set - var isEnabled: Boolean = setupEconomy() + var isEnabled: Boolean = false private set - private fun setupEconomy(): Boolean { - if (plugin.server.pluginManager.getPlugin("Vault") == null) { - return false + init { + refreshProvider() + plugin.server.pluginManager.registerEvents(this, plugin) + } + + /** + * Re-queries ServicesManager for the current Economy provider. + * Safe to call at any time; does not require the "Vault" plugin to be loaded. + */ + fun refreshProvider() { + val registration = plugin.server.servicesManager.getRegistration(Economy::class.java) + economy = registration?.provider + isEnabled = economy != null + } + + @EventHandler(priority = EventPriority.MONITOR) + fun onServiceRegister(event: ServiceRegisterEvent) { + if (event.provider is Economy) { + refreshProvider() + } + } + + @EventHandler(priority = EventPriority.MONITOR) + fun onServiceUnregister(event: ServiceUnregisterEvent) { + if (event.provider is Economy) { + economy = null + isEnabled = false } - val registration = plugin.server.servicesManager.getRegistration(Economy::class.java) ?: return false - economy = registration.provider - return economy != null } fun has(player: Player, amount: Double): Boolean { diff --git a/src/main/java/org/cubexmc/metro/service/TicketService.kt b/src/main/java/org/cubexmc/metro/service/TicketService.kt index b598b88..7a7a9f8 100644 --- a/src/main/java/org/cubexmc/metro/service/TicketService.kt +++ b/src/main/java/org/cubexmc/metro/service/TicketService.kt @@ -190,7 +190,11 @@ class TicketService( transaction.markCharged() val owner: UUID? = transaction.line.owner if (owner != null) { - vault.deposit(owner, transaction.price) + if (!vault.deposit(owner, transaction.price)) { + // Owner deposit failed: refund the passenger and report failure + vault.deposit(transaction.player.uniqueId, transaction.price) + return TicketChargeStatus.TRANSACTION_FAILED + } } return TicketChargeStatus.CHARGED } @@ -261,7 +265,11 @@ class TicketService( } val owner = line.owner if (owner != null) { - vault.deposit(owner, priceToCharge) + if (!vault.deposit(owner, priceToCharge)) { + // Owner deposit failed: refund the passenger and report failure + vault.deposit(player.uniqueId, priceToCharge) + return TicketChargeStatus.TRANSACTION_FAILED + } } return TicketChargeStatus.CHARGED } diff --git a/src/test/java/org/cubexmc/metro/gui/GuiListenerTest.java b/src/test/java/org/cubexmc/metro/gui/GuiListenerTest.java index 4bf2f8c..ab209a4 100644 --- a/src/test/java/org/cubexmc/metro/gui/GuiListenerTest.java +++ b/src/test/java/org/cubexmc/metro/gui/GuiListenerTest.java @@ -5,23 +5,32 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.Set; + import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryDragEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.InventoryView; import org.cubexmc.metro.Metro; import org.junit.jupiter.api.Test; class GuiListenerTest { + private InventoryView viewWithTop(Inventory topInventory) { + InventoryView view = mock(InventoryView.class); + when(view.getTopInventory()).thenReturn(topInventory); + return view; + } + @Test void shouldIgnoreInventoryClicksOutsideMetroGui() { GuiListener listener = new GuiListener(mock(Metro.class)); InventoryClickEvent event = mock(InventoryClickEvent.class); - Inventory inventory = mock(Inventory.class); + Inventory topInventory = mock(Inventory.class); - when(event.getInventory()).thenReturn(inventory); - when(inventory.getHolder()).thenReturn(mock(InventoryHolder.class)); + when(event.getView()).thenReturn(viewWithTop(topInventory)); + when(topInventory.getHolder()).thenReturn(mock(InventoryHolder.class)); listener.onInventoryClick(event); @@ -32,12 +41,12 @@ void shouldIgnoreInventoryClicksOutsideMetroGui() { void shouldCancelMetroGuiClicksBeforeIgnoringOutsideSlots() { GuiListener listener = new GuiListener(mock(Metro.class)); InventoryClickEvent event = mock(InventoryClickEvent.class); - Inventory inventory = mock(Inventory.class); + Inventory topInventory = mock(Inventory.class); GuiHolder holder = new GuiHolder(GuiHolder.GuiType.MAIN_MENU); - when(event.getInventory()).thenReturn(inventory); - when(inventory.getHolder()).thenReturn(holder); - when(inventory.getSize()).thenReturn(9); + when(event.getView()).thenReturn(viewWithTop(topInventory)); + when(topInventory.getHolder()).thenReturn(holder); + when(topInventory.getSize()).thenReturn(9); when(event.getRawSlot()).thenReturn(99); listener.onInventoryClick(event); @@ -46,27 +55,45 @@ void shouldCancelMetroGuiClicksBeforeIgnoringOutsideSlots() { } @Test - void shouldCancelDraggingInsideMetroGui() { + void shouldCancelDragWhenSlotsAffectTopInventory() { GuiListener listener = new GuiListener(mock(Metro.class)); InventoryDragEvent event = mock(InventoryDragEvent.class); - Inventory inventory = mock(Inventory.class); + Inventory topInventory = mock(Inventory.class); - when(event.getInventory()).thenReturn(inventory); - when(inventory.getHolder()).thenReturn(new GuiHolder(GuiHolder.GuiType.LINE_LIST)); + when(event.getView()).thenReturn(viewWithTop(topInventory)); + when(topInventory.getHolder()).thenReturn(new GuiHolder(GuiHolder.GuiType.LINE_LIST)); + when(topInventory.getSize()).thenReturn(27); + when(event.getRawSlots()).thenReturn(Set.of(5, 12)); listener.onInventoryDrag(event); verify(event).setCancelled(true); } + @Test + void shouldAllowDragConfinedToPlayerLowerInventory() { + GuiListener listener = new GuiListener(mock(Metro.class)); + InventoryDragEvent event = mock(InventoryDragEvent.class); + Inventory topInventory = mock(Inventory.class); + + when(event.getView()).thenReturn(viewWithTop(topInventory)); + when(topInventory.getHolder()).thenReturn(new GuiHolder(GuiHolder.GuiType.LINE_LIST)); + when(topInventory.getSize()).thenReturn(27); + when(event.getRawSlots()).thenReturn(Set.of(27, 36, 45)); + + listener.onInventoryDrag(event); + + verify(event, never()).setCancelled(true); + } + @Test void shouldIgnoreDraggingOutsideMetroGui() { GuiListener listener = new GuiListener(mock(Metro.class)); InventoryDragEvent event = mock(InventoryDragEvent.class); - Inventory inventory = mock(Inventory.class); + Inventory topInventory = mock(Inventory.class); - when(event.getInventory()).thenReturn(inventory); - when(inventory.getHolder()).thenReturn(null); + when(event.getView()).thenReturn(viewWithTop(topInventory)); + when(topInventory.getHolder()).thenReturn(null); listener.onInventoryDrag(event); diff --git a/src/test/java/org/cubexmc/metro/integration/VaultIntegrationTest.java b/src/test/java/org/cubexmc/metro/integration/VaultIntegrationTest.java new file mode 100644 index 0000000..f0e8740 --- /dev/null +++ b/src/test/java/org/cubexmc/metro/integration/VaultIntegrationTest.java @@ -0,0 +1,144 @@ +package org.cubexmc.metro.integration; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import net.milkbowl.vault.economy.Economy; +import net.milkbowl.vault.economy.EconomyResponse; + +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.ServicesManager; +import org.bukkit.plugin.ServicePriority; +import org.cubexmc.metro.Metro; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class VaultIntegrationTest { + + private Metro plugin; + private Server server; + private PluginManager pluginManager; + private ServicesManager servicesManager; + private Economy economy; + private EconomyResponse successResponse; + + @BeforeEach + void setUp() { + plugin = mock(Metro.class); + server = mock(Server.class); + pluginManager = mock(PluginManager.class); + servicesManager = mock(ServicesManager.class); + economy = mock(Economy.class); + successResponse = mock(EconomyResponse.class); + + when(plugin.getServer()).thenReturn(server); + when(server.getServicesManager()).thenReturn(servicesManager); + when(server.getPluginManager()).thenReturn(pluginManager); + when(successResponse.transactionSuccess()).thenReturn(true); + } + + @Test + void shouldBeDisabledWhenNoEconomyProvider() { + when(servicesManager.getRegistration(eq(Economy.class))).thenReturn(null); + + VaultIntegration vault = new VaultIntegration(plugin); + + assertFalse(vault.isEnabled()); + assertNull(vault.economy); + } + + @Test + void shouldResolveProviderAtConstruction() { + var registration = mock(net.milkbowl.vault.economy.Economy.class); + when(servicesManager.getRegistration(eq(Economy.class))) + .thenReturn(new org.bukkit.plugin.RegisteredService<>( + Economy.class, economy, plugin, ServicePriority.Normal)); + + VaultIntegration vault = new VaultIntegration(plugin); + + assertTrue(vault.isEnabled()); + assertNotNull(vault.economy); + } + + @Test + void shouldRefreshProviderOnDemand() { + when(servicesManager.getRegistration(eq(Economy.class))).thenReturn(null); + VaultIntegration vault = new VaultIntegration(plugin); + + assertFalse(vault.isEnabled()); + + // Economy provider appears later + when(servicesManager.getRegistration(eq(Economy.class))) + .thenReturn(new org.bukkit.plugin.RegisteredService<>( + Economy.class, economy, plugin, ServicePriority.Normal)); + vault.refreshProvider(); + + assertTrue(vault.isEnabled()); + } + + @Test + void shouldClearProviderOnRefreshWhenUnregistered() { + when(servicesManager.getRegistration(eq(Economy.class))) + .thenReturn(new org.bukkit.plugin.RegisteredService<>( + Economy.class, economy, plugin, ServicePriority.Normal)); + VaultIntegration vault = new VaultIntegration(plugin); + + assertTrue(vault.isEnabled()); + + // Economy provider disappears + when(servicesManager.getRegistration(eq(Economy.class))).thenReturn(null); + vault.refreshProvider(); + + assertFalse(vault.isEnabled()); + assertNull(vault.economy); + } + + @Test + void shouldDelegateHasCheckToProvider() { + when(servicesManager.getRegistration(eq(Economy.class))) + .thenReturn(new org.bukkit.plugin.RegisteredService<>( + Economy.class, economy, plugin, ServicePriority.Normal)); + VaultIntegration vault = new VaultIntegration(plugin); + Player player = mock(Player.class); + + when(economy.has(player, 10.0)).thenReturn(true); + assertTrue(vault.has(player, 10.0)); + + when(economy.has(player, 99.0)).thenReturn(false); + assertFalse(vault.has(player, 99.0)); + } + + @Test + void shouldReturnFalseForAllOperationsWhenDisabled() { + when(servicesManager.getRegistration(eq(Economy.class))).thenReturn(null); + VaultIntegration vault = new VaultIntegration(plugin); + Player player = mock(Player.class); + + assertFalse(vault.has(player, 5.0)); + assertFalse(vault.withdraw(player, 5.0)); + assertFalse(vault.deposit(null, 5.0)); + } + + @Test + void shouldWithdrawFromPlayer() { + when(servicesManager.getRegistration(eq(Economy.class))) + .thenReturn(new org.bukkit.plugin.RegisteredService<>( + Economy.class, economy, plugin, ServicePriority.Normal)); + VaultIntegration vault = new VaultIntegration(plugin); + Player player = mock(Player.class); + + when(economy.withdrawPlayer(player, 10.0)).thenReturn(successResponse); + assertTrue(vault.withdraw(player, 10.0)); + verify(economy).withdrawPlayer(player, 10.0); + } +} diff --git a/src/test/java/org/cubexmc/metro/service/TicketServiceTest.java b/src/test/java/org/cubexmc/metro/service/TicketServiceTest.java index 5d65515..2aa6eaf 100644 --- a/src/test/java/org/cubexmc/metro/service/TicketServiceTest.java +++ b/src/test/java/org/cubexmc/metro/service/TicketServiceTest.java @@ -144,11 +144,89 @@ void shouldOnlyChargeBasePriceForBoarding() { verify(vault).withdraw(player, 3.0); } + @Test + void shouldRefundPlayerWhenOwnerDepositFailsInCharge() { + VaultIntegration vault = enabledVault(); + Player player = player(); + UUID playerUuid = UUID.randomUUID(); + UUID owner = UUID.randomUUID(); + Line line = line(5.0); + line.setOwner(owner); + + when(player.getUniqueId()).thenReturn(playerUuid); + when(vault.has(player, 5.0)).thenReturn(true); + when(vault.withdraw(player, 5.0)).thenReturn(true); + when(vault.deposit(owner, 5.0)).thenReturn(false); + + TicketService service = new TicketService(() -> vault, () -> true); + TicketService.TicketTransaction txn = service.createTransaction(player, line); + + assertEquals(TicketChargeStatus.TRANSACTION_FAILED, service.charge(txn)); + verify(vault).withdraw(player, 5.0); + verify(vault).deposit(owner, 5.0); + verify(vault).deposit(playerUuid, 5.0); + } + + @Test + void shouldRefundPlayerWhenOwnerDepositFailsInChargePrice() { + VaultIntegration vault = enabledVault(); + Player player = player(); + UUID playerUuid = UUID.randomUUID(); + UUID owner = UUID.randomUUID(); + Line line = line(5.0); + line.setOwner(owner); + line.setPriceRule(new PriceRule(PriceRule.PricingMode.DISTANCE, 2.0)); + + when(player.getUniqueId()).thenReturn(playerUuid); + when(vault.has(player, 3.0)).thenReturn(true); + when(vault.withdraw(player, 3.0)).thenReturn(true); + when(vault.deposit(owner, 3.0)).thenReturn(false); + + TicketService service = new TicketService(() -> vault, () -> true); + + assertEquals(TicketChargeStatus.TRANSACTION_FAILED, service.chargePrice(player, line, 3.0)); + verify(vault).withdraw(player, 3.0); + verify(vault).deposit(owner, 3.0); + verify(vault).deposit(playerUuid, 3.0); + } + + @Test + void shouldSkipDepositWhenLineHasNoOwner() { + VaultIntegration vault = enabledVault(); + Player player = player(); + Line line = line(5.0); + + when(vault.has(player, 5.0)).thenReturn(true); + when(vault.withdraw(player, 5.0)).thenReturn(true); + + TicketService service = new TicketService(() -> vault, () -> true); + TicketService.TicketTransaction txn = service.createTransaction(player, line); + + assertEquals(TicketChargeStatus.CHARGED, service.charge(txn)); + verify(vault, never()).deposit(any(), anyDouble()); + } + + @Test + void shouldSkipOwnerDepositWhenDepositFailsInChargePriceAndNoOwner() { + VaultIntegration vault = enabledVault(); + Player player = player(); + Line line = line(5.0); + + when(vault.has(player, 5.0)).thenReturn(true); + when(vault.withdraw(player, 5.0)).thenReturn(true); + + TicketService service = new TicketService(() -> vault, () -> true); + + assertEquals(TicketChargeStatus.CHARGED, service.chargePrice(player, line, 5.0)); + verify(vault, never()).deposit(any(), anyDouble()); + } + private VaultIntegration enabledVault() { VaultIntegration vault = mock(VaultIntegration.class); when(vault.isEnabled()).thenReturn(true); when(vault.format(5.0)).thenReturn("$5.00"); when(vault.format(7.0)).thenReturn("$7.00"); + when(vault.format(3.0)).thenReturn("$3.00"); return vault; }