From 0ff3a703519334d53245299386d7b623dea339d2 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:52:00 +0000 Subject: [PATCH 1/5] test: complete plugin coverage and fix runtime edge cases --- .github/workflows/build.yml | 8 + README.md | 10 +- pom.xml | 63 + .../vehicleframework/VehicleFramework.java | 5 +- .../vehicleframework/bones/BoneRotator.java | 2 +- .../vehicleframework/data/DamageData.java | 4 +- .../vehicleframework/data/DeathOverride.java | 2 +- .../vehicleframework/data/ParticleData.java | 2 +- .../vehicleframework/data/SoundData.java | 2 +- .../ActiveVehicleSnapshotFactory.java | 7 +- .../vehicleframework/database/LogWriter.java | 5 +- .../database/VehiclePayloadCodec.java | 16 +- .../database/VehicleRepository.java | 12 +- .../database/VehicleSqliteBackup.java | 70 +- .../effects/CustomEffect.java | 8 +- .../loaders/ConfigLoader.java | 30 +- .../managers/CommandManager.java | 1 + .../managers/InventoryManager.java | 9 +- .../managers/OwnershipGUIManager.java | 1 + .../managers/RepairManager.java | 30 +- .../managers/SpawnManager.java | 15 +- .../managers/VehicleManager.java | 4 +- .../managers/spawner/VehicleSpawner.java | 2 +- .../permissions/Permissions.java | 1 + .../projectiles/BulletRaycast.java | 16 +- .../projectiles/HitChecker.java | 19 +- .../protocol/PacketConverter.java | 1 - .../vehicleframework/tracks/ThrottleTape.java | 5 +- .../tracks/TrackBuildAnimator.java | 15 +- .../tracks/TrackCommands.java | 18 +- .../vehicleframework/tracks/TrackCurve.java | 3 + .../tracks/TrackDisplayManager.java | 13 - .../tracks/TrackJunction.java | 2 +- .../tracks/TrackRegistry.java | 58 +- .../tracks/TrackResettle.java | 6 +- .../tracks/TrackRouteQuery.java | 20 +- .../vehicleframework/tracks/TrackSpline.java | 52 +- .../tracks/TrackToolListener.java | 4 - .../tracks/TrackVisualBake.java | 3 - .../vehicleframework/tracks/TrainRoute.java | 4 - .../tracks/TrainTapeInteract.java | 2 +- .../util/ConditionChecker.java | 89 +- .../util/ExplosionCreator.java | 5 +- .../vehicleframework/util/LightEffect.java | 2 +- .../util/LocationChecker.java | 3 - .../vehicleframework/util/Metrics.java | 905 --------------- .../vehicles/ActiveVehicle.java | 1 - .../vehicleframework/vehicles/Vehicle.java | 11 +- .../vehicles/VehicleHealthDecay.java | 2 +- .../vehicles/component/Engine.java | 25 +- .../vehicles/component/GearedEngine.java | 33 +- .../vehicles/component/SinkableHull.java | 2 +- .../component/propulsion/Throttle.java | 2 +- .../vehicles/controller/FloatController.java | 4 - .../vehicles/controller/GroundEngineLog.java | 13 +- .../controller/TerrainFollowEngine.java | 22 +- .../controller/TerrainFollowMath.java | 9 +- .../controller/VehicleMovementController.java | 3 - .../vehicles/handlers/BehaviourHandler.java | 4 +- .../vehicles/handlers/ComponentHandler.java | 9 +- .../vehicles/handlers/StateHandler.java | 20 +- .../vehicles/handlers/TrainHandler.java | 22 +- .../handlers/container/Container.java | 13 +- .../handlers/state/AnimationHandler.java | 6 +- .../vehicles/handlers/state/InputHandler.java | 18 +- .../handlers/train/ConsistRelinker.java | 16 +- .../vehicles/handlers/train/DeckBody.java | 10 +- .../vehicleframework/vehicles/seat/Seat.java | 21 +- .../vehicleframework/vehicles/util/VFX.java | 4 +- .../vehicleframework/weapons/Weapon.java | 2 +- .../weapons/WeaponAimOffset.java | 2 +- .../weapons/ammunition/Ammunition.java | 27 +- .../ammunition/data/AmmunitionData.java | 4 +- .../ammunition/data/projectile/ItemModel.java | 2 +- .../controller/WeaponMovementController.java | 16 +- .../weapons/handlers/AmmunitionHandler.java | 18 +- .../weapons/shooter/DefaultShooter.java | 4 +- .../weapons/shooter/ProjectileShooter.java | 4 + .../MetricsIntegrationTest.java | 43 + .../VehicleFrameworkCoverageTest.java | 192 ++++ .../bones/BoneRuntimeCoverageTest.java | 77 ++ .../data/RuntimeValuesCoverageTest.java | 102 ++ .../database/DatabaseLoggingCoverageTest.java | 175 +++ .../database/DatabaseRuntimeCoverageTest.java | 223 ++++ .../database/VehicleBackupCoverageTest.java | 293 +++++ .../database/VehiclePayloadCoverageTest.java | 223 ++++ .../effects/EffectsCoverageTest.java | 79 ++ .../ConfigurationLoadersCoverageTest.java | 287 +++++ .../CommandAndOwnershipCoverageTest.java | 220 ++++ .../InventoryManagerCoverageTest.java | 246 ++++ .../managers/SpawnAndRepairCoverageTest.java | 378 ++++++ .../managers/VehicleManagerCoverageTest.java | 931 +++++++++++++++ .../spawner/VehicleSpawnerCoverageTest.java | 165 +++ .../projectiles/ProjectilesCoverageTest.java | 154 +++ .../test/RegistryFixture.java | 98 ++ .../tracks/TrackCommandsCoverageTest.java | 792 +++++++++++++ .../TrackDisplayManagerCoverageTest.java | 811 +++++++++++++ .../TrackGeometryRuntimeCoverageTest.java | 1014 +++++++++++++++++ .../tracks/TrackLoggingCoverageTest.java | 297 +++++ .../tracks/TrackPersistenceCoverageTest.java | 281 +++++ .../tracks/TrackRuntimeCoverageTest.java | 397 +++++++ .../util/RuntimeUtilitiesCoverageTest.java | 363 ++++++ .../vehicles/ActiveVehicleTest.java | 572 ++++++++++ .../component/GearedEngineCoverageTest.java | 484 ++++++++ .../VehicleComponentsCoverageTest.java | 729 ++++++++++++ .../RuntimeSupportCoverageTest.java | 183 +++ .../TerrainFollowEngineCoverageTest.java | 587 ++++++++++ .../controller/TerrainFollowMathTest.java | 6 +- .../VehicleControllersCoverageTest.java | 327 ++++++ ...VehicleMovementControllerCoverageTest.java | 544 +++++++++ .../handlers/TrainHandlerCoverageTest.java | 654 +++++++++++ .../handlers/VehicleHandlersCoverageTest.java | 550 +++++++++ .../train/TrainRuntimeCoverageTest.java | 301 +++++ .../weapons/WeaponsCoverageTest.java | 256 +++++ .../org.mockito.plugins.MockMaker | 2 +- 115 files changed, 13532 insertions(+), 1407 deletions(-) delete mode 100644 src/main/java/net/tfminecraft/vehicleframework/util/Metrics.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/MetricsIntegrationTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/bones/BoneRuntimeCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/database/DatabaseLoggingCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/database/DatabaseRuntimeCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/database/VehicleBackupCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/database/VehiclePayloadCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/effects/EffectsCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/managers/InventoryManagerCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/managers/SpawnAndRepairCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/managers/spawner/VehicleSpawnerCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/projectiles/ProjectilesCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/test/RegistryFixture.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/tracks/TrackCommandsCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/tracks/TrackDisplayManagerCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/tracks/TrackGeometryRuntimeCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/tracks/TrackLoggingCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/tracks/TrackPersistenceCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/tracks/TrackRuntimeCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/util/RuntimeUtilitiesCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/vehicles/ActiveVehicleTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/vehicles/component/GearedEngineCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/vehicles/component/VehicleComponentsCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/RuntimeSupportCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowEngineCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleControllersCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleMovementControllerCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandlerCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleHandlersCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/TrainRuntimeCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 495c41d..48d130d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,3 +74,11 @@ jobs: name: unit-test-reports-${{ github.run_id }}-${{ github.run_attempt }} path: target/surefire-reports/ if-no-files-found: error + + - name: Upload coverage report + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/README.md b/README.md index 58e95bb..10c858e 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,14 @@ mvn -B --no-transfer-progress clean verify JUnit 5 and Mockito cover track geometry, train routing and placement, persistence, locomotive behaviour and other logic that runs without a server. They do not start a live Paper server or load ModelEngine models. The Build workflow runs the suite on pull -requests targeting `main` and on pushes to `main` and uploads the Surefire reports. No coverage gate is -enforced. +requests targeting `main` and on pushes to `main` and uploads the Surefire reports. JaCoCo requires 100% line coverage across all production classes, with no +coverage exclusions. HTML and XML reports are written to `target/site/jacoco/` +and uploaded by the Build workflow. + +bStats is bundled from the upstream `bstats-bukkit:3.1.0` Maven artifact and +relocated into the plugin's namespace. This replaces the same-version vendored +copy; third-party dependency bytecode is outside the plugin's production-source +coverage denominator. Metrics configuration and lifecycle integration remain tested. ## License diff --git a/pom.xml b/pom.xml index 3fa61eb..0225a1a 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,17 @@ + + io.netty + netty-buffer + 4.2.8.Final + test + + + org.bstats + bstats-bukkit + 3.1.0 + it.unimi.dsi @@ -132,9 +143,19 @@ 2.0.17 test + + com.mojang + authlib + 7.0.61 + test + + + minecraft-libraries + https://libraries.minecraft.net/ + papermc https://repo.papermc.io/repository/maven-public/ @@ -168,6 +189,38 @@ + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + prepare-agent + + coverage-report + verify + report + + + coverage-check + verify + check + + + + BUNDLE + + + LINE + MISSEDCOUNT + 0 + + + + + + + + org.apache.maven.plugins maven-compiler-plugin @@ -177,6 +230,12 @@ org.apache.maven.plugins maven-surefire-plugin 3.2.5 + + + + false + + org.apache.maven.plugins @@ -217,6 +276,10 @@ + + org.bstats + net.tfminecraft.vehicleframework.shadeutils.bstats + org.apache.commons.lang3 net.tfminecraft.vehicleframework.shadeutils.lang3 diff --git a/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java b/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java index 84ff32e..be45ee5 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java +++ b/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java @@ -25,7 +25,7 @@ import net.tfminecraft.vehicleframework.managers.CommandManager; import net.tfminecraft.vehicleframework.managers.VehicleManager; import net.tfminecraft.vehicleframework.protocol.VehiclePacketListener; -import net.tfminecraft.vehicleframework.util.Metrics; +import org.bstats.bukkit.Metrics; import net.tfminecraft.vehicleframework.util.MythicMobsIntegration; import net.tfminecraft.vehicleframework.util.TabCompletion; import net.tfminecraft.vehicleframework.tracks.TrackBuildAnimator; @@ -187,6 +187,7 @@ public void loadConfigs() { configLoader.load(new File(getDataFolder(), "config.yml")); trainsLoader.load(new File(getDataFolder(), "trains.yml")); VFLogger.info("Loading fuel..."); + FuelLoader.get().clear(); fuelLoader.load(new File(getDataFolder(), "fuel.yml")); weaponTemplateLoader.clear(); VFLogger.info("Loading weapon templates..."); @@ -201,6 +202,7 @@ public void loadConfigs() { deathTemplateLoader.loadFolder(new File(getDataFolder(), "templates/death")); File folder = new File(getDataFolder(), "vehicles"); VFLogger.info("Loading vehicles..."); + VehicleLoader.get().clear(); for (final File file : folder.listFiles()) { if(!file.isDirectory()) { vehicleLoader.load(file); @@ -209,6 +211,7 @@ public void loadConfigs() { folder = new File(getDataFolder(), "ammunition"); VFLogger.info("Loading ammunition..."); + AmmunitionLoader.get().clear(); for (final File file : folder.listFiles()) { if(!file.isDirectory()) { ammunitionLoader.load(file); diff --git a/src/main/java/net/tfminecraft/vehicleframework/bones/BoneRotator.java b/src/main/java/net/tfminecraft/vehicleframework/bones/BoneRotator.java index 4366155..2131bbe 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/bones/BoneRotator.java +++ b/src/main/java/net/tfminecraft/vehicleframework/bones/BoneRotator.java @@ -213,7 +213,7 @@ private void rotateNoAdd(double x, double y, double z) { } public void normalize(boolean nx, boolean ny, boolean nz) { - rotateToTarget(0f, 0f, 0f, 2f, true, true, true); + rotateToTarget(0f, 0f, 0f, 1f, ny, nx, nz); } public AxisAngle4d getAngles() { diff --git a/src/main/java/net/tfminecraft/vehicleframework/data/DamageData.java b/src/main/java/net/tfminecraft/vehicleframework/data/DamageData.java index 9c77e3b..9f89979 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/data/DamageData.java +++ b/src/main/java/net/tfminecraft/vehicleframework/data/DamageData.java @@ -12,7 +12,7 @@ public DamageData(List list) { return; } for(String s : list) { - String type = s.split("\\(")[0].toUpperCase(); + String type = s.split("\\(")[0].toUpperCase(java.util.Locale.ROOT); Double damage = Double.parseDouble(s.split("\\(")[1].replace(")", "")); modifiers.put(type, damage); } @@ -26,7 +26,7 @@ public DamageData(Map map) { if (entry.getKey() == null || entry.getValue() == null) { continue; } - modifiers.put(entry.getKey().toUpperCase(), toDouble(entry.getValue())); + modifiers.put(entry.getKey().toUpperCase(java.util.Locale.ROOT), toDouble(entry.getValue())); } } diff --git a/src/main/java/net/tfminecraft/vehicleframework/data/DeathOverride.java b/src/main/java/net/tfminecraft/vehicleframework/data/DeathOverride.java index 202a840..c525cb4 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/data/DeathOverride.java +++ b/src/main/java/net/tfminecraft/vehicleframework/data/DeathOverride.java @@ -13,7 +13,7 @@ public class DeathOverride { public DeathOverride(ConfigurationSection config) { conditions = config.getStringList("conditions"); - death = VehicleDeath.valueOf(config.getString("type").toUpperCase()); + death = VehicleDeath.valueOf(config.getString("type").toUpperCase(java.util.Locale.ROOT)); } public List getConditions() { diff --git a/src/main/java/net/tfminecraft/vehicleframework/data/ParticleData.java b/src/main/java/net/tfminecraft/vehicleframework/data/ParticleData.java index d61717f..4f39c75 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/data/ParticleData.java +++ b/src/main/java/net/tfminecraft/vehicleframework/data/ParticleData.java @@ -22,7 +22,7 @@ public class ParticleData { public ParticleData(ConfigurationSection config) { try { - particle = Particle.valueOf(config.getString("particle", "FLAME").toUpperCase()); + particle = Particle.valueOf(config.getString("particle", "FLAME").toUpperCase(java.util.Locale.ROOT)); } catch (Exception e) { // TODO: handle exception particle = Particle.FLAME; diff --git a/src/main/java/net/tfminecraft/vehicleframework/data/SoundData.java b/src/main/java/net/tfminecraft/vehicleframework/data/SoundData.java index 98d0fac..b5453e4 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/data/SoundData.java +++ b/src/main/java/net/tfminecraft/vehicleframework/data/SoundData.java @@ -67,7 +67,7 @@ public void playSound(List players, Location loc, Vector speed, float pi // Calculate the relative velocity along the direction of the sound Vector relativePosition = loc.toVector().subtract(playerLoc.toVector()); // Source to listener double distance = relativePosition.length(); // Normalize to get direction - relativePosition.normalize(); + if (distance > 0) relativePosition.multiply(1.0 / distance); // Project the speed and player velocity onto the direction vector double sourceVelocity = speed.dot(relativePosition); // Source velocity along line of sight diff --git a/src/main/java/net/tfminecraft/vehicleframework/database/ActiveVehicleSnapshotFactory.java b/src/main/java/net/tfminecraft/vehicleframework/database/ActiveVehicleSnapshotFactory.java index 160f96b..488192b 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/database/ActiveVehicleSnapshotFactory.java +++ b/src/main/java/net/tfminecraft/vehicleframework/database/ActiveVehicleSnapshotFactory.java @@ -65,10 +65,7 @@ public static SnapshotAttempt tryFromLive(ActiveVehicle vehicle) { String message = ex.getMessage(); return SnapshotAttempt.fail(message == null || message.isBlank() ? "encode failed" : "encode failed: " + message); } - if (payload == null || payload.isBlank()) { - return SnapshotAttempt.fail("empty payload"); - } - String owner = vehicle.getOwnerData() == null ? "none" : vehicle.getOwnerData().getOwner(); + String owner = vehicle.getOwnerData().getOwner(); double x = loc.getX(); double z = loc.getZ(); return SnapshotAttempt.ok(new VehicleSnapshot( @@ -166,7 +163,7 @@ private static String encodePayload(ActiveVehicle v) { default: break; } - componentsObject.put(type.toString().toLowerCase(), componentData); + componentsObject.put(type.toString().toLowerCase(java.util.Locale.ROOT), componentData); } vehicleData.put("components", componentsObject); diff --git a/src/main/java/net/tfminecraft/vehicleframework/database/LogWriter.java b/src/main/java/net/tfminecraft/vehicleframework/database/LogWriter.java index 03886f8..9c26ee0 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/database/LogWriter.java +++ b/src/main/java/net/tfminecraft/vehicleframework/database/LogWriter.java @@ -74,7 +74,10 @@ public synchronized void logEntry(String s) { } // Check if rotation is needed - long lineCount = Files.lines(logFile.toPath()).count(); + long lineCount; + try (var lines = Files.lines(logFile.toPath())) { + lineCount = lines.count(); + } if (lineCount > 400) { rotateLog(); } diff --git a/src/main/java/net/tfminecraft/vehicleframework/database/VehiclePayloadCodec.java b/src/main/java/net/tfminecraft/vehicleframework/database/VehiclePayloadCodec.java index b4befb1..14954c5 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/database/VehiclePayloadCodec.java +++ b/src/main/java/net/tfminecraft/vehicleframework/database/VehiclePayloadCodec.java @@ -86,7 +86,7 @@ public static Optional decode(JSONObject json, String uuid) { ? asInt(componentData.get("sinkprogress")) : 0; try { - Component c = Component.valueOf(componentType.toUpperCase()); + Component c = Component.valueOf(componentType.toUpperCase(java.util.Locale.ROOT)); switch (c) { case ENGINE: if (componentData.containsKey("throttle")) { @@ -247,7 +247,7 @@ public static String encode(IncompleteVehicle vehicle) { if (type == Component.GEARED_ENGINE) { componentData.put("gear", (long) vehicle.getGear()); } - componentsObject.put(type.toString().toLowerCase(), componentData); + componentsObject.put(type.toString().toLowerCase(java.util.Locale.ROOT), componentData); } } json.put("components", componentsObject); @@ -353,13 +353,15 @@ private static boolean bool(Object value) { private static float asFloat(Object value) { if (value instanceof Number number) { - return number.floatValue(); + float result = number.floatValue(); + return Float.isFinite(result) ? result : 0f; } if (value == null) { return 0f; } try { - return Float.parseFloat(String.valueOf(value)); + float result = Float.parseFloat(String.valueOf(value)); + return Float.isFinite(result) ? result : 0f; } catch (NumberFormatException ex) { return 0f; } @@ -367,13 +369,15 @@ private static float asFloat(Object value) { private static double asDouble(Object value) { if (value instanceof Number number) { - return number.doubleValue(); + double result = number.doubleValue(); + return Double.isFinite(result) ? result : 0d; } if (value == null) { return 0d; } try { - return Double.parseDouble(String.valueOf(value)); + double result = Double.parseDouble(String.valueOf(value)); + return Double.isFinite(result) ? result : 0d; } catch (NumberFormatException ex) { return 0d; } diff --git a/src/main/java/net/tfminecraft/vehicleframework/database/VehicleRepository.java b/src/main/java/net/tfminecraft/vehicleframework/database/VehicleRepository.java index f2b6e4c..0c1707d 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/database/VehicleRepository.java +++ b/src/main/java/net/tfminecraft/vehicleframework/database/VehicleRepository.java @@ -464,7 +464,17 @@ public void runInTransaction(Runnable work) { if (work == null) { return; } - database.runTransaction(connection -> work.run()); + try { + database.runTransaction(connection -> work.run()); + } catch (RuntimeException failure) { + // Row mutations update this cache eagerly; rollback must restore it too. + try { + rebuildOccupiedChunks(); + } catch (RuntimeException indexFailure) { + failure.addSuppressed(indexFailure); + } + throw failure; + } } private static StoredVehicleMeta toMeta(VehicleSnapshot snapshot) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/database/VehicleSqliteBackup.java b/src/main/java/net/tfminecraft/vehicleframework/database/VehicleSqliteBackup.java index 4f107df..03e4fe8 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/database/VehicleSqliteBackup.java +++ b/src/main/java/net/tfminecraft/vehicleframework/database/VehicleSqliteBackup.java @@ -107,7 +107,8 @@ public static boolean isSnapshotName(String name) { public static File findNewestValid(File backupDir) { List snapshots = listSnapshots(backupDir); - snapshots.sort(Comparator.comparingLong(File::lastModified).reversed()); + snapshots.sort(Comparator.comparingLong(File::lastModified).reversed() + .thenComparing(VehicleSqliteBackup::compareSnapshotNames)); for (File snapshot : snapshots) { if (isValidDatabase(snapshot)) { return snapshot; @@ -117,39 +118,53 @@ public static File findNewestValid(File backupDir) { } public static boolean isValidDatabase(File dbFile) { - if (dbFile == null || !dbFile.isFile()) { + if (dbFile == null || !dbFile.isFile() || dbFile.length() == 0) { return false; } - VehicleRepository repository = null; - try { - repository = VehicleRepository.open(dbFile); - return true; - } catch (RuntimeException ignored) { - return false; - } finally { - if (repository != null) { - try { - repository.close(); - } catch (Exception ignored) { - } + String url = "jdbc:sqlite:" + dbFile.toPath().toAbsolutePath().toUri().toASCIIString() + "?mode=ro"; + try (java.sql.Connection connection = java.sql.DriverManager.getConnection(url); + java.sql.Statement statement = connection.createStatement()) { + try (java.sql.ResultSet check = statement.executeQuery("PRAGMA quick_check")) { + if (!check.next() || !"ok".equalsIgnoreCase(check.getString(1)) || check.next()) return false; } + // Require the original vehicle schema. Later name/owner columns can be migrated on restore. + try (java.sql.ResultSet schema = statement.executeQuery( + "SELECT uuid,type_id,world,x,y,z,yaw,chunk_x,chunk_z,payload_json,schema_version,revision,deleted,updated_at FROM vehicles LIMIT 0")) { + return true; + } + } catch (java.sql.SQLException invalid) { + return false; } } public static void replaceLive(File liveFile, File backupFile) throws IOException { - File parent = liveFile.getParentFile(); - if (parent != null) { - parent.mkdirs(); - } - sidelineCorrupt(liveFile); - deleteSidecar(liveFile, "-wal"); - deleteSidecar(liveFile, "-shm"); - Files.copy(backupFile.toPath(), liveFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + File parent = liveFile.getAbsoluteFile().getParentFile(); + Files.createDirectories(parent.toPath()); + java.nio.file.Path staged = Files.createTempFile(parent.toPath(), "vehicles-restore-", ".db"); + try (java.io.Closeable cleanup = () -> Files.deleteIfExists(staged)) { + // Finish reading the backup before touching the live database or its journal. + Files.copy(backupFile.toPath(), staged, StandardCopyOption.REPLACE_EXISTING); + File original = sidelineCorrupt(liveFile); + try { + Files.move(staged, liveFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException installFailure) { + if (original != null) { + try { + Files.move(original.toPath(), liveFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException restoreFailure) { + installFailure.addSuppressed(restoreFailure); + } + } + throw installFailure; + } + deleteSidecar(liveFile, "-wal"); + deleteSidecar(liveFile, "-shm"); + } } - private static void sidelineCorrupt(File liveFile) { + private static File sidelineCorrupt(File liveFile) throws IOException { if (!liveFile.exists()) { - return; + return null; } File corrupt = new File( liveFile.getParentFile(), @@ -162,12 +177,9 @@ private static void sidelineCorrupt(File liveFile) { suffix++; } if (!liveFile.renameTo(corrupt)) { - try { - Files.move(liveFile.toPath(), corrupt.toPath(), StandardCopyOption.REPLACE_EXISTING); - } catch (IOException ignored) { - liveFile.delete(); - } + Files.move(liveFile.toPath(), corrupt.toPath(), StandardCopyOption.REPLACE_EXISTING); } + return corrupt; } private static void deleteSidecar(File liveFile, String suffix) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/effects/CustomEffect.java b/src/main/java/net/tfminecraft/vehicleframework/effects/CustomEffect.java index 0476ad1..e57f030 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/effects/CustomEffect.java +++ b/src/main/java/net/tfminecraft/vehicleframework/effects/CustomEffect.java @@ -98,7 +98,7 @@ private static boolean checkEffectCondition(ActiveVehicle v, String info) { } private void particle(List players, ActiveModel m, String info) { - Particle particle = Particle.valueOf(info.split("\\;")[0].toUpperCase()); + Particle particle = Particle.valueOf(info.split("\\;")[0].toUpperCase(java.util.Locale.ROOT)); VectorBone bone = new VectorBone(m.getBone(info.split("\\;")[1].split("\\.")[0]).get(), m.getBone(info.split("\\;")[1].split("\\.")[1]).get()); int amount = Integer.parseInt(info.split("\\;")[2]); double spread = Double.parseDouble(info.split("\\;")[3]); @@ -142,14 +142,12 @@ private void animation(ActiveModel m, String info) { } private void death(ActiveVehicle v, String death) { - if(VehicleDeath.valueOf(death.toUpperCase()) == null) return; if(v.isDestroyed()) return; - v.kill(VehicleDeath.valueOf(death.toUpperCase())); + v.kill(VehicleDeath.valueOf(death.toUpperCase(java.util.Locale.ROOT))); } private void startFire(ActiveVehicle v, String component) { - Component c = Component.valueOf(component.toUpperCase()); - if(c == null) return; + Component c = Component.valueOf(component.toUpperCase(java.util.Locale.ROOT)); if(v.hasComponent(c) && !v.getComponent(c).isOnFire()) v.getComponent(c).startFire(); } diff --git a/src/main/java/net/tfminecraft/vehicleframework/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/vehicleframework/loaders/ConfigLoader.java index f9887e5..0534581 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/vehicleframework/loaders/ConfigLoader.java @@ -2,6 +2,7 @@ import java.io.File; import java.io.IOException; +import java.util.Locale; import org.bukkit.Material; import org.bukkit.configuration.InvalidConfigurationException; @@ -20,11 +21,16 @@ public void load(File configFile) { config.load(configFile); } catch (IOException | InvalidConfigurationException e) { e.printStackTrace(); + return; } + Cache.ignoreExplode.clear(); + Cache.ignoreLands.clear(); + Cache.ignoreGround.clear(); + Cache.convertExplode.clear(); if(config.contains("ignore-explosion")) { for(String s : config.getStringList("ignore-explosion")) { try { - Cache.ignoreExplode.add(Material.valueOf(s.toUpperCase())); + Cache.ignoreExplode.add(Material.valueOf(s.toUpperCase(Locale.ROOT))); } catch (Exception e) { VFLogger.log(s+" is not a material"); } @@ -33,7 +39,7 @@ public void load(File configFile) { if(config.contains("ignore-landing")) { for(String s : config.getStringList("ignore-landing")) { try { - Cache.ignoreLands.add(Material.valueOf(s.toUpperCase())); + Cache.ignoreLands.add(Material.valueOf(s.toUpperCase(Locale.ROOT))); } catch (Exception e) { VFLogger.log(s+" is not a material"); } @@ -43,7 +49,7 @@ public void load(File configFile) { if(config.contains("ignore-ground")) { for(String s : config.getStringList("ignore-ground")) { try { - Cache.ignoreGround.add(Material.valueOf(s.toUpperCase())); + Cache.ignoreGround.add(Material.valueOf(s.toUpperCase(Locale.ROOT))); } catch (Exception e) { VFLogger.log(s+" is not a material"); } @@ -52,13 +58,17 @@ public void load(File configFile) { if(config.contains("convert-explosion")) { for(String s : config.getStringList("convert-explosion")) { - String from = s.split("\\.")[0]; - String to = s.split("\\.")[1]; - try { - Cache.convertExplode.put(Material.valueOf(from.toUpperCase()), Material.valueOf(to.toUpperCase())); - } catch (Exception e) { - VFLogger.log("either "+from+" or "+s+" is not a material"); - } + try { + String[] parts = s.split("\\.", -1); + if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) { + VFLogger.log("Invalid explosion conversion: " + s); + continue; + } + Cache.convertExplode.put(Material.valueOf(parts[0].trim().toUpperCase(Locale.ROOT)), + Material.valueOf(parts[1].trim().toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException e) { + VFLogger.log("Invalid explosion conversion: " + s); + } } } diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java index edcd766..4e84a38 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java @@ -34,6 +34,7 @@ public class CommandManager implements Listener, CommandExecutor{ @Override public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { if(cmd.getName().equalsIgnoreCase(cmd1)) { + if (args.length == 0) return false; if(args.length >= 1 && args[0].equalsIgnoreCase("track")) { return TrackCommands.handle(sender, args); } diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/InventoryManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/InventoryManager.java index 73baf9d..3be837b 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/InventoryManager.java @@ -40,12 +40,15 @@ public class InventoryManager { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void seatSelection(Inventory i, Player p, ActiveVehicle v, boolean open) { - if(open) { - i = VehicleFramework.plugin.getServer().createInventory(new VFInventoryHolder(v.getUUID(), VFGUI.SEAT_SELECTION), 27, "§7Select Seat"); + int size = Math.max(27, ((v.getSeatHandler().getSeats().size() + 10) / 9) * 9); + if(open || i.getSize() < size) { + i = VehicleFramework.plugin.getServer().createInventory(new VFInventoryHolder(v.getUUID(), VFGUI.SEAT_SELECTION), size, "§7Select Seat"); + open = true; } + i.clear(); int x = 0; for(Seat seat : v.getSeatHandler().getSeats()) { - if(x == 25) x++; // slot 25 is reserved for ownership settings + if(x == 25) x += 2; // ownership and dismount controls i.setItem(x, getSeatItem(v, seat)); x++; } diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipGUIManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipGUIManager.java index 92f3888..4ca7527 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipGUIManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipGUIManager.java @@ -30,6 +30,7 @@ public void ownershipGui(Inventory i, Player p, ActiveVehicle v, boolean open) { } // Slot 0 – Toggle whitelisting + i.clear(); i.setItem(0, createToggleWhitelistButton(v.getOwnerData().isWhiteListed())); // Slot 2 – Add player to whitelist if(Cache.allowWhitelist) i.setItem(2, createAddToWhitelistButton()); diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/RepairManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/RepairManager.java index 44692de..0a7e01f 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/RepairManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/RepairManager.java @@ -42,6 +42,7 @@ public class RepairManager implements Listener{ private HashMap activeTool = new HashMap<>(); private HashMap beingRepaired = new HashMap<>(); private HashMap weaponRepair = new HashMap<>(); + private HashMap repairAttempts = new HashMap<>(); public RepairManager(VehicleManager m) { manager = m; @@ -64,6 +65,7 @@ public boolean isBeingRepaired(VehicleComponent c) { } public void stop(Player p) { + repairAttempts.remove(p); repairing.remove(p); activeTool.remove(p); if(weaponRepair.containsKey(p)) { @@ -94,7 +96,7 @@ public void repair(Player p, ActiveVehicle v) { p.sendMessage("§cCannot repair while flying"); return; } - if(v.getAccessPanel().getSpeed() > 0.2) { + if(Math.abs(v.getAccessPanel().getSpeed()) > 0.2) { p.sendMessage("§cCannot repair while moving"); return; } @@ -112,21 +114,13 @@ public void repair(Player p, ActiveVehicle v) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void tick() { - for(Player p : repairing.keySet()) { + for(Player p : new java.util.ArrayList<>(repairing.keySet())) { if(!p.getOpenInventory().getTitle().equalsIgnoreCase("§7Repair Vehicle")) { stop(p); continue; } inv.repairWindow(p.getOpenInventory().getTopInventory(), p, repairing.get(p), false, activeTool.get(p)); } - for(Player p : weaponRepair.keySet()) { - if(repairing.containsKey(p)) continue; - if(!p.getOpenInventory().getTitle().equalsIgnoreCase("§7Repair Weapon")) { - stop(p); - continue; - } - inv.repairWindow(p.getOpenInventory().getTopInventory(), p, repairing.get(p), false, activeTool.get(p)); - } } @EventHandler @@ -137,6 +131,7 @@ public void repairEvent(InventoryClickEvent e) { if(!h.getType().equals(VFGUI.REPAIR)) return; ActiveVehicle v = manager.get(h.getId()); e.setCancelled(true); + if (v == null || e.getRawSlot() < 0 || e.getRawSlot() >= e.getView().getTopInventory().getSize()) return; ItemStack i = e.getCurrentItem(); if(i == null) return; ItemMeta m = i.getItemMeta(); @@ -194,15 +189,19 @@ public void repairComponent(Player p, ItemMeta m, NamespacedKey key, ActiveVehic c.getHealthData().startRepair(); p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_BELL, 1f, 1f); beingRepaired.put(p, c); + repairing.put(p, v); + Object attempt = new Object(); + repairAttempts.put(p, attempt); new BukkitRunnable() { @SuppressWarnings("deprecation") @Override public void run() { - if(v.isDestroyed()) return; - if(!beingRepaired.containsKey(p)) return; + if (repairAttempts.get(p) != attempt) return; + if(v.isDestroyed()) { stop(p); return; } v.updateBoard(); beingRepaired.remove(p); repairing.remove(p); + repairAttempts.remove(p); p.sendMessage("§aRepaired §e"+Text.capitalize(c.getType().toString().toLowerCase())); p.getWorld().playSound(p.getLocation(), Sound.BLOCK_ANVIL_USE, 1f, 1f); } @@ -228,14 +227,17 @@ public void repairWeapon(Player p, ItemMeta m, NamespacedKey key, ActiveVehicle p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_BELL, 1f, 1f); repairing.put(p, v); weaponRepair.put(p, w); + Object attempt = new Object(); + repairAttempts.put(p, attempt); new BukkitRunnable() { @Override public void run() { - if(v.isDestroyed()) return; - if(!weaponRepair.containsKey(p)) return; + if (repairAttempts.get(p) != attempt) return; + if(v.isDestroyed()) { stop(p); return; } v.updateBoard(); weaponRepair.remove(p); repairing.remove(p); + repairAttempts.remove(p); p.sendMessage("§aRepaired §e"+w.getName()); p.getWorld().playSound(p.getLocation(), Sound.BLOCK_ANVIL_USE, 1f, 1f); } diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/SpawnManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/SpawnManager.java index 22216fb..7c0dabe 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/SpawnManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/SpawnManager.java @@ -38,7 +38,6 @@ public class SpawnManager implements Listener { private VehicleManager vehicleManager; private BukkitTask spawnTickTask; - private final Set spawning = new HashSet<>(); public SpawnManager(VehicleManager m) { vehicleManager = m; @@ -88,7 +87,6 @@ public void start() { public void reload() { spawns.clear(); - spawning.clear(); enqueueLoadedChunks(); startTickCycle(); } @@ -106,9 +104,6 @@ private void enqueueLoadedChunks() { } private void enqueueChunk(Chunk chunk) { - if (chunk == null || chunk.getWorld() == null) { - return; - } VehiclePersistence persistence = VehiclePersistence.current(); if (persistence == null) { return; @@ -161,15 +156,7 @@ public void run() { if (!loc.hasNearby()) { continue; } - String uuid = stripJson(loc.getFile()); - if (uuid == null || !spawning.add(uuid)) { - continue; - } - try { - trySpawn(loc); - } finally { - spawning.remove(uuid); - } + trySpawn(loc); } } }.runTaskTimer(VehicleFramework.plugin, 0L, 20L); diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java index c3e1ae0..d8609f1 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java @@ -578,8 +578,8 @@ public void towAttach(Player p, ActiveVehicle v) { @EventHandler public void swap(PlayerSwapHandItemsEvent e) { Player p = e.getPlayer(); - if(get(p) == null) return; - ActiveVehicle v = get(p); + ActiveVehicle v = mountedVehicle(p); + if(v == null) return; v.key(p, Keybind.SWAP); } // Keep the existing legacy text representation, formatting, and exact-string comparisons. diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/spawner/VehicleSpawner.java b/src/main/java/net/tfminecraft/vehicleframework/managers/spawner/VehicleSpawner.java index 2cf1f47..6b10e36 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/spawner/VehicleSpawner.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/spawner/VehicleSpawner.java @@ -43,8 +43,8 @@ public ActiveVehicle spawn(Location loc, Vehicle v, VehicleManager manager, Inco } } else { ArmorStand a = loc.getWorld().spawn(loc, ArmorStand.class); - a.setVisible(false); e = a; + a.setVisible(false); } // SQLite owns persistence; never let Bukkit/ME independently restore this runtime entity. diff --git a/src/main/java/net/tfminecraft/vehicleframework/permissions/Permissions.java b/src/main/java/net/tfminecraft/vehicleframework/permissions/Permissions.java index 050ee92..fcd9299 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/permissions/Permissions.java +++ b/src/main/java/net/tfminecraft/vehicleframework/permissions/Permissions.java @@ -4,6 +4,7 @@ public class Permissions { + private Permissions() {} public static String Permission_Spawn; public static String Permission_Admin; diff --git a/src/main/java/net/tfminecraft/vehicleframework/projectiles/BulletRaycast.java b/src/main/java/net/tfminecraft/vehicleframework/projectiles/BulletRaycast.java index 2ca38b9..852299a 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/projectiles/BulletRaycast.java +++ b/src/main/java/net/tfminecraft/vehicleframework/projectiles/BulletRaycast.java @@ -60,9 +60,10 @@ public static boolean handleSegment( if (entityResult != null && entityResult.getHitEntity() != null) { Entity hit = entityResult.getHitEntity(); Location hitPoint = entityResult.getHitPosition().toLocation(from.getWorld()); + if (handleBlockSegment(from, hitPoint, direction, from.distance(hitPoint), ammoData, bullet, weapon, players)) return true; if (hit instanceof LivingEntity living) { - if (bullet.getData().isExplosive()) { + if (Weapon.effectiveExplosive(weapon, bullet.getData())) { triggerExplosion(hitPoint, bullet, weapon); } else { ExplosionCreator.applyDamage( @@ -78,7 +79,7 @@ public static boolean handleSegment( targetVehicle.damage( Weapon.effectiveDamageType(weapon, ammoData), Weapon.effectiveDamage(weapon, ammoData)); - if (bullet.getData().isExplosive()) { + if (Weapon.effectiveExplosive(weapon, bullet.getData())) { triggerExplosion(hitPoint, bullet, weapon); } return true; @@ -116,11 +117,10 @@ private static boolean handleBlockSegment( ActiveWeapon weapon, List players) { World world = from.getWorld(); - Vector stepVector = direction.clone().multiply(BLOCK_STEP); Location current = from.clone(); for (double traveled = 0; traveled < distance; traveled += BLOCK_STEP) { - current.add(stepVector); + current.add(direction.clone().multiply(Math.min(BLOCK_STEP, distance - traveled))); Block block = current.getBlock(); if (block.getType().isAir()) { @@ -146,7 +146,7 @@ private static boolean handleBlockSegment( continue; } - if (bullet.getData().isExplosive()) { + if (Weapon.effectiveExplosive(weapon, bullet.getData())) { triggerExplosion(current, bullet, weapon); } else { Location impact = ImpactVfx.onBlockSurface(current, direction, block); @@ -201,15 +201,15 @@ static boolean intersectsCollision(Block block, Location point) { } public static void triggerExplosion(Location loc, Bullet bullet, ActiveWeapon weapon) { - if (!bullet.getData().isExplosive()) { + if (!Weapon.effectiveExplosive(weapon, bullet.getData())) { return; } AmmunitionData ammo = bullet.getData(); loc.getWorld().playSound(loc, Sound.ENTITY_GENERIC_EXPLODE, 8, 1); ExplosionCreator.triggerExplosion( loc, - ammo.getYield(), - ammo.getRadius(), + Weapon.effectiveYield(weapon, ammo), + Weapon.effectiveRadius(weapon, ammo), Weapon.effectiveDamage(weapon, ammo), Weapon.effectiveDamageType(weapon, ammo)); } diff --git a/src/main/java/net/tfminecraft/vehicleframework/projectiles/HitChecker.java b/src/main/java/net/tfminecraft/vehicleframework/projectiles/HitChecker.java index 1d3ad99..f094847 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/projectiles/HitChecker.java +++ b/src/main/java/net/tfminecraft/vehicleframework/projectiles/HitChecker.java @@ -113,37 +113,37 @@ public boolean hasHitIgnoreWater(Entity e, List ignore) { Location loc = e.getLocation(); Block blockAtLocation = loc.getBlock(); - if (check(blockAtLocation, true)) { + if (check(blockAtLocation)) { return true; } Block below = loc.clone().subtract(0, 1, 0).getBlock(); - if (check(below, true)) { + if (check(below)) { return true; } Block above = loc.clone().add(0, 1, 0).getBlock(); - if (check(above, true)) { + if (check(above)) { return true; } Block north = loc.clone().add(0, 0, -1).getBlock(); - if (check(north, true)) { + if (check(north)) { return true; } Block south = loc.clone().add(0, 0, 1).getBlock(); - if (check(south, true)) { + if (check(south)) { return true; } Block west = loc.clone().add(-1, 0, 0).getBlock(); - if (check(west, true)) { + if (check(west)) { return true; } Block east = loc.clone().add(1, 0, 0).getBlock(); - if (check(east, true)) { + if (check(east)) { return true; } @@ -162,10 +162,7 @@ public boolean hasHitIgnoreWater(Entity e, List ignore) { return false; } - private boolean check(Block b, boolean ignoreWater) { - if(!ignoreWater) { - return b.getType() != Material.AIR && b.getType() != Material.LIGHT; - } + private boolean check(Block b) { return b.getType() != Material.AIR && b.getType() != Material.LIGHT && !waterBlocks.contains(b.getType()) && !(b.getBlockData() instanceof Waterlogged && ((Waterlogged) b.getBlockData()).isWaterlogged()); } diff --git a/src/main/java/net/tfminecraft/vehicleframework/protocol/PacketConverter.java b/src/main/java/net/tfminecraft/vehicleframework/protocol/PacketConverter.java index 0384ff7..06ce6c7 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/protocol/PacketConverter.java +++ b/src/main/java/net/tfminecraft/vehicleframework/protocol/PacketConverter.java @@ -22,7 +22,6 @@ public List convert(float sideways, float forward, boolean space, boole if (forward > 0) list.add(Keybind.SPACE_W); if (forward < 0) list.add(Keybind.SPACE_S); list.add(Keybind.SPACE); - if (sneak) list.add(Keybind.SHIFT); } else { if (sideways > 0) list.add(Keybind.A); if (sideways < 0) list.add(Keybind.D); diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/ThrottleTape.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/ThrottleTape.java index 05b54c0..d7a4970 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/ThrottleTape.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/ThrottleTape.java @@ -136,9 +136,6 @@ static int lookup(List samples, double s, int sign) { Sample a = matched.get(i); Sample b = matched.get(i + 1); if (s >= a.s && s <= b.s) { - if (b.s == a.s) { - return a.throttle * a.orientation; - } double t = (s - a.s) / (b.s - a.s); return (int) Math.round(a.throttle * a.orientation + t * (b.throttle * b.orientation - a.throttle * a.orientation)); } @@ -223,7 +220,7 @@ public AppendResult tryAppend(double s, int sign, int throttle, String sampleSpl String junction = blankToNull(junctionId); if (!samples.isEmpty()) { Sample last = samples.get(samples.size() - 1); - String lastSpline = last.resolvedSpline(this.splineId); + String lastSpline = blankToNull(last.resolvedSpline(this.splineId)); boolean splineChanged = !sameId(lastSpline, spline); boolean junctionChanged = !sameId(last.junctionId, junction); boolean throttleChanged = last.throttle != throttle || last.sign != nsign || last.orientation != orientation; diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackBuildAnimator.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackBuildAnimator.java index 83be1f1..9e8dcc2 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackBuildAnimator.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackBuildAnimator.java @@ -86,7 +86,8 @@ public static void tick() { continue; } Player payer = job.playerId == null ? null : Bukkit.getPlayer(job.playerId); - if (TrackPieces.pays(payer) && !TrackPieces.consumeOne(payer)) { + if ((job.playerId != null && payer == null) + || (TrackPieces.pays(payer) && !TrackPieces.consumeOne(payer))) { it.remove(); persist(job); if (payer != null && payer.isOnline()) { @@ -240,14 +241,11 @@ private List visible() { } private static List bakePoints(List points) { - if (points == null || points.size() < 2) { - return List.of(); - } return TrackSpline.fromPoints(UUID.randomUUID(), "world", false, points).visuals(); } private boolean joinsAtKeepEnd() { - if (keep.isEmpty() || stroke.size() < 2) { + if (keep.isEmpty()) { return false; } double[] a = keep.get(keep.size() - 1); @@ -270,12 +268,9 @@ private void fx() { if (world == null) { return; } - float yaw = 0; int at = Math.min(revealed, stroke.size()) - 1; - if (at >= 1) { - double[] a = stroke.get(at - 1); - yaw = (float) Math.toDegrees(Math.atan2(-(p[0] - a[0]), p[2] - a[2])); - } + double[] a = stroke.get(at - 1); + float yaw = (float) Math.toDegrees(Math.atan2(-(p[0] - a[0]), p[2] - a[2])); TrackFx.place(world, new TrackPose(p[0], p[1], p[2], yaw, 0)); if (!Cache.trackBuildSwing) { return; diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackCommands.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackCommands.java index 99995c5..41d2ae8 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackCommands.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackCommands.java @@ -412,7 +412,8 @@ public static void finish(Player player, Location a, Location b) { a.getWorld().getName(), a.getWorld(), a.getX(), a.getY(), a.getZ(), - b.getX(), b.getY(), b.getZ()); + b.getX(), b.getY(), b.getZ(), + TrackPieces.pays(player) ? TrackPieces.count(player) : Integer.MAX_VALUE); TrackAnchorSession.clear(player); announceLay(player, result, presentLay(player, result), false); } catch (TrackLayException e) { @@ -581,9 +582,6 @@ public static void applyDig(Player player, DigResult result, Location at, TrackS } private static Presented presentLay(Player player, TrackLayResult result) { - if (result == null || result.spline() == null) { - return Presented.none(); - } int cost = TrackPieces.cost(result.stroke); if (result.sequential() && TrackBuildAnimator.sequential(player)) { boolean started = TrackBuildAnimator.start( @@ -609,9 +607,6 @@ private static Presented presentLay(Player player, TrackLayResult result) { } private static Optional remaining(TrackLayResult result) { - if (result == null || result.spline() == null) { - return Optional.empty(); - } return registry().get(result.spline().getId()) .filter(spline -> spline.getSamples().size() >= 2); } @@ -621,9 +616,6 @@ private static void announceLay( TrackLayResult result, Presented presented, boolean branch) { - if (player == null || result == null || presented == null) { - return; - } Optional now = presented.spline; if (now.isPresent()) { TrackSpline spline = now.get(); @@ -666,15 +658,9 @@ private Presented(Optional spline, int paid, int cost, boolean sequ this.sequential = sequential; } - private static Presented none() { - return new Presented(Optional.empty(), 0, 0, false); - } } private static void burstPlace(Player player, TrackLayResult result) { - if (player == null || player.getWorld() == null || result == null) { - return; - } double[] p; float yaw = 0; if (result.stroke != null && result.stroke.size() >= 1) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackCurve.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackCurve.java index 44ddd98..63e3117 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackCurve.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackCurve.java @@ -303,6 +303,9 @@ private static List fit( double horiz = Math.hypot(vx, vz); if (Math.abs(turn) < 1e-3) { if (Math.abs(sx * vz - sz * vx) < 1e-6 * horiz) { + if (sx * vx + sz * vz <= 0) { + return null; + } return TrackGenerate.densify(ax, ay, az, bx, ay, bz, step); } for (double r = radius; r > 0; r = nextRadius(r, minRadius)) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackDisplayManager.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackDisplayManager.java index 4bd2f0b..7912102 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackDisplayManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackDisplayManager.java @@ -479,10 +479,6 @@ public void onBlockExplode(BlockExplodeEvent event) { } private void spawnSplineInChunk(TrackSpline spline, Chunk chunk) { - if (TrackBuildAnimator.isBuilding(spline.getId())) { - TrackBuildAnimator.spawnIntoChunk(spline, chunk); - return; - } int cx = chunk.getX(); int cz = chunk.getZ(); World world = chunk.getWorld(); @@ -520,9 +516,6 @@ public void spawnSwitchesForSpline(TrackSpline spline) { } for (TrackJunction junction : registry.junctionsOn(spline.getId())) { TrackSwitchPose pose = switchPose(spline, junction); - if (pose == null) { - continue; - } int cx = TrackChunks.chunkCoord(pose.x); int cz = TrackChunks.chunkCoord(pose.z); if (!world.isChunkLoaded(cx, cz)) { @@ -553,9 +546,6 @@ public void refreshSwitch(TrackJunction junction) { return; } TrackSwitchPose pose = switchPose(stem, junction); - if (pose == null) { - return; - } int cx = TrackChunks.chunkCoord(pose.x); int cz = TrackChunks.chunkCoord(pose.z); if (!world.isChunkLoaded(cx, cz)) { @@ -584,9 +574,6 @@ private void spawnSwitchInChunk(TrackJunction junction, TrackSpline stem, Chunk return; } TrackSwitchPose pose = switchPose(stem, junction); - if (pose == null) { - return; - } if (!TrackChunks.inChunk(pose.x, pose.z, chunk.getX(), chunk.getZ())) { return; } diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackJunction.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackJunction.java index 8f57e57..01b4b53 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackJunction.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackJunction.java @@ -174,7 +174,7 @@ private static Side parseSide(Object raw) { throw new IllegalArgumentException("junction needs side"); } try { - return Side.valueOf(String.valueOf(raw).trim().toUpperCase()); + return Side.valueOf(String.valueOf(raw).trim().toUpperCase(java.util.Locale.ROOT)); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("junction side must be LEFT or RIGHT"); } diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRegistry.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRegistry.java index fc1878f..9c9a28b 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRegistry.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRegistry.java @@ -74,11 +74,8 @@ public void loadFromDisk() { splines.clear(); junctions.clear(); for (TrackSpline spline : store.loadAll()) { - TrackSpline promoted = spline.promotedLoop(Cache.trackJoinDistance); - splines.put(promoted.getId(), promoted); - if (promoted.isLoop() && !spline.isLoop()) { - store.save(promoted); - } + // TrackSpline.fromJson already promotes legacy closed polylines. + splines.put(spline.getId(), spline); } for (TrackStore.LoadedJunction loaded : store.loadAllJunctions()) { TrackJunction junction = loaded.junction; @@ -108,7 +105,7 @@ public TrackSpline createBetween( } } - public TrackLayResult lay( + public TrackLayResult lay( String world, double ax, double ay, double az, double bx, double by, double bz) throws TrackLayException { @@ -120,6 +117,14 @@ public TrackLayResult lay( World bukkitWorld, double ax, double ay, double az, double bx, double by, double bz) throws TrackLayException { + return lay(world, bukkitWorld, ax, ay, az, bx, by, bz, Integer.MAX_VALUE); + } + + /** Connections must be affordable in full because they merge existing track records. */ + public TrackLayResult lay( + String world, World bukkitWorld, + double ax, double ay, double az, + double bx, double by, double bz, int connectionBudget) throws TrackLayException { Optional atA = findEnd(world, ax, ay, az); Optional atB = findEnd(world, bx, by, bz); TrackLog.layAttempt(world, ax, ay, az, bx, by, bz, atA.isPresent(), atB.isPresent()); @@ -127,13 +132,13 @@ public TrackLayResult lay( if (atA.isPresent() && atB.isPresent() && atA.get().spline.getId().equals(atB.get().spline.getId()) && atA.get().prepend != atB.get().prepend) { - StrokeLay laid = closeLoop(atA.get(), atB.get(), bukkitWorld); + StrokeLay laid = closeLoop(atA.get(), atB.get(), bukkitWorld, connectionBudget); TrackLog.layOk(laid.spline, "loop"); return finishLay(TrackLayResult.of(TrackLayResult.Kind.CONNECT, laid.spline, laid.stroke, 0)); } if (atA.isPresent() && atB.isPresent() && !atA.get().spline.getId().equals(atB.get().spline.getId())) { - StrokeLay laid = connect(atA.get(), atB.get(), bukkitWorld); + StrokeLay laid = connect(atA.get(), atB.get(), bukkitWorld, connectionBudget); TrackLog.layOk(laid.spline, "connect"); return finishLay(TrackLayResult.of(TrackLayResult.Kind.CONNECT, laid.spline, laid.stroke, 0)); } @@ -422,11 +427,12 @@ private static double horizontal(double[] a, double[] b) { return Math.hypot(b[0] - a[0], b[2] - a[2]); } - private StrokeLay closeLoop(TrackEnd from, TrackEnd to, World bukkitWorld) throws TrackLayException { + private StrokeLay closeLoop(TrackEnd from, TrackEnd to, World bukkitWorld, int connectionBudget) throws TrackLayException { TrackSpline spline = from.spline; TrackSample originA = from.prepend ? spline.first() : spline.last(); TrackSample originB = to.prepend ? spline.first() : spline.last(); List extra = joinCurve(from, originA, to, originB); + requireConnectionBudget(extra, connectionBudget); TrackClearance.check(bukkitWorld, extra, this, Set.of(spline.getId())); // Close the loop in the track's own direction. Reversing it would turn // round any train on it and leave its junctions facing the wrong way. @@ -449,7 +455,7 @@ private StrokeLay closeLoop(TrackEnd from, TrackEnd to, World bukkitWorld) throw return new StrokeLay(replace(next), extra, 0); } - private StrokeLay connect(TrackEnd from, TrackEnd to, World bukkitWorld) throws TrackLayException { + private StrokeLay connect(TrackEnd from, TrackEnd to, World bukkitWorld, int connectionBudget) throws TrackLayException { // Laid from `from` to `to`, joining from a start reverses `from` and // joining to an end reverses `to`. Building the joined track the other // way round flips both. Trains face +s, so a track with a train on it @@ -473,6 +479,7 @@ private StrokeLay connect(TrackEnd from, TrackEnd to, World bukkitWorld) throws TrackSample originA = from.prepend ? from.spline.first() : from.spline.last(); TrackSample originB = to.prepend ? to.spline.first() : to.spline.last(); List extra = joinCurve(from, originA, to, originB); + requireConnectionBudget(extra, connectionBudget); TrackClearance.check( bukkitWorld, extra, this, Set.of(from.spline.getId(), to.spline.getId())); List fromPart = points(from.spline, keepReversed); @@ -512,6 +519,13 @@ private StrokeLay connect(TrackEnd from, TrackEnd to, World bukkitWorld) throws return new StrokeLay(stored, extra, 0); } + private static void requireConnectionBudget(List stroke, int budget) throws TrackLayException { + int cost = TrackPieces.cost(stroke); + if (cost > budget) { + throw new TrackLayException("You need " + cost + " track to complete this connection (have " + budget + ")."); + } + } + /** The stroke from one track end to another, leaving the first and entering the second along their own headings. */ private static List joinCurve(TrackEnd from, TrackSample originA, TrackEnd to, TrackSample originB) throws TrackLayException { @@ -626,9 +640,6 @@ public TrackSpline layBranch( throw new TrackLayException("Junction already has a branch"); } TrackSpline stem = splines.get(junction.stemSplineId); - if (stem == null) { - throw new TrackLayException("Junction stem track is missing"); - } ensureFrogClear(stem, junction.s, junction.id); TrackPose pose = stem.sampleAt(junction.s); float yaw = pose.yaw; @@ -723,12 +734,9 @@ private List branchCurve( } private static TrackJunction.Side branchSide(float yaw, List extra) { - if (extra.size() >= 2) { - double[] a = extra.get(0); - double[] b = extra.get(1); - return TrackJunction.sideFrom(yaw, b[0] - a[0], b[2] - a[2]); - } - return TrackJunction.Side.RIGHT; + double[] a = extra.get(0); + double[] b = extra.get(1); + return TrackJunction.sideFrom(yaw, b[0] - a[0], b[2] - a[2]); } public boolean delete(UUID id) { @@ -987,9 +995,6 @@ private void clearBranchRefs(UUID splineId) { } for (UUID junctionId : drop) { TrackJunction junction = junctions.remove(junctionId); - if (junction == null) { - continue; - } TrackSpline stem = splines.get(junction.stemSplineId); String world = stem != null ? stem.getWorld() : "unknown"; store.deleteJunction(world, junctionId); @@ -1002,9 +1007,6 @@ private static boolean branchIdEquals(TrackJunction junction, UUID splineId) { } private static double polylineLength(List points) { - if (points == null || points.size() < 2) { - return 0; - } double len = 0; for (int i = 1; i < points.size(); i++) { double[] a = points.get(i - 1); @@ -1205,9 +1207,6 @@ private void dropJunctionAndBranch(TrackJunction junction) { } private DigResult removeJunctionTurnout(TrackJunction junction, World bukkitWorld) { - if (junction == null) { - return DigResult.none(); - } UUID branchId = junction.branchSplineId; deleteJunction(junction.id); if (branchId == null) { @@ -1263,9 +1262,6 @@ public void pruneNestedShortTracks() { } private boolean isFullyNestedInLongerTrack(TrackSpline candidate) { - if (candidate.getSamples().size() < 2) { - return false; - } for (TrackSpline host : inWorld(candidate.getWorld())) { if (host.getId().equals(candidate.getId())) { continue; diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackResettle.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackResettle.java index 7275c2f..048abe9 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackResettle.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackResettle.java @@ -81,6 +81,9 @@ static void smoothInward( } double next; if (inBand) { + if (sit > hi + 1e-4) { + break; + } next = sit; } else if (to[1] < lo) { next = lo; @@ -93,9 +96,6 @@ static void smoothInward( } next = sit; } - if (Math.abs(next - to[1]) < 1e-4) { - break; - } to[1] = next; changed++; j = i; diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRouteQuery.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRouteQuery.java index b4fcc46..8a47796 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRouteQuery.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackRouteQuery.java @@ -193,9 +193,6 @@ private static void oneWay(List> adj, int from, int to, double weight if (from == to || !Double.isFinite(weight)) { return; } - if (weight < 0) { - weight = 0; - } adj.get(from).add(new Edge(to, weight, leg)); } @@ -293,11 +290,6 @@ private static List cover(TrackSpline spline, double x, double z, doub } private static void addInterval(List raw, double s0, double s1) { - if (s1 < s0) { - double swap = s0; - s0 = s1; - s1 = swap; - } raw.add(new Interval(s0, s1)); } @@ -419,7 +411,7 @@ private static void linkSpline( if (!nearA.isEmpty() && !nearB.isEmpty()) { oneWay(adj, SRC, DST, spline, piece, pieceSeparation(piece, nearA, nearB, length)); } - if (onSpline == null || onSpline.isEmpty()) { + if (onSpline == null) { continue; } List onPiece = new ArrayList<>(); @@ -518,11 +510,6 @@ private static List brokenSpans(TrackSpline spline) { } double s0 = edgeStartS(spline, edge); double s1 = edgeEndS(spline, edge); - if (s1 < s0) { - double swap = s0; - s0 = s1; - s1 = swap; - } if (s1 <= s0 + EPS) { continue; } @@ -546,10 +533,7 @@ private static double edgeEndS(TrackSpline spline, int edge) { if (edge < n - 1) { return samples.get(edge + 1).s; } - if (spline.isLoop()) { - return spline.length(); - } - return samples.get(n - 1).s; + return spline.length(); } private static double seam(double s, double length) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackSpline.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackSpline.java index 08b5aee..026ce10 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackSpline.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackSpline.java @@ -29,7 +29,7 @@ public TrackSpline(UUID id, String world, boolean loop, List sample this.id = id == null ? UUID.randomUUID() : id; this.world = world == null ? "" : world; this.loop = loop; - this.samples = List.copyOf(samples); + this.samples = List.copyOf(rising(samples) ? samples : recomputeS(samples)); int expected = edgeCount(this.samples.size(), loop); List segs = segments == null ? defaultSegments(expected) : new ArrayList<>(segments); while (segs.size() < expected) { @@ -207,12 +207,12 @@ public TrackAdvance advance(double s, double ds) { } if (ds > 0) { - Double stop = firstBrokenAhead(from, to); + Double stop = firstBrokenAhead(from, to, loop && ds >= len); if (stop != null) { return new TrackAdvance(stop, true); } } else { - Double stop = firstBrokenBehind(from, to); + Double stop = firstBrokenBehind(from, to, loop && -ds >= len); if (stop != null) { return new TrackAdvance(stop, true); } @@ -329,7 +329,7 @@ public static TrackSpline fromJson(JSONObject root) { loaded.add(new TrackSample(x, y, z, yaw, pitch, s)); } // sampleAt and advance need s rising along the track. - if (missingS || !rising(loaded)) { + if (missingS) { loaded = recomputeS(loaded); } JSONArray segArr = (JSONArray) root.get("segments"); @@ -350,41 +350,37 @@ public void invalidateVisuals() { visualCache = null; } - private Double firstBrokenAhead(double from, double to) { - boolean wrap = loop && to + 1e-12 < from - 1e-9; + private Double firstBrokenAhead(double from, double to, boolean fullLap) { + boolean wrap = fullLap || (loop && to + 1e-12 < from - 1e-9); Double stop = null; for (int i : brokenEdges) { double start = edgeStartS(i); boolean crosses; if (wrap) { - crosses = start + 1e-12 > from || start <= to + 1e-12; + crosses = fullLap || start + 1e-12 > from || start <= to + 1e-12; } else { crosses = start > from + 1e-12 && start <= to + 1e-12; } if (!crosses) { continue; } - if (stop == null) { - stop = start; - } else if (wrap) { - if (start > from && (stop <= to || start < stop)) { - stop = start; - } - } else if (start < stop) { + if (stop == null || (wrap && start > from && stop <= from)) { stop = start; } } return stop; } - private Double firstBrokenBehind(double from, double to) { - boolean wrap = loop && to > from + 1e-9; + private Double firstBrokenBehind(double from, double to, boolean fullLap) { + boolean wrap = fullLap || (loop && to > from + 1e-9); double best = Double.NaN; for (int i : brokenEdges) { double end = edgeEndS(i); if (wrap) { - if (end < from - 1e-12 || end + 1e-12 >= to) { - if (Double.isNaN(best) || end > best) { + if (fullLap || end < from - 1e-12 || end + 1e-12 >= to) { + if (Double.isNaN(best) + || (end < from && (best >= from || end > best)) + || (end >= from && best >= from && end > best)) { best = end; } } @@ -411,27 +407,18 @@ private double edgeEndS(int edgeIndex) { if (edgeIndex < samples.size() - 1) { return samples.get(edgeIndex + 1).s; } - if (loop) { - return length(); - } - return samples.get(samples.size() - 1).s; + return length(); } private TrackSample edgeStart(int edgeIndex) { - if (edgeIndex < samples.size()) { - return samples.get(Math.min(edgeIndex, samples.size() - 1)); - } - return samples.get(samples.size() - 1); + return samples.get(edgeIndex); } private TrackSample edgeEndSample(int edgeIndex) { if (edgeIndex < samples.size() - 1) { return samples.get(edgeIndex + 1); } - if (loop) { - return samples.get(0); - } - return samples.get(samples.size() - 1); + return samples.get(0); } private TrackPose lerpEdge(int edgeIndex, double t) { @@ -475,8 +462,11 @@ private static List defaultSegments(int count) { } private static boolean rising(List samples) { + if (samples.getFirst().s != 0) { + return false; + } for (int i = 1; i < samples.size(); i++) { - if (!(samples.get(i).s >= samples.get(i - 1).s)) { + if (!Double.isFinite(samples.get(i).s) || samples.get(i).s < samples.get(i - 1).s) { return false; } } diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackToolListener.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackToolListener.java index 547dcea..3a33bff 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackToolListener.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackToolListener.java @@ -47,10 +47,6 @@ public void onInteract(PlayerInteractEvent event) { } return; } - if (layer && !TrackSupport.isValidClick(block)) { - player.sendMessage("§cClick solid ground, not grass or plants."); - return; - } if (at == null) { if (layer) { player.sendMessage("§cClick solid ground, not grass or plants."); diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackVisualBake.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackVisualBake.java index 7539a2e..96acf5a 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackVisualBake.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrackVisualBake.java @@ -163,9 +163,6 @@ private static int index(int start, int offset, int n, boolean loop) { int idx = start + offset; if (loop) { idx %= n; - if (idx < 0) { - idx += n; - } return idx; } return idx; diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainRoute.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainRoute.java index f25d356..d679258 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainRoute.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainRoute.java @@ -91,10 +91,6 @@ public Walk walk(Position start, double distance, boolean select, boolean stopAt return new Walk(at, left, false, crossed); } if (fromBranch) { - TrackSpline stem = registry.get(next.stemSplineId).orElse(null); - if (stem == null) { - return new Walk(at, left, false, crossed); - } crossed.put(next.id, true); at = new Position(next.stemSplineId, next.s, at.orientation() * next.facingSign); atPoint.add(next.id); diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainTapeInteract.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainTapeInteract.java index 71570a5..98b668c 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainTapeInteract.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainTapeInteract.java @@ -101,7 +101,7 @@ private static boolean install(Player player, TrainHandler train, ItemStack hand private static boolean eject(Player player, TrainHandler train) { ThrottleTape tape = train.getInstalledTape(); ItemStack item = TLibs.getItemAPI().getCreator().getItemFromPath(Cache.trackRecorderItem); - if (item == null) { + if (item == null || item.getType().isAir()) { player.sendMessage("§cCould not create a recorder item"); return true; } diff --git a/src/main/java/net/tfminecraft/vehicleframework/util/ConditionChecker.java b/src/main/java/net/tfminecraft/vehicleframework/util/ConditionChecker.java index 66de44e..bb64cb1 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/util/ConditionChecker.java +++ b/src/main/java/net/tfminecraft/vehicleframework/util/ConditionChecker.java @@ -13,17 +13,23 @@ public class ConditionChecker { public static boolean checkConditions(ActiveVehicle vehicle, List conditions) { - boolean fulfilled = true; - for(String c : conditions) { - if(!fulfilled) break; - String type = c.split("\\(")[0]; - String value = c.split("\\(")[1].replace(")", ""); - if(!checkCondition(vehicle, type, value)) fulfilled = false; - } - return fulfilled; + for (String condition : conditions) { + if (condition == null) return false; + int start = condition.indexOf('('); + if (start <= 0 || !condition.endsWith(")")) return false; + String type = condition.substring(0, start).trim(); + String value = condition.substring(start + 1, condition.length() - 1).trim(); + if (!checkCondition(vehicle, type, value)) return false; + } + return true; } public static boolean checkCondition(ActiveVehicle vehicle, String type, String value) { + if (type == null || value == null || value.isBlank()) return false; + type = type.trim(); + value = value.trim(); + if (java.util.Set.of("lift", "passengers", "is_passenger", "has_fuel").contains(type.toLowerCase(java.util.Locale.ROOT)) + && !value.equalsIgnoreCase("true") && !value.equalsIgnoreCase("false")) return false; if(type.equalsIgnoreCase("state")) { if(!vehicle.getStateHandler().getCurrentState().getType().toString().equalsIgnoreCase(value)) return false; } else if(type.equalsIgnoreCase("lift")) { @@ -39,9 +45,9 @@ public static boolean checkCondition(ActiveVehicle vehicle, String type, String } else if(type.equalsIgnoreCase("passengers")) { if(!(vehicle.getSeatHandler().hasPassengers() == Boolean.parseBoolean(value))) return false; } else if(type.equalsIgnoreCase("seat_filled")) { - if(vehicle.getAccessPanel().getSeat(value) != null && !vehicle.getAccessPanel().getSeat(value).isOccupied()) return false; + if(vehicle.getAccessPanel().getSeat(value) == null || !vehicle.getAccessPanel().getSeat(value).isOccupied()) return false; } else if(type.equalsIgnoreCase("seat_empty")) { - if(vehicle.getAccessPanel().getSeat(value) != null && vehicle.getAccessPanel().getSeat(value).isOccupied()) return false; + if(vehicle.getAccessPanel().getSeat(value) == null || vehicle.getAccessPanel().getSeat(value).isOccupied()) return false; } else if(type.equalsIgnoreCase("is_passenger")) { boolean passenger = vehicle.hasParent(); if(value.equalsIgnoreCase("true") && !passenger) return false; @@ -54,13 +60,13 @@ public static boolean checkCondition(ActiveVehicle vehicle, String type, String Throttle throttle = vehicle.getThrottle(); if(throttle == null) return false; int amount = throttle.getCurrent(); - if(amount > Integer.parseInt(value)) return false; + try { if(amount > Integer.parseInt(value)) return false; } catch (NumberFormatException invalid) { return false; } return true; } else if(type.equalsIgnoreCase("throttle_more_than")) { Throttle throttle = vehicle.getThrottle(); if(throttle == null) return false; int amount = throttle.getCurrent(); - if(amount < Integer.parseInt(value)) return false; + try { if(amount < Integer.parseInt(value)) return false; } catch (NumberFormatException invalid) { return false; } return true; } else if(type.equalsIgnoreCase("OR")) { if(!orStatement(vehicle, value)) return false; @@ -68,14 +74,13 @@ public static boolean checkCondition(ActiveVehicle vehicle, String type, String if(!andStatement(vehicle, value)) return false; } else if(type.equalsIgnoreCase("health_percent")) { return checkHealthPercent(vehicle, value); - } + } else { + return false; + } return true; } private static boolean checkHealthPercent(ActiveVehicle vehicle, String value) { - if (value == null || value.isBlank()) { - return false; - } String componentName = null; String comparison = null; Integer threshold = null; @@ -108,7 +113,7 @@ private static boolean checkHealthPercent(ActiveVehicle vehicle, String value) { } catch (NumberFormatException e) { return false; } - comparison = key.toLowerCase(); + comparison = key.toLowerCase(java.util.Locale.ROOT); continue; } return false; @@ -118,7 +123,7 @@ private static boolean checkHealthPercent(ActiveVehicle vehicle, String value) { } Component component; try { - component = Component.valueOf(componentName.toUpperCase()); + component = Component.valueOf(componentName.toUpperCase(java.util.Locale.ROOT)); } catch (IllegalArgumentException e) { return false; } @@ -126,34 +131,26 @@ private static boolean checkHealthPercent(ActiveVehicle vehicle, String value) { return false; } int percent = vehicle.getComponent(component).getHealthData().getHealthPercentage(); - return switch (comparison) { - case "less_than" -> percent < threshold; - case "more_than" -> percent > threshold; - case "at_most" -> percent <= threshold; - case "at_least" -> percent >= threshold; - default -> false; - }; + if (comparison.equals("less_than")) return percent < threshold; + if (comparison.equals("more_than")) return percent > threshold; + if (comparison.equals("at_most")) return percent <= threshold; + return percent >= threshold; // The parser accepts only these four comparisons. } - private static boolean orStatement(ActiveVehicle vehicle, String s) { - List conditions = new ArrayList<>(Arrays.asList(s.split(";"))); - boolean fulfilled = false; - for(String c : conditions) { - if(fulfilled) break; - String type = c.split("\\=")[0]; - String value = c.split("\\=")[1]; - if(checkCondition(vehicle, type, value)) fulfilled = true; - } - return fulfilled; - } - - private static boolean andStatement(ActiveVehicle vehicle, String s) { - List conditions = new ArrayList<>(Arrays.asList(s.split(";"))); - for(String c : conditions) { - String type = c.split("\\=")[0]; - String value = c.split("\\=")[1]; - if(!checkCondition(vehicle, type, value)) return false; - } - return true; - } + private static boolean orStatement(ActiveVehicle vehicle, String conditions) { + for (String condition : conditions.split(";", -1)) { + int separator = condition.indexOf('='); + if (separator <= 0) return false; + if (checkCondition(vehicle, condition.substring(0, separator), condition.substring(separator + 1))) return true; + } + return false; + } + + private static boolean andStatement(ActiveVehicle vehicle, String conditions) { + for (String condition : conditions.split(";", -1)) { + int separator = condition.indexOf('='); + if (separator <= 0 || !checkCondition(vehicle, condition.substring(0, separator), condition.substring(separator + 1))) return false; + } + return true; + } } diff --git a/src/main/java/net/tfminecraft/vehicleframework/util/ExplosionCreator.java b/src/main/java/net/tfminecraft/vehicleframework/util/ExplosionCreator.java index 62a3c33..9507e5d 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/util/ExplosionCreator.java +++ b/src/main/java/net/tfminecraft/vehicleframework/util/ExplosionCreator.java @@ -34,13 +34,14 @@ public static void triggerExplosion(Location explosionCenter, double yield, doub triggerExplosion(explosionCenter, yield, blastRadius, damage, cause, false); } public static void triggerExplosion(Location explosionCenter, double yield, double blastRadius, double damage, String cause, boolean fire) { + if (!Double.isFinite(blastRadius) || blastRadius <= 0) return; VFExplosionEvent event = new VFExplosionEvent(explosionCenter); Bukkit.getPluginManager().callEvent(event); if (event.isCancelled()) { return; } - if (VehicleFramework.getTrackDisplayManager() != null) { + if (event.doesBlockDamage() && VehicleFramework.getTrackDisplayManager() != null) { VehicleFramework.getTrackDisplayManager().breakInRadius(explosionCenter, blastRadius); } int particles = (int) Math.round(yield*15); @@ -113,7 +114,7 @@ public static void triggerExplosion(Location explosionCenter, double yield, doub } } - if (fire) { + if (fire && event.doesBlockDamage()) { igniteArea(explosionCenter, yield, blastRadius, random); } diff --git a/src/main/java/net/tfminecraft/vehicleframework/util/LightEffect.java b/src/main/java/net/tfminecraft/vehicleframework/util/LightEffect.java index f2f520a..7488da3 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/util/LightEffect.java +++ b/src/main/java/net/tfminecraft/vehicleframework/util/LightEffect.java @@ -23,7 +23,7 @@ public void createTemporaryLight(Location loc, int lightLevel) { new BukkitRunnable() { public void run() { - b.setType(Material.AIR); + if (b.getType() == Material.LIGHT) b.setType(Material.AIR); Cache.lightLocations.remove(loc); } }.runTaskLater(VehicleFramework.plugin, 2L); diff --git a/src/main/java/net/tfminecraft/vehicleframework/util/LocationChecker.java b/src/main/java/net/tfminecraft/vehicleframework/util/LocationChecker.java index 33c08b5..38e6a4a 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/util/LocationChecker.java +++ b/src/main/java/net/tfminecraft/vehicleframework/util/LocationChecker.java @@ -29,9 +29,6 @@ public static boolean isWaterBlock(Block block) { if (water.contains(block.getType())) { return true; } - if (block.isLiquid()) { - return true; - } if (block.getBlockData() instanceof Waterlogged waterlogged && waterlogged.isWaterlogged()) { return true; } diff --git a/src/main/java/net/tfminecraft/vehicleframework/util/Metrics.java b/src/main/java/net/tfminecraft/vehicleframework/util/Metrics.java deleted file mode 100644 index 6eb957f..0000000 --- a/src/main/java/net/tfminecraft/vehicleframework/util/Metrics.java +++ /dev/null @@ -1,905 +0,0 @@ -/* - * This Metrics class was auto-generated and can be copied into your project if you are - * not using a build tool like Gradle or Maven for dependency management. - * - * IMPORTANT: You are not allowed to modify this class, except changing the package. - * - * Disallowed modifications include but are not limited to: - * - Remove the option for users to opt-out - * - Change the frequency for data submission - * - Obfuscate the code (every obfuscator should allow you to make an exception for specific files) - * - Reformat the code (if you use a linter, add an exception) - * - * Violations will result in a ban of your plugin and account from bStats. - */ -package net.tfminecraft.vehicleframework.util; - -import java.io.BufferedReader; -import java.io.ByteArrayOutputStream; -import java.io.DataOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; -import java.lang.reflect.Method; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashSet; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.Callable; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.function.BiConsumer; -import java.util.function.Consumer; -import java.util.function.Supplier; -import java.util.logging.Level; -import java.util.stream.Collectors; -import java.util.zip.GZIPOutputStream; -import javax.net.ssl.HttpsURLConnection; -import org.bukkit.Bukkit; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.Player; -import org.bukkit.plugin.Plugin; - -public class Metrics { - - private final Plugin plugin; - - private final MetricsBase metricsBase; - - /** - * Creates a new Metrics instance. - * - * @param plugin Your plugin instance. - * @param serviceId The id of the service. It can be found at What is my plugin id? - */ - public Metrics(Plugin plugin, int serviceId) { - this.plugin = plugin; - // Get the config file - File bStatsFolder = new File(plugin.getDataFolder().getParentFile(), "bStats"); - File configFile = new File(bStatsFolder, "config.yml"); - YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); - if (!config.isSet("serverUuid")) { - config.addDefault("enabled", true); - config.addDefault("serverUuid", UUID.randomUUID().toString()); - config.addDefault("logFailedRequests", false); - config.addDefault("logSentData", false); - config.addDefault("logResponseStatusText", false); - // Inform the server owners about bStats - config - .options() - .setHeader(java.util.Arrays.asList(( - "bStats (https://bStats.org) collects some basic information for plugin authors, like how\n" - + "many people use their plugin and their total player count. It's recommended to keep bStats\n" - + "enabled, but if you're not comfortable with this, you can turn this setting off. There is no\n" - + "performance penalty associated with having metrics enabled, and data sent to bStats is fully\n" - + "anonymous.").split("\n", -1))) - .copyDefaults(true); - try { - config.save(configFile); - } catch (IOException ignored) { - } - } - // Load the data - boolean enabled = config.getBoolean("enabled", true); - String serverUUID = config.getString("serverUuid"); - boolean logErrors = config.getBoolean("logFailedRequests", false); - boolean logSentData = config.getBoolean("logSentData", false); - boolean logResponseStatusText = config.getBoolean("logResponseStatusText", false); - boolean isFolia = false; - try { - isFolia = Class.forName("io.papermc.paper.threadedregions.RegionizedServer") != null; - } catch (Exception e) { - } - metricsBase = - new // See https://github.com/Bastian/bstats-metrics/pull/126 - // See https://github.com/Bastian/bstats-metrics/pull/126 - // See https://github.com/Bastian/bstats-metrics/pull/126 - // See https://github.com/Bastian/bstats-metrics/pull/126 - // See https://github.com/Bastian/bstats-metrics/pull/126 - // See https://github.com/Bastian/bstats-metrics/pull/126 - // See https://github.com/Bastian/bstats-metrics/pull/126 - MetricsBase( - "bukkit", - serverUUID, - serviceId, - enabled, - this::appendPlatformData, - this::appendServiceData, - isFolia - ? null - : submitDataTask -> Bukkit.getScheduler().runTask(plugin, submitDataTask), - plugin::isEnabled, - (message, error) -> this.plugin.getLogger().log(Level.WARNING, message, error), - (message) -> this.plugin.getLogger().log(Level.INFO, message), - logErrors, - logSentData, - logResponseStatusText, - false); - } - - /** Shuts down the underlying scheduler service. */ - public void shutdown() { - metricsBase.shutdown(); - } - - /** - * Adds a custom chart. - * - * @param chart The chart to add. - */ - public void addCustomChart(CustomChart chart) { - metricsBase.addCustomChart(chart); - } - - private void appendPlatformData(JsonObjectBuilder builder) { - builder.appendField("playerAmount", getPlayerAmount()); - builder.appendField("onlineMode", Bukkit.getOnlineMode() ? 1 : 0); - builder.appendField("bukkitVersion", Bukkit.getVersion()); - builder.appendField("bukkitName", Bukkit.getName()); - builder.appendField("javaVersion", System.getProperty("java.version")); - builder.appendField("osName", System.getProperty("os.name")); - builder.appendField("osArch", System.getProperty("os.arch")); - builder.appendField("osVersion", System.getProperty("os.version")); - builder.appendField("coreCount", Runtime.getRuntime().availableProcessors()); - } - - private void appendServiceData(JsonObjectBuilder builder) { - builder.appendField("pluginVersion", plugin.getPluginMeta().getVersion()); - } - - private int getPlayerAmount() { - try { - // Around MC 1.8 the return type was changed from an array to a collection, - // This fixes java.lang.NoSuchMethodError: - // org.bukkit.Bukkit.getOnlinePlayers()Ljava/util/Collection; - Method onlinePlayersMethod = Class.forName("org.bukkit.Server").getMethod("getOnlinePlayers"); - return onlinePlayersMethod.getReturnType().equals(Collection.class) - ? ((Collection) onlinePlayersMethod.invoke(Bukkit.getServer())).size() - : ((Player[]) onlinePlayersMethod.invoke(Bukkit.getServer())).length; - } catch (Exception e) { - // Just use the new method if the reflection failed - return Bukkit.getOnlinePlayers().size(); - } - } - - public static class MetricsBase { - - /** The version of the Metrics class. */ - public static final String METRICS_VERSION = "3.1.0"; - - private static final String REPORT_URL = "https://bStats.org/api/v2/data/%s"; - - private final ScheduledExecutorService scheduler; - - private final String platform; - - private final String serverUuid; - - private final int serviceId; - - private final Consumer appendPlatformDataConsumer; - - private final Consumer appendServiceDataConsumer; - - private final Consumer submitTaskConsumer; - - private final Supplier checkServiceEnabledSupplier; - - private final BiConsumer errorLogger; - - private final Consumer infoLogger; - - private final boolean logErrors; - - private final boolean logSentData; - - private final boolean logResponseStatusText; - - private final Set customCharts = new HashSet<>(); - - private final boolean enabled; - - /** - * Creates a new MetricsBase class instance. - * - * @param platform The platform of the service. - * @param serviceId The id of the service. - * @param serverUuid The server uuid. - * @param enabled Whether or not data sending is enabled. - * @param appendPlatformDataConsumer A consumer that receives a {@code JsonObjectBuilder} and - * appends all platform-specific data. - * @param appendServiceDataConsumer A consumer that receives a {@code JsonObjectBuilder} and - * appends all service-specific data. - * @param submitTaskConsumer A consumer that takes a runnable with the submit task. This can be - * used to delegate the data collection to a another thread to prevent errors caused by - * concurrency. Can be {@code null}. - * @param checkServiceEnabledSupplier A supplier to check if the service is still enabled. - * @param errorLogger A consumer that accepts log message and an error. - * @param infoLogger A consumer that accepts info log messages. - * @param logErrors Whether or not errors should be logged. - * @param logSentData Whether or not the sent data should be logged. - * @param logResponseStatusText Whether or not the response status text should be logged. - * @param skipRelocateCheck Whether or not the relocate check should be skipped. - */ - public MetricsBase( - String platform, - String serverUuid, - int serviceId, - boolean enabled, - Consumer appendPlatformDataConsumer, - Consumer appendServiceDataConsumer, - Consumer submitTaskConsumer, - Supplier checkServiceEnabledSupplier, - BiConsumer errorLogger, - Consumer infoLogger, - boolean logErrors, - boolean logSentData, - boolean logResponseStatusText, - boolean skipRelocateCheck) { - ScheduledThreadPoolExecutor scheduler = - new ScheduledThreadPoolExecutor( - 1, - task -> { - Thread thread = new Thread(task, "bStats-Metrics"); - thread.setDaemon(true); - return thread; - }); - // We want delayed tasks (non-periodic) that will execute in the future to be - // cancelled when the scheduler is shutdown. - // Otherwise, we risk preventing the server from shutting down even when - // MetricsBase#shutdown() is called - scheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); - this.scheduler = scheduler; - this.platform = platform; - this.serverUuid = serverUuid; - this.serviceId = serviceId; - this.enabled = enabled; - this.appendPlatformDataConsumer = appendPlatformDataConsumer; - this.appendServiceDataConsumer = appendServiceDataConsumer; - this.submitTaskConsumer = submitTaskConsumer; - this.checkServiceEnabledSupplier = checkServiceEnabledSupplier; - this.errorLogger = errorLogger; - this.infoLogger = infoLogger; - this.logErrors = logErrors; - this.logSentData = logSentData; - this.logResponseStatusText = logResponseStatusText; - if (!skipRelocateCheck) { - checkRelocation(); - } - if (enabled) { - // WARNING: Removing the option to opt-out will get your plugin banned from - // bStats - startSubmitting(); - } - } - - public void addCustomChart(CustomChart chart) { - this.customCharts.add(chart); - } - - public void shutdown() { - scheduler.shutdown(); - } - - private void startSubmitting() { - final Runnable submitTask = - () -> { - if (!enabled || !checkServiceEnabledSupplier.get()) { - // Submitting data or service is disabled - scheduler.shutdown(); - return; - } - if (submitTaskConsumer != null) { - submitTaskConsumer.accept(this::submitData); - } else { - this.submitData(); - } - }; - // Many servers tend to restart at a fixed time at xx:00 which causes an uneven - // distribution of requests on the - // bStats backend. To circumvent this problem, we introduce some randomness into - // the initial and second delay. - // WARNING: You must not modify and part of this Metrics class, including the - // submit delay or frequency! - // WARNING: Modifying this code will get your plugin banned on bStats. Just - // don't do it! - long initialDelay = (long) (1000 * 60 * (3 + Math.random() * 3)); - long secondDelay = (long) (1000 * 60 * (Math.random() * 30)); - scheduler.schedule(submitTask, initialDelay, TimeUnit.MILLISECONDS); - scheduler.scheduleAtFixedRate( - submitTask, initialDelay + secondDelay, 1000 * 60 * 30, TimeUnit.MILLISECONDS); - } - - private void submitData() { - final JsonObjectBuilder baseJsonBuilder = new JsonObjectBuilder(); - appendPlatformDataConsumer.accept(baseJsonBuilder); - final JsonObjectBuilder serviceJsonBuilder = new JsonObjectBuilder(); - appendServiceDataConsumer.accept(serviceJsonBuilder); - JsonObjectBuilder.JsonObject[] chartData = - customCharts.stream() - .map(customChart -> customChart.getRequestJsonObject(errorLogger, logErrors)) - .filter(Objects::nonNull) - .toArray(JsonObjectBuilder.JsonObject[]::new); - serviceJsonBuilder.appendField("id", serviceId); - serviceJsonBuilder.appendField("customCharts", chartData); - baseJsonBuilder.appendField("service", serviceJsonBuilder.build()); - baseJsonBuilder.appendField("serverUUID", serverUuid); - baseJsonBuilder.appendField("metricsVersion", METRICS_VERSION); - JsonObjectBuilder.JsonObject data = baseJsonBuilder.build(); - scheduler.execute( - () -> { - try { - // Send the data - sendData(data); - } catch (Exception e) { - // Something went wrong! :( - if (logErrors) { - errorLogger.accept("Could not submit bStats metrics data", e); - } - } - }); - } - - private void sendData(JsonObjectBuilder.JsonObject data) throws Exception { - if (logSentData) { - infoLogger.accept("Sent bStats metrics data: " + data.toString()); - } - String url = String.format(REPORT_URL, platform); - HttpsURLConnection connection = (HttpsURLConnection) java.net.URI.create(url).toURL().openConnection(); - // Compress the data to save bandwidth - byte[] compressedData = compress(data.toString()); - connection.setRequestMethod("POST"); - connection.addRequestProperty("Accept", "application/json"); - connection.addRequestProperty("Connection", "close"); - connection.addRequestProperty("Content-Encoding", "gzip"); - connection.addRequestProperty("Content-Length", String.valueOf(compressedData.length)); - connection.setRequestProperty("Content-Type", "application/json"); - connection.setRequestProperty("User-Agent", "Metrics-Service/1"); - connection.setDoOutput(true); - try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) { - outputStream.write(compressedData); - } - StringBuilder builder = new StringBuilder(); - try (BufferedReader bufferedReader = - new BufferedReader(new InputStreamReader(connection.getInputStream()))) { - String line; - while ((line = bufferedReader.readLine()) != null) { - builder.append(line); - } - } - if (logResponseStatusText) { - infoLogger.accept("Sent data to bStats and received response: " + builder); - } - } - - /** Checks that the class was properly relocated. */ - private void checkRelocation() { - // You can use the property to disable the check in your test environment - if (System.getProperty("bstats.relocatecheck") == null - || !System.getProperty("bstats.relocatecheck").equals("false")) { - // Maven's Relocate is clever and changes strings, too. So we have to use this - // little "trick" ... :D - final String defaultPackage = - new String(new byte[] {'o', 'r', 'g', '.', 'b', 's', 't', 'a', 't', 's'}); - final String examplePackage = - new String(new byte[] {'y', 'o', 'u', 'r', '.', 'p', 'a', 'c', 'k', 'a', 'g', 'e'}); - // We want to make sure no one just copy & pastes the example and uses the wrong - // package names - if (MetricsBase.class.getPackage().getName().startsWith(defaultPackage) - || MetricsBase.class.getPackage().getName().startsWith(examplePackage)) { - throw new IllegalStateException("bStats Metrics class has not been relocated correctly!"); - } - } - } - - /** - * Gzips the given string. - * - * @param str The string to gzip. - * @return The gzipped string. - */ - private static byte[] compress(final String str) throws IOException { - if (str == null) { - return null; - } - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - try (GZIPOutputStream gzip = new GZIPOutputStream(outputStream)) { - gzip.write(str.getBytes(StandardCharsets.UTF_8)); - } - return outputStream.toByteArray(); - } - } - - public static class AdvancedBarChart extends CustomChart { - - private final Callable> callable; - - /** - * Class constructor. - * - * @param chartId The id of the chart. - * @param callable The callable which is used to request the chart data. - */ - public AdvancedBarChart(String chartId, Callable> callable) { - super(chartId); - this.callable = callable; - } - - @Override - protected JsonObjectBuilder.JsonObject getChartData() throws Exception { - JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); - Map map = callable.call(); - if (map == null || map.isEmpty()) { - // Null = skip the chart - return null; - } - boolean allSkipped = true; - for (Map.Entry entry : map.entrySet()) { - if (entry.getValue().length == 0) { - // Skip this invalid - continue; - } - allSkipped = false; - valuesBuilder.appendField(entry.getKey(), entry.getValue()); - } - if (allSkipped) { - // Null = skip the chart - return null; - } - return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); - } - } - - public static class SimplePie extends CustomChart { - - private final Callable callable; - - /** - * Class constructor. - * - * @param chartId The id of the chart. - * @param callable The callable which is used to request the chart data. - */ - public SimplePie(String chartId, Callable callable) { - super(chartId); - this.callable = callable; - } - - @Override - protected JsonObjectBuilder.JsonObject getChartData() throws Exception { - String value = callable.call(); - if (value == null || value.isEmpty()) { - // Null = skip the chart - return null; - } - return new JsonObjectBuilder().appendField("value", value).build(); - } - } - - public static class DrilldownPie extends CustomChart { - - private final Callable>> callable; - - /** - * Class constructor. - * - * @param chartId The id of the chart. - * @param callable The callable which is used to request the chart data. - */ - public DrilldownPie(String chartId, Callable>> callable) { - super(chartId); - this.callable = callable; - } - - @Override - public JsonObjectBuilder.JsonObject getChartData() throws Exception { - JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); - Map> map = callable.call(); - if (map == null || map.isEmpty()) { - // Null = skip the chart - return null; - } - boolean reallyAllSkipped = true; - for (Map.Entry> entryValues : map.entrySet()) { - JsonObjectBuilder valueBuilder = new JsonObjectBuilder(); - boolean allSkipped = true; - for (Map.Entry valueEntry : map.get(entryValues.getKey()).entrySet()) { - valueBuilder.appendField(valueEntry.getKey(), valueEntry.getValue()); - allSkipped = false; - } - if (!allSkipped) { - reallyAllSkipped = false; - valuesBuilder.appendField(entryValues.getKey(), valueBuilder.build()); - } - } - if (reallyAllSkipped) { - // Null = skip the chart - return null; - } - return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); - } - } - - public static class SingleLineChart extends CustomChart { - - private final Callable callable; - - /** - * Class constructor. - * - * @param chartId The id of the chart. - * @param callable The callable which is used to request the chart data. - */ - public SingleLineChart(String chartId, Callable callable) { - super(chartId); - this.callable = callable; - } - - @Override - protected JsonObjectBuilder.JsonObject getChartData() throws Exception { - int value = callable.call(); - if (value == 0) { - // Null = skip the chart - return null; - } - return new JsonObjectBuilder().appendField("value", value).build(); - } - } - - public static class MultiLineChart extends CustomChart { - - private final Callable> callable; - - /** - * Class constructor. - * - * @param chartId The id of the chart. - * @param callable The callable which is used to request the chart data. - */ - public MultiLineChart(String chartId, Callable> callable) { - super(chartId); - this.callable = callable; - } - - @Override - protected JsonObjectBuilder.JsonObject getChartData() throws Exception { - JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); - Map map = callable.call(); - if (map == null || map.isEmpty()) { - // Null = skip the chart - return null; - } - boolean allSkipped = true; - for (Map.Entry entry : map.entrySet()) { - if (entry.getValue() == 0) { - // Skip this invalid - continue; - } - allSkipped = false; - valuesBuilder.appendField(entry.getKey(), entry.getValue()); - } - if (allSkipped) { - // Null = skip the chart - return null; - } - return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); - } - } - - public static class AdvancedPie extends CustomChart { - - private final Callable> callable; - - /** - * Class constructor. - * - * @param chartId The id of the chart. - * @param callable The callable which is used to request the chart data. - */ - public AdvancedPie(String chartId, Callable> callable) { - super(chartId); - this.callable = callable; - } - - @Override - protected JsonObjectBuilder.JsonObject getChartData() throws Exception { - JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); - Map map = callable.call(); - if (map == null || map.isEmpty()) { - // Null = skip the chart - return null; - } - boolean allSkipped = true; - for (Map.Entry entry : map.entrySet()) { - if (entry.getValue() == 0) { - // Skip this invalid - continue; - } - allSkipped = false; - valuesBuilder.appendField(entry.getKey(), entry.getValue()); - } - if (allSkipped) { - // Null = skip the chart - return null; - } - return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); - } - } - - public abstract static class CustomChart { - - private final String chartId; - - protected CustomChart(String chartId) { - if (chartId == null) { - throw new IllegalArgumentException("chartId must not be null"); - } - this.chartId = chartId; - } - - public JsonObjectBuilder.JsonObject getRequestJsonObject( - BiConsumer errorLogger, boolean logErrors) { - JsonObjectBuilder builder = new JsonObjectBuilder(); - builder.appendField("chartId", chartId); - try { - JsonObjectBuilder.JsonObject data = getChartData(); - if (data == null) { - // If the data is null we don't send the chart. - return null; - } - builder.appendField("data", data); - } catch (Throwable t) { - if (logErrors) { - errorLogger.accept("Failed to get data for custom chart with id " + chartId, t); - } - return null; - } - return builder.build(); - } - - protected abstract JsonObjectBuilder.JsonObject getChartData() throws Exception; - } - - public static class SimpleBarChart extends CustomChart { - - private final Callable> callable; - - /** - * Class constructor. - * - * @param chartId The id of the chart. - * @param callable The callable which is used to request the chart data. - */ - public SimpleBarChart(String chartId, Callable> callable) { - super(chartId); - this.callable = callable; - } - - @Override - protected JsonObjectBuilder.JsonObject getChartData() throws Exception { - JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); - Map map = callable.call(); - if (map == null || map.isEmpty()) { - // Null = skip the chart - return null; - } - for (Map.Entry entry : map.entrySet()) { - valuesBuilder.appendField(entry.getKey(), new int[] {entry.getValue()}); - } - return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); - } - } - - /** - * An extremely simple JSON builder. - * - *

While this class is neither feature-rich nor the most performant one, it's sufficient enough - * for its use-case. - */ - public static class JsonObjectBuilder { - - private StringBuilder builder = new StringBuilder(); - - private boolean hasAtLeastOneField = false; - - public JsonObjectBuilder() { - builder.append("{"); - } - - /** - * Appends a null field to the JSON. - * - * @param key The key of the field. - * @return A reference to this object. - */ - public JsonObjectBuilder appendNull(String key) { - appendFieldUnescaped(key, "null"); - return this; - } - - /** - * Appends a string field to the JSON. - * - * @param key The key of the field. - * @param value The value of the field. - * @return A reference to this object. - */ - public JsonObjectBuilder appendField(String key, String value) { - if (value == null) { - throw new IllegalArgumentException("JSON value must not be null"); - } - appendFieldUnescaped(key, "\"" + escape(value) + "\""); - return this; - } - - /** - * Appends an integer field to the JSON. - * - * @param key The key of the field. - * @param value The value of the field. - * @return A reference to this object. - */ - public JsonObjectBuilder appendField(String key, int value) { - appendFieldUnescaped(key, String.valueOf(value)); - return this; - } - - /** - * Appends an object to the JSON. - * - * @param key The key of the field. - * @param object The object. - * @return A reference to this object. - */ - public JsonObjectBuilder appendField(String key, JsonObject object) { - if (object == null) { - throw new IllegalArgumentException("JSON object must not be null"); - } - appendFieldUnescaped(key, object.toString()); - return this; - } - - /** - * Appends a string array to the JSON. - * - * @param key The key of the field. - * @param values The string array. - * @return A reference to this object. - */ - public JsonObjectBuilder appendField(String key, String[] values) { - if (values == null) { - throw new IllegalArgumentException("JSON values must not be null"); - } - String escapedValues = - Arrays.stream(values) - .map(value -> "\"" + escape(value) + "\"") - .collect(Collectors.joining(",")); - appendFieldUnescaped(key, "[" + escapedValues + "]"); - return this; - } - - /** - * Appends an integer array to the JSON. - * - * @param key The key of the field. - * @param values The integer array. - * @return A reference to this object. - */ - public JsonObjectBuilder appendField(String key, int[] values) { - if (values == null) { - throw new IllegalArgumentException("JSON values must not be null"); - } - String escapedValues = - Arrays.stream(values).mapToObj(String::valueOf).collect(Collectors.joining(",")); - appendFieldUnescaped(key, "[" + escapedValues + "]"); - return this; - } - - /** - * Appends an object array to the JSON. - * - * @param key The key of the field. - * @param values The integer array. - * @return A reference to this object. - */ - public JsonObjectBuilder appendField(String key, JsonObject[] values) { - if (values == null) { - throw new IllegalArgumentException("JSON values must not be null"); - } - String escapedValues = - Arrays.stream(values).map(JsonObject::toString).collect(Collectors.joining(",")); - appendFieldUnescaped(key, "[" + escapedValues + "]"); - return this; - } - - /** - * Appends a field to the object. - * - * @param key The key of the field. - * @param escapedValue The escaped value of the field. - */ - private void appendFieldUnescaped(String key, String escapedValue) { - if (builder == null) { - throw new IllegalStateException("JSON has already been built"); - } - if (key == null) { - throw new IllegalArgumentException("JSON key must not be null"); - } - if (hasAtLeastOneField) { - builder.append(","); - } - builder.append("\"").append(escape(key)).append("\":").append(escapedValue); - hasAtLeastOneField = true; - } - - /** - * Builds the JSON string and invalidates this builder. - * - * @return The built JSON string. - */ - public JsonObject build() { - if (builder == null) { - throw new IllegalStateException("JSON has already been built"); - } - JsonObject object = new JsonObject(builder.append("}").toString()); - builder = null; - return object; - } - - /** - * Escapes the given string like stated in https://www.ietf.org/rfc/rfc4627.txt. - * - *

This method escapes only the necessary characters '"', '\'. and '\u0000' - '\u001F'. - * Compact escapes are not used (e.g., '\n' is escaped as "\u000a" and not as "\n"). - * - * @param value The value to escape. - * @return The escaped value. - */ - private static String escape(String value) { - final StringBuilder builder = new StringBuilder(); - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - if (c == '"') { - builder.append("\\\""); - } else if (c == '\\') { - builder.append("\\\\"); - } else if (c <= '\u000F') { - builder.append("\\u000").append(Integer.toHexString(c)); - } else if (c <= '\u001F') { - builder.append("\\u00").append(Integer.toHexString(c)); - } else { - builder.append(c); - } - } - return builder.toString(); - } - - /** - * A super simple representation of a JSON object. - * - *

This class only exists to make methods of the {@link JsonObjectBuilder} type-safe and not - * allow a raw string inputs for methods like {@link JsonObjectBuilder#appendField(String, - * JsonObject)}. - */ - public static class JsonObject { - - private final String value; - - private JsonObject(String value) { - this.value = value; - } - - @Override - public String toString() { - return value; - } - } - } -} diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/ActiveVehicle.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/ActiveVehicle.java index 83d8dff..37ef84c 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/ActiveVehicle.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/ActiveVehicle.java @@ -368,7 +368,6 @@ private void loadIncomplete(IncompleteVehicle inc) { initializeRotations(inc.getRotations()); initializeContainers(inc.getContainers()); name = inc.getName(); - uuid = inc.getUUID(); ownerData.setOwner(inc.getOwner()); ownerData.setWhiteListed(inc.isWhitelisted()); ownerData.setWhiteList(inc.getWhitelist()); diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/Vehicle.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/Vehicle.java index acd23b4..b8ebc4c 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/Vehicle.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/Vehicle.java @@ -75,18 +75,20 @@ public class Vehicle { public Vehicle(String key, ConfigurationSection config) { id = key; + for (String required : List.of("skins", "states", "components")) { + if (!config.isConfigurationSection(required)) { + throw new IllegalArgumentException("Vehicle '" + key + "' requires a " + required + " section"); + } + } name = config.getString("name", key); fixed = config.getBoolean("fixed", false); towable = config.getBoolean("towable", false); if(!config.contains("model")) VFLogger.log(key+" does not contain a model field"); model = config.getString("model"); - if(!config.isConfigurationSection("skins")) VFLogger.log(key+" has no skins section"); skinHandler = new SkinHandler(model, config.getConfigurationSection("skins")); - if(!config.isConfigurationSection("states")) VFLogger.log(key+" has no states section"); stateHandler = new StateHandler(config.getConfigurationSection("states")); - if(!config.isConfigurationSection("components")) VFLogger.log(key+" has no components section"); componentHandler = new ComponentHandler(config.getConfigurationSection("components")); if(!config.contains("seats")) VFLogger.log(key+" has no seats section"); @@ -144,6 +146,9 @@ private void loadDeathData(ConfigurationSection config) { ConfigurationSection sink = config.getConfigurationSection("sink"); deathData.add(new DeathData(VehicleDeath.SINK, sink)); } + if(config.isConfigurationSection("die")) { + deathData.add(new DeathData(VehicleDeath.DIE, config.getConfigurationSection("die"))); + } if(config.isConfigurationSection("crash")) { ConfigurationSection crash = config.getConfigurationSection("crash"); deathData.add(new DeathData(VehicleDeath.CRASH, crash)); diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/VehicleHealthDecay.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/VehicleHealthDecay.java index f531dd5..cc8f422 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/VehicleHealthDecay.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/VehicleHealthDecay.java @@ -36,7 +36,7 @@ public static double nextDamage( } double fraction = Math.max(0.0, fractionOfMax); double minRemaining = Math.max(0.0, Math.min(1.0, minHealthFraction)); - double maxAllowedDamage = maxHealth * (1.0 - minRemaining); + double maxAllowedDamage = Math.max(currentDamage, maxHealth * (1.0 - minRemaining)); double next = currentDamage + maxHealth * fraction; if (next > maxAllowedDamage) { next = maxAllowedDamage; diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/Engine.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/Engine.java index 012fc84..9db29c4 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/Engine.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/Engine.java @@ -220,8 +220,6 @@ public void tick(List nearby) { } int current = throttle.getCurrent(); AccessPanel panel = v.getAccessPanel(); - panel.setSpeed(getSpeed()); - panel.setTurnRate(getTurnRate()); boolean reverse = false; if(current < 0) { current = current*-1; @@ -234,7 +232,7 @@ public void tick(List nearby) { } throttle.setThrottle(current); } - panel.setReverse(reverse); + if(healthData.getHealthPercentage() < 1 && started) stop(); if (tapeThrottle == null) { if (v == null || !v.isTrain() || v.hasParent() || !v.getTrainHandler().isRecording()) { normalize(); @@ -243,6 +241,13 @@ public void tick(List nearby) { v.getTrainHandler().maybeRecordSample(throttle.getCurrent()); } } + if (tank.useFuel() && tank.getCurrent() == 0) { + if (started) stop(); + throttle.setThrottle(0); + } + panel.setSpeed(getSpeed()); + panel.setTurnRate(getTurnRate()); + panel.setReverse(throttle.getCurrent() < 0); if(throttle.getCurrent() == 0) { return; } @@ -255,14 +260,6 @@ public void tick(List nearby) { if (v != null && v.getEntity() != null && !bones.isEmpty()) { PersistenceLog.particleOffset(v, bones.get(0).getBaseLocation(), v.getEntity().getLocation()); } - if(healthData.getHealthPercentage() < 1 && started) stop(); - if(tank.getCurrent() == 0 && tank.useFuel()) { - if(started) { - stop(); - } else { - throttle.setThrottle(0); - } - } } public void start(Player p) { @@ -277,7 +274,7 @@ public void start(Player p) { } starting = true; p.sendMessage("§e["+alias+"] starting..."); - if(Math.random()*100 > healthData.getHealthPercentage()) { + if(rollStartChance() > healthData.getHealthPercentage()) { if(!v.hasEffect(CustomAction.ENGINE_START_FAIL)) defaultFail(p); else { CustomEffect effect = v.playEffect(CustomAction.ENGINE_START_FAIL); @@ -307,6 +304,10 @@ public void run() { }.runTaskTimer(VehicleFramework.plugin, 0L, 1L); } } + double rollStartChance() { + return Math.random() * 100; + } + private void defaultStart(Player p) { p.sendMessage("§e["+alias+"] §astarted!"); started = true; diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/GearedEngine.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/GearedEngine.java index 62cf503..989b95b 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/GearedEngine.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/GearedEngine.java @@ -65,7 +65,9 @@ public GearedEngine(ConfigurationSection config) { ConfigurationSection particleConfig = config.getConfigurationSection("particles"); particles = ParticleLoader.getParticlesFromConfig(particleConfig); } - if(!config.isConfigurationSection("gears")) VFLogger.log("Geared engine has no gears!"); + if (!config.isConfigurationSection("gears") || config.getConfigurationSection("gears").getKeys(false).isEmpty()) { + throw new IllegalArgumentException("Geared engine requires at least one gear"); + } Set set = config.getConfigurationSection("gears").getKeys(false); List list = new ArrayList(set); @@ -74,6 +76,9 @@ public GearedEngine(ConfigurationSection config) { gears.add(new Gear(this, config.getConfigurationSection("gears."+key))); } currentGear = config.getInt("start-gear", 0); + if (currentGear < 0 || currentGear >= gears.size()) { + throw new IllegalArgumentException("start-gear must refer to a configured gear"); + } defaultGear = currentGear; boneList = (List) config.getList("particle-bones", new ArrayList()); tank = new FuelTank(config); @@ -187,7 +192,6 @@ public void throttle(boolean down) { } private void shift(int dir) { - if(shifting) return; shifting = true; new BukkitRunnable() { int i = 0; @@ -276,8 +280,6 @@ public void tick(List nearby) { } int current = g.getThrottle().getCurrent(); AccessPanel panel = v.getAccessPanel(); - panel.setSpeed(getSpeed()); - panel.setTurnRate(getTurnRate()); boolean reverse = false; if(current < 0) { current = current*-1; @@ -290,7 +292,7 @@ public void tick(List nearby) { } getGear().getThrottle().setThrottle(current); } - panel.setReverse(reverse); + if(healthData.getHealthPercentage() < 1 && started) stop(); if (tapeThrottle == null) { if (v == null || !v.isTrain() || v.hasParent() || !v.getTrainHandler().isRecording()) { normalize(g); @@ -299,6 +301,13 @@ public void tick(List nearby) { v.getTrainHandler().maybeRecordSample(g.getThrottle().getCurrent()); } } + if (tank.useFuel() && tank.getCurrent() == 0) { + if (started) stop(); + g.getThrottle().setThrottle(0); + } + panel.setSpeed(getSpeed()); + panel.setTurnRate(getTurnRate()); + panel.setReverse(g.getThrottle().getCurrent() < 0); if(g.getThrottle().getCurrent() == 0) { return; } @@ -311,16 +320,12 @@ public void tick(List nearby) { if (v != null && v.getEntity() != null && !bones.isEmpty()) { PersistenceLog.particleOffset(v, bones.get(0).getBaseLocation(), v.getEntity().getLocation()); } - if(healthData.getHealthPercentage() < 1 && started) stop(); - if(tank.getCurrent() == 0 && tank.useFuel()) { - if(started) { - stop(); - } else { - getGear().getThrottle().setThrottle(0); - } - } } + double rollStartChance() { + return Math.random() * 100; + } + public void start(Player p) { if(starting) return; if(cooldown.containsKey(p)) { @@ -334,7 +339,7 @@ public void start(Player p) { } starting = true; p.sendMessage("§e["+alias+"] starting..."); - if(Math.random()*100 > healthData.getHealthPercentage()) { + if(rollStartChance() > healthData.getHealthPercentage()) { if(!v.hasEffect(CustomAction.ENGINE_START_FAIL)) defaultFail(p); else { CustomEffect effect = v.playEffect(CustomAction.ENGINE_START_FAIL); diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/SinkableHull.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/SinkableHull.java index c02abac..55ff126 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/SinkableHull.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/SinkableHull.java @@ -77,7 +77,7 @@ public void slowTick(List nearby) { sinking = false; if(sinkProgress > 0) { if(hasPump()) { - sinkProgress -= pump.getPower()/10; + sinkProgress = Math.max(0, sinkProgress - pump.getPower()/10); } else { sinkProgress -= 1; } diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/propulsion/Throttle.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/propulsion/Throttle.java index b35edbe..1156efc 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/propulsion/Throttle.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/propulsion/Throttle.java @@ -14,7 +14,7 @@ public class Throttle { public Throttle(String name, int x, int n, VehicleComponent en) { if(name != null) this.name = name; - else name = "Throttle"; + else this.name = "Throttle"; max = x; min = n; current = 0; diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/FloatController.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/FloatController.java index 263a05e..60f0de1 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/FloatController.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/FloatController.java @@ -105,10 +105,6 @@ private double calculateUpriverLift(ActiveVehicle v) { } Entity entity = v.getEntity(); - if (entity == null || !entity.isValid()) { - return 0; - } - Location loc = entity.getLocation(); Vector forward = loc.getDirection().clone(); forward.setY(0); diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/GroundEngineLog.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/GroundEngineLog.java index a8b8b48..903223d 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/GroundEngineLog.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/GroundEngineLog.java @@ -43,15 +43,12 @@ public static void configure(boolean loggingEnabled, boolean wipeLog, File dataF if (dataFolder != null) { logFile = dataFolder.toPath().resolve(LOG_DIRECTORY).resolve(LOG_FILE_NAME); } - if (wipeLog) { + if (wipeLog && logFile != null) { wipeLogFile(); } } private static void wipeLogFile() { - if (logFile == null) { - return; - } synchronized (LOCK) { try { Files.deleteIfExists(logFile); @@ -81,15 +78,11 @@ public static String formatGearedEngine(GearedEngine engine) { return ""; } Gear gear = engine.getGear(); - Throttle throttle = gear == null ? null : gear.getThrottle(); - String thr = formatThrottle(throttle); - if (thr.isEmpty()) { - return ""; - } + String thr = formatThrottle(gear.getThrottle()); return String.format( "gear=%d gearName=%s %s shifting=%s started=%s", engine.getCurrentGear(), - gear == null ? "null" : gear.getName(), + gear.getName(), thr, engine.isShifting(), engine.isStarted()); diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowEngine.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowEngine.java index 92ce2fe..073be86 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowEngine.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowEngine.java @@ -347,17 +347,23 @@ private static Result airborneStep( rt.vz = tick.vz; double dx = rt.vx; double dz = rt.vz; - double destY = current.getY() + rt.vy; - boolean yBlocked = destinationBlocked(entity, current, locAt(current, current.getX(), destY, current.getZ())); + double verticalDistance = Math.abs(rt.vy); + double verticalDirection = Math.signum(rt.vy); + double clearVertical = TerrainFollowMath.farthestUnblocked( + verticalDistance, + TerrainFollowMath.SLIDE_STEP, + d -> destinationBlocked(entity, current, + locAt(current, current.getX(), current.getY() + verticalDirection * d, current.getZ()))); + double destY = current.getY() + verticalDirection * clearVertical; + boolean yBlocked = clearVertical + 1e-6 < verticalDistance; if (yBlocked) { - destY = current.getY(); rt.vy = 0; } final double slideY = destY; double dist = Math.hypot(dx, dz); double ox = 0; double oz = 0; - boolean aabbBlocked = false; + boolean aabbBlocked = yBlocked; if (dist > 1e-9) { double far = TerrainFollowMath.farthestUnblocked( dist, @@ -368,7 +374,7 @@ private static Result airborneStep( locAt(current, current.getX() + dx * d / dist, slideY, current.getZ() + dz * d / dist))); ox = dx * far / dist; oz = dz * far / dist; - aabbBlocked = far + 1e-6 < dist; + aabbBlocked |= far + 1e-6 < dist; rt.vx = ox; rt.vz = oz; } @@ -500,9 +506,6 @@ private static void spawnTerrainDebug(ActiveVehicle v, List starts, Li } private static void spawnEndRod(List nearby, Location loc) { - if (loc == null || loc.getWorld() == null) { - return; - } for (Player p : nearby) { if (p == null || !p.isOnline() || p.getWorld() == null) { continue; @@ -515,9 +518,6 @@ private static void spawnEndRod(List nearby, Location loc) { } private static RayTraceResult rayDown(Location start) { - if (start == null || start.getWorld() == null) { - return null; - } RayTraceResult down = start.getWorld().rayTraceBlocks( start, new Vector(0, -1, 0), diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowMath.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowMath.java index 38f8044..d486b32 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowMath.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowMath.java @@ -64,16 +64,13 @@ public static Vector headingFromYaw(float yawDeg) { } /** - * Farthest distance in [0, maxDist] that is not blocked, sampled every {@code step}. - * Full {@code maxDist} is used when it is clear. {@code blockedAtDist} is true when that distance clips. + * Farthest distance in [0, maxDist] with a clear path, sampled every {@code step}. + * Full {@code maxDist} is used only when all samples are clear. {@code blockedAtDist} is true when that distance clips. */ public static double farthestUnblocked(double maxDist, double step, DoublePredicate blockedAtDist) { if (maxDist <= 1e-9 || blockedAtDist == null) { return 0; } - if (!blockedAtDist.test(maxDist)) { - return maxDist; - } double lastClear = 0; double increment = Math.max(1e-6, step); for (double d = increment; d < maxDist - 1e-12; d += increment) { @@ -82,7 +79,7 @@ public static double farthestUnblocked(double maxDist, double step, DoublePredic } lastClear = d; } - return lastClear; + return blockedAtDist.test(maxDist) ? lastClear : maxDist; } public static double approachY(double currentY, double targetY, double maxDelta) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleMovementController.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleMovementController.java index ce31fa1..c3cb6a0 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleMovementController.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleMovementController.java @@ -270,9 +270,6 @@ private void down(Player p) { } private boolean applyTerrainFollow(Direction dir) { - if (state.isBreakState()) { - return false; - } if (state.isDefault()) { return false; } diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/BehaviourHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/BehaviourHandler.java index 052dd2a..96a9ed4 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/BehaviourHandler.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/BehaviourHandler.java @@ -99,8 +99,8 @@ private void getFloat(ConfigurationSection config) { if(!shouldFloat) return; if(config.contains("float-in")) { for(String s : config.getStringList("float-in")) { - if(Material.valueOf(s.toUpperCase()) == null) continue; - floatsIn.add(Material.valueOf(s.toUpperCase())); + Material material = Material.matchMaterial(s); + if (material != null) floatsIn.add(material); } } else { floatsIn.add(Material.WATER); diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/ComponentHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/ComponentHandler.java index a2c5ee1..59141b7 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/ComponentHandler.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/ComponentHandler.java @@ -33,7 +33,7 @@ public ComponentHandler(ConfigurationSection config) { List list = new ArrayList(set); for(String c : list) { - Component cType = Component.valueOf(c.toUpperCase()); + Component cType = Component.valueOf(c.toUpperCase(java.util.Locale.ROOT)); ConfigurationSection compSection = config.getConfigurationSection(c); switch (cType) { case ENGINE: @@ -81,13 +81,18 @@ public void fireSpread() { if(c.isOnFire()) { for(VehicleComponent o : getComponents()) { if(o.isOnFire()) continue; - if(Math.floor(Math.random()*100)>c.getFire().getProgress()/2) continue; + if(rollFireSpreadChance()>c.getFire().getProgress()/2) continue; o.startFire(); } } } } + double rollFireSpreadChance() { + return Math.floor(Math.random() * 100); + } + public void randomFire() { + if (components.isEmpty()) return; int i = (int) Math.floor(Math.random()*components.size()); int safeguard = 0; while(components.get(i).isOnFire() && safeguard < 10) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/StateHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/StateHandler.java index 7dfb2f7..80ad007 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/StateHandler.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/StateHandler.java @@ -120,7 +120,7 @@ public void tick() { swapState(State.FLOATING, "water"); } else if (VehicleStateRules.shouldSwapToFlying( flying != null && !flying.isDefault(), - checkAllBlocks(blocksBelow, "air"))) { + allBlocksInAir(blocksBelow))) { swapState(State.FLYING, "air"); } else { swapState(State.GROUND, "ground"); @@ -160,22 +160,14 @@ private Set getBlocksBoundingBox(BoundingBox box, World world, double yOf return blocks; } - private boolean checkAllBlocks(Set blocks, String type) { - if(type.equalsIgnoreCase("air")) { - for (Block block : blocks) { - if(!LocationChecker.isInAir(block.getLocation())) return false; - } - } else if(type.equalsIgnoreCase("water")) { - for (Block block : blocks) { - if(!LocationChecker.isInWater(block.getLocation())) return false; - } + private boolean allBlocksInAir(Set blocks) { + for (Block block : blocks) { + if (!LocationChecker.isInAir(block.getLocation())) return false; } - - return true; + return true; } - - + private void swapState(State s, String reason) { if(!hasState(s)) return; if(states.get(s) == state) return; diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java index 7960ce4..0494779 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java @@ -219,11 +219,15 @@ public boolean attach(Player p, ActiveVehicle target) { p.sendMessage("§cTarget train already has a car attached"); return false; } + ActiveVehicle loco = locoOf(target); + if (loco == v) { + p.sendMessage("§cA train cannot be attached to itself or one of its own cars"); + return false; + } target.getTrainHandler().setChild(v); v.setParent(target); p.sendMessage("§aConnected car to train"); p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); - ActiveVehicle loco = locoOf(target); if (loco.getTrainHandler().isBound()) { loco.getTrainHandler().placeLoadedCars(); } else { @@ -350,17 +354,11 @@ public void holdJunction(TrackJunction.Side side) { return; } TrackRegistry registry = VehicleFramework.getTrackRegistry(); - if (registry == null) { - return; - } Map used = new LinkedHashMap<>(); List cars = planCars(used); retainRoutes(cars, used); int direction = controlDirection(); Position lead = leadingPosition(cars, direction); - if (lead == null) { - return; - } TrackSpline spline = registry.get(lead.splineId()).orElse(null); TrackJunction next = null; double nearest = Double.POSITIVE_INFINITY; @@ -531,9 +529,7 @@ public void maybeRecordSample(int throttle) { .map(branch -> branch.id.toString()).orElse(null); ThrottleTape.AppendResult result = recordingTape.tryAppend( s, travelSign, throttle, spline.getId().toString(), junction, orientation); - int hold = recordingTape.getSamples().isEmpty() - ? 0 - : recordingTape.getSamples().get(recordingTape.getSamples().size() - 1).holdTicks; + int hold = recordingTape.getSamples().get(recordingTape.getSamples().size() - 1).holdTicks; RecorderLog.sample(v, result, throttle, hold); if (result == ThrottleTape.AppendResult.CAPPED) { finishRecordingCappedOrUnbound("Recording stopped: tape is full"); @@ -545,9 +541,6 @@ public void maybeRecordSample(int throttle) { } private boolean onRecordedCircuit(TrackRegistry registry, TrackSpline spline) { - if (recordingTape == null || spline == null) { - return false; - } if (recordingTape.matchesSpline(spline.getId())) { return true; } @@ -1431,9 +1424,6 @@ private void applyPose(ActiveVehicle vehicle, TrackPose pose) { } private static int facingSign(Location loc, TrackPose pose) { - if (loc == null || pose == null) { - return 1; - } Vector look = loc.getDirection().clone(); look.setY(0); if (look.lengthSquared() < 1e-8) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java index cdb6c43..a4e0044 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java @@ -299,7 +299,11 @@ public void loadFromJson(JsonObject json) { items.add(null); } - if (!json.has("items")) return; + if (!json.has("items")) { + pushLive(); + updateBoneVisibility(); + return; + } JsonArray itemsArray = json.getAsJsonArray("items"); for (JsonElement elem : itemsArray) { @@ -332,12 +336,7 @@ private static int readSlot(JsonElement saved) { private ItemStack readItem(int slot, JsonElement saved) { Exception failure = null; - List candidates = List.of(); - try { - candidates = snbtCandidates(saved); - } catch (RuntimeException e) { - failure = e; - } + List candidates = snbtCandidates(saved); for (String snbt : candidates) { try { ItemStack stack = NBT.itemStackFromNBT(NBT.parseNBT(snbt)); diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/AnimationHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/AnimationHandler.java index 2329610..45ee400 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/AnimationHandler.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/AnimationHandler.java @@ -23,7 +23,7 @@ public class AnimationHandler { public AnimationHandler(ConfigurationSection config) { for (Animation animation : Animation.values()) { - String enumString = animation.name().toLowerCase(); + String enumString = animation.name().toLowerCase(java.util.Locale.ROOT); if (config.contains(enumString)) { animations.put(animation, config.getStringList(enumString)); } else { @@ -202,8 +202,8 @@ public void setSpeed(Animation a, double speed) { public void stop(Animation a) { for(String s : animations.get(a)) { BlueprintAnimation anim = m.getBlueprint().getAnimations().get(s); - if(anim == null) return; - if(!m.getAnimationHandler().isPlayingAnimation(s)) return; + if(anim == null) continue; + if(!m.getAnimationHandler().isPlayingAnimation(s)) continue; m.getAnimationHandler().stopAnimation(s); } } diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/InputHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/InputHandler.java index 2dc5fd8..241e457 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/InputHandler.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/InputHandler.java @@ -12,17 +12,15 @@ public class InputHandler { public InputHandler(ConfigurationSection config) { for (Keybind keybind : Keybind.values()) { - String enumString = keybind.name(); - if (config.contains(enumString)) { - if(Input.valueOf(config.getString(enumString).toUpperCase()) != null) { - keybinds.put(keybind, Input.valueOf(config.getString(enumString).toUpperCase())); - } - } else { - keybinds.put(keybind, Input.NONE); - } - } + String enumString = keybind.name(); + if (config.contains(enumString)) { + keybinds.put(keybind, Input.valueOf(config.getString(enumString).toUpperCase(java.util.Locale.ROOT))); + } else { + keybinds.put(keybind, Input.NONE); + } + } } - + public InputHandler() { for (Keybind keybind : Keybind.values()) { keybinds.put(keybind, Input.NONE); diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/ConsistRelinker.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/ConsistRelinker.java index 76f90ac..fc5bb19 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/ConsistRelinker.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/ConsistRelinker.java @@ -29,7 +29,7 @@ public static void tryLink(ActiveVehicle vehicle) { if (child == null) { PersistenceLog.append("TRY_LINK child-missing pending=" + childId + " uuid=" + vehicle.getUUID()); } - if (child != null && child.isTrain() && !child.equals(vehicle)) { + if (child != null && child.isTrain() && canLink(vehicle, child)) { train.setChild(child); child.setParent(vehicle); train.setPendingChild(null); @@ -42,7 +42,7 @@ public static void tryLink(ActiveVehicle vehicle) { if (parent == null) { PersistenceLog.append("TRY_LINK parent-missing pending=" + parentId + " uuid=" + vehicle.getUUID()); } - if (parent != null && parent.isTrain() && !parent.equals(vehicle)) { + if (parent != null && parent.isTrain() && canLink(parent, vehicle)) { parent.getTrainHandler().setChild(vehicle); vehicle.setParent(parent); train.setPendingParent(null); @@ -50,7 +50,9 @@ public static void tryLink(ActiveVehicle vehicle) { } } ActiveVehicle loco = vehicle; + java.util.Set visited = new java.util.HashSet<>(); while (loco != null && loco.hasParent()) { + if (!visited.add(loco)) return; loco = loco.getParent(); } if (loco != null && loco.isTrain() && loco.getTrainHandler().isBound()) { @@ -59,6 +61,16 @@ public static void tryLink(ActiveVehicle vehicle) { PersistenceLog.tryLink("after", vehicle); } + private static boolean canLink(ActiveVehicle parent, ActiveVehicle child) { + java.util.Set visited = new java.util.HashSet<>(); + ActiveVehicle ancestor = parent; + while (ancestor != null) { + if (ancestor.equals(child) || !visited.add(ancestor)) return false; + ancestor = ancestor.hasParent() ? ancestor.getParent() : null; + } + return true; + } + public static void onRemove(ActiveVehicle vehicle, VehicleRemovePayload payload) { if (vehicle == null || !vehicle.isTrain()) { return; diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/DeckBody.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/DeckBody.java index e1b53e1..e68b3f5 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/DeckBody.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/DeckBody.java @@ -217,6 +217,7 @@ private void spawn(World world, Deck.Box box) { display.getPersistentDataContainer().set(k, PersistentDataType.STRING, owner); } }); + carriers.add(carrier); Shulker shulker = world.spawn(at, Shulker.class, s -> { s.setPersistent(false); s.setRemoveWhenFarAway(false); @@ -235,14 +236,13 @@ private void spawn(World world, Deck.Box box) { s.getPersistentDataContainer().set(k, PersistentDataType.STRING, owner); } }); + boxes.add(shulker); if (!carrier.isValid() || !shulker.isValid()) { - carrier.remove(); - shulker.remove(); throw new IllegalStateException("deck box spawn was cancelled"); } - carrier.addPassenger(shulker); - carriers.add(carrier); - boxes.add(shulker); + if (!carrier.addPassenger(shulker)) { + throw new IllegalStateException("deck box mount was cancelled"); + } PARTS.put(carrier.getUniqueId(), v); PARTS.put(shulker.getUniqueId(), v); } diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/seat/Seat.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/seat/Seat.java index e4bf415..19a2b1f 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/seat/Seat.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/seat/Seat.java @@ -27,17 +27,17 @@ public class Seat { private String bone; public Seat(String s, String id) { - if(s.split("\\(").length < 2) VFLogger.log("invalid seat detected for vehicle "+id); - - String t = s.split("\\(")[0]; - String seat = s.split("\\(")[1].replace(")", ""); - - if(SeatType.valueOf(t.toUpperCase()) == null) { - type = SeatType.PASSENGER; //handles malinput in config - } else { - type = SeatType.valueOf(t.toUpperCase()); + int open = s.indexOf('('); + if (open <= 0 || !s.endsWith(")") || open == s.length() - 2) { + throw new IllegalArgumentException("Invalid seat '" + s + "' for vehicle " + id); } - bone = seat; + String seatType = s.substring(0, open).trim(); + try { + type = SeatType.valueOf(seatType.toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException invalidType) { + type = SeatType.PASSENGER; + } + bone = s.substring(open + 1, s.length() - 1); } public Seat(SeatType t, String bone) { @@ -60,6 +60,7 @@ public Entity getEntity() { return e; } public void mount(ActiveVehicle v, ActiveVehicle parent) { + if (isOccupied()) return; mount(v.getEntity()); mountedVehicle = v; v.setParent(parent); diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/util/VFX.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/util/VFX.java index 408e203..e05e02b 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/util/VFX.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/util/VFX.java @@ -33,9 +33,7 @@ public VFX(VFX another, ActiveModel m) { } public void updateModel(ActiveModel m) { - for(ModelBone bone : modelBones) { - bone = m.getBone(bone.getBoneId()).get(); - } + modelBones.replaceAll(bone -> m.getBone(bone.getBoneId()).orElse(bone)); } public List getBoneList() { diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/Weapon.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/Weapon.java index 31d7c5b..a9e2422 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/weapons/Weapon.java +++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/Weapon.java @@ -72,7 +72,7 @@ public Weapon(String key, ConfigurationSection config) { projectileDamage = config.getInt("projectile-damage"); } if (config.contains("projectile-damage-type")) { - projectileDamageType = config.getString("projectile-damage-type").toUpperCase(); + projectileDamageType = config.getString("projectile-damage-type").toUpperCase(java.util.Locale.ROOT); } if (config.contains("projectile-speed")) { projectileSpeed = config.getDouble("projectile-speed"); diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/WeaponAimOffset.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/WeaponAimOffset.java index b5263ea..1895c88 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/weapons/WeaponAimOffset.java +++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/WeaponAimOffset.java @@ -38,7 +38,7 @@ public ConvertedAngle applyToAngles(ConvertedAngle desired, String headAxis) { float yaw = ConvertedAngle.wrapDegrees(desired.getYaw() + bodyYaw); float pitch = desired.getPitch(); float roll = desired.getRoll(); - String axis = headAxis == null ? "x" : headAxis.toLowerCase(); + String axis = headAxis == null ? "x" : headAxis.toLowerCase(java.util.Locale.ROOT); if (axis.equals("z")) { pitch = ConvertedAngle.wrapDegrees(pitch + headRoll); } else if (axis.equals("y")) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/Ammunition.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/Ammunition.java index 4ec7ae2..bde4ec1 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/Ammunition.java +++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/Ammunition.java @@ -14,23 +14,13 @@ public class Ammunition { public static Ammunition create(String key, ConfigurationSection config) { String t = config.getString("type", "CANNONBALL"); - if(Projectile.valueOf(t.toUpperCase()) == null) VFLogger.log(key+" ammunition has malformed type"); - Projectile type = Projectile.valueOf(t.toUpperCase()); - switch(type) { - case BULLET: - return new Bullet(key, config); - case CANNONBALL: - return new Ammunition(key, config); - case CLUSTER: - return new ClusterBomb(key, config); - case TORPEDO: - return new FusedExplosive(key, config); - case BOMB: - return new FusedExplosive(key, config); - default: - return new Ammunition(key, config); - - } + Projectile type = Projectile.valueOf(t.toUpperCase(java.util.Locale.ROOT)); + return switch (type) { + case BULLET -> new Bullet(key, config); + case CANNONBALL -> new Ammunition(key, config); + case CLUSTER -> new ClusterBomb(key, config); + case TORPEDO, BOMB -> new FusedExplosive(key, config); + }; } public Ammunition (String key, ConfigurationSection config) { @@ -38,8 +28,7 @@ public Ammunition (String key, ConfigurationSection config) { name = config.getString("name", key); data = new AmmunitionData(config); String t = config.getString("type", "CANNONBALL"); - if(Projectile.valueOf(t.toUpperCase()) == null) VFLogger.log(key+" ammunition has malformed type"); - type = Projectile.valueOf(t.toUpperCase()); + type = Projectile.valueOf(t.toUpperCase(java.util.Locale.ROOT)); } public Projectile getType() { diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/AmmunitionData.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/AmmunitionData.java index 62045df..128f9bc 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/AmmunitionData.java +++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/AmmunitionData.java @@ -53,7 +53,7 @@ public AmmunitionData(ConfigurationSection config) { radius = config.getInt("radius", 5); damage = config.getInt("damage", 8); rounds = config.getInt("rounds", 1); - damageType = config.getString("damage-type", "PROJECTILE").toUpperCase(); + damageType = config.getString("damage-type", "PROJECTILE").toUpperCase(java.util.Locale.ROOT); if(config.isConfigurationSection("sounds")) { ConfigurationSection soundConfig = config.getConfigurationSection("sounds"); sfx = SoundLoader.getSoundsFromConfig(soundConfig); @@ -256,7 +256,7 @@ private LingeringCloudData parseLingeringCloud(String input) { int effectDurationSeconds = 5; if (potionMatcher.find()) { - String potionId = potionMatcher.group(1).trim().toUpperCase(); + String potionId = potionMatcher.group(1).trim().toUpperCase(java.util.Locale.ROOT); amplifier = Integer.parseInt(potionMatcher.group(2)); effectDurationSeconds = Integer.parseInt(potionMatcher.group(3)); diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/projectile/ItemModel.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/projectile/ItemModel.java index c5f51bc..6c7a805 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/projectile/ItemModel.java +++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/projectile/ItemModel.java @@ -16,7 +16,7 @@ public class ItemModel implements ProjectileModel{ private int model; public ItemModel(ConfigurationSection config) { - material = Material.valueOf(config.getString("material").toUpperCase()); + material = Material.valueOf(config.getString("material").toUpperCase(java.util.Locale.ROOT)); small = config.getBoolean("small", false); model = config.getInt("model-data", 0); } diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/controller/WeaponMovementController.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/controller/WeaponMovementController.java index 00926ad..3dd011b 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/weapons/controller/WeaponMovementController.java +++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/controller/WeaponMovementController.java @@ -96,10 +96,6 @@ private void initiateBones(ActiveModel m, String body, String head, String axis, } else { headRotator = new BoneRotator(v, v.getEntity(), m.getBone(head).get(), limits); } - if(axis == null) { - VFLogger.log("weapon has no axis"); - return; - } this.axis = axis; } @@ -206,9 +202,6 @@ public void trackCursor(Player player) { } Location target = WeaponTargetResolver.resolveTarget(player, v, cursorRange); - if (target == null) { - return; - } if (Cache.weaponAimDebug && player.getWorld() != null && target.getWorld() != null && player.getWorld().equals(target.getWorld())) { @@ -229,9 +222,6 @@ public void trackCursor(Player player) { ConvertedAngle desiredAngles = offset.applyToAngles(ConvertedAngle.fromDirection(desired), axis); Vector current = aimVector.getVector(); - if (current == null || current.lengthSquared() < 1e-12) { - return; - } float rate = effectiveTurnRate(); float yawErr = WeaponAimAligner.yawError(current, desiredAngles); @@ -250,9 +240,6 @@ public void trackCursor(Player player) { yawSettled = WeaponAimAligner.updateSettled(boneYawErr, yawSettled); Vector afterBody = aimVector.getVector(); - if (afterBody == null || afterBody.lengthSquared() < 1e-12) { - return; - } float boneElevErr = WeaponAimAligner.toBoneElevationStep( WeaponAimAligner.elevationError(afterBody, desiredAngles, axis)); float headStep = WeaponAimAligner.followStep(boneElevErr, rate, elevationSettled); @@ -332,7 +319,8 @@ private void applyHeadStep(float headStep) { * instead of silently ignoring the input. */ private boolean canTurn(Player p) { - if (fixed || effectiveTurnRate() > 0f) { + if (fixed || bodyRotator == null || headRotator == null || axis == null) return false; + if (effectiveTurnRate() > 0f) { return true; } notifyBroken(p); diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/handlers/AmmunitionHandler.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/handlers/AmmunitionHandler.java index b76cd59..07de727 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/weapons/handlers/AmmunitionHandler.java +++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/handlers/AmmunitionHandler.java @@ -62,7 +62,7 @@ public AmmunitionHandler(ConfigurationSection config) { if(config.contains("reload-states")) { for(String s : config.getStringList("reload-states")) { try { - reloadStates.add(State.valueOf(s.toUpperCase())); + reloadStates.add(State.valueOf(s.toUpperCase(java.util.Locale.ROOT))); } catch (IllegalArgumentException e) { System.out.println("Invalid state in reload-states: " + s); } @@ -81,16 +81,18 @@ public AmmunitionHandler(ActiveModel m, ActiveVehicle vehicle, ActiveWeapon weap reloadTime = -1; baseReloadTime = another.getBaseReloadTime(); cooldown = another.getCooldown(); + delay = another.delay; activeCooldown = 0L; currentBone = 0; reloadStates = another.getReloadStates(); for(String bone : bones) { - if(!bone.contains(".")) { + String[] parts = bone.split("\\.", -1); + if(parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) { VFLogger.log(weapon.getId()+" has an invalid exit bone: "+bone); continue; } - String base = bone.split("\\.")[0]; - String alignment = bone.split("\\.")[1]; + String base = parts[0]; + String alignment = parts[1]; if(m.getBone(base).isEmpty()) { VFLogger.log(weapon.getId()+" has an invalid exit bone "+base); continue; @@ -177,6 +179,7 @@ private void reload(Player p, Ammunition a) { } public void reloadStart(Player p, Ammunition a) { + if (exitBones.isEmpty()) return; reloadTime = WeaponPerformance.effectiveReloadSeconds( baseReloadTime, w.getHealthData(), Cache.weaponDegradedReloadMultiplier); playSound(exitBones.get(0), SoundArg.RELOAD_START); @@ -199,6 +202,7 @@ public void run() { } public void load(Player p, ItemStack i, boolean message) { + if (exitBones.isEmpty()) return; if(i.getType().equals(Material.AIR)) return; @SuppressWarnings("deprecation") ItemAPI api = TLibs.getItemAPI(); @@ -206,7 +210,7 @@ public void load(Player p, ItemStack i, boolean message) { if(AmmunitionLoader.getByInput(input) == null) return; Ammunition a = AmmunitionLoader.getByInput(input); if(!reloadStates.contains(v.getCurrentState().getType())) { - if(message) p.sendMessage("§cCannot reload in the "+v.getCurrentState().getType().toString().toLowerCase().replace("_", " ")+ " state"); + if(message) p.sendMessage("§cCannot reload in the "+v.getCurrentState().getType().toString().toLowerCase(java.util.Locale.ROOT).replace("_", " ")+ " state"); return; } if(hasAmmo()) { @@ -240,11 +244,12 @@ private void shootProjectiles(List nearby) { w.getAnimationHandler().animate(Animation.SHOOT); } VectorBone bone = exitBones.get(currentBone); + Ammunition shotAmmo = ammo; if(delay > 0) { new BukkitRunnable() { @Override public void run() { - projectileShooter.shoot(nearby, v.getEntity(), bone.getBaseLocation(), bone.getVector(), ammo, w); + projectileShooter.shoot(nearby, v.getEntity(), bone.getBaseLocation(), bone.getVector(), shotAmmo, w); particle(bone); playSound(bone, SoundArg.SHOOT); } @@ -265,6 +270,7 @@ private void decrease() { v.updateBoard(); } public void shoot(Player p, List nearby) { + if (exitBones.isEmpty()) return; if(!hasAmmo()) { p.sendMessage("§cNo ammo"); return; diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/DefaultShooter.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/DefaultShooter.java index 0a82cce..33662a6 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/DefaultShooter.java +++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/DefaultShooter.java @@ -66,7 +66,8 @@ public void run() { e.remove(); projectiles.remove(e); Cache.projectiles.remove(e); - cancel(); + cancel(); + return; } // Add the offset for visual effects @@ -125,6 +126,7 @@ public void run() { projectiles.remove(armorStand); Cache.projectiles.remove(armorStand); cancel(); + return; } a.getData().fx(players, armorStand.getLocation(), 1f, i); i++; diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/ProjectileShooter.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/ProjectileShooter.java index d5004cd..a64f0b0 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/ProjectileShooter.java +++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/ProjectileShooter.java @@ -63,6 +63,10 @@ public void lightEffect(Location loc) { Integer i = 0; public void run() { + if (b.getType() != Material.LIGHT) { + cancel(); + return; + } if(i == 1) { level.setLevel(15); b.setBlockData(level, true); diff --git a/src/test/java/net/tfminecraft/vehicleframework/MetricsIntegrationTest.java b/src/test/java/net/tfminecraft/vehicleframework/MetricsIntegrationTest.java new file mode 100644 index 0000000..0b0b732 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/MetricsIntegrationTest.java @@ -0,0 +1,43 @@ +package net.tfminecraft.vehicleframework; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Logger; +import org.bstats.bukkit.Metrics; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MetricsIntegrationTest { + @TempDir Path directory; + + @Test void dependencyPreservesExistingOptOutAndServerIdentity() throws Exception { + Path config=directory.resolve("bStats/config.yml"); Files.createDirectories(config.getParent()); + String original="enabled: false\nserverUuid: retained-server-id\nlogFailedRequests: true\n"; + Files.writeString(config,original); + Metrics metrics=new Metrics(plugin(),23136); + try { assertEquals(original,Files.readString(config)); } + finally { metrics.shutdown(); } + } + + @Test void dependencyCreatesACompatibleConfigurationOnFirstStartup() { + Metrics metrics=new Metrics(plugin(),23136); + try { + YamlConfiguration config=YamlConfiguration.loadConfiguration(directory.resolve("bStats/config.yml").toFile()); + assertTrue(config.getBoolean("enabled")); assertNotNull(config.getString("serverUuid")); + assertFalse(config.getBoolean("logSentData")); assertFalse(config.getBoolean("logFailedRequests")); + } finally { metrics.shutdown(); } + } + + private Plugin plugin() { + Plugin plugin=mock(Plugin.class); + when(plugin.getDataFolder()).thenReturn(directory.resolve("VehicleFramework").toFile()); + when(plugin.getLogger()).thenReturn(Logger.getLogger("metrics-integration-test")); + when(plugin.isEnabled()).thenReturn(true); + return plugin; + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java new file mode 100644 index 0000000..6ae8cfc --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java @@ -0,0 +1,192 @@ +package net.tfminecraft.vehicleframework; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.*; +import java.util.*; +import java.util.logging.Logger; +import org.bukkit.*; +import org.bukkit.command.PluginCommand; +import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginDescriptionFile; +import org.bukkit.plugin.PluginLoader; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.*; +import io.papermc.paper.plugin.configuration.PluginMeta; +import io.papermc.paper.plugin.provider.classloader.ConfiguredPluginClassLoader; +import io.papermc.paper.plugin.provider.classloader.PluginClassLoaderGroup; +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.dynamic.loading.ClassLoadingStrategy; +import com.comphenix.protocol.ProtocolLibrary; +import com.comphenix.protocol.ProtocolManager; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.database.SqliteDatabase; +import net.tfminecraft.coreprotect.CoreProtect; +import net.tfminecraft.coreprotect.CoreProtectAPI; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.loaders.*; + +class VehicleFrameworkCoverageTest { + @BeforeAll static void bootstrapRegistries() { + net.tfminecraft.vehicleframework.test.RegistryFixture.initialize(); + } + @TempDir Path temp; + private final List scopes = new ArrayList<>(); + private final Server server = mock(Server.class); + private final PluginManager plugins = mock(PluginManager.class); + private final BukkitScheduler scheduler = mock(BukkitScheduler.class); + private final Logger log = mock(Logger.class); + private final ProtocolManager protocol = mock(ProtocolManager.class); + private VehicleFramework plugin; + private VehicleFramework previousPlugin; + private boolean previousCoreProtect; + + @BeforeEach void setup() throws Exception { + previousPlugin = VehicleFramework.plugin; + previousCoreProtect = Cache.coreProtect; Cache.coreProtect = false; + MockedStatic bukkit = keep(mockStatic(Bukkit.class)); + bukkit.when(Bukkit::getLogger).thenReturn(log); bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); + bukkit.when(Bukkit::getVersion).thenReturn("Paper (MC: 1.21.10)"); + bukkit.when(Bukkit::getBukkitVersion).thenReturn("1.21.10-R0.1-SNAPSHOT"); + bukkit.when(Bukkit::getUnsafe).thenReturn(mock(UnsafeValues.class, RETURNS_DEEP_STUBS)); + when(server.getPluginManager()).thenReturn(plugins); when(server.getLogger()).thenReturn(log); + when(server.getScheduler()).thenReturn(scheduler); when(server.getWorlds()).thenReturn(List.of()); + when(server.getVersion()).thenReturn("Paper (MC: 1.21.10)"); + when(server.getBukkitVersion()).thenReturn("1.21.10-R0.1-SNAPSHOT"); + when(scheduler.runTaskTimer(any(Plugin.class), any(Runnable.class), anyLong(), anyLong())).thenReturn(mock(BukkitTask.class)); + when(scheduler.runTaskLater(any(Plugin.class), any(Runnable.class), anyLong())).thenReturn(mock(BukkitTask.class)); + keep(mockStatic(TLibs.class)).when(TLibs::getItemAPI).thenReturn(mock(ItemAPI.class, RETURNS_DEEP_STUBS)); + keep(mockStatic(ProtocolLibrary.class)).when(ProtocolLibrary::getProtocolManager).thenReturn(protocol); + keep(mockStatic(net.kyori.adventure.util.Services.class, CALLS_REAL_METHODS)) + .when(() -> net.kyori.adventure.util.Services.service(PluginLoader.class)).thenReturn(Optional.of(mock(PluginLoader.class))); + var description = new PluginDescriptionFile("VehicleFramework", "test", VehicleFramework.class.getName()); + var loader = new TestPluginLoader(description); + // The platform fixture supplies JavaPlugin's required configured classloader; the production constructor runs. + plugin = new ByteBuddy().subclass(VehicleFramework.class).make() + .load(loader, ClassLoadingStrategy.Default.INJECTION).getLoaded().getConstructor().newInstance(); + PluginCommand command = mock(PluginCommand.class); when(command.getPlugin()).thenReturn(plugin); + when(server.getPluginCommand("vf")).thenReturn(command); + Files.createDirectories(temp.resolve("bStats")); + Files.writeString(temp.resolve("bStats/config.yml"), "enabled: false\nserverUuid: lifecycle-test\n"); + } + + @AfterEach void cleanup() throws Exception { + if (plugin != null) plugin.onDisable(); + VehicleFramework.plugin = previousPlugin; + Cache.coreProtect = previousCoreProtect; + VehicleLoader.get().clear(); AmmunitionLoader.get().clear(); FuelLoader.get().clear(); + new WeaponTemplateLoader().clear(); new DeathTemplateLoader().clear(); new ArmorTemplateLoader().clear(); + Collections.reverse(scopes); for (AutoCloseable scope : scopes) scope.close(); + } + + @Test void startupCreatesDefaultsRegistersSystemsAndClosesItsDatabase() throws Exception { + plugin.onEnable(); + assertSame(plugin, VehicleFramework.getInstance()); assertNotNull(VehicleFramework.getVehicleRepository()); + assertNotNull(VehicleFramework.getTrackRegistry()); assertNotNull(VehicleFramework.getTrackDisplayManager()); + assertNotNull(VehicleFramework.getPacketListener()); assertNotNull(VehicleFramework.getLog()); + assertTrue(Files.isRegularFile(temp.resolve("plugin/data/vehicles.db"))); + assertTrue(Files.isRegularFile(temp.resolve("plugin/templates/weapons/gun_turret.yml"))); + assertNotNull(FuelLoader.getByString("coal")); assertFalse(WeaponTemplateLoader.map.isEmpty()); + verify(plugins, times(6)).registerEvents(any(Listener.class), same(plugin)); + verify(protocol).addPacketListener(any()); verify(plugins, never()).disablePlugin(plugin); + plugin.onDisable(); assertNull(VehicleFramework.getVehicleRepository()); + } + + @Test void reloadRemovesDeletedFuelVehicleAndAmmunitionDefinitions() throws Exception { + plugin.createFolders(); plugin.createConfigs(); + Path oldVehicle = temp.resolve("plugin/vehicles/old.yml"), oldAmmo = temp.resolve("plugin/ammunition/old.yml"); + Files.writeString(oldVehicle, "old_cart:\n model: default\n skins: {}\n states: {}\n components: {}\n seats: []\n"); + Files.writeString(oldAmmo, "old_shell: {}\n"); Files.writeString(temp.resolve("plugin/fuel.yml"), "old_fuel: {}\n"); + plugin.onEnable(); + assertNotNull(VehicleLoader.getByString("old_cart")); assertNotNull(AmmunitionLoader.getByString("old_shell")); + assertNotNull(FuelLoader.getByString("old_fuel")); + Files.delete(oldVehicle); Files.delete(oldAmmo); Files.writeString(temp.resolve("plugin/fuel.yml"), "{}\n"); + plugin.reload(); + assertAll(() -> assertNull(VehicleLoader.getByString("old_cart")), + () -> assertNull(AmmunitionLoader.getByString("old_shell")), () -> assertNull(FuelLoader.getByString("old_fuel"))); + } + + @Test void defaultFilesAreCreatedOnceAndUserEditsArePreserved() throws Exception { + plugin.createFolders(); plugin.createConfigs(); + for (String folder : List.of("data/tracks", "data/backups", "vehicles", "ammunition", "templates/weapons", "templates/armor", "templates/roles", "templates/death")) { + assertTrue(Files.isDirectory(temp.resolve("plugin/" + folder))); + } + Path config = temp.resolve("plugin/config.yml"); Files.writeString(config, "skin-item: user.changed\n"); + plugin.createFolders(); plugin.createConfigs(); assertEquals("skin-item: user.changed\n", Files.readString(config)); + Files.createDirectory(temp.resolve("plugin/vehicles/nested")); Files.createDirectory(temp.resolve("plugin/ammunition/nested")); + plugin.loadConfigs(); assertEquals("user.changed", Cache.skinItem); + assertTrue(VehicleLoader.get().isEmpty()); assertTrue(AmmunitionLoader.get().isEmpty()); + } + + @Test void databaseOpenFailureDisablesThePluginBeforeRegisteringListeners() throws Exception { + Files.createDirectory(temp.resolve("plugin")); Files.writeString(temp.resolve("plugin/data"), "not a directory"); + plugin.onEnable(); assertNull(VehicleFramework.getVehicleRepository()); + verify(plugins).disablePlugin(plugin); verify(plugins, never()).registerEvents(any(), any()); + verify(log).warning(contains("Failed to open SQLite vehicles.db")); + } + + @Test void databaseCloseFailureStillReleasesTheRepositoryReference() throws Exception { + SqliteDatabase actual = new SqliteDatabase(temp.resolve("connection-fault.db").toFile()); + try { + MockedConstruction database = keep(mockConstruction(SqliteDatabase.class, + withSettings().defaultAnswer(AdditionalAnswers.delegatesTo(actual)), (db, context) -> + doThrow(new IllegalStateException("connection close failed")).when(db).close())); + plugin.onEnable(); assertNotNull(VehicleFramework.getVehicleRepository()); + assertDoesNotThrow(plugin::onDisable); assertNull(VehicleFramework.getVehicleRepository()); + verify(database.constructed().get(0)).close(); + verify(log).warning(contains("Failed to close SQLite vehicles.db: connection close failed")); + } finally { actual.close(); } + } + + @Test void integrationDetectionRegistersMythicMobsAndOnlyEnablesCoreProtectForItsActivePlugin() { + VehicleFramework.plugin = plugin; + plugin.setPlugins(); assertFalse(Cache.coreProtect); + Plugin unrelated = mock(Plugin.class); when(unrelated.isEnabled()).thenReturn(true); + when(plugins.getPlugin("CoreProtect")).thenReturn(unrelated); plugin.setPlugins(); assertFalse(Cache.coreProtect); + CoreProtect core = mock(CoreProtect.class); when(plugins.getPlugin("CoreProtect")).thenReturn(core); + plugin.setPlugins(); assertFalse(Cache.coreProtect); + when(core.isEnabled()).thenReturn(true); when(plugins.isPluginEnabled("MythicMobs")).thenReturn(true); + plugin.setPlugins(); assertTrue(Cache.coreProtect); + verify(plugins).registerEvents(isA(net.tfminecraft.vehicleframework.util.MythicMobsIntegration.class), same(plugin)); + } + + @Test void coreProtectApiRejectsMissingWrongDisabledAndOutdatedImplementations() { + VehicleFramework.plugin = plugin; assertNull(VehicleFramework.getCoreProtect()); + when(plugins.getPlugin("CoreProtect")).thenReturn(mock(Plugin.class)); assertNull(VehicleFramework.getCoreProtect()); + CoreProtect core = mock(CoreProtect.class); CoreProtectAPI api = mock(CoreProtectAPI.class); + when(plugins.getPlugin("CoreProtect")).thenReturn(core); when(core.getAPI()).thenReturn(api); + assertNull(VehicleFramework.getCoreProtect()); when(api.isEnabled()).thenReturn(true); when(api.APIVersion()).thenReturn(9); + assertNull(VehicleFramework.getCoreProtect()); when(api.APIVersion()).thenReturn(10); assertSame(api, VehicleFramework.getCoreProtect()); + } + + private T keep(T scope) { scopes.add(scope); return scope; } + + private final class TestPluginLoader extends ClassLoader implements ConfiguredPluginClassLoader { + private final PluginDescriptionFile description; + private JavaPlugin loaded; + TestPluginLoader(PluginDescriptionFile description) { super(VehicleFramework.class.getClassLoader()); this.description = description; } + @Override public PluginMeta getConfiguration() { return description; } + @Override public Class loadClass(String name, boolean resolve, boolean global, boolean libraries) throws ClassNotFoundException { + return super.loadClass(name, resolve); + } + @Override public void init(JavaPlugin value) { + loaded = value; + value.init(server, description, temp.resolve("plugin").toFile(), temp.resolve("plugin.jar").toFile(), this, description, log); + } + @Override public JavaPlugin getPlugin() { return loaded; } + @Override public PluginClassLoaderGroup getGroup() { return null; } + @Override public void close() {} + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/bones/BoneRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/bones/BoneRuntimeCoverageTest.java new file mode 100644 index 0000000..f5ee995 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/bones/BoneRuntimeCoverageTest.java @@ -0,0 +1,77 @@ +package net.tfminecraft.vehicleframework.bones; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; +import org.joml.Quaternionf; +import org.junit.jupiter.api.*; +import org.mockito.ArgumentCaptor; +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.bone.ModelBone; +import com.comphenix.protocol.ProtocolLibrary; +import com.comphenix.protocol.ProtocolManager; +import com.comphenix.protocol.events.*; +import com.comphenix.protocol.reflect.StructureModifier; +import net.tfminecraft.vehicleframework.enums.Keybind; +import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig; +import net.tfminecraft.vehicleframework.protocol.PacketConverter; +import net.tfminecraft.vehicleframework.protocol.VehiclePacketListener; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.util.AccessPanel; + +class BoneRuntimeCoverageTest { + Rig r;ActiveVehicle vehicle;ActiveModel model;AccessPanel panel;Map bones; + @BeforeEach void setup(){r=new Rig();vehicle=r.vehicle();panel=new AccessPanel();when(vehicle.getAccessPanel()).thenReturn(panel);model=mock(ActiveModel.class,RETURNS_DEEP_STUBS);bones=new HashMap<>();when(model.getBone(anyString())).thenAnswer(c->Optional.ofNullable(bones.get(c.getArgument(0))));} + @AfterEach void cleanup(){r.close();} + + @Test void normalizationPreservesUnselectedAxes() { + BoneRotator rotator=rotator("body",new RotationLimits());rotator.setRotation(30,0,0,true,true,true);rotator.normalize(true,false,false);assertEquals(30,rotator.getConvertedAngles().getYaw(),.001,"Normalizing X must preserve Y rotation");rotator.normalize(false,true,false);assertEquals(0,rotator.getConvertedAngles().getYaw(),.001); + } + + @Test void normalizationSnapsToNeutralWithoutOvershooting() { + BoneRotator rotator=rotator("body",new RotationLimits());rotator.setRotation(40,0,0,true,true,true);rotator.normalize(true,true,true);assertEquals(0,rotator.getConvertedAngles().getYaw(),.001,"Interpolation past 1 mirrors the angle instead of resetting it");assertEquals(0,rotator.getDriveYaw(),.001); + } + + @Test void directionAnglesWrapAndKeepInputVectorsIntact() { + ConvertedAngle explicit=new ConvertedAngle(10,20,30);assertEquals(10,explicit.getYaw());assertEquals(20,explicit.getPitch());assertEquals(30,explicit.getRoll());assertEquals(90,new ConvertedAngle(new Quaternionf().rotateY((float)Math.PI/2)).getYaw(),.001);assertEquals(30,new ConvertedAngle(new Quaternionf().rotateX((float)Math.PI/6)).getPitch(),.001);assertEquals(45,new ConvertedAngle(new Quaternionf().rotateZ((float)Math.PI/4)).getRoll(),.001);assertEquals(0,ConvertedAngle.fromDirection(null).getYaw());assertEquals(0,ConvertedAngle.fromDirection(new Vector()).getPitch());Vector direction=new Vector(2,0,0);assertEquals(-90,ConvertedAngle.fromDirection(direction).getYaw(),.001);assertEquals(new Vector(2,0,0),direction);assertEquals(-90,ConvertedAngle.fromDirection(new Vector(0,1,0)).getPitch(),.001);assertEquals(-90,ConvertedAngle.wrapDegrees(270));assertEquals(90,ConvertedAngle.wrapDegrees(-270));assertEquals(180,ConvertedAngle.wrapDegrees(-180));assertEquals(20,ConvertedAngle.shortestDelta(170,-170)); + } + + @Test void rotationLimitsApplyOptionalAxesDefaultsWrappingAndClamps()throws Exception { + RotationLimits none=new RotationLimits();assertTrue(none.withinAll(300,100,200));assertEquals(300,none.clampYaw(300));assertEquals(100,none.clampPitch(100));assertEquals(200,none.clampRoll(200));RotationLimits configured=new RotationLimits(Rig.yaml("min-yaw: -30\nmax-yaw: 30\nmin-pitch: -45\nmax-pitch: 45\nmin-roll: -20\nmax-roll: 20\n"));assertEquals(-30,configured.getMinYaw());assertEquals(30,configured.getMaxYaw());assertEquals(-45,configured.getMinPitch());assertEquals(45,configured.getMaxPitch());assertEquals(-20,configured.getMinRoll());assertEquals(20,configured.getMaxRoll());assertTrue(configured.withinAll(30,45,20));assertTrue(configured.withinYaw(350));assertTrue(configured.withinYaw(-350));assertFalse(configured.withinYaw(40));assertFalse(configured.withinYaw(-40));assertFalse(configured.withinAll(0,46,0));assertFalse(configured.withinAll(0,0,21));assertEquals(30,configured.clampYaw(90));assertEquals(-45,configured.clampPitch(-90));assertEquals(20,configured.clampRoll(50));assertEquals(10,configured.clampYaw(10)); + RotationLimits partial=new RotationLimits(Rig.yaml("max-yaw: 30\nmin-pitch: -40\nmax-roll: 10\n"));assertEquals(-180,partial.getMinYaw());assertEquals(90,partial.getMaxPitch());assertEquals(-180,partial.getMinRoll());assertTrue(new RotationLimits(Rig.yaml("")).withinAll(400,200,300));assertFalse(RotationLimits.yawOnly(-1,1).withinYaw(2));assertTrue(RotationLimits.yawOnly(-1,1).withinPitch(200));assertFalse(RotationLimits.pitchOnly(-1,1).withinPitch(2));assertTrue(RotationLimits.pitchOnly(-1,1).withinRoll(200));assertFalse(RotationLimits.rollOnly(-1,1).withinRoll(2));assertTrue(RotationLimits.rollOnly(-1,1).withinYaw(200)); + } + + @Test void boneRotationsMaintainDriveHeadingLimitsSmoothingAndEntityFacing()throws Exception { + BoneRotator rotator=rotator("body",RotationLimits.yawOnly(-5,5));assertEquals("body",rotator.getId());assertSame(bones.get("body"),rotator.getBone());assertSame(rotator,panel.getRotator("body"));rotator.rotate(0,2,0);assertEquals(0,rotator.getDriveYaw(),.001);rotator.rotate(0,.1,0);assertEquals(Math.toDegrees(.01),rotator.getDriveYaw(),.001);rotator.rotate(0,0,0);rotator.reset();assertEquals(0,rotator.getDriveYaw(),.001);rotator.rotateSmoothed(0,10,0);assertEquals(Math.toDegrees(.05),rotator.getDriveYaw(),.001);rotator.resetSmoothing();rotator.reset();rotator.rotateSmoothed(0,10,0);assertEquals(Math.toDegrees(.05),rotator.getDriveYaw(),.001);rotator.rotateEntity(25,-10);assertEquals(25,vehicle.getEntity().getLocation().getYaw());assertEquals(-10,vehicle.getEntity().getLocation().getPitch());assertEquals(64,vehicle.getEntity().getLocation().getY());assertTrue(Double.isFinite(rotator.getAngles().angle));Quaternionf heading=new Quaternionf().rotateY((float)Math.toRadians(20));rotator.rawSet(heading.x,heading.y,heading.z,heading.w);assertEquals(20,rotator.getDriveYaw(),.001);ModelBone replacement=bone("body",new Location(r.world,0,64,0));rotator.updateModel(model);assertSame(replacement,rotator.getBone());verify(replacement).setManualAnimator(rotator.getAnimator());assertEquals(20,rotator.getConvertedAngles().getYaw(),.001);assertTrue(rotator.rotateToTarget(20,0,0,1,true,true,true));assertFalse(rotator.rotateToTarget(0,10,0,.5f,false,true,false));assertTrue(rotator.getConvertedAngles().getPitch()>0); + } + + @Test void vectorBonesTrackReplacementModelsAndCoincidentFallbacks() { + ModelBone base=bone("base",new Location(r.world,1,64,1)),align=bone("aim",new Location(r.world,1,64,3));VectorBone vector=new VectorBone(base,align);assertEquals("base",vector.getId());assertSame(base,vector.getBase());assertSame(align,vector.getAlign());assertSame(model,vector.getModel());assertEquals(new Location(r.world,1,64,1),vector.getBaseLocation());assertEquals(new Location(r.world,1,64,3),vector.getAlignLocation());assertEquals(new Vector(0,0,1),vector.getVector());ModelBone replacement=bone("base",new Location(r.world,1,64,3));vector.updateModel(model);assertSame(replacement,vector.getBase());assertEquals(new Vector(1,0,0),vector.getVector()); + } + + @Test void rotationTargetsBecomeActiveAtTheTargetAndReactivateAfterConditionsChange()throws Exception { + BoneRotator base=rotator("base",new RotationLimits());RotationTarget stored=new RotationTarget(Rig.yaml("yaw: 30\npitch: 0\nroll: 0\ninterval: 1\nconditions: [has_fuel(true)]\n"));assertEquals("none",stored.getRotatorOverride());assertEquals(List.of("has_fuel(true)"),stored.getConditions());assertEquals(30,stored.getYaw());assertEquals(0,stored.getPitch());assertEquals(0,stored.getRoll());assertTrue(stored.shouldYaw());assertTrue(stored.shouldPitch());assertTrue(stored.shouldRoll());assertEquals(1,stored.getInterval());RotationTarget active=new RotationTarget(vehicle,stored,base);assertSame(base,active.getRotator());assertFalse(active.isActive());active.run(vehicle);assertEquals(0,base.getDriveYaw());when(vehicle.hasFuel()).thenReturn(true);active.run(vehicle);assertFalse(active.isActive());active.run(vehicle);assertTrue(active.isActive());active.run(vehicle);assertEquals(30,base.getDriveYaw(),.001);active.updateModel();when(vehicle.hasFuel()).thenReturn(false);active.run(vehicle);assertFalse(active.isActive());when(vehicle.hasFuel()).thenReturn(true);active.run(vehicle);assertTrue(active.isActive()); + BoneRotator override=rotator("override",new RotationLimits());RotationTarget selected=new RotationTarget(vehicle,new RotationTarget(Rig.yaml("rotator: override\n")),base);assertSame(override,selected.getRotator());assertFalse(selected.shouldYaw());assertFalse(selected.shouldPitch());assertFalse(selected.shouldRoll());RotationTarget missing=new RotationTarget(vehicle,new RotationTarget(Rig.yaml("rotator: missing\n")),base);assertSame(base,missing.getRotator());selected.setRotator(base);assertSame(base,selected.getRotator()); + } + + @Test void packetConversionDistinguishesHeldJumpSneakAndOppositeAxes() { + PacketConverter converter=new PacketConverter();assertEquals(List.of(Keybind.A,Keybind.W),converter.convert(1,1,false,false));assertEquals(List.of(Keybind.D,Keybind.S),converter.convert(-1,-1,false,false));assertEquals(List.of(),converter.convert(0,0,false,false));assertEquals(List.of(Keybind.SPACE_A,Keybind.SPACE_W,Keybind.SPACE),converter.convert(1,1,true,false));assertEquals(List.of(Keybind.SPACE_D,Keybind.SPACE_S,Keybind.SPACE),converter.convert(-1,-1,true,false));assertEquals(List.of(Keybind.SHIFT_A,Keybind.SHIFT_W,Keybind.SHIFT,Keybind.SPACE),converter.convert(1,1,true,true));assertEquals(List.of(Keybind.SHIFT_D,Keybind.SHIFT_S,Keybind.SHIFT),converter.convert(-1,-1,false,true));assertEquals(List.of(Keybind.SHIFT),converter.convert(0,0,false,true)); + } + + @Test void packetListenerReplaysHeldInputsUntilQuitAndCancelsOppositeDirections() { + Server server=mock(Server.class);when(server.getVersion()).thenReturn("Paper (MC: 1.21.10)");when(server.getBukkitVersion()).thenReturn("1.21.10-R0.1-SNAPSHOT");r.bukkit.when(Bukkit::getServer).thenReturn(server);r.bukkit.when(Bukkit::getVersion).thenReturn("Paper (MC: 1.21.10)");r.bukkit.when(Bukkit::getBukkitVersion).thenReturn("1.21.10-R0.1-SNAPSHOT");r.bukkit.when(Bukkit::getLogger).thenReturn(java.util.logging.Logger.getAnonymousLogger());ProtocolManager protocol=mock(ProtocolManager.class); + try(var library=mockStatic(ProtocolLibrary.class);var reflection=mockStatic(com.comphenix.protocol.utility.MinecraftReflection.class);var nativeStacks=mockConstruction(org.bukkit.inventory.ItemStack.class)){ + reflection.when(com.comphenix.protocol.utility.MinecraftReflection::getItemStackClass).thenReturn(org.bukkit.inventory.ItemStack.class); + reflection.when(com.comphenix.protocol.utility.MinecraftReflection::getBlockClass).thenReturn(org.bukkit.block.Block.class); + library.when(ProtocolLibrary::getProtocolManager).thenReturn(protocol);VehiclePacketListener listener=new VehiclePacketListener(r.manager);listener.register();ArgumentCaptor capture=ArgumentCaptor.forClass(PacketListener.class);verify(protocol).addPacketListener(capture.capture());PacketListener adapter=capture.getValue();Player driver=r.entity(Player.class,new Location(r.world,0,64,0)),other=r.entity(Player.class,new Location(r.world,1,64,0));r.bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(driver,other));PacketEvent event=mock(PacketEvent.class,RETURNS_DEEP_STUBS);when(event.getPlayer()).thenReturn(driver);StructureModifier structures=mock(StructureModifier.class);when(event.getPacket().getStructures()).thenReturn(structures);when(structures.size()).thenReturn(0);adapter.onPacketReceiving(event);r.tick();verify(r.manager,never()).inputPacket(any(),anyFloat(),anyFloat(),anyBoolean(),anyBoolean());InternalStructure input=mock(InternalStructure.class,RETURNS_DEEP_STUBS);when(structures.size()).thenReturn(1);when(structures.read(0)).thenReturn(input);boolean[] flags={true,false,true,false,true,false};when(input.getBooleans().read(anyInt())).thenAnswer(c->flags[c.getArgument(0)]);adapter.onPacketReceiving(event);r.ticks(2);verify(r.manager,times(2)).inputPacket(driver,1,1,true,false);Arrays.fill(flags,true);adapter.onPacketReceiving(event);r.tick();verify(r.manager).inputPacket(driver,0,0,true,true);Arrays.fill(flags,false);flags[1]=true;flags[3]=true;adapter.onPacketReceiving(event);r.tick();verify(r.manager).inputPacket(driver,-1,-1,false,false);listener.unregisterPlayer(driver);clearInvocations(r.manager);r.tick();verifyNoInteractions(r.manager); + } + } + + private BoneRotator rotator(String id,RotationLimits limits){return new BoneRotator(vehicle,vehicle.getEntity(),bone(id,new Location(r.world,0,64,0)),limits);} + private ModelBone bone(String id,Location location){ModelBone bone=mock(ModelBone.class,RETURNS_DEEP_STUBS);when(bone.getBoneId()).thenReturn(id);when(bone.getActiveModel()).thenReturn(model);when(bone.getLocation()).thenAnswer(c->location.clone());when(bone.getLocalTransform().getSafeLeftQuaternion()).thenReturn(new Quaternionf());when(bone.getLocalTransform().getSafePosition()).thenReturn(new org.joml.Vector3f());when(bone.getLocalTransform().getSafeScale()).thenReturn(new org.joml.Vector3f(1));bones.put(id,bone);return bone;} +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java new file mode 100644 index 0000000..838d6a7 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java @@ -0,0 +1,102 @@ +package net.tfminecraft.vehicleframework.data; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.logging.Logger; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.*; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.enums.*; +import net.tfminecraft.vehicleframework.events.*; +import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +class RuntimeValuesCoverageTest { + Rig r; + @BeforeEach void setup(){r=new Rig();} + @AfterEach void cleanup(){if(r!=null)r.close();} + + @Test void colocatedSoundSourceKeepsItsConfiguredPitch()throws Exception { + Location source=new Location(r.world,0,64,0);Player player=r.entity(Player.class,source); + SoundData sound=new SoundData(Rig.yaml("sound: engine\ndoppler: true\npitch: 1.2\n"));sound.playSound(List.of(player),source,new Vector(),1); + verify(player).playSound(source,"engine",1f,1.2f); + } + + @Test void damageCauseIdsUseRootLocaleForBothConfigurationForms() { + Locale previous=Locale.getDefault();try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertTrue(new DamageData(List.of("fire(0.5)")).hasModifier("FIRE"));assertTrue(new DamageData(Map.of("fire",.5)).hasModifier("FIRE"));}finally{Locale.setDefault(previous);} + } + + @Test void particleIdsUseRootLocaleRatherThanSilentlyChangingTheEffect()throws Exception { + Locale previous=Locale.getDefault();try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertEquals(Particle.CRIT,new ParticleData(Rig.yaml("particle: crit\n")).getParticle());}finally{Locale.setDefault(previous);} + } + + @Test void deathOverrideIdsUseRootLocale()throws Exception { + Locale previous=Locale.getDefault();try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertEquals(VehicleDeath.SINK,new DeathOverride(Rig.yaml("type: sink\n")).getDeath());}finally{Locale.setDefault(previous);} + } + + @Test void damageModifiersReadNumbersStringsNullMapsAndNullEntries() { + DamageData list=new DamageData(List.of("EXPLOSION(0.5)","FIRE(2)"));assertEquals(.5,list.getModifier("EXPLOSION"));assertTrue(list.hasModifier("FIRE"));assertFalse(list.hasModifier("OTHER"));assertEquals(2,list.getModifiers().size());assertTrue(new DamageData((List)null).getModifiers().isEmpty());assertTrue(new DamageData((Map)null).getModifiers().isEmpty());Map values=new LinkedHashMap<>();values.put(null,1);values.put("skip",null);values.put("projectile",2);values.put("fire","0.25");DamageData mapped=new DamageData(values);assertEquals(Map.of("PROJECTILE",2d,"FIRE",.25d),mapped.getModifiers());assertThrows(NumberFormatException.class,()->new DamageData(Map.of("fire","invalid"))); + } + + @Test void repairCountsDownClampsDamageAndReportsHealthBands() { + HealthData health=new HealthData(100,5,2);assertEquals(100,health.getHealth());assertEquals(5,health.getDamage());assertEquals(2,health.getBaseRepairTime());assertEquals(-1,health.getRepairTime());assertFalse(health.isUnderRepair());health.tick();health.startRepair();assertTrue(health.isUnderRepair());assertEquals("0.1s",health.getRepairString());health.tick();assertEquals(1,health.getRepairTime());health.tick();assertFalse(health.isUnderRepair());assertEquals(0,health.getDamage(),"Every configured repair restores at least seven health");health.startRepair();health.stopRepair();assertFalse(health.isUnderRepair());health.setDamage(120);assertEquals(100,health.getDamage());health.setDamage(95);health.damage(20);assertEquals(100,health.getDamage()); + int[] percentages={90,70,50,30,10};String[] colors={"§2","§a","§e","§c","§4"};for(int i=0;i(List.of("alex")));owner.addToWhiteList("sam");owner.removeFromWhiteList("alex");assertEquals(List.of("sam"),owner.getWhiteList());owner.setWhiteListed(false);assertFalse(owner.isWhiteListed());assertFalse(owner.isTicketsEnabled());assertNull(owner.getTicketId());owner.setTicketId(" ");assertNull(owner.getTicketId());owner.setTicketsEnabled(true);String generated=owner.getTicketId();assertNotNull(UUID.fromString(generated));owner.toggleTickets();assertFalse(owner.isTicketsEnabled());owner.toggleTickets();assertEquals(generated,owner.getTicketId());owner.setTicketId("ticket-1");assertEquals("ticket-1",owner.getTicketId());owner.setTicketId(null);assertNull(owner.getTicketId());Cache.allowWhitelist=false;assertFalse(new OwnerData().isWhiteListed());Cache.allowWhitelist=true;Cache.whitelistedByDefault=false;assertFalse(new OwnerData().isWhiteListed());}finally{Cache.allowWhitelist=allow;Cache.whitelistedByDefault=defaults;} + } + + @Test void namingTimeoutAndStoredSummariesRetainTheirPublicValues() { + ActiveVehicle vehicle=r.vehicle();NamingData naming=new NamingData(vehicle);assertSame(vehicle,naming.getVehicle());for(int i=0;i<30;i++)assertFalse(naming.tick());assertTrue(naming.tick());assertTrue(naming.tick());StoredVehicleMeta stored=new StoredVehicleMeta("uuid","Wagon","cart","player_alex");assertEquals("uuid",stored.getUuid());assertEquals("Wagon",stored.getName());assertEquals("cart",stored.getTypeId());assertEquals("player_alex",stored.getOwner());for(String owner:Arrays.asList(null,""," ","none","player_","player_none","company_acme"))assertFalse(StoredVehicleMeta.isPlayerOwner(owner));assertTrue(StoredVehicleMeta.isPlayerOwner("PLAYER_alex"));Location location=new Location(r.world,1,64,2);OwnedVehicleSummary summary=new OwnedVehicleSummary("uuid","Wagon","cart",Optional.of(location),true,"player_alex");assertEquals("uuid",summary.getUuid());assertEquals("Wagon",summary.getName());assertEquals("cart",summary.getTypeId());assertEquals(Optional.of(location),summary.getLocation());assertTrue(summary.isSpawned());assertEquals("player_alex",summary.getOwner());OwnedVehicleSummary unloaded=new OwnedVehicleSummary("id","Stored","cart",null,false);assertFalse(unloaded.isSpawned());assertTrue(unloaded.getLocation().isEmpty());assertNull(unloaded.getOwner()); + } + + @Test void deathConfigurationExposesSoundsFragmentsAndConditionalOverrides()throws Exception { + DeathData empty=new DeathData(VehicleDeath.DIE,Rig.yaml(""));assertEquals(VehicleDeath.DIE,empty.getType());assertEquals(0,empty.getDuration());assertEquals(0,empty.getFragments());assertTrue(empty.getSfx().isEmpty());assertFalse(empty.hasOverrides());DeathData configured=new DeathData(VehicleDeath.EXPLODE,Rig.yaml("duration: 20\nfragments: 3\nsounds: {bang: {sound: custom.bang}}\noverrides:\n water:\n type: sink\n conditions: [state(floating)]\n"));assertEquals(20,configured.getDuration());assertEquals(3,configured.getFragments());assertEquals("custom.bang",configured.getSfx().getFirst().getSound());assertTrue(configured.hasOverrides());assertEquals(VehicleDeath.SINK,configured.getOverrides().getFirst().getDeath());assertEquals(List.of("state(floating)"),configured.getOverrides().getFirst().getConditions()); + } + + @Test void removalPayloadsDistinguishDeathFromUnloadAndRequireDeathCause() { + assertThrows(IllegalArgumentException.class,()->VehicleRemovePayload.death(null));VehicleRemovePayload death=VehicleRemovePayload.death(VehicleDeath.CRASH);assertEquals(VehicleRemoveType.DEATH,death.getType());assertTrue(death.isDeath());assertFalse(death.isRemove());assertEquals(Optional.of(VehicleDeath.CRASH),death.getDeathCause());assertTrue(death.getRemoveReason().isEmpty());VehicleRemovePayload remove=VehicleRemovePayload.remove(VehicleRemoveReason.UNLOAD);assertEquals(VehicleRemoveType.REMOVE,remove.getType());assertTrue(remove.isRemove());assertFalse(remove.isDeath());assertEquals(Optional.of(VehicleRemoveReason.UNLOAD),remove.getRemoveReason());assertTrue(remove.getDeathCause().isEmpty());assertEquals(Optional.of(VehicleRemoveReason.GENERIC),VehicleRemovePayload.remove(null).getRemoveReason()); + } + + @Test void soundConfigurationControlsVolumePitchClampsAndDoppler()throws Exception { + Location source=new Location(r.world,0,64,0);Player player=r.entity(Player.class,new Location(r.world,10,64,0));SoundData regular=new SoundData(Rig.yaml("sound: custom.motor\nvolume: 2\npitch: 1.2\ndelay: 3\n"));assertEquals("custom.motor",regular.getSound());assertEquals(2,regular.getVolume());assertEquals(1.2f,regular.getPitch());assertEquals(3,regular.getDelay());assertFalse(regular.isPitched());assertFalse(regular.isDoppler());regular.playSound(source);regular.playSound(List.of(player),source,2f);regular.playSound(List.of(player),source,new Vector(),2f);verify(r.world,times(2)).playSound(source,"custom.motor",2f,1.2f);verify(player).playSound(source,"custom.motor",2f,1.2f); + SoundData pitched=new SoundData(Rig.yaml("sound: custom.pitched\npitched: true\nmin-pitch: 0.8\nmax-pitch: 1.5\n"));assertTrue(pitched.isPitched());pitched.playSound(List.of(player),source,new Vector(),.1f);pitched.playSound(List.of(player),source,new Vector(),3f);verify(player).playSound(source,"custom.pitched",1f,.8f);verify(player).playSound(source,"custom.pitched",1f,1.5f);pitched.playSound(List.of(player),source,.1f);pitched.playSound(List.of(player),source,3f);verify(r.world).playSound(source,"custom.pitched",1f,.8f);verify(r.world).playSound(source,"custom.pitched",1f,1.5f); + SoundData doppler=new SoundData(Rig.yaml("sound: custom.doppler\npitched: true\ndoppler: true\n"));assertTrue(doppler.isDoppler());doppler.playSound(List.of(player),source,new Vector(-1000,0,0),1);doppler.playSound(List.of(player),source,new Vector(300,0,0),1);verify(player).playSound(source,"custom.doppler",1f,.5f);verify(player).playSound(source,"custom.doppler",1f,2f); + } + + @Test void particleConfigurationUsesOffsetsAndLimitsVisibleDelivery()throws Exception { + ParticleData fallback=new ParticleData(Rig.yaml("particle: missing\n"));assertEquals(Particle.FLAME,fallback.getParticle());ParticleData data=new ParticleData(Rig.yaml("particle: crit\namount: 2\nspread: 0\nspeed: 0.25\nx: 1\ny: 1\nz: 1\n"));assertEquals(Particle.CRIT,data.getParticle());assertEquals(2,data.getAmount());assertEquals(0,data.getSpread());assertEquals(.25,data.getSpeed());Location source=new Location(r.world,0,64,0);data.spawnParticle(null,new Vector(0,0,1));data.spawnParticle(new Location(null,0,0,0),new Vector(0,0,1));data.spawnParticle(source,new Vector(0,0,1));Vector expected=new Vector(1,1,2).normalize();verify(r.world,times(2)).spawnParticle(Particle.CRIT,source,0,expected.getX(),expected.getY(),expected.getZ(),.25,null,true);Player viewer=r.entity(Player.class,source);when(r.world.getPlayers()).thenReturn(List.of(viewer));data.spawnParticleVisible(source,new Vector(0,0,1));verify(viewer,times(2)).spawnParticle(Particle.CRIT,source,0,expected.getX(),expected.getY(),expected.getZ(),.25); + ParticleData negative=new ParticleData(Rig.yaml("amount: 1\nspread: 0\nx: -1\ny: -1\nz: -1\n"));negative.spawnParticle(source,new Vector());Vector backwards=new Vector(-1,-1,-1).normalize();verify(r.world).spawnParticle(Particle.FLAME,source,0,backwards.getX(),backwards.getY(),backwards.getZ(),.4,null,true); + } + + @Test void timedSoundsPreventOverlapAndCopiesStartIdle()throws Exception { + TimedSound sound=new TimedSound(Rig.yaml("sound: timer\nduration: 3\n"));assertFalse(sound.isPlaying());assertEquals(3,sound.getDuration());Location location=new Location(r.world,0,64,0);sound.playSound(location);sound.playSound(location);assertTrue(sound.isPlaying());verify(r.world).playSound(location,"timer",1f,1f);TimedSound copy=new TimedSound(sound);assertSame(sound.getSound(),copy.getSound());assertEquals(3,copy.getDuration());assertFalse(copy.isPlaying());r.ticks(2);assertTrue(sound.isPlaying());r.tick();assertFalse(sound.isPlaying());sound.playSound(location);verify(r.world,times(2)).playSound(location,"timer",1f,1f); + } + + @Test void eventsPreservePayloadsCancellationAndStableHandlerLists() { + ActiveVehicle vehicle=r.vehicle();Player player=r.entity(Player.class,new Location(r.world,0,64,0));VehicleSpawnEvent spawn=new VehicleSpawnEvent(vehicle);assertSame(vehicle,spawn.getVehicle());assertSame(VehicleSpawnEvent.getHandlerList(),spawn.getHandlers());VehicleRemovePayload payload=VehicleRemovePayload.death(VehicleDeath.DIE);VehicleRemoveEvent remove=new VehicleRemoveEvent(vehicle,payload);assertSame(vehicle,remove.getVehicle());assertSame(payload,remove.getPayload());assertSame(VehicleRemoveEvent.getHandlerList(),remove.getHandlers());assertTrue(new VehicleRemoveEvent(vehicle,null).getPayload().isRemove());VehicleOwnerClaimedEvent owner=new VehicleOwnerClaimedEvent(player,vehicle,"none","player_alex");assertSame(player,owner.getPlayer());assertSame(vehicle,owner.getVehicle());assertEquals("none",owner.getPreviousOwner());assertEquals("player_alex",owner.getNewOwner());assertFalse(owner.isCancelled());owner.setCancelled(true);assertTrue(owner.isCancelled());assertSame(VehicleOwnerClaimedEvent.getHandlerList(),owner.getHandlers());VehiclePreInteractEvent interact=new VehiclePreInteractEvent(player,vehicle);assertSame(vehicle,interact.getVehicle());assertFalse(interact.isCancelled());interact.setCancelled(true);assertTrue(interact.isCancelled());assertSame(VehiclePreInteractEvent.getHandlerList(),interact.getHandlers());VehicleRepairStartEvent repair=new VehicleRepairStartEvent(player,vehicle);assertSame(vehicle,repair.getVehicle());assertFalse(repair.isCancelled());repair.setCancelled(true);assertTrue(repair.isCancelled());assertSame(VehicleRepairStartEvent.getHandlerList(),repair.getHandlers());VFEntityDamageEvent damage=new VFEntityDamageEvent(vehicle.getEntity(),player,"FIRE",10);assertSame(vehicle.getEntity(),damage.getEntity());assertSame(player,damage.getDamager());assertEquals("FIRE",damage.getCause());assertEquals(10,damage.getDamage());damage.setDamage(5);assertEquals(5,damage.getDamage());assertFalse(damage.isCancelled());damage.setCancelled(true);assertTrue(damage.isCancelled());assertSame(VFEntityDamageEvent.getHandlerList(),damage.getHandlers());boolean before=Cache.blockDamage;try{Cache.blockDamage=true;Location center=player.getLocation();VFExplosionEvent explosion=new VFExplosionEvent(center);assertSame(center,explosion.getLocation());assertTrue(explosion.doesBlockDamage());explosion.setBlockDamage(false);assertFalse(explosion.doesBlockDamage());assertFalse(explosion.isCancelled());explosion.setCancelled(true);assertTrue(explosion.isCancelled());assertSame(VFExplosionEvent.getHandlerList(),explosion.getHandlers());}finally{Cache.blockDamage=before;} + } + + @Test void cacheCleanupRemovesProjectilesAndOnlyTemporaryLights() { + Set old=new HashSet<>(Cache.lightLocations);try{new Cache();Entity projectile=r.entity(Entity.class,new Location(r.world,0,64,0));Cache.projectiles.add(null);Cache.projectiles.add(projectile);Cache.removeProjectiles();verify(projectile).remove();Location light=new Location(r.world,0,64,0),stone=new Location(r.world,1,64,0);r.block(0,64,0,Material.LIGHT);r.block(1,64,0,Material.STONE);Cache.lightLocations.clear();Cache.lightLocations.addAll(List.of(light,stone));Cache.removeLights();assertEquals(Material.AIR,light.getBlock().getType());assertEquals(Material.STONE,stone.getBlock().getType());}finally{Cache.lightLocations.clear();Cache.lightLocations.addAll(old);} + } + + @Test void applyingTrackStylePublishesTheConfiguredModelsAndOffset() { + String small=Cache.trackItemSmall,medium=Cache.trackItemMedium,large=Cache.trackItemLarge,appliedSmall=Cache.appliedTrackItemSmall,appliedMedium=Cache.appliedTrackItemMedium,appliedLarge=Cache.appliedTrackItemLarge;double offset=Cache.trackDisplayYOffset,appliedOffset=Cache.appliedTrackDisplayYOffset; + try{Cache.trackItemSmall="small";Cache.trackItemMedium="medium";Cache.trackItemLarge="large";Cache.trackDisplayYOffset=.75;Cache.applyTrackDisplayStyle();assertEquals("small",Cache.appliedTrackItemSmall);assertEquals("medium",Cache.appliedTrackItemMedium);assertEquals("large",Cache.appliedTrackItemLarge);assertEquals(.75,Cache.appliedTrackDisplayYOffset);}finally{Cache.trackItemSmall=small;Cache.trackItemMedium=medium;Cache.trackItemLarge=large;Cache.appliedTrackItemSmall=appliedSmall;Cache.appliedTrackItemMedium=appliedMedium;Cache.appliedTrackItemLarge=appliedLarge;Cache.trackDisplayYOffset=offset;Cache.appliedTrackDisplayYOffset=appliedOffset;} + } + + @Test void loggerRoutesSeverityPluginNamesAndOnlineCreatorMessages() { + r.close();r=null;try(var bukkit=mockStatic(Bukkit.class)){ + Logger logger=mock(Logger.class);bukkit.when(Bukkit::getLogger).thenReturn(logger);JavaPlugin plugin=mock(JavaPlugin.class);when(plugin.getName()).thenReturn("TestPlugin");Player player=mock(Player.class);new VFLogger();VFLogger.log("broken");VFLogger.info("ready");VFLogger.log(plugin,"bad config");VFLogger.info(plugin,"loaded");verify(logger).warning("[VehicleFramework] Error! broken");verify(logger).info("[VehicleFramework] ready");verify(logger).warning("[TestPlugin] Error! bad config");verify(logger).info("[TestPlugin] loaded");VFLogger.message(player,"hello");verify(player).sendMessage("§a[VehicleFramework] §ehello");VFLogger.creatorLog("first");bukkit.when(()->Bukkit.getPlayerExact("drefvelin")).thenReturn(player);VFLogger.creatorLog("offline");verify(player,never()).sendMessage(contains("offline"));when(player.isOnline()).thenReturn(true);VFLogger.creatorLog("trace");verify(player).sendMessage("§a[VFLogger]§e: §btrace"); + } + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseLoggingCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseLoggingCoverageTest.java new file mode 100644 index 0000000..913dbba --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseLoggingCoverageTest.java @@ -0,0 +1,175 @@ +package net.tfminecraft.vehicleframework.database; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.*; +import java.nio.file.*; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.*; +import java.util.stream.Stream; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.entity.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.*; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.enums.SeatType; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.seat.Seat; + +class DatabaseLoggingCoverageTest { + @TempDir Path folder; + private final World world = mock(World.class); + private final List vehicles = new ArrayList<>(); + + @BeforeEach void world() { + when(world.getName()).thenReturn("harbour"); Chunk chunk = mock(Chunk.class); + when(chunk.getX()).thenReturn(2); when(chunk.getZ()).thenReturn(-3); when(chunk.isLoaded()).thenReturn(true); + when(world.getChunkAt(any(Location.class))).thenReturn(chunk); + PersistenceLog.configure(false, folder.toFile()); + } + @AfterEach void resetLogging() { + PersistenceLog.configure(false, folder.toFile()); for (ActiveVehicle vehicle : vehicles) PersistenceLog.unload(vehicle, "test cleanup"); + } + + @Test void countingLogLinesClosesTheFilesystemStream() throws Exception { + LogWriter log = new LogWriter(folder.toFile()); Path file = folder.resolve("logs/log.txt"); AtomicBoolean closed = new AtomicBoolean(); + try (Stream stream = Files.lines(file).onClose(() -> closed.set(true)); MockedStatic files = mockStatic(Files.class, call -> { + if (call.getMethod().getName().equals("lines") && call.getArguments().length == 1 && file.equals(call.getArgument(0))) return stream; + return call.callRealMethod(); + })) { + log.logEntry("resource probe"); + assertTrue(closed.get(), "Line-count stream must close after each logged entry"); + } + assertTrue(Files.readString(file).contains("resource probe")); + } + + @Test void logRotationPreservesAllLinesAndAvoidsSameDayArchiveCollisions() throws Exception { + LogWriter writer = new LogWriter(folder.toFile()); Path current = folder.resolve("logs/log.txt"); + assertTrue(Files.exists(folder.resolve("logs/older"))); new LogWriter(folder.toFile()); + writer.logEntry("first"); assertTrue(Files.readString(current).contains("] first")); + String date = new SimpleDateFormat("dd-MM-yyyy").format(new Date()); Path archived = folder.resolve("logs/older/log_" + date + ".txt"); + Files.writeString(current, "preserved\n".repeat(400)); writer.logEntry("last in first archive"); + assertEquals(401, Files.readAllLines(archived).size()); assertEquals(0, Files.size(current)); + Files.writeString(current, "second\n".repeat(400)); writer.logEntry("last in second archive"); + Path second = folder.resolve("logs/older/log_" + date + "_1.txt"); + assertEquals(401, Files.readAllLines(second).size()); assertEquals(0, Files.size(current)); + assertTrue(Files.readString(archived).contains("last in first archive")); + } + + @Test void logWriteAndRotationFailuresPreserveTheExistingFiles() throws Exception { + Files.createDirectories(folder.resolve("logs")); Files.writeString(folder.resolve("logs/older"), "directory obstruction"); + LogWriter writer = new LogWriter(folder.toFile()); Path current = folder.resolve("logs/log.txt"); + Files.writeString(current, "entry\n".repeat(400)); assertDoesNotThrow(() -> writer.logEntry("rotation fails")); + assertEquals(401, Files.readAllLines(current).size()); assertEquals("directory obstruction", Files.readString(folder.resolve("logs/older"))); + Files.delete(current); Files.createDirectory(current); assertDoesNotThrow(() -> writer.logEntry("cannot append to directory")); + Path obstruction = folder.resolve("plugin-is-file"); Files.writeString(obstruction, "do not replace"); + assertDoesNotThrow(() -> new LogWriter(obstruction.toFile())); assertEquals("do not replace", Files.readString(obstruction)); + } + + @Test void optionalCoreProtectIntegrationReportsEntryExitPlacementAndRemoval() { + boolean previous = Cache.coreProtect; Player pilot = player(); ActiveVehicle vehicle = vehicle(); Block block = mock(Block.class); + when(block.getLocation()).thenReturn(location(32, 64, -48)); when(block.getType()).thenReturn(Material.OAK_PLANKS); + try (var framework = mockStatic(VehicleFramework.class, RETURNS_DEEP_STUBS)) { + var core = VehicleFramework.getCoreProtect(); Cache.coreProtect = false; + LogWriter.logEnter(pilot, vehicle); LogWriter.logExit(pilot, vehicle); LogWriter.logPlace("pilot", block); LogWriter.logBreak("pilot", block); + verifyNoInteractions(core); Cache.coreProtect = true; + LogWriter.logEnter(pilot, vehicle); LogWriter.logExit(pilot, vehicle); LogWriter.logPlace("pilot", block); LogWriter.logBreak("pilot", block); + verify(core, times(2)).logInteraction("Pilot", vehicle.getEntity().getLocation()); verify(core).logChat(pilot, "+tug"); verify(core).logChat(pilot, "-tug"); + verify(core).logPlacement("pilot", block.getLocation(), Material.OAK_PLANKS, null); verify(core).logRemoval("pilot", block.getLocation(), Material.OAK_PLANKS, null); + } finally { Cache.coreProtect = previous; } + } + + @Test void persistenceLogAppendsAcrossConfigurationAndDescribesPositionsAndPlayers() throws Exception { + assertFalse(PersistenceLog.isEnabled()); PersistenceLog.append("disabled"); assertFalse(Files.exists(logFile())); + PersistenceLog.configure(true, folder.toFile()); assertTrue(PersistenceLog.isEnabled()); PersistenceLog.append(null); + PersistenceLog.append("before reload"); PersistenceLog.configure(true, null); PersistenceLog.append("after reload"); + String log = log(); assertTrue(log.contains("before reload")); assertTrue(log.contains("after reload")); + assertEquals("null", PersistenceLog.xyz(null)); assertEquals("null", PersistenceLog.xyz(new Location(null, 0, 0, 0))); + assertEquals("1.250,2.500,-3.750", PersistenceLog.xyz(1.25, 2.5, -3.75)); + assertTrue(PersistenceLog.xyz(location(32.25, 64, -48.5)).contains("harbour 32.250,64.000,-48.500 yaw=0.000 pitch=0.000 chunk=2,-3 loaded=true")); + assertEquals("player=null", PersistenceLog.player(null)); Player pilot = player(); assertTrue(PersistenceLog.player(pilot).contains("bukkitVehicle=none")); + Entity mount = mock(Entity.class); UUID mountId = UUID.randomUUID(); when(mount.getUniqueId()).thenReturn(mountId); when(pilot.getVehicle()).thenReturn(mount); + when(pilot.isInsideVehicle()).thenReturn(true); assertTrue(PersistenceLog.player(pilot).contains("inside=true bukkitVehicle=" + mountId)); + assertEquals("vehicle=null", PersistenceLog.vehicle(null)); ActiveVehicle vehicle = vehicle(); + assertTrue(PersistenceLog.vehicle(vehicle).contains("train=false")); when(vehicle.getSeatHandler()).thenReturn(null); + when(vehicle.getEntity()).thenReturn(null); assertTrue(PersistenceLog.vehicle(vehicle).contains("entity=null dead=true train=false vfPassengers=0")); + } + + @Test void trainSummariesUseLiveRelationsOrPendingIdentifiers() { + ActiveVehicle vehicle = vehicle(); when(vehicle.isTrain()).thenReturn(true); var train = vehicle.getTrainHandler(); + UUID track = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + when(train.isBound()).thenReturn(true); when(train.getSplineId()).thenReturn(track); when(train.getS()).thenReturn(4.5); when(train.getTravelSign()).thenReturn(-1); + when(train.getPendingParent()).thenReturn("pending-parent"); when(train.getPendingChild()).thenReturn("pending-child"); + String pending = PersistenceLog.vehicle(vehicle); assertTrue(pending.contains("bound=true spline=" + track + " s=4.500 sign=-1 parent=pending-parent child=pending-child")); + when(vehicle.hasParent()).thenReturn(true); when(vehicle.getParent().getUUID()).thenReturn("live-parent"); + when(train.hasChild()).thenReturn(true); when(train.getChild().getUUID()).thenReturn("live-child"); + assertTrue(PersistenceLog.vehicle(vehicle).contains("parent=live-parent child=live-child liveParent=true liveChild=true")); + } + + @Test void lifecycleAndMountEventsRecordActionDetailsAndSpawnCycle() throws Exception { + PersistenceLog.configure(true, folder.toFile()); ActiveVehicle vehicle = vehicle(); Player pilot = player(); Seat seat = new Seat(SeatType.CAPTAIN, "driver"); + int before = PersistenceLog.spawnCycle(); PersistenceLog.spawnManagerStart(3); assertEquals(before + 1, PersistenceLog.spawnCycle()); + assertEquals(before + 2, PersistenceLog.nextSpawnCycle()); Location here = location(32, 64, -48); + PersistenceLog.spawnLoad("cart.json", here); PersistenceLog.spawnQueued("cart.json", here, "harbour:2,-3"); PersistenceLog.spawned(vehicle, here); + PersistenceLog.saveVehicle(vehicle, here); PersistenceLog.saveSpawn("cart.json", here); + PersistenceLog.loadJson("cart.json", "uuid", "tug", null); PersistenceLog.loadJson("cart.json", "uuid", "tug", ConsistData.unbound()); + PersistenceLog.loadJson("cart.json", "uuid", "tug", new ConsistData("p", "c", "track", 4.5, -1)); + PersistenceLog.tryLink("reload", vehicle); PersistenceLog.mount(pilot, vehicle, seat, true, true); PersistenceLog.mount(null, null, null, false, false); + PersistenceLog.remount(pilot, vehicle, "driver"); PersistenceLog.remount(vehicle.getEntity(), vehicle, "harness"); + PersistenceLog.dismount(pilot, vehicle, true); PersistenceLog.dismount(vehicle.getEntity(), vehicle, false); + PersistenceLog.unload(null, "absent"); PersistenceLog.unload(vehicle, "chunk unloaded"); + String log = log(); + for (String marker : List.of("SPAWN_MANAGER_START", "SPAWN_LOAD", "SPAWN_QUEUED", "SPAWNED", "SAVE_VEHICLE", "SAVE_SPAWN", "LOAD_JSON", "TRY_LINK", "MOUNT", "REMOUNT", "DISMOUNT", "UNLOAD")) assertTrue(log.contains(marker), marker); + assertTrue(log.contains("consist=parent=p child=c spline=track s=4.5 sign=-1")); assertTrue(log.contains("seat=driver type=CAPTAIN manager=true meSeatMap=true")); + } + + @Test void posePlacementAndParticleLogsThrottleNoiseAndResetWhenVehicleUnloads() throws Exception { + PersistenceLog.configure(true, folder.toFile()); ActiveVehicle vehicle = vehicle(); when(vehicle.getSpawnTime()).thenReturn(System.currentTimeMillis() - 30000); + Location here = location(32, 64, -48), near = here.clone().add(.1, 0, 0), far = here.clone().add(2, 0, 0); + PersistenceLog.placeCars(null); PersistenceLog.applyPose(null, "ignored", here, far); PersistenceLog.particleOffset(null, here, far); + ActiveVehicle unidentified = mock(ActiveVehicle.class); PersistenceLog.particleOffset(unidentified, here, far); + for (int i = 0; i < 22; i++) { PersistenceLog.placeCars(vehicle); PersistenceLog.applyPose(vehicle, "settle", here, near); } + assertEquals(20, count("PLACE_CARS")); assertEquals(20, count("APPLY_POSE")); + PersistenceLog.applyPose(vehicle, "large correction", here, far); assertEquals(21, count("APPLY_POSE")); + PersistenceLog.particleOffset(vehicle, here, near); PersistenceLog.particleOffset(vehicle, here, far); PersistenceLog.particleOffset(vehicle, here, far); + assertEquals(1, count("SMOKE_OFFSET")); PersistenceLog.unload(vehicle, "reload"); + PersistenceLog.placeCars(vehicle); PersistenceLog.applyPose(vehicle, "reload", here, near); PersistenceLog.particleOffset(vehicle, here, far); + assertEquals(21, count("PLACE_CARS")); assertEquals(22, count("APPLY_POSE")); assertEquals(2, count("SMOKE_OFFSET")); + PersistenceLog.applyPose(vehicle, "null endpoints", null, here); PersistenceLog.applyPose(vehicle, "worldless", new Location(null, 1, 2, 3), here); + World other = mock(World.class); Chunk otherChunk = mock(Chunk.class); when(other.getChunkAt(any(Location.class))).thenReturn(otherChunk); + PersistenceLog.applyPose(vehicle, "other world", new Location(other, 1, 2, 3), here); assertTrue(log().contains("gap=-1.000")); + when(vehicle.getEntity()).thenReturn(null); PersistenceLog.applyPose(vehicle, "removed entity", here, far); assertTrue(log().contains("after=null")); + } + + @Test void filesystemFailureIsReportedWithoutCrashingTheCaller() throws Exception { + Path obstruction = folder.resolve("logs"); Files.writeString(obstruction, "keep this file"); + Logger logger = Logger.getLogger(PersistenceLog.class.getName()); List records = new ArrayList<>(); + Handler capture = new Handler() { public void publish(LogRecord record) { records.add(record); } public void flush() {} public void close() {} }; + logger.addHandler(capture); + try { assertDoesNotThrow(() -> PersistenceLog.configure(true, folder.toFile())); } finally { logger.removeHandler(capture); } + assertTrue(records.stream().anyMatch(record -> record.getMessage().contains("Failed to write persistence.log"))); + assertEquals("keep this file", Files.readString(obstruction)); + } + + private ActiveVehicle vehicle() { + ActiveVehicle vehicle = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); Entity entity = mock(Entity.class); + when(vehicle.getUUID()).thenReturn(UUID.randomUUID().toString()); when(vehicle.getId()).thenReturn("tug"); when(vehicle.getName()).thenReturn("Tug"); + when(vehicle.getSpawnTime()).thenReturn(System.currentTimeMillis()); when(vehicle.getEntity()).thenReturn(entity); + when(entity.getUniqueId()).thenReturn(UUID.randomUUID()); when(entity.getLocation()).thenReturn(location(32, 64, -48)); when(entity.getPassengers()).thenReturn(List.of()); + when(vehicle.getSeatHandler().getPassengers()).thenReturn(List.of()); vehicles.add(vehicle); return vehicle; + } + private Player player() { + Player player = mock(Player.class); when(player.getName()).thenReturn("Pilot"); when(player.getLocation()).thenReturn(location(32, 64, -48)); return player; + } + private Location location(double x, double y, double z) { return new Location(world, x, y, z); } + private Path logFile() { return folder.resolve("logs/persistence.log"); } + private String log() throws IOException { return Files.readString(logFile()); } + private long count(String marker) throws IOException { return log().lines().filter(line -> line.contains(marker)).count(); } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseRuntimeCoverageTest.java new file mode 100644 index 0000000..80c1236 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseRuntimeCoverageTest.java @@ -0,0 +1,223 @@ +package net.tfminecraft.vehicleframework.database; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.google.gson.JsonParser; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.data.HealthData; +import net.tfminecraft.vehicleframework.enums.Component; +import net.tfminecraft.vehicleframework.loaders.VehicleLoader; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.Vehicle; +import net.tfminecraft.vehicleframework.vehicles.component.VehicleComponent; + +class DatabaseRuntimeCoverageTest { + @TempDir Path directory; + static final String ID = VehiclePayloadCoverageTest.ID; + static final String PAYLOAD = "{\"id\":\"cart\",\"owner\":\"player_Alice\",\"whitelisted\":true,\"whitelist\":[\"Bob\"],\"components\":{\"hull\":{\"damage\":5}}}"; + + @Test void liveSaveReturnsPreciseStoredSavedAndFailureOutcomes() { + VehicleRepository repository = mock(VehicleRepository.class); + VehiclePersistence persistence = new VehiclePersistence(repository); + ActiveVehicle live = VehiclePayloadCoverageTest.liveVehicle(); + assertSame(repository, persistence.repository()); + when(repository.saveLive(any())).thenReturn(true); + assertTrue(persistence.saveLive(live)); + when(repository.saveLive(any())).thenReturn(false); + assertTrue(persistence.saveLiveResult(live).isFailed()); + when(repository.findLive(ID)).thenReturn(Optional.of(snapshot(PAYLOAD))); + assertTrue(persistence.saveLiveResult(live).isAlreadyStored()); + doThrow(new IllegalStateException()).when(repository).saveLive(any()); + assertEquals("SQLite save failed", persistence.saveLiveResult(live).reason()); + doThrow(new IllegalStateException("locked")).when(repository).saveLive(any()); + assertEquals("SQLite save failed: locked", persistence.saveLiveResult(live).reason()); + when(live.getEntity()).thenReturn(null); + assertEquals("entity invalid", persistence.saveLiveResult(live).reason()); + assertTrue(persistence.saveLiveResult(null).isFailed()); + doThrow(new IllegalStateException()).when(repository).findLive(ID); + assertTrue(persistence.resolveFailedLiveSave(ID, " ").isFailed()); + assertTrue(persistence.resolveFailedLiveSave(" ", null).reason().contains("unknown")); + assertTrue(VehiclePersistResult.failed(null).isFailed()); + assertEquals("unknown", VehiclePersistResult.failed(" ").reason()); + try (var framework = mockStatic(VehicleFramework.class)) { + assertNull(VehiclePersistence.current()); + framework.when(VehicleFramework::getVehicleRepository).thenReturn(repository); + assertSame(repository, VehiclePersistence.current().repository()); + } + } + + @Test void missingRepositoryAndClosedDatabaseFailWithoutMutatingOrThrowing() { + VehiclePersistence missing = new VehiclePersistence(null); + assertTrue(missing.saveLiveResult(null).isFailed()); + assertFalse(missing.saveLive(snapshot(PAYLOAD))); + assertFalse(missing.tombstone(ID)); + assertFalse(missing.checkpointWal(true)); + assertFalse(missing.vacuumIntoBackup()); + VehicleRepository repository = VehicleRepository.open(directory.resolve("closed.db").toFile()); + repository.saveLive(snapshot(PAYLOAD)); + repository.close(); + VehiclePersistence closed = new VehiclePersistence(repository); + try (var logger = mockStatic(VFLogger.class)) { + logger.when(() -> VFLogger.log(anyString())).thenThrow(new IllegalStateException("logger unavailable")); + assertTrue(closed.loadIncomplete(ID).isEmpty()); + assertFalse(closed.tombstone(ID)); + assertFalse(closed.checkpointWal(false)); + assertTrue(closed.findChunk("world", 0, 0).isEmpty()); + assertTrue(closed.findLive(ID).isEmpty()); + assertTrue(closed.countByOwner("player_Alice", List.of()).isEmpty()); + assertTrue(closed.listByOwner("player_Alice").isEmpty()); + assertTrue(closed.listPlayerOwned().isEmpty()); + assertTrue(closed.readMeta(ID).isEmpty()); + assertFalse(closed.clearOwnership(ID)); + assertFalse(closed.applyStoredDecay(ID, .1, .03)); + assertFalse(closed.saveLive(snapshot(PAYLOAD))); + } + VehicleRepository broken = mock(VehicleRepository.class); + when(broken.hasLiveInChunk(anyString(), anyInt(), anyInt())).thenThrow(new IllegalStateException()); + assertFalse(new VehiclePersistence(broken).hasLiveInChunk("world", 0, 0)); + } + + @Test void ownershipAndDecayChangesCommitTogetherAndSurviveRestart() { + Path file = directory.resolve("mutations.db"); + VehicleRepository repository = VehicleRepository.open(file.toFile()); + try (var templates = mockStatic(VehicleLoader.class)) { + VehiclePersistence persistence = new VehiclePersistence(repository); + assertFalse(persistence.clearOwnership("missing")); + assertFalse(persistence.applyStoredDecay("missing", .1, .03)); + assertTrue(persistence.loadIncomplete("missing").isEmpty()); + assertFalse(persistence.tombstone(null)); assertFalse(persistence.tombstone(" ")); + repository.saveLive(snapshot(PAYLOAD)); + assertFalse(persistence.applyStoredDecay(ID, .1, .03)); + Vehicle template = mock(Vehicle.class, RETURNS_DEEP_STUBS); + VehicleComponent hull = mock(VehicleComponent.class); + when(hull.getType()).thenReturn(Component.HULL); when(hull.getHealthData()).thenReturn(new HealthData(100, 0, 1)); + when(template.getComponentHandler().getComponents()).thenReturn(List.of(hull)); + templates.when(() -> VehicleLoader.getByString("cart")).thenReturn(template); + assertTrue(persistence.applyStoredDecay(ID, 0, .03)); + assertEquals(1, repository.findLive(ID).orElseThrow().getRevision()); + assertTrue(persistence.applyStoredDecay(ID, .1, .03)); + assertTrue(persistence.clearOwnership(ID)); + assertTrue(persistence.checkpointWal(false)); assertTrue(persistence.checkpointWal(true)); + } finally { repository.close(); } + repository = VehicleRepository.open(file.toFile()); + try { + VehicleSnapshot row = repository.findLive(ID).orElseThrow(); + var payload = JsonParser.parseString(row.getPayloadJson()).getAsJsonObject(); + assertEquals(3, row.getRevision()); assertEquals("none", row.getOwner()); + assertEquals("none", payload.get("owner").getAsString()); + assertFalse(payload.get("whitelisted").getAsBoolean()); + assertTrue(payload.getAsJsonArray("whitelist").isEmpty()); + assertEquals(15, payload.getAsJsonObject("components").getAsJsonObject("hull").get("damage").getAsDouble()); + repository.saveLive(snapshot("{")); + VehiclePersistence persistence = new VehiclePersistence(repository); + assertFalse(persistence.clearOwnership(ID)); + assertFalse(persistence.applyStoredDecay(ID, .1, .03)); + assertEquals("{", repository.findLive(ID).orElseThrow().getPayloadJson()); + } finally { repository.close(); } + } + + @Test void tombstoneAndBackupFailuresKeepTheirReturnContracts() { + VehicleRepository repository = mock(VehicleRepository.class); + VehiclePersistence persistence = new VehiclePersistence(repository); + when(repository.find(ID)).thenReturn(Optional.of(snapshot(PAYLOAD))); + assertFalse(persistence.tombstone(ID)); + VehicleFramework previous = VehicleFramework.plugin; + try { + VehicleFramework.plugin = null; + assertFalse(persistence.vacuumIntoBackup()); + VehicleFramework.plugin = mock(VehicleFramework.class); + assertFalse(persistence.vacuumIntoBackup()); + when(VehicleFramework.plugin.getDataFolder()).thenReturn(directory.toFile()); + when(repository.vacuumIntoBackup(any())).thenReturn(true); + assertTrue(persistence.vacuumIntoBackup()); + doThrow(new IllegalStateException()).when(repository).vacuumIntoBackup(any()); + assertFalse(persistence.vacuumIntoBackup()); + } finally { VehicleFramework.plugin = previous; } + } + + @Test void repositoryFiltersInvalidRowsAndRebuildsOccupancyOnRollback() throws Exception { + VehicleRepository repository = VehicleRepository.open(directory.resolve("filters.db").toFile()); + try { + repository.upsert(null); assertFalse(repository.saveLive(null)); + VehicleSnapshot invalid = new VehicleSnapshot(null, "cart", null, null, "world", 0, 0, 0, 0, 0, 0, "{}", 2, 1, false, 1); + repository.upsert(invalid); assertFalse(repository.saveLive(invalid)); + for (String absent : Arrays.asList(null, " ")) { + assertTrue(repository.find(absent).isEmpty()); + assertTrue(repository.findLive(absent).isEmpty()); + assertTrue(repository.findNear(absent,0,0,1).isEmpty()); + assertTrue(repository.listLiveByOwner(absent).isEmpty()); + assertEquals(0,repository.tombstone(absent)); + assertEquals(0,repository.tombstone(absent,1,1)); + } + repository.runInTransaction(null); + repository.upsert(new VehicleSnapshot("legacy-untyped", " ", "Unknown", "player_Alice", "world", 0,64,0,0,0,0,"{}",1,1,false,1)); + repository.saveLive(snapshot(PAYLOAD)); + assertTrue(repository.countLiveByOwner(null, null).isEmpty()); + assertTrue(repository.countLiveByOwner(" ", null).isEmpty()); + assertTrue(repository.countLiveByOwner("player_Alice", Arrays.asList(null, " ", ID.toUpperCase(Locale.ROOT))).isEmpty()); + assertEquals(Map.of("cart", 1), repository.countLiveByOwner("player_Alice", null)); + assertThrows(IllegalStateException.class, () -> repository.runInTransaction(() -> { + repository.tombstone(ID); + throw new IllegalStateException("rollback"); + })); + assertTrue(repository.hasLiveInChunk("world", 0, 0)); + assertFalse(repository.vacuumIntoBackup(null)); + assertEquals(1, repository.findLive(ID).orElseThrow().getSchemaVersion()); + assertEquals(1,repository.tombstone(ID)); + try (var connection=java.sql.DriverManager.getConnection("jdbc:sqlite:"+directory.resolve("filters.db")); + var statement=connection.createStatement()) { + statement.execute("CREATE TRIGGER reject_writes BEFORE INSERT ON vehicles BEGIN SELECT RAISE(IGNORE); END"); + } + assertFalse(repository.saveLive(snapshot(PAYLOAD)), "A database-side rejection must not report persistence"); + assertTrue(repository.find(ID).orElseThrow().isDeleted()); + assertEquals(1,repository.findChunk("world",0,0).size(), "Only the unrelated stored row should remain live"); + } finally { repository.close(); } + } + + @Test void chunkIndexClearsDeletedAndInvalidLocationsWithoutLeakingCounts() { + OccupiedChunkIndex index = new OccupiedChunkIndex(); + index.replace(null); index.putLive((VehicleSnapshot) null); index.putLive((OccupiedChunkIndex.LiveLocation) null); + index.putLive(null, "world", 0, 0); index.putLive(" ", "world", 0, 0); index.putLive(ID, null, 0, 0); index.putLive(ID, " ", 0, 0); + assertEquals(0, index.count(null, 0, 0)); assertEquals(0, index.count(" ", 0, 0)); + index.putLive(snapshot(PAYLOAD)); + index.putLive(new VehicleSnapshot(ID, "cart", "Cart", "none", "world", 0, 0, 0, 0, 0, 0, "{}", 1, 1, true, 1)); + assertFalse(index.isOccupied("world", 0, 0)); index.remove(null); index.remove(" "); + } + + @Test void failureLoggingAndRevisionCopiesPreserveUsefulState() { + VehicleRepository repository = mock(VehicleRepository.class); + when(repository.findChunk("world", 0, 0)).thenThrow(new IllegalStateException("offline")); + try (var logger = mockStatic(VFLogger.class)) { + assertTrue(new VehiclePersistence(repository).findChunk("world", 0, 0).isEmpty()); + logger.verify(() -> VFLogger.log("SQLite chunk query failed: offline")); + } + VehicleSnapshot original = snapshot(PAYLOAD), next = original.withRevision(4, 123); + assertEquals(4, next.getRevision()); assertEquals(123, next.getUpdatedAt()); + assertEquals(original.getPayloadJson(), next.getPayloadJson()); + assertEquals(original.getUuid(), next.getUuid()); assertEquals(original.getOwner(), next.getOwner()); + assertEquals(1, original.getRevision()); + assertTrue(new IncompleteComponent(Component.HULL, 1, 0, 0).hasDamage()); + assertFalse(new IncompleteComponent(Component.HULL, 0, 0, 0).hasDamage()); + } + + @Test void legacyConsistStringsAndMissingDataRemainCompatible() { + ConsistData empty = ConsistData.fromJson(null); + assertTrue(empty.isUnbound()); assertDoesNotThrow(() -> empty.put(null)); + ConsistData legacy = ConsistData.fromJson(VehiclePayloadCoverageTest.object( + "{\"splineId\":\"track\",\"s\":\"12.5\",\"travelSign\":\"-1\",\"junction\":\"switch\",\"diverge\":\"true\"}")); + assertEquals(12.5, legacy.getS()); assertEquals(-1, legacy.getTravelSign()); assertTrue(legacy.isDiverge()); + ConsistData invalid = ConsistData.fromJson(VehiclePayloadCoverageTest.object( + "{\"splineId\":\"track\",\"s\":\"invalid\",\"travelSign\":\"invalid\"}")); + assertNull(invalid.getS()); assertEquals(1, invalid.getTravelSign()); + } + + static VehicleSnapshot snapshot(String payload) { + return new VehicleSnapshot(ID, "cart", "Cart", "player_Alice", "world", 0, 64, 0, 45, 0, 0, payload, 1, 1, false, 1); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/database/VehicleBackupCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/database/VehicleBackupCoverageTest.java new file mode 100644 index 0000000..0926472 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/database/VehicleBackupCoverageTest.java @@ -0,0 +1,293 @@ +package net.tfminecraft.vehicleframework.database; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.*; +import java.nio.file.*; +import java.nio.file.attribute.FileTime; +import java.sql.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Stream; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.*; +import net.tfminecraft.tlibs.database.SqliteDatabase; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; +import net.tfminecraft.vehicleframework.VFLogger; + +class VehicleBackupCoverageTest { + @TempDir Path folder; + + @Test void failedRestoreLeavesOriginalDatabaseAndItsSidecarsUntouched() throws Exception { + Path live = folder.resolve("vehicles.db"); Files.writeString(live, "recoverable original bytes"); + Path wal = folder.resolve("vehicles.db-wal"), shm = folder.resolve("vehicles.db-shm"); Files.writeString(wal, "wal bytes"); Files.writeString(shm, "shm bytes"); + assertThrows(IOException.class, () -> VehicleSqliteBackup.replaceLive(live.toFile(), folder.resolve("missing.db").toFile())); + assertTrue(Files.exists(live), "Failed backup reads must not remove the live database"); assertEquals("recoverable original bytes", Files.readString(live)); + assertEquals("wal bytes", Files.readString(wal)); assertEquals("shm bytes", Files.readString(shm)); + } + + @Test void backupValidationRejectsEmptyFilesWithoutInitializingThem() throws Exception { + Path empty = Files.createFile(folder.resolve("vehicles-empty.db")); + assertFalse(VehicleSqliteBackup.isValidDatabase(empty.toFile()), "A zero-byte file is not a usable vehicle backup"); assertEquals(0, Files.size(empty)); + } + + @Test void backupValidationRejectsOtherSqliteDatabasesWithoutCreatingVehicleTables() throws Exception { + Path unrelated = folder.resolve("vehicles-unrelated.db"); + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + unrelated); Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE unrelated(value TEXT)"); statement.execute("INSERT INTO unrelated VALUES ('preserve')"); + } + byte[] original = Files.readAllBytes(unrelated); assertFalse(VehicleSqliteBackup.isValidDatabase(unrelated.toFile())); + assertArrayEquals(original, Files.readAllBytes(unrelated)); + } + + @Test void restoreSelectsAnOlderRealBackupOverANewerEmptySnapshot() throws Exception { + Path backups = Files.createDirectory(folder.resolve("backups")); File good = database(backups.resolve("vehicles-20260101-000000.db")); + Path empty = Files.createFile(backups.resolve("vehicles-20260102-000000.db")); + Files.setLastModifiedTime(good.toPath(), FileTime.fromMillis(1000)); Files.setLastModifiedTime(empty, FileTime.fromMillis(2000)); + assertEquals(good, VehicleSqliteBackup.findNewestValid(backups.toFile())); assertEquals(0, Files.size(empty)); + } + + @Test void newestBackupUsesSequenceNumbersWhenFilesystemTimestampsTie() throws Exception { + Path backups = Files.createDirectory(folder.resolve("backups")); File old = database(backups.resolve("vehicles-20260101-000000.db")); + File newest = backups.resolve("vehicles-20260101-000000-10.db").toFile(); Files.copy(old.toPath(), newest.toPath()); + Files.setLastModifiedTime(old.toPath(), FileTime.fromMillis(1000)); Files.setLastModifiedTime(newest.toPath(), FileTime.fromMillis(1000)); + File directory = spy(backups.toFile()); doReturn(new File[]{old, newest}).when(directory).listFiles(); + assertEquals(newest, VehicleSqliteBackup.findNewestValid(directory)); + } + + @Test void failedSidelineNeverDeletesTheOriginalDatabase() throws Exception { + Path live = folder.resolve("vehicles.db"); Files.writeString(live, "original"); File backup = database(folder.resolve("backup.db")); + File source = spy(live.toFile()); doReturn(false).when(source).renameTo(any(File.class)); + try (var files = mockStatic(Files.class, call -> { + if (call.getMethod().getName().equals("move") && live.equals(call.getArgument(0))) throw new IOException("simulated move failure"); + return call.callRealMethod(); + })) { + assertThrows(IOException.class, () -> VehicleSqliteBackup.replaceLive(source, backup)); + } + assertEquals("original", Files.readString(live)); + } + + @Test void failedFinalInstallationRestoresTheOriginalDatabaseAndRetainsItsJournals() throws Exception { + Path live = Files.writeString(folder.resolve("vehicles.db"), "original recoverable bytes"); File backup = database(folder.resolve("backup.db")); + Path wal = Files.writeString(folder.resolve("vehicles.db-wal"), "original wal"), shm = Files.writeString(folder.resolve("vehicles.db-shm"), "original shm"); + try (var files = mockStatic(Files.class, call -> { + if (call.getMethod().getName().equals("move") && ((Path)call.getArgument(0)).getFileName().toString().startsWith("vehicles-restore-")) + throw new IOException("simulated final install failure"); + return call.callRealMethod(); + })) { + IOException failure = assertThrows(IOException.class, () -> VehicleSqliteBackup.replaceLive(live.toFile(), backup)); + assertEquals("simulated final install failure", failure.getMessage()); + } + assertTrue(Files.exists(live), "A failed final rename must restore the original live file"); assertEquals("original recoverable bytes", Files.readString(live)); + assertEquals("original wal", Files.readString(wal)); assertEquals("original shm", Files.readString(shm)); + try (Stream files = Files.list(folder)) { assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith("vehicles-restore-"))); } + } + + @Test void refusedLegacyRenameCanFallBackToNioMoveWithoutLosingTheOriginal() throws Exception { + Path live = Files.writeString(folder.resolve("vehicles.db"), "original"); File backup = database(folder.resolve("backup.db")); + File source = spy(live.toFile()); doReturn(false).when(source).renameTo(any(File.class)); + VehicleSqliteBackup.replaceLive(source, backup); assertTrue(VehicleSqliteBackup.isValidDatabase(live.toFile())); + try (Stream files = Files.list(folder)) { + assertTrue(files.filter(path -> path.getFileName().toString().startsWith("vehicles.db.corrupt-")).anyMatch(path -> read(path).equals("original"))); + } + } + + @Test void readonlyValidationPreservesLegacySchemaUntilAnActualRestoreMigratesIt() throws Exception { + Path backups = Files.createDirectory(folder.resolve("backups")); Path old = backups.resolve("vehicles-20250101-000000.db"); + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + old); Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE vehicles(uuid TEXT PRIMARY KEY,type_id TEXT,world TEXT,x REAL,y REAL,z REAL,yaw REAL,chunk_x INTEGER,chunk_z INTEGER,payload_json TEXT,schema_version INTEGER,revision INTEGER,deleted INTEGER,updated_at INTEGER)"); + statement.execute("INSERT INTO vehicles VALUES('legacy','tug','world',1,64,1,0,0,0,'{}',1,1,0,1)"); + } + byte[] original = Files.readAllBytes(old); assertTrue(VehicleSqliteBackup.isValidDatabase(old.toFile())); assertArrayEquals(original, Files.readAllBytes(old)); + Path live = Files.writeString(folder.resolve("vehicles.db"), "corrupt"); + try (var logger = mockStatic(VFLogger.class)) { + VehicleRepository restored = VehicleRepository.openWithRestore(live.toFile(), backups.toFile()); + try { assertEquals("", restored.findLive("legacy").orElseThrow().getName()); assertEquals("none", restored.findLive("legacy").orElseThrow().getOwner()); } + finally { restored.close(); } + } + assertArrayEquals(original, Files.readAllBytes(old)); + } + + @Test void validationRejectsJdbcResourceFailuresAndClosesTheRemainingResources() throws Exception { + File backup = database(folder.resolve("backup.db")); Connection connection = mock(Connection.class); Statement statement = mock(Statement.class); + ResultSet result = mock(ResultSet.class); when(connection.createStatement()).thenReturn(statement); + when(statement.executeQuery("PRAGMA quick_check")).thenReturn(result); when(result.next()).thenReturn(true, false); when(result.getString(1)).thenReturn("ok"); + doThrow(new SQLException("failed to close integrity result")).when(result).close(); + try (var driver = mockStatic(DriverManager.class)) { + driver.when(() -> DriverManager.getConnection(anyString())).thenReturn(connection); + assertFalse(VehicleSqliteBackup.isValidDatabase(backup)); + } + verify(result).close(); verify(statement).close(); verify(connection).close(); assertTrue(VehicleSqliteBackup.isValidDatabase(backup)); + } + + @Test void integrityErrorRowsRejectABackupAndCloseEveryJdbcResource() throws Exception { + File backup = database(folder.resolve("backup.db")); Connection connection = mock(Connection.class); Statement statement = mock(Statement.class); + ResultSet result = mock(ResultSet.class); when(connection.createStatement()).thenReturn(statement); + when(statement.executeQuery("PRAGMA quick_check")).thenReturn(result); when(result.next()).thenReturn(true, false); when(result.getString(1)).thenReturn("page 5: invalid index"); + try (var driver = mockStatic(DriverManager.class)) { + driver.when(() -> DriverManager.getConnection(anyString())).thenReturn(connection); assertFalse(VehicleSqliteBackup.isValidDatabase(backup)); + } + verify(result).close(); verify(statement).close(); verify(connection).close(); + verify(statement, times(1)).executeQuery(anyString()); + } + + @Test void rollbackFailurePreservesTheArchivedOriginalAndReportsBothIoFailures() throws Exception { + Path live = Files.writeString(folder.resolve("vehicles.db"), "original"); File backup = database(folder.resolve("backup.db")); + Path wal = Files.writeString(folder.resolve("vehicles.db-wal"), "wal"); + try (var files = mockStatic(Files.class, call -> { + if (call.getMethod().getName().equals("move")) { + String name = ((Path)call.getArgument(0)).getFileName().toString(); + if (name.startsWith("vehicles-restore-")) throw new IOException("install failed"); + if (name.startsWith("vehicles.db.corrupt-")) throw new IOException("rollback failed"); + } + return call.callRealMethod(); + })) { + IOException failure = assertThrows(IOException.class, () -> VehicleSqliteBackup.replaceLive(live.toFile(), backup)); + assertEquals("install failed", failure.getMessage()); assertEquals(1, failure.getSuppressed().length); + assertEquals("rollback failed", failure.getSuppressed()[0].getMessage()); + } + assertEquals("wal", Files.readString(wal)); + try (Stream files = Files.list(folder)) { + assertTrue(files.filter(path -> path.getFileName().toString().startsWith("vehicles.db.corrupt-")).anyMatch(path -> read(path).equals("original"))); + } + } + + @Test void cleanupFailureCannotHideTheInstallFailureOrItsFailedRollback() throws Exception { + Path live = Files.writeString(folder.resolve("vehicles.db"), "original database bytes"); + File backup = database(folder.resolve("backup.db")); + Path wal = Files.writeString(folder.resolve("vehicles.db-wal"), "original wal"); + Path shm = Files.writeString(folder.resolve("vehicles.db-shm"), "original shm"); + IOException install = new IOException("install failed"), rollback = new IOException("rollback failed"), cleanup = new IOException("cleanup failed"); + IOException reported; + try (var files = mockStatic(Files.class, call -> { + if (call.getMethod().getName().equals("move")) { + String name = ((Path)call.getArgument(0)).getFileName().toString(); + if (name.startsWith("vehicles-restore-")) throw install; + if (name.startsWith("vehicles.db.corrupt-")) throw rollback; + } + if (call.getMethod().getName().equals("deleteIfExists") + && ((Path)call.getArgument(0)).getFileName().toString().startsWith("vehicles-restore-")) throw cleanup; + return call.callRealMethod(); + })) { + reported = assertThrows(IOException.class, () -> VehicleSqliteBackup.replaceLive(live.toFile(), backup)); + } + Path archived; + try (Stream files = Files.list(folder)) { + archived = files.filter(path -> path.getFileName().toString().startsWith("vehicles.db.corrupt-")).findFirst().orElseThrow(); + } + assertAll( + () -> assertSame(install, reported, "Cleanup must not replace the primary restore error"), + () -> assertArrayEquals(new Throwable[]{rollback, cleanup}, reported.getSuppressed()), + () -> assertEquals("original database bytes", Files.readString(archived)), + () -> assertEquals("original wal", Files.readString(wal)), + () -> assertEquals("original shm", Files.readString(shm))); + } + + @Test void backupNamesEscapeSqlQuotesAndRotationKeepsOnlyNewestSnapshots() throws Exception { + assertEquals(folder.resolve("data/backups").toFile(), VehicleSqliteBackup.backupDir(folder.toFile())); + assertTrue(VehicleSqliteBackup.vacuumIntoSql(folder.resolve("it's.db").toFile()).contains("it''s.db")); + assertFalse(VehicleSqliteBackup.isSnapshotName(null)); assertFalse(VehicleSqliteBackup.isSnapshotName("notes.db")); + assertTrue(VehicleSqliteBackup.isSnapshotName("vehicles-backup.DB")); + assertTrue(VehicleSqliteBackup.listSnapshots(null).isEmpty()); assertTrue(VehicleSqliteBackup.listSnapshots(folder.resolve("absent").toFile()).isEmpty()); + Path snapshots = Files.createDirectory(folder.resolve("snapshots")); File first = VehicleSqliteBackup.nextBackupFile(snapshots.toFile()); Files.createFile(first.toPath()); + File next = VehicleSqliteBackup.nextBackupFile(snapshots.toFile()); assertNotEquals(first, next); + Files.createDirectory(snapshots.resolve("vehicles-directory.db")); Files.writeString(snapshots.resolve("notes.txt"), "keep"); + for (String name : List.of("vehicles-20250101-000000.db", "vehicles-20260101-000000.db", "vehicles-20260101-000000-9.db", "vehicles-20260101-000000-10.db", "vehicles-invalid.db", "vehicles-invalid2.db", "vehicles-20269999-000000.db")) { + Path file = Files.writeString(snapshots.resolve(name), name); Files.setLastModifiedTime(file, FileTime.fromMillis(1000)); + } + Files.setLastModifiedTime(first.toPath(), FileTime.fromMillis(0)); VehicleSqliteBackup.rotate(snapshots.toFile()); + Set kept = new HashSet<>(); for (File file : VehicleSqliteBackup.listSnapshots(snapshots.toFile())) kept.add(file.getName()); + assertEquals(Set.of("vehicles-20260101-000000.db", "vehicles-20260101-000000-9.db", "vehicles-20260101-000000-10.db"), kept); + assertEquals("keep", Files.readString(snapshots.resolve("notes.txt"))); assertTrue(Files.isDirectory(snapshots.resolve("vehicles-directory.db"))); + } + + @Test void successfulReplacementPreservesCorruptCopiesAndClearsStaleWalSidecars() throws Exception { + File backup = database(folder.resolve("backup.db")); Path live = folder.resolve("nested/vehicles.db"); + VehicleSqliteBackup.replaceLive(live.toFile(), backup); assertTrue(VehicleSqliteBackup.isValidDatabase(live.toFile())); + Files.writeString(live, "first corrupt bytes"); Files.writeString(live.resolveSibling("vehicles.db-wal"), "wal"); Files.writeString(live.resolveSibling("vehicles.db-shm"), "shm"); + String stamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); Path collision = live.resolveSibling("vehicles.db.corrupt-" + stamp); + Files.writeString(collision, "older evidence"); VehicleSqliteBackup.replaceLive(live.toFile(), backup); + assertEquals("older evidence", Files.readString(collision)); + try (Stream files = Files.list(live.getParent())) { assertTrue(files.filter(p -> p.getFileName().toString().startsWith("vehicles.db.corrupt-")).anyMatch(p -> read(p).equals("first corrupt bytes"))); } + assertFalse(Files.exists(live.resolveSibling("vehicles.db-wal"))); assertFalse(Files.exists(live.resolveSibling("vehicles.db-shm"))); + assertTrue(VehicleSqliteBackup.isValidDatabase(live.toFile())); + } + + @Test void repositoryRestoreRecoversRowsAndIgnoresLoggingFailures() throws Exception { + Path backups = Files.createDirectory(folder.resolve("backups")); File good = database(backups.resolve("vehicles-20260101-000000.db")); + Path live = Files.writeString(folder.resolve("vehicles.db"), "corrupt"); + try (var logger = mockStatic(VFLogger.class)) { + logger.when(() -> VFLogger.log(anyString())).thenThrow(new IllegalStateException("logger unavailable")); + VehicleRepository restored = VehicleRepository.openWithRestore(live.toFile(), backups.toFile()); + try { assertEquals("tug", restored.findLive("vehicle-1").orElseThrow().getTypeId()); assertEquals("wal", restored.journalMode()); assertEquals(5000, restored.busyTimeoutMillis()); } + finally { restored.close(); } + } + VehicleRepository normal = VehicleRepository.openWithRestore(good, folder.resolve("missing-backups").toFile()); normal.close(); + } + + @Test void failedVacuumPreservesExistingBackupsAndLiveRows() throws Exception { + File live = database(folder.resolve("vehicles.db")); VehicleRepository repository = VehicleRepository.open(live); + try { + assertFalse(repository.vacuumIntoBackup(null)); File backups = Files.createDirectory(folder.resolve("backups")).toFile(); + assertTrue(repository.vacuumIntoBackup(backups)); File saved = VehicleSqliteBackup.listSnapshots(backups).get(0); byte[] original = Files.readAllBytes(saved.toPath()); + Path blocked = Files.writeString(folder.resolve("blocked-directory"), "keep"); assertThrows(RuntimeException.class, () -> repository.vacuumIntoBackup(blocked.toFile())); + assertArrayEquals(original, Files.readAllBytes(saved.toPath())); assertTrue(repository.findLive("vehicle-1").isPresent()); + repository.checkpointWal(false); repository.checkpointWal(true); + } finally { repository.close(); } + } + + @Test void corruptOrMissingCandidatesCannotBeSelected() throws Exception { + assertFalse(VehicleSqliteBackup.isValidDatabase(null)); assertFalse(VehicleSqliteBackup.isValidDatabase(folder.toFile())); + Files.writeString(folder.resolve("vehicles-corrupt.db"), "not sqlite"); + assertNull(VehicleSqliteBackup.findNewestValid(folder.toFile())); + Path live = Files.writeString(folder.resolve("broken-live.db"), "not sqlite"); + assertThrows(RuntimeException.class, () -> VehicleRepository.openWithRestore(live.toFile(), folder.toFile())); + } + + @Test void failedOpenReportsIntegrityAndPreservesTheConnectionCloseFailure() throws Exception { + Connection connection = mock(Connection.class); PreparedStatement statement = mock(PreparedStatement.class); ResultSet result = mock(ResultSet.class); + when(connection.prepareStatement("PRAGMA quick_check")).thenReturn(statement); when(statement.executeQuery()).thenReturn(result); + when(result.next()).thenReturn(true, false); when(result.getString(1)).thenReturn("corrupt page"); + IllegalStateException closing = new IllegalStateException("close failure"); + try (var databases = mockConstruction(SqliteDatabase.class, (database, context) -> { + when(database.getConnection()).thenReturn(connection); doThrow(closing).when(database).close(); + })) { + SqliteDatabaseException failure = assertThrows(SqliteDatabaseException.class, () -> VehicleRepository.open(folder.resolve("invalid.db").toFile())); + assertTrue(failure.getMessage().contains("integrity check failed")); assertArrayEquals(new Throwable[]{closing}, failure.getSuppressed()); + verify(databases.constructed().get(0)).close(); verify(statement).close(); verify(result).close(); + } + } + + @Test void databaseQueryFailuresAreReportedAndFailedConnectionsClose() throws Exception { + Connection connection = mock(Connection.class); when(connection.prepareStatement("PRAGMA quick_check")).thenThrow(new SQLException("storage read failure")); + try (var databases = mockConstruction(SqliteDatabase.class, (database, context) -> when(database.getConnection()).thenReturn(connection))) { + SqliteDatabaseException failure = assertThrows(SqliteDatabaseException.class, () -> VehicleRepository.open(folder.resolve("failed.db").toFile())); + assertTrue(failure.getMessage().contains("PRAGMA quick_check")); assertEquals("storage read failure", failure.getCause().getMessage()); + verify(databases.constructed().get(0)).close(); + } + } + + @Test void restoreWriteFailureKeepsTheOriginalFailureAndDatabaseBytes() throws Exception { + Path backups = Files.createDirectory(folder.resolve("backups")); database(backups.resolve("vehicles-20260101-000000.db")); + Path restricted = Files.createDirectory(folder.resolve("read-only")); Path live = Files.writeString(restricted.resolve("vehicles.db"), "corrupt but preserved"); + Set original = Files.getPosixFilePermissions(restricted); + Files.setPosixFilePermissions(restricted, java.nio.file.attribute.PosixFilePermissions.fromString("r-x------")); + try { + RuntimeException failure = assertThrows(RuntimeException.class, () -> VehicleRepository.openWithRestore(live.toFile(), backups.toFile())); + assertTrue(failure.getSuppressed().length > 0); assertEquals("corrupt but preserved", Files.readString(live)); + } finally { Files.setPosixFilePermissions(restricted, original); } + } + + private File database(Path path) { + VehicleRepository repository = VehicleRepository.open(path.toFile()); + try { repository.saveLive(new VehicleSnapshot("vehicle-1", "tug", "world", 1, 64, 1, 0, 0, 0, + "{\"id\":\"tug\",\"components\":{}}", VehicleRepository.SCHEMA_VERSION, 1, false, 1)); } + finally { repository.close(); } + return path.toFile(); + } + private String read(Path path) { try { return Files.readString(path); } catch (IOException e) { throw new UncheckedIOException(e); } } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/database/VehiclePayloadCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/database/VehiclePayloadCoverageTest.java new file mode 100644 index 0000000..6227bd3 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/database/VehiclePayloadCoverageTest.java @@ -0,0 +1,223 @@ +package net.tfminecraft.vehicleframework.database; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.joml.Quaternionf; +import org.json.simple.JSONObject; +import org.json.simple.JSONValue; +import org.junit.jupiter.api.Test; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.tfminecraft.vehicleframework.bones.BoneRotator; +import net.tfminecraft.vehicleframework.enums.Component; +import net.tfminecraft.vehicleframework.loaders.AmmunitionLoader; +import net.tfminecraft.vehicleframework.tracks.ThrottleTape; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.component.Engine; +import net.tfminecraft.vehicleframework.vehicles.component.GearedEngine; +import net.tfminecraft.vehicleframework.vehicles.component.SinkableHull; +import net.tfminecraft.vehicleframework.vehicles.handlers.container.Container; +import net.tfminecraft.vehicleframework.vehicles.seat.Seat; +import net.tfminecraft.vehicleframework.weapons.ActiveWeapon; +import net.tfminecraft.vehicleframework.weapons.ammunition.Ammunition; + +@SuppressWarnings("unchecked") +class VehiclePayloadCoverageTest { + static final String ID = "11111111-2222-3333-4444-555555555555"; + + @Test void completePayloadRoundTripsStateWithoutDependingOnDefaultLocale() { + Locale previous = Locale.getDefault(); + Ammunition ammo = mock(Ammunition.class); + when(ammo.getId()).thenReturn("shell"); + try (var loader = mockStatic(AmmunitionLoader.class)) { + loader.when(() -> AmmunitionLoader.getByString("shell")).thenReturn(ammo); + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + IncompleteVehicle decoded = VehiclePayloadCodec.decode(""" + {"id":"locomotive","name":"Express","skin":"blue","owner":"player_Alice", + "yaw":"45.5","uuid":"payload-id","whitelisted":true,"whitelist":["Bob",null], + "ticketsEnabled":true,"ticketId":"rail-pass", + "components":{"geared_engine":{"damage":"3.5","fire":"8","sinkprogress":2,"gear":"3","fuel":"12.5","throttle":"60"},"wings":{"damage":4}}, + "rotators":{"turret":{"x":0.1,"y":0.2,"z":0.3,"w":0.9}}, + "weapons":{"cannon":{"damage":"7.5","ammo":"shell","count":"9"},"empty":{"damage":0}}, + "passengers":{"front":{"player":"Alice"},"back":{"entity":"11111111-2222-3333-4444-555555555555"}}, + "containers":{"box":{"id":"box","items":[]}}, + "parent":"parent","child":"child","splineId":"track","s":4.5,"travelSign":-1, + "throttleTape":{"splineId":"track","samples":[{"s":0,"sign":1,"throttle":60}]}, + "locomotiveOverdrive":{"remaining":100}} + """, ID).orElseThrow(); + assertEquals(2, decoded.getComponents().size()); + String encoded = VehiclePayloadCodec.encode(decoded); + assertTrue(encoded.contains("geared_engine")); + assertTrue(encoded.contains("wings")); + IncompleteVehicle restored = VehiclePayloadCodec.decode(encoded, ID).orElseThrow(); + assertEquals("Express", restored.getName()); + assertEquals("blue", restored.getSkin()); + assertEquals("player_Alice", restored.getOwner()); + assertEquals(ID, restored.getUUID()); + assertEquals(45.5f, restored.getYaw()); + assertEquals(3, restored.getGear()); + assertEquals(60, restored.getThrottle()); + assertEquals(12.5, restored.getFuel()); + assertEquals(List.of("Bob"), restored.getWhitelist()); + assertTrue(restored.isWhitelisted()); + assertTrue(restored.isTicketsEnabled()); + assertEquals("rail-pass", restored.getTicketId()); + assertEquals(2, restored.getPassengers().size()); + assertEquals(9, restored.getWeapons().stream().filter(w -> w.getId().equals("cannon")).findFirst().orElseThrow().getCount()); + assertEquals(0.9f, restored.getRotations().getFirst().getW()); + assertEquals("box", restored.getContainers().getFirst().get("id").getAsString()); + assertEquals("parent", restored.getConsist().getParent()); + assertEquals(60, restored.getThrottleTape().lookup(0, 1)); + assertNotNull(restored.getLocomotiveOverdrive()); + } finally { Locale.setDefault(previous); } + } + + @Test void malformedAndLegacyPayloadsAreHandledWithoutInventingVehicles() { + for (String input : Arrays.asList(null, "", " ", "{", "[]", "null", "{}", "{\"id\":\" \"}")) + assertTrue(VehiclePayloadCodec.decode(input, null).isEmpty()); + assertTrue(VehiclePayloadCodec.decode((JSONObject) null, ID).isEmpty()); + assertEquals("{}", VehiclePayloadCodec.encode(null)); + IncompleteVehicle legacy = VehiclePayloadCodec.decode("{\"id\":\"cart\",\"uuid\":\"legacy\",\"name\":\" \",\"owner\":null}", " ").orElseThrow(); + assertEquals("legacy", legacy.getUUID()); + assertEquals("cart", legacy.getName()); + assertEquals("none", legacy.getOwner()); + JSONObject root = object("{\"id\":\"cart\",\"yaw\":\"bad\",\"components\":{},\"rotators\":{},\"weapons\":{},\"passengers\":{},\"containers\":{\"absent\":null}}"); + for (String section : List.of("components", "rotators", "weapons", "passengers")) { + JSONObject entries = (JSONObject) root.get(section); + entries.put(1, new JSONObject()); entries.put("invalid", "text"); + } + JSONObject components = (JSONObject) root.get("components"); + components.put("unknown", new JSONObject()); + components.put("engine", object("{\"damage\":\"bad\",\"throttle\":null,\"fuel\":null,\"fire\":\"bad\"}")); + components.put("hull", object("{\"damage\":null}")); + JSONObject passengers = (JSONObject) root.get("passengers"); + passengers.put("bad-id", object("{\"entity\":\"bad\"}")); + passengers.put("empty", new JSONObject()); + IncompleteVehicle result = VehiclePayloadCodec.decode(root, ID).orElseThrow(); + assertEquals(2, result.getComponents().size()); + assertEquals(0, result.getThrottle()); + assertEquals(0, result.getFuel()); + assertEquals(0, result.getYaw()); + assertTrue(result.getPassengers().isEmpty()); + root.put("yaw", null); + assertEquals(0, VehiclePayloadCodec.decode(root, ID).orElseThrow().getYaw()); + ((JSONObject) root.get("rotators")).put("incomplete", new JSONObject()); + assertTrue(VehiclePayloadCodec.decode(root, ID).isEmpty()); + assertTrue(VehiclePayloadCodec.loadContainers(null).isEmpty()); + } + + @Test void nonFiniteNumbersCannotPoisonStoredVehicleState() { + for (Object invalid : List.of("NaN", "Infinity", Double.NaN, Double.POSITIVE_INFINITY)) { + JSONObject payload = object("{\"id\":\"cart\",\"components\":{\"engine\":{}}}"); + payload.put("yaw", invalid); + JSONObject engine = (JSONObject) ((JSONObject) payload.get("components")).get("engine"); + engine.put("fuel", invalid); engine.put("damage", invalid); + IncompleteVehicle loaded = VehiclePayloadCodec.decode(payload, ID).orElseThrow(); + assertEquals(0f, loaded.getYaw()); + assertEquals(0d, loaded.getFuel()); + assertEquals(0d, loaded.getComponents().getFirst().getDamage()); + assertDoesNotThrow(() -> VehiclePayloadCodec.encode(loaded)); + } + } + + @Test void incompleteCollectionsCanBeEmptyOrContainUnusableOptionalEntries() { + IncompleteVehicle empty = new IncompleteVehicle(ID, "cart", "Cart", "cart", null, null, null, null, null, 0, 1, 0, 0, "none", false, null, null); + assertTrue(VehiclePayloadCodec.decode(VehiclePayloadCodec.encode(empty), ID).isPresent()); + JsonObject valid = JsonParser.parseString("{\"id\":\"box\",\"items\":[]}").getAsJsonObject(); + IncompleteVehicle partial = new IncompleteVehicle(ID, "cart", "Cart", "cart", + Arrays.asList(null, new IncompleteComponent(null, 0, 0, 0), new IncompleteComponent(Component.ENGINE, 5, 4, 2)), + List.of(), List.of(), List.of(), Arrays.asList(null, new JsonObject(), valid), 40, 2, 25, 3, "none", false, List.of()); + partial.setThrottleTape(new ThrottleTape("track")); + IncompleteVehicle reloaded = VehiclePayloadCodec.decode(VehiclePayloadCodec.encode(partial), ID).orElseThrow(); + assertEquals(1, reloaded.getComponents().size()); + assertEquals(40, reloaded.getThrottle()); + assertEquals(1, reloaded.getContainers().size()); + assertNull(reloaded.getThrottleTape()); + } + + @Test void liveSnapshotsRejectMissingIdentityOrUnreadableStateWithUsefulReasons() { + assertEquals("vehicle is null", ActiveVehicleSnapshotFactory.tryFromLive(null).failureReason()); + ActiveVehicle v = mock(ActiveVehicle.class); + assertEquals("no UUID", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason()); + when(v.getUUID()).thenReturn(" "); + assertTrue(ActiveVehicleSnapshotFactory.fromLive(v).isEmpty()); + when(v.getUUID()).thenReturn(ID); + assertEquals("entity invalid", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason()); + Entity e = mock(Entity.class); when(v.getEntity()).thenReturn(e); when(e.isDead()).thenReturn(true); + assertEquals("entity invalid", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason()); + when(e.isDead()).thenReturn(false); when(e.getLocation()).thenThrow(new IllegalStateException()); + assertEquals("could not read location", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason()); + doReturn(null).when(e).getLocation(); + assertEquals("no world", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason()); + when(e.getLocation()).thenReturn(new Location(null, 0, 0, 0)); + assertEquals("no world", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason()); + when(e.getLocation()).thenReturn(new Location(mock(World.class), 0, 0, 0)); + when(v.getSkinHandler()).thenThrow(new IllegalStateException()); + assertEquals("encode failed", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason()); + doThrow(new IllegalStateException("missing skin")).when(v).getSkinHandler(); + assertEquals("encode failed: missing skin", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason()); + } + + @Test void liveSnapshotPreservesContainersSeatsWeaponsTrainAndNegativeChunkCoordinates() { + ActiveVehicle v = liveVehicle(); + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + when(engine.getType()).thenReturn(Component.ENGINE); + when(engine.getThrottle().getCurrent()).thenReturn(40); when(engine.getFuelTank().getCurrent()).thenReturn(12d); + GearedEngine geared = mock(GearedEngine.class, RETURNS_DEEP_STUBS); + when(geared.getType()).thenReturn(Component.GEARED_ENGINE); when(geared.getCurrentGear()).thenReturn(3); + when(geared.getGear().getThrottle().getCurrent()).thenReturn(60); when(geared.getFuelTank().getCurrent()).thenReturn(12d); + SinkableHull hull = mock(SinkableHull.class, RETURNS_DEEP_STUBS); + when(hull.getType()).thenReturn(Component.HULL); when(hull.isSinking()).thenReturn(true); when(hull.getSinkProgress()).thenReturn(8); + when(hull.isOnFire()).thenReturn(true); when(hull.getFire().getProgress()).thenReturn(9); + when(v.getComponents()).thenReturn(List.of(engine, geared, hull)); + Container container = mock(Container.class); when(container.getId()).thenReturn("box"); + when(container.getAsJson()).thenReturn(JsonParser.parseString("{\"id\":\"box\",\"items\":[]}").getAsJsonObject()); + when(v.hasContainers()).thenReturn(true); when(v.getContainerHandler().getContainers()).thenReturn(new HashMap<>(Map.of("box", container))); + Player player = mock(Player.class); when(player.getName()).thenReturn("Alice"); + Entity animal = mock(Entity.class); when(animal.getUniqueId()).thenReturn(UUID.fromString(ID)); + Entity unseated = mock(Entity.class); + when(v.getSeatHandler().getPassengers()).thenReturn(List.of(player, animal, unseated)); + Seat front = mock(Seat.class); when(front.getBone()).thenReturn("front"); when(v.getSeat(player)).thenReturn(front); + Seat back = mock(Seat.class); when(back.getBone()).thenReturn("back"); when(v.getSeat(animal)).thenReturn(back); when(v.getSeat(unseated)).thenReturn(null); + BoneRotator rotator = mock(BoneRotator.class, RETURNS_DEEP_STUBS); when(rotator.getId()).thenReturn("turret"); + when(rotator.getAnimator().getRotation()).thenReturn(new Quaternionf(0, 0, 0, 1)); + when(v.getAccessPanel().getRotators()).thenReturn(List.of(rotator)); + ActiveWeapon weapon = mock(ActiveWeapon.class, RETURNS_DEEP_STUBS); when(weapon.getId()).thenReturn("gun"); + when(weapon.getAmmunitionHandler().hasAmmo()).thenReturn(true); when(weapon.getAmmunitionHandler().getAmmo().getId()).thenReturn("shell"); + when(weapon.getAmmunitionHandler().getCount()).thenReturn(6); when(v.getWeaponHandler().getWeapons()).thenReturn(List.of(weapon)); + when(v.isTrain()).thenReturn(true); when(v.isLocomotive()).thenReturn(true); + when(v.getTrainHandler().toConsistData()).thenReturn(new ConsistData(null, "child", "track", 5d)); + when(v.getTrainHandler().getOverdrive().toJson()).thenReturn(object("{\"active\":true}")); + when(v.getTrainHandler().getInstalledTape()).thenReturn(new ThrottleTape("track", List.of(new ThrottleTape.Sample(0, 1, 60)))); + when(v.getOwnerData().getTicketId()).thenReturn("ticket"); when(v.getOwnerData().getWhiteList()).thenReturn(List.of("Bob")); + VehicleSnapshot snapshot = ActiveVehicleSnapshotFactory.fromLive(v).orElseThrow(); + assertEquals(-2, snapshot.getChunkX()); assertEquals(-1, snapshot.getChunkZ()); + assertEquals("world", snapshot.getWorld()); assertEquals("player_Alice", snapshot.getOwner()); + JsonObject payload = JsonParser.parseString(snapshot.getPayloadJson()).getAsJsonObject(); + assertEquals(2, payload.getAsJsonObject("passengers").size()); + assertEquals(8, payload.getAsJsonObject("components").getAsJsonObject("hull").get("sinkprogress").getAsInt()); + assertEquals(6, payload.getAsJsonObject("weapons").getAsJsonObject("gun").get("count").getAsInt()); + assertEquals("child", payload.get("child").getAsString()); assertTrue(payload.has("throttleTape")); + when(v.hasParent()).thenReturn(true); when(v.isLocomotive()).thenReturn(false); + assertFalse(JsonParser.parseString(ActiveVehicleSnapshotFactory.fromLive(v).orElseThrow().getPayloadJson()).getAsJsonObject().has("throttleTape")); + } + + static ActiveVehicle liveVehicle() { + ActiveVehicle v = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); + World world = mock(World.class); when(world.getName()).thenReturn("world"); + when(v.getUUID()).thenReturn(ID); when(v.getId()).thenReturn("cart"); when(v.getName()).thenReturn("Cart"); + Location location = spy(new Location(world, -16.5, 64, -0.5, 45, 0)); + doReturn(world).when(location).getWorld(); + when(v.getEntity().getLocation()).thenReturn(location); + when(v.getSkinHandler().getCurrentSkin().getId()).thenReturn("blue"); + when(v.getOwnerData().getOwner()).thenReturn("player_Alice"); + return v; + } + static JSONObject object(String json) { return (JSONObject) JSONValue.parse(json); } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/effects/EffectsCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/effects/EffectsCoverageTest.java new file mode 100644 index 0000000..123343d --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/effects/EffectsCoverageTest.java @@ -0,0 +1,79 @@ +package net.tfminecraft.vehicleframework.effects; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; +import com.ticxo.modelengine.api.ModelEngineAPI; +import com.ticxo.modelengine.api.animation.BlueprintAnimation; +import com.ticxo.modelengine.api.animation.property.SimpleProperty; +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.bone.ModelBone; +import com.ticxo.modelengine.api.model.bone.SimpleManualAnimator; +import com.ticxo.modelengine.api.utils.config.ConfigCache; +import net.tfminecraft.vehicleframework.enums.Component; +import net.tfminecraft.vehicleframework.enums.VehicleDeath; +import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +class EffectsCoverageTest { + Rig r; ActiveVehicle vehicle; ActiveModel model; Player viewer; + final Map bones=new HashMap<>(); + final Map animations=new HashMap<>(); + MockedStatic api; + + @BeforeEach void setup(){ + r=new Rig();vehicle=r.vehicle();model=mock(ActiveModel.class,RETURNS_DEEP_STUBS);viewer=r.entity(Player.class,new Location(r.world,0,64,0));when(vehicle.getModel()).thenReturn(model); + when(model.getBone(anyString())).thenAnswer(c->Optional.ofNullable(bones.get(c.getArgument(0))));when(model.getBlueprint().getAnimations()).thenReturn(animations); + api=mockStatic(ModelEngineAPI.class);ConfigCache config=mock(ConfigCache.class);api.when(ModelEngineAPI::getConfigCache).thenReturn(config);bone("base",new Location(r.world,0,64,0));bone("aim",new Location(r.world,0,64,1)); + } + @AfterEach void cleanup(){api.close();r.close();} + + @Test void effectIdentifiersDoNotDependOnTheServerLocale(){ + when(vehicle.hasComponent(Component.WINGS)).thenReturn(true);Locale saved=Locale.getDefault(); + try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));CustomEffect effect=new CustomEffect(List.of("particle(crit;base.aim;1;0;0.2)","start_fire(wings)","death(sink)"));assertDoesNotThrow(()->effect.play(List.of(viewer),vehicle));verify(vehicle).kill(VehicleDeath.SINK);verify(vehicle.getComponent(Component.WINGS)).startFire();}finally{Locale.setDefault(saved);} + } + + @Test void emptyEffectsFinishImmediatelyAndExposeConfiguredCommands(){ + CustomEffect empty=new CustomEffect(List.of());assertTrue(empty.isEmpty());assertTrue(empty.isFinished());empty.play(List.of(),vehicle);assertTrue(empty.isFinished());assertEquals(List.of(),empty.getCommands()); + CustomEffect effect=new CustomEffect(List.of("delay(2)","sound(base;test.horn;2;0.5)"));assertFalse(effect.isEmpty());assertEquals(2,effect.getCommands().size());effect.play(List.of(viewer),vehicle);assertFalse(effect.isFinished());r.tick();verify(r.world,never()).playSound(any(Location.class),anyString(),anyFloat(),anyFloat());r.tick();assertTrue(effect.isFinished());verify(r.world).playSound(new Location(r.world,0,64,0),"test.horn",2f,.5f); + } + + @Test void particleEffectsAimAlongBonesAndEmitForEachViewer(){ + CustomEffect effect=new CustomEffect(List.of("particle(flame;base.aim;3;0;0.4)"));effect.play(List.of(viewer),vehicle);verify(viewer,times(3)).spawnParticle(Particle.FLAME,new Location(r.world,0,64,0),0,0,0,1,.4);assertTrue(effect.isFinished()); + } + + @Test void conditionsStopFollowingCommandsAndPreserveCompositeValues(){ + for(String condition:List.of("condition()","condition( ;true)","condition(has_fuel)","condition(has_fuel;true)","condition(AND;has_fuel=true;passengers=true)")){ + CustomEffect effect=new CustomEffect(List.of(condition,"death(explode)"));effect.play(List.of(),vehicle);assertTrue(effect.isFinished());verify(vehicle,never()).kill(any()); + } + when(vehicle.hasFuel()).thenReturn(true);when(vehicle.getSeatHandler().hasPassengers()).thenReturn(true);CustomEffect passes=new CustomEffect(List.of("condition(AND;has_fuel=true;passengers=true)","death(explode)"));passes.play(List.of(),vehicle);verify(vehicle).kill(VehicleDeath.EXPLODE); + } + + @Test void deathAndFireEffectsRespectExistingDestructionAndBurning(){ + when(vehicle.isDestroyed()).thenReturn(true);new CustomEffect(List.of("death(crash)")).play(List.of(),vehicle);verify(vehicle,never()).kill(any()); + new CustomEffect(List.of("start_fire(hull)")).play(List.of(),vehicle);verify(vehicle.getComponent(Component.HULL),never()).startFire(); + when(vehicle.hasComponent(Component.HULL)).thenReturn(true);when(vehicle.getComponent(Component.HULL).isOnFire()).thenReturn(true);new CustomEffect(List.of("start_fire(hull)")).play(List.of(),vehicle);verify(vehicle.getComponent(Component.HULL),never()).startFire(); + when(vehicle.getComponent(Component.HULL).isOnFire()).thenReturn(false);new CustomEffect(List.of("start_fire(hull)")).play(List.of(),vehicle);verify(vehicle.getComponent(Component.HULL)).startFire(); + } + + @Test void animationsIgnoreMissingAndAlreadyPlayingEntries(){ + new CustomEffect(List.of("animation(missing)")).play(List.of(),vehicle);verify(model.getAnimationHandler(),never()).playAnimation(any(SimpleProperty.class),anyBoolean()); + BlueprintAnimation animation=new BlueprintAnimation(model.getBlueprint(),"fly");animation.setLength(1);animation.setLoopMode(BlueprintAnimation.LoopMode.LOOP);animations.put("fly",animation);when(model.getAnimationHandler().isPlayingAnimation("fly")).thenReturn(true);new CustomEffect(List.of("animation(fly)")).play(List.of(),vehicle);verify(model.getAnimationHandler(),never()).playAnimation(any(SimpleProperty.class),anyBoolean());when(model.getAnimationHandler().isPlayingAnimation("fly")).thenReturn(false);new CustomEffect(List.of("animation(fly)")).play(List.of(),vehicle);verify(model.getAnimationHandler()).playAnimation(any(SimpleProperty.class),eq(true)); + } + + @Test void boneFreezingCreatesAndReleasesTheAnimatorIdempotently(){ + ModelBone bone=bones.get("base");new CustomEffect(List.of("freeze_bone(missing;true)","freeze_bone(base;false)","freeze_bone(base;true)","freeze_bone(base;true)")).play(List.of(),vehicle);assertNotNull(bone.getManualAnimator());verify(bone,times(1)).setManualAnimator(any(SimpleManualAnimator.class));new CustomEffect(List.of("freeze_bone(base;false)")).play(List.of(),vehicle);assertNull(bone.getManualAnimator());verify(bone).setManualAnimator(null); + } + + private ModelBone bone(String id,Location location){ + ModelBone bone=mock(ModelBone.class,RETURNS_DEEP_STUBS);when(bone.getBoneId()).thenReturn(id);when(bone.getActiveModel()).thenReturn(model);when(bone.getLocation()).thenAnswer(c->location.clone());when(bone.getLocalTransform().getSafeLeftQuaternion()).thenReturn(new org.joml.Quaternionf());when(bone.getLocalTransform().getSafePosition()).thenReturn(new org.joml.Vector3f());when(bone.getLocalTransform().getSafeScale()).thenReturn(new org.joml.Vector3f(1)); + AtomicReference animator=new AtomicReference<>();when(bone.getManualAnimator()).thenAnswer(c->animator.get());doAnswer(c->{animator.set(c.getArgument(0));return null;}).when(bone).setManualAnimator(any());bones.put(id,bone);return bone; + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java new file mode 100644 index 0000000..c72c1d4 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java @@ -0,0 +1,287 @@ +package net.tfminecraft.vehicleframework.loaders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.*; +import java.util.*; +import org.bukkit.*; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; +import com.ticxo.modelengine.api.ModelEngineAPI; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.data.DamageData; +import net.tfminecraft.vehicleframework.util.ConfigMerger; +import net.tfminecraft.vehicleframework.vehicles.Vehicle; +import net.tfminecraft.vehicleframework.vehicles.fuel.Fuel; +import net.tfminecraft.vehicleframework.weapons.ammunition.*; + +class ConfigurationLoadersCoverageTest { + @TempDir Path temp; + private MockedStatic logger; + private MockedStatic models; + private final Map fuels = new HashMap<>(FuelLoader.get()); + private final Map ammunition = new HashMap<>(AmmunitionLoader.get()); + private final Map vehicles = new HashMap<>(VehicleLoader.get()); + private final List ignoredExplosions = new ArrayList<>(Cache.ignoreExplode); + private final List ignoredLandings = new ArrayList<>(Cache.ignoreLands); + private final List ignoredGround = new ArrayList<>(Cache.ignoreGround); + private final Map conversions = new HashMap<>(Cache.convertExplode); + + @BeforeEach void setup() { + logger = mockStatic(VFLogger.class); models = mockStatic(ModelEngineAPI.class); + FuelLoader.get().clear(); AmmunitionLoader.get().clear(); VehicleLoader.get().clear(); + new WeaponTemplateLoader().clear(); new DeathTemplateLoader().clear(); new ArmorTemplateLoader().clear(); + Cache.ignoreExplode.clear(); Cache.ignoreLands.clear(); Cache.ignoreGround.clear(); Cache.convertExplode.clear(); + } + @AfterEach void cleanup() throws Exception { + File blank = yaml("reset.yml", "{}"); new ConfigLoader().load(blank); new TrainsLoader().load(blank); + FuelLoader.get().clear(); FuelLoader.get().putAll(fuels); + AmmunitionLoader.get().clear(); AmmunitionLoader.get().putAll(ammunition); + VehicleLoader.get().clear(); VehicleLoader.get().putAll(vehicles); + Cache.ignoreExplode.clear(); Cache.ignoreExplode.addAll(ignoredExplosions); + Cache.ignoreLands.clear(); Cache.ignoreLands.addAll(ignoredLandings); + Cache.ignoreGround.clear(); Cache.ignoreGround.addAll(ignoredGround); + Cache.convertExplode.clear(); Cache.convertExplode.putAll(conversions); + new WeaponTemplateLoader().clear(); new DeathTemplateLoader().clear(); new ArmorTemplateLoader().clear(); + models.close(); logger.close(); + } + + @Test void configReloadReplacesRemovedMaterialRules() throws Exception { + ConfigLoader loader = new ConfigLoader(); + loader.load(yaml("first.yml", "ignore-explosion: [stone]\nignore-landing: [dirt]\nignore-ground: [gravel]\nconvert-explosion: [stone.dirt]\n")); + loader.load(yaml("second.yml", "ignore-explosion: [sand]\nignore-landing: []\nignore-ground: []\nconvert-explosion: []\n")); + assertEquals(List.of(Material.SAND), Cache.ignoreExplode); + assertTrue(Cache.ignoreLands.isEmpty()); assertTrue(Cache.ignoreGround.isEmpty()); assertTrue(Cache.convertExplode.isEmpty()); + } + + @Test void malformedExplosionConversionDoesNotPreventOtherConfigLoading() throws Exception { + File file = yaml("conversion.yml", "convert-explosion: [STONE, 'stone.', invalid.dirt, stone.dirt]\nenable-logging: true\n"); + assertDoesNotThrow(() -> new ConfigLoader().load(file)); + assertEquals(Map.of(Material.STONE, Material.DIRT), Cache.convertExplode); assertTrue(Cache.enableLogging); + } + + @Test void configLoadsValidMaterialsAndAllRuntimeOptions() throws Exception { + new ConfigLoader().load(yaml("config.yml", """ + ignore-explosion: [stone, invalid] + ignore-landing: [sand, invalid] + ignore-ground: [gravel, invalid] + convert-explosion: [stone.dirt, invalid.dirt] + enable-logging: true + block-damage: false + despawn-distance: 17 + skin-item: custom.skin + repair-item: custom.repair + destroy-item: custom.destroy + ticket-item: custom.ticket + mythicmob: testmob + allow-whitelist: true + whitelisted-by-default: true + weapon-degraded-reload-multiplier: 0.5 + weapon-aim-debug: true + terrain-follow-debug: true + ground-engine-logging: true + persistence-logging: true + wipe-log: true + """)); + assertEquals(List.of(Material.STONE), Cache.ignoreExplode); assertEquals(List.of(Material.SAND), Cache.ignoreLands); + assertEquals(List.of(Material.GRAVEL), Cache.ignoreGround); assertEquals(Material.DIRT, Cache.convertExplode.get(Material.STONE)); + assertTrue(Cache.enableLogging); assertFalse(Cache.blockDamage); assertEquals(289, Cache.despawnDistance); + assertEquals("custom.skin", Cache.skinItem); assertEquals("custom.repair", Cache.repairItem); + assertEquals("custom.destroy", Cache.destroyItem); assertEquals("custom.ticket", Cache.ticketItem); assertEquals("testmob", Cache.mythicMob); + assertTrue(Cache.allowWhitelist); assertTrue(Cache.whitelistedByDefault); assertEquals(1, Cache.weaponDegradedReloadMultiplier); + assertTrue(Cache.weaponAimDebug); assertTrue(Cache.terrainFollowDebug); assertTrue(Cache.groundEngineLogging); + assertTrue(Cache.persistenceLogging); assertTrue(Cache.wipeLog); + logger.verify(() -> VFLogger.log("invalid is not a material"), times(3)); + } + + @Test void trainsUseDefaultsWhenFxAndBuildSectionsAreAbsent() throws Exception { + new TrainsLoader().load(yaml("trains.yml", "{}")); + assertEquals("ia.tfmc:track_small", Cache.trackItemSmall); assertEquals("ia.tfmc:track_medium", Cache.trackItemMedium); + assertEquals("ia.tfmc:track_large", Cache.trackItemLarge); assertEquals("v.iron_shovel", Cache.trackLayerItem); + assertEquals(3, Cache.trackSnapDistance); assertEquals(2.5, Cache.trainClearanceHeight); + assertEquals(Particle.BLOCK, Cache.trackFxParticle); assertEquals(Material.GRAVEL, Cache.trackFxBlock); + assertEquals(3, Cache.trackFxCount); assertEquals(3, Cache.trackFxWidth); assertEquals(2.5, Cache.trackFxSoundInterval); + assertEquals(4, Cache.trackBuildIntervalTicks); assertTrue(Cache.trackBuildSwing); assertEquals(6, Cache.trackBuildCount); + assertEquals(1, Cache.trackBuildWidth); assertEquals(Particle.BLOCK, Cache.trackBuildParticle); assertEquals(Material.GRAVEL, Cache.trackBuildBlock); + } + + @ParameterizedTest @ValueSource(strings = {"BLOCK_CRACK", "BLOCK_DUST", "invalid", "none", " "}) + void trainsParseLegacyParticlesValidateBlocksAndClampUnsafeRanges(String particle) throws Exception { + YamlConfiguration config = new YamlConfiguration(); + config.set("fx.particle", particle); config.set("build.particle", particle); + config.set("fx.particle-block", "not_a_block"); config.set("build.particle-block", "not_a_block"); + config.set("fx.width", 0); config.set("fx.count", -1); config.set("fx.sound-interval-blocks", 0); + config.set("build.interval-ticks", -1); config.set("build.count", -1); config.set("build.width", 0); + config.set("clearance.width", 0); config.set("clearance.height", 0); config.set("vehicle-y-offset", 2); + config.set("snap-distance", 0); config.set("max-grade-degrees", 3); config.set("desired-grade-degrees", 8); + config.set("min-lay-distance", 10); config.set("max-junction-length", 4); + config.set("lay-retry-seconds", -1); config.set("remove-cooldown-seconds", -1); + config.set("resync-chunks-per-tick", 0); config.set("switch.throw-degrees-per-second", 0); + File file = yaml("trains.yml", config.saveToString()); new TrainsLoader().load(file); + Particle expected = particle.isBlank() || particle.equals("none") ? null : Particle.BLOCK; + assertEquals(expected, Cache.trackFxParticle); assertEquals(expected, Cache.trackBuildParticle); + assertEquals(Material.GRAVEL, Cache.trackFxBlock); assertEquals(Material.GRAVEL, Cache.trackBuildBlock); + assertEquals(1, Cache.trackFxWidth); assertEquals(0, Cache.trackFxCount); assertEquals(.25, Cache.trackFxSoundInterval); + assertEquals(0, Cache.trackBuildIntervalTicks); assertEquals(0, Cache.trackBuildCount); assertEquals(1, Cache.trackBuildWidth); + assertEquals(.5, Cache.trainClearanceWidth); assertEquals(2.5, Cache.trainClearanceHeight); assertEquals(.5, Cache.trackSnapDistance); + assertEquals(3, Cache.trackDesiredGradeDegrees); assertEquals(10, Cache.trackMaxJunctionLength); + assertEquals(0, Cache.trackLayRetryMs); assertEquals(0, Cache.trackRemoveCooldownMs); + assertEquals(1, Cache.trackResyncChunksPerTick); assertEquals(1, Cache.trackSwitchThrowDegreesPerSecond); + } + + @Test void unreadableOrMalformedConfigDoesNotReplaceTheLastWorkingSettings() throws Exception { + File valid = yaml("valid.yml", "skin-item: custom.skin\nitem-small: custom.track\n"); + new ConfigLoader().load(valid); new TrainsLoader().load(valid); + File missing = temp.resolve("missing.yml").toFile(); File malformed = yaml("malformed.yml", "broken: [unterminated"); + new ConfigLoader().load(missing); new ConfigLoader().load(malformed); + new TrainsLoader().load(missing); new TrainsLoader().load(malformed); + assertEquals("custom.skin", Cache.skinItem); assertEquals("custom.track", Cache.trackItemSmall); + } + + @ParameterizedTest @ValueSource(strings = {"weapon", "death", "armor", "role"}) + void templateFoldersFilterFilesPreserveFirstDefinitionAndSkipNonSections(String kind) throws Exception { + java.util.function.Consumer load = templateLoader(kind, false); + java.util.function.Consumer loadFolder = templateLoader(kind, true); + Map> target = templates(kind); + File first = yaml(kind + "/first.YML", "base:\n damage: 5\n nested:\n radius: 2\n values: [one, two]\ninvalid: scalar\n"); + load.accept(first); assertEquals(5, target.get("base").get("damage")); assertFalse(target.containsKey("invalid")); + load.accept(yaml(kind + "/duplicate.yaml", "base:\n damage: 99\nsecond:\n damage: 7\n")); + assertEquals(5, target.get("base").get("damage")); assertEquals(7, target.get("second").get("damage")); + yaml(kind + "/ignored.txt", "ignored: {damage: 8}"); yaml(kind + "/nested/ignored.yml", "nested: {damage: 9}"); + loadFolder.accept(temp.resolve(kind).toFile()); assertEquals(Set.of("base", "second"), target.keySet()); + loadFolder.accept(null); loadFolder.accept(first); loadFolder.accept(temp.resolve("missing").toFile()); + Path inaccessible = Files.createDirectory(temp.resolve("unreadable")); + var permissions = Files.getPosixFilePermissions(inaccessible); + try { + Files.setPosixFilePermissions(inaccessible, Set.of()); + assertNull(inaccessible.toFile().listFiles(), "Fixture must model an unreadable directory"); + loadFolder.accept(inaccessible.toFile()); + } finally { Files.setPosixFilePermissions(inaccessible, permissions); } + load.accept(temp.resolve("missing.yml").toFile()); load.accept(yaml("bad.yml", "key: [unterminated")); + assertEquals(Set.of("base", "second"), target.keySet()); + } + + @ParameterizedTest @ValueSource(strings = {"weapon", "death"}) + void templatesResolveDeepInstanceOverridesWithoutMutatingTheTemplate(String kind) throws Exception { + templateLoader(kind, false).accept(yaml("template.yml", "base:\n damage: 5\n nested:\n radius: 2\n count: 3\n values: [one, two]\n")); + java.util.function.BiFunction resolve = kind.equals("weapon") + ? WeaponTemplateLoader::resolve : DeathTemplateLoader::resolve; + java.util.function.Function> find = kind.equals("weapon") + ? WeaponTemplateLoader::getByString : DeathTemplateLoader::getByString; + assertNull(find.apply(null)); assertNull(find.apply("missing")); assertNotNull(find.apply("base")); + assertNull(resolve.apply("test", null)); + YamlConfiguration standalone = configuration("damage: 8\n"); assertSame(standalone, resolve.apply("test", standalone)); + standalone.set("template", " "); assertSame(standalone, resolve.apply("test", standalone)); + standalone.set("template", "missing"); assertNull(resolve.apply("test", standalone)); + ConfigurationSection merged = resolve.apply("test", configuration("template: base\nnested:\n radius: 9\nvalues: [three]\n")); + assertEquals(5, merged.getInt("damage")); assertEquals(9, merged.getInt("nested.radius")); assertEquals(3, merged.getInt("nested.count")); + assertEquals(List.of("three"), merged.getStringList("values")); assertFalse(merged.contains("template")); + assertEquals(2, ((Map) find.apply("base").get("nested")).get("radius")); + assertEquals(List.of("one", "two"), find.apply("base").get("values")); + } + + @Test void armorRoleAndInlineDamageLayersResolveInOrderWithoutMutatingTemplates() throws Exception { + ArmorTemplateLoader loader = new ArmorTemplateLoader(); + loader.load(yaml("armor.yml", "plated: {PROJECTILE: 0.5, FIRE: 0.25}\n"), ArmorTemplateLoader.armor, "armor"); + loader.load(yaml("roles.yml", "carrier: {PROJECTILE: 0.75, FALL: 0.5}\n"), ArmorTemplateLoader.roles, "role"); + DamageData damage = ArmorTemplateLoader.resolve("hull", configuration("armor: plated\nrole: carrier\ndamage:\n PROJECTILE: 0.9\n")); + assertEquals(.9, damage.getModifier("PROJECTILE")); assertEquals(.25, damage.getModifier("FIRE")); assertEquals(.5, damage.getModifier("FALL")); + assertEquals(.5, ArmorTemplateLoader.armor.get("plated").get("PROJECTILE")); + damage = ArmorTemplateLoader.resolve("hull", configuration("armor: missing\nrole: carrier\ndamage: [FALL(0.8)]\n")); + assertEquals(.8, damage.getModifier("FALL")); assertEquals(.75, damage.getModifier("PROJECTILE")); + damage = ArmorTemplateLoader.resolve("hull", configuration("role: missing\n")); assertTrue(damage.getModifiers().isEmpty()); + assertTrue(ArmorTemplateLoader.resolve("hull", null).getModifiers().isEmpty()); + assertEquals(.7, ArmorTemplateLoader.resolve("hull", configuration("damage: {fall: 0.7}")).getModifier("FALL")); + assertEquals(.4, ArmorTemplateLoader.resolve("hull", configuration("damage: [fall(0.4)]")).getModifier("FALL")); + assertNull(ArmorTemplateLoader.damageOverlay(null)); assertNull(ArmorTemplateLoader.damageOverlay(new YamlConfiguration())); + assertEquals(Map.of(), ArmorTemplateLoader.listToMap(null)); + assertEquals(Map.of(), ArmorTemplateLoader.mergeLayers(null, null, null)); + assertEquals(Map.of("FALL", .5), ArmorTemplateLoader.mergeLayers(null, Map.of("FALL", .5), null)); + } + + @Test void absentTemplateLayersAndNestedValuesAreCopiedWithoutAliasing() { + assertEquals(Map.of(), ConfigMerger.fromSection(null)); assertTrue(ConfigMerger.toConfiguration(null).getKeys(false).isEmpty()); + List nested = new ArrayList<>(List.of(new LinkedHashMap<>(Map.of("amount", 3)))); + Map base = new LinkedHashMap<>(Map.of("entries", nested)); + Map copy = ConfigMerger.overlay(base, null); + assertEquals(base, copy); assertNotSame(base, copy); assertNotSame(nested, copy.get("entries")); + nested.clear(); assertEquals(1, ((List) copy.get("entries")).size()); + assertEquals(Map.of("name", "test"), ConfigMerger.overlay(null, Map.of("name", "test"))); + } + + @Test void fuelEntriesExposeConfiguredNamesAmountsSoundsAndCaseInsensitiveItemLookup() throws Exception { + new FuelLoader().load(yaml("fuel.yml", """ + coal: + name: Coal + item: v.coal + amount: 45 + refuel-while-running: true + sound: minecraft:block.lava.extinguish + sound-volume: 0.4 + sound-pitch: 1.2 + fallback: {} + """)); + Fuel fuel = FuelLoader.getByString("coal"); assertNotNull(fuel); assertSame(fuel, FuelLoader.getByInput("V.COAL")); + assertTrue(FuelLoader.itemIsFuel("V.COAL")); assertFalse(FuelLoader.itemIsFuel("missing")); assertNull(FuelLoader.getByInput("missing")); + assertNull(FuelLoader.getByString("missing")); assertEquals(2, FuelLoader.get().size()); + assertEquals("coal", fuel.getId()); assertEquals("Coal", fuel.getName()); assertEquals(45, fuel.getAmount()); assertTrue(fuel.refuelWhileRunning()); + assertEquals("minecraft:block.lava.extinguish", fuel.getSound()); assertEquals(.4f, fuel.getSoundVolume()); assertEquals(1.2f, fuel.getSoundPitch()); + assertEquals("v.bedrock", FuelLoader.getByString("fallback").getItem()); assertEquals(100, FuelLoader.getByString("fallback").getAmount()); + } + + @Test void ammunitionAndVehicleFilesRegisterRealConfiguredTemplates() throws Exception { + new AmmunitionLoader().load(yaml("ammo.yml", "round: {type: BULLET, input: custom.round, damage: 12}\nshell: {input: custom.shell}\n")); + assertInstanceOf(Bullet.class, AmmunitionLoader.getByString("round")); assertNotNull(AmmunitionLoader.getByString("shell")); + assertSame(AmmunitionLoader.getByString("round"), AmmunitionLoader.getByInput("CUSTOM.ROUND")); + assertNull(AmmunitionLoader.getByString("missing")); assertNull(AmmunitionLoader.getByInput("missing")); assertEquals(2, AmmunitionLoader.get().size()); + new VehicleLoader().load(yaml("vehicles.yml", "cart:\n name: Cargo Cart\n model: default\n skins: {default: {model: cart_model}}\n states: {}\n components: {hull: {health: 100}}\n seats: []\n")); + Vehicle cart = VehicleLoader.getByString("cart"); assertNotNull(cart); assertEquals("Cargo Cart", cart.getName()); + assertEquals("cart", cart.getId()); assertEquals("default", cart.getModel()); assertEquals("cart_model", cart.getSkinHandler().getCurrentSkin().getModel()); + assertEquals(1, cart.getComponentHandler().getComponents().size()); assertNull(VehicleLoader.getByString("missing")); + } + + @Test void basicLoadersIgnoreUnreadableAndMalformedFilesWithoutDiscardingExistingEntries() throws Exception { + new FuelLoader().load(yaml("fuel.yml", "coal: {}")); + new AmmunitionLoader().load(yaml("ammunition.yml", "shell: {}")); + File absent = temp.resolve("absent.yml").toFile(), broken = yaml("broken.yml", "bad: [unterminated"); + for (File file : List.of(absent, broken)) { + new FuelLoader().load(file); new AmmunitionLoader().load(file); new VehicleLoader().load(file); + } + assertNotNull(FuelLoader.getByString("coal")); assertNotNull(AmmunitionLoader.getByString("shell")); assertTrue(VehicleLoader.get().isEmpty()); + } + + private java.util.function.Consumer templateLoader(String kind, boolean folder) { + return switch (kind) { + case "weapon" -> folder ? new WeaponTemplateLoader()::loadFolder : new WeaponTemplateLoader()::load; + case "death" -> folder ? new DeathTemplateLoader()::loadFolder : new DeathTemplateLoader()::load; + case "armor" -> folder ? new ArmorTemplateLoader()::loadArmorFolder : file -> new ArmorTemplateLoader().load(file, ArmorTemplateLoader.armor, "armor"); + case "role" -> folder ? new ArmorTemplateLoader()::loadRoleFolder : file -> new ArmorTemplateLoader().load(file, ArmorTemplateLoader.roles, "role"); + default -> throw new IllegalArgumentException(kind); + }; + } + private Map> templates(String kind) { + return switch (kind) { + case "weapon" -> WeaponTemplateLoader.map; + case "death" -> DeathTemplateLoader.map; + case "armor" -> ArmorTemplateLoader.armor; + case "role" -> ArmorTemplateLoader.roles; + default -> throw new IllegalArgumentException(kind); + }; + } + private YamlConfiguration configuration(String content) throws Exception { + YamlConfiguration config = new YamlConfiguration(); config.loadFromString(content); return config; + } + + private File yaml(String name, String content) throws Exception { + Path path = temp.resolve(name); Files.createDirectories(path.getParent()); Files.writeString(path, content); return path.toFile(); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java new file mode 100644 index 0000000..2065fd3 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java @@ -0,0 +1,220 @@ +package net.tfminecraft.vehicleframework.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import org.bukkit.*; +import org.bukkit.command.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.*; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.data.*; +import net.tfminecraft.vehicleframework.enums.*; +import net.tfminecraft.vehicleframework.enums.Input; +import net.tfminecraft.vehicleframework.loaders.*; +import net.tfminecraft.vehicleframework.managers.inventory.VFInventoryHolder; +import net.tfminecraft.vehicleframework.tracks.*; +import net.tfminecraft.vehicleframework.util.TabCompletion; +import net.tfminecraft.vehicleframework.vehicles.*; +import net.tfminecraft.vehicleframework.vehicles.state.VehicleState; +import net.tfminecraft.vehicleframework.weapons.ammunition.Ammunition; + +class CommandAndOwnershipCoverageTest { + private final List scopes = new ArrayList<>(); + private final VehicleManager vehicles = mock(VehicleManager.class); + private final Player player = mock(Player.class, RETURNS_DEEP_STUBS); + private final Server server = mock(Server.class); + private final World world = mock(World.class); + private final Command command = mock(Command.class); + private final CommandManager commands = new CommandManager(); + private final OwnershipGUIManager ownership = new OwnershipGUIManager(); + private final List menus = new ArrayList<>(); + private final OwnerData owner = new OwnerData(); + private final ActiveVehicle vehicle = mock(ActiveVehicle.class); + private VehicleFramework previousPlugin, plugin; + private boolean previousAllowWhitelist; + private MockedStatic framework; + private final Map previousAmmo = new HashMap<>(AmmunitionLoader.get()); + private final Map previousVehicles = new HashMap<>(VehicleLoader.get()); + + @BeforeEach void setup() { + previousPlugin = VehicleFramework.plugin; plugin = mock(VehicleFramework.class); VehicleFramework.plugin = plugin; + when(plugin.getName()).thenReturn("VehicleFramework"); when(plugin.namespace()).thenReturn("vehicleframework"); + when(plugin.getServer()).thenReturn(server); when(command.getName()).thenReturn("vf"); + when(player.getName()).thenReturn("Alex"); when(player.getWorld()).thenReturn(world); + when(world.getName()).thenReturn("world"); when(player.getLocation()).thenReturn(new Location(world, 2, 64, 3)); + framework = keep(mockStatic(VehicleFramework.class)); framework.when(VehicleFramework::getVehicleManager).thenReturn(vehicles); + framework.when(VehicleFramework::getInstance).thenReturn(plugin); + when(vehicle.getUUID()).thenReturn("vehicle-id"); when(vehicle.getOwnerData()).thenReturn(owner); when(vehicle.ticketSource()).thenReturn(vehicle); + when(server.createInventory(any(InventoryHolder.class), anyInt(), anyString())).thenAnswer(call -> { + Inventory menu = inventory(call.getArgument(1), call.getArgument(0)); menus.add(menu); return menu; + }); + keep(mockConstruction(ItemStack.class, (item, context) -> configure(item, (Material) context.arguments().get(0)))); + previousAllowWhitelist = Cache.allowWhitelist; Cache.allowWhitelist = true; + AmmunitionLoader.get().clear(); VehicleLoader.get().clear(); + } + @AfterEach void cleanup() throws Exception { + Collections.reverse(scopes); for (AutoCloseable scope : scopes) scope.close(); + VehicleFramework.plugin = previousPlugin; Cache.allowWhitelist = previousAllowWhitelist; + AmmunitionLoader.get().clear(); AmmunitionLoader.get().putAll(previousAmmo); + VehicleLoader.get().clear(); VehicleLoader.get().putAll(previousVehicles); + } + + @Test void bareVehicleCommandReturnsUsageInsteadOfThrowing() { + assertFalse(assertDoesNotThrow(() -> run(player))); + } + + @Test void ownershipRefreshRemovesDisabledWhitelistControls() { + Inventory menu = inventory(27, null); ownership.ownershipGui(menu, player, vehicle, false); + assertEquals(Material.WRITABLE_BOOK, menu.getItem(2).getType()); + Cache.allowWhitelist = false; ownership.ownershipGui(menu, player, vehicle, false); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, menu.getItem(2).getType()); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, menu.getItem(4).getType()); + } + + @Test void commandsRespectNamePermissionAndUnknownArguments() { + when(command.getName()).thenReturn("other"); assertFalse(run(player, "reload")); + when(command.getName()).thenReturn("VF"); assertTrue(run(player, "spawn", "cart")); + verify(player).sendMessage("§cYou do not have access to this command!"); verifyNoInteractions(vehicles); + when(player.hasPermission("vehicleframework.spawn")).thenReturn(true); + assertTrue(run(player, "reload")); verify(plugin, never()).reload(); + when(player.hasPermission("vf.admin")).thenReturn(true); assertFalse(run(player, "unknown")); + } + + @ParameterizedTest @ValueSource(strings = {"keybinds", "ammo", "kill", "spawn", "takeover"}) + void playerOnlyCommandsRejectConsoleWithoutPlayerCasts(String verb) { + CommandSender console = mock(CommandSender.class); when(console.hasPermission("vf.admin")).thenReturn(true); + assertTrue(verb.equals("kill") || verb.equals("spawn") ? run(console, verb, "10") : run(console, verb)); + verifyNoInteractions(vehicles); + } + + @Test void keybindsReportTheCurrentVehicleStateAndSkipUnboundActions() { + assertFalse(run(player, "keybinds")); verify(player).sendMessage("§cYou are not in a vehicle"); + when(vehicles.getByPassenger(player)).thenReturn(vehicle); + VehicleState state = mock(VehicleState.class, RETURNS_DEEP_STUBS); when(vehicle.getCurrentState()).thenReturn(state); + when(state.getType()).thenReturn(State.GROUND); + when(state.getInputHandler().getMappings()).thenReturn(new HashMap<>(Map.of(Keybind.W, Input.THROTTLE_UP, Keybind.S, Input.NONE))); + assertTrue(run(player, "keybinds")); verify(player).sendMessage("§bKeybinds for state: §aGround"); + verify(player).sendMessage("§eW §f-> §aSpeed Up"); verify(player, never()).sendMessage("§eS §f-> §aNone"); + } + + @Test void ownedVehicleListShowsLoadedCoordinatesAndUnknownStoredLocations() { + assertTrue(run(player, "findvehicles")); verify(player).sendMessage("§7You do not own any vehicles."); + OwnedVehicleSummary loaded = mock(OwnedVehicleSummary.class), stored = mock(OwnedVehicleSummary.class), lostWorld = mock(OwnedVehicleSummary.class); + when(loaded.getName()).thenReturn("Cart"); when(loaded.getLocation()).thenReturn(Optional.of(new Location(world, 10, 64, -12))); + when(stored.getName()).thenReturn("Stored"); when(stored.getLocation()).thenReturn(Optional.empty()); + when(lostWorld.getName()).thenReturn("Lost"); when(lostWorld.getLocation()).thenReturn(Optional.of(new Location(null, 0, 64, 0))); + when(vehicles.listOwnedVehicles("player_Alex")).thenReturn(List.of(loaded, stored, lostWorld)); + assertTrue(run(player, "findvehicles")); verify(player).sendMessage("§eCart §7- §fworld 10, 64, -12"); + verify(player).sendMessage("§eStored §7- §7location unknown (stored)"); verify(player).sendMessage("§eLost §7- §7location unknown (stored)"); + CommandSender console = mock(CommandSender.class); assertTrue(run(console, "findvehicles")); + verify(console).sendMessage("§cOnly players can use this command."); + } + + @Test void spawnKillAmmoTakeoverAndReloadReachTheirAuthorizedActions() { + when(player.hasPermission("vf.admin")).thenReturn(true); assertTrue(run(player, "spawn", "cart")); + verify(vehicles).spawn(player.getLocation(), "cart"); assertTrue(run(player, "kill", "invalid")); + verify(player).sendMessage("§cPlease enter a valid number for the radius."); + when(vehicles.kill(player, player.getLocation(), 20)).thenReturn(3); assertTrue(run(player, "kill", "20")); + verify(player).sendMessage("§a[VehicleFramework] §e§aKilled 3 entities within 20 blocks."); + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); keep(mockStatic(TLibs.class)).when(TLibs::getItemAPI).thenReturn(api); + YamlConfiguration config = new YamlConfiguration(); config.set("input", "custom.shell"); + AmmunitionLoader.get().put("shell", new Ammunition("shell", config)); ItemStack shell = mock(ItemStack.class); + when(api.getCreator().getItemFromPath("custom.shell")).thenReturn(shell); + assertTrue(run(player, "ammo")); verify(shell).setAmount(64); verify(player.getInventory()).addItem(shell); + assertTrue(run(player, "takeover")); verify(vehicles).startTakeover(player); + assertTrue(run(player, "reload")); verify(plugin).reload(); + verify(player).sendMessage("§a[VehicleFramework] §eReload complete!"); + CommandSender console = mock(CommandSender.class); when(console.hasPermission("vf.admin")).thenReturn(true); + assertTrue(run(console, "reload")); verify(plugin, times(2)).reload(); + } + + @Test void trackCommandDelegatesItsArgumentsAndResult() { + String[] args = {"track", "list"}; MockedStatic tracks = keep(mockStatic(TrackCommands.class)); + tracks.when(() -> TrackCommands.handle(player, args)).thenReturn(true); + assertTrue(commands.onCommand(player, command, "vf", args)); tracks.verify(() -> TrackCommands.handle(player, args)); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void ownershipMenuReflectsWhitelistTicketsAndLinkedTrainOwnership(boolean enabled) { + owner.setWhiteListed(enabled); owner.setWhiteList(new ArrayList<>(List.of("player_Alex", "group_staff"))); + ActiveVehicle locomotive = mock(ActiveVehicle.class); OwnerData trainOwner = new OwnerData(); trainOwner.setTicketsEnabled(enabled); + when(locomotive.getOwnerData()).thenReturn(trainOwner); when(vehicle.ticketSource()).thenReturn(locomotive); + ownership.ownershipGui(null, player, vehicle, true); Inventory menu = menus.get(0); + assertEquals(enabled ? Material.LIME_DYE : Material.GRAY_DYE, menu.getItem(0).getType()); + assertEquals(enabled ? "§aTickets: ON" : "§7Tickets: OFF", menu.getItem(8).getItemMeta().getDisplayName()); + assertEquals(Material.WRITABLE_BOOK, menu.getItem(2).getType()); assertEquals(Material.BARRIER, menu.getItem(6).getType()); + assertTrue(menu.getItem(4).getItemMeta().getLore().contains("§7Players on whitelist: §f2")); + assertEquals(VFGUI.OWNERSHIP, ((VFInventoryHolder) menu.getHolder()).getType()); + assertSame(vehicle, ((VFInventoryHolder) menu.getHolder()).getVehicle().orElseThrow()); verify(player).openInventory(menu); + ItemStack button = ownership.createOwnershipButton(); assertEquals(Material.GOLDEN_HELMET, button.getType()); + assertEquals("§6Ownership Settings", button.getItemMeta().getDisplayName()); + } + + @Test void whitelistMenuStoresOriginalNamesReservesBackButtonAndClearsRemovedEntries() { + List names = new ArrayList<>(List.of("player_Alex", "staff")); + for (int i = 2; i < 30; i++) names.add("player_" + i); owner.setWhiteList(names); + ownership.whitelistGui(null, player, vehicle, true); Inventory menu = menus.get(0); + assertEquals("§eAlex", menu.getItem(0).getItemMeta().getDisplayName()); assertEquals("§estaff", menu.getItem(1).getItemMeta().getDisplayName()); + assertEquals("player_Alex", menu.getItem(0).getItemMeta().getPersistentDataContainer().get(new NamespacedKey("vehicleframework", "vf_whitelist_entry"), PersistentDataType.STRING)); + assertEquals(Material.PLAYER_HEAD, menu.getItem(25).getType()); assertEquals("§7Back", menu.getItem(26).getItemMeta().getDisplayName()); + assertEquals(VFGUI.WHITELIST, ((VFInventoryHolder) menu.getHolder()).getType()); + owner.setWhiteList(List.of()); ownership.whitelistGui(menu, player, vehicle, false); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, menu.getItem(0).getType()); verify(player).openInventory(menu); + } + + @Test void completionsFollowPermissionsAndOfferKnownVehiclesAndWorldTracks() { + TabCompletion completion = new TabCompletion(); + assertNull(completion.onTabComplete(mock(CommandSender.class), command, "vf", new String[]{""})); + assertEquals(List.of("findvehicles", "keybinds"), completion.onTabComplete(player, command, "vf", new String[]{""})); + assertTrue(completion.onTabComplete(player, command, "vf", new String[]{"spawn", ""}).isEmpty()); + when(player.hasPermission("vehicleframework.spawn")).thenReturn(true); + assertEquals(List.of("findvehicles", "keybinds", "spawn", "kill", "ammo"), completion.onTabComplete(player, command, "vf", new String[]{""})); + Vehicle cart = mock(Vehicle.class); when(cart.getId()).thenReturn("cart"); VehicleLoader.get().put("cart", cart); + assertEquals(List.of("cart"), completion.onTabComplete(player, command, "vf", new String[]{"spawn", ""})); + assertEquals(List.of("10", "20", "50"), completion.onTabComplete(player, command, "vf", new String[]{"kill", ""})); + when(player.hasPermission("vf.admin")).thenReturn(true); + assertTrue(completion.onTabComplete(player, command, "vf", new String[]{""}).containsAll(List.of("takeover", "reload", "track"))); + assertEquals(List.of("start", "end", "list", "info", "particles", "clearance", "dump", "delete", "bind", "unbind", "resync"), + completion.onTabComplete(player, command, "vf", new String[]{"track", ""})); + assertTrue(completion.onTabComplete(player, command, "vf", new String[]{"track", "delete", ""}).isEmpty()); + TrackRegistry registry = mock(TrackRegistry.class); TrackSpline track = mock(TrackSpline.class); UUID id = UUID.randomUUID(); when(track.getId()).thenReturn(id); + when(registry.inWorld("world")).thenReturn(List.of(track)); framework.when(VehicleFramework::getTrackRegistry).thenReturn(registry); + for (String verb : List.of("delete", "clearance")) assertEquals(List.of(id.toString()), completion.onTabComplete(player, command, "vf", new String[]{"track", verb, ""})); + assertNull(completion.onTabComplete(player, command, "vf", new String[]{"track", "unknown", ""})); + assertNull(completion.onTabComplete(player, command, "vf", new String[]{"unknown", ""})); + when(command.getName()).thenReturn("other"); assertNull(completion.onTabComplete(player, command, "other", new String[]{""})); + } + + private boolean run(CommandSender sender, String... args) { return commands.onCommand(sender, command, "vf", args); } + private Inventory inventory(int size, InventoryHolder holder) { + Inventory menu = mock(Inventory.class); ItemStack[] contents = new ItemStack[size]; + when(menu.getSize()).thenReturn(size); when(menu.getHolder()).thenReturn(holder); + when(menu.getItem(anyInt())).thenAnswer(call -> contents[call.getArgument(0)]); + doAnswer(call -> { contents[call.getArgument(0)] = call.getArgument(1); return null; }).when(menu).setItem(anyInt(), nullable(ItemStack.class)); + doAnswer(call -> { Arrays.fill(contents, null); return null; }).when(menu).clear(); return menu; + } + private void configure(ItemStack item, Material type) { + ItemMeta meta = mock(ItemMeta.class); PersistentDataContainer data = mock(PersistentDataContainer.class); + Map tags = new HashMap<>(); String[] name = {""}; List> lore = new ArrayList<>(List.of(List.of())); + when(item.getType()).thenReturn(type); when(item.getItemMeta()).thenReturn(meta); when(item.setItemMeta(meta)).thenReturn(true); + when(meta.getPersistentDataContainer()).thenReturn(data); + doAnswer(call -> { tags.put(call.getArgument(0), call.getArgument(2)); return null; }).when(data).set(any(), eq(PersistentDataType.STRING), anyString()); + when(data.get(any(), eq(PersistentDataType.STRING))).thenAnswer(call -> tags.get(call.getArgument(0))); + doAnswer(call -> { name[0] = call.getArgument(0); return null; }).when(meta).setDisplayName(anyString()); when(meta.getDisplayName()).thenAnswer(call -> name[0]); + doAnswer(call -> { lore.set(0, List.copyOf(call.getArgument(0))); return null; }).when(meta).setLore(anyList()); when(meta.getLore()).thenAnswer(call -> lore.get(0)); + } + private T keep(T scope) { scopes.add(scope); return scope; } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/InventoryManagerCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/InventoryManagerCoverageTest.java new file mode 100644 index 0000000..30700c2 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/InventoryManagerCoverageTest.java @@ -0,0 +1,246 @@ +package net.tfminecraft.vehicleframework.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import org.bukkit.*; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.entity.*; +import org.bukkit.inventory.*; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.data.*; +import net.tfminecraft.vehicleframework.enums.Component; +import net.tfminecraft.vehicleframework.enums.SeatType; +import net.tfminecraft.vehicleframework.enums.VFGUI; +import net.tfminecraft.vehicleframework.managers.inventory.VFInventoryHolder; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.component.*; +import net.tfminecraft.vehicleframework.vehicles.handlers.*; +import net.tfminecraft.vehicleframework.vehicles.handlers.container.Container; +import net.tfminecraft.vehicleframework.vehicles.handlers.container.ContainerHandler; +import net.tfminecraft.vehicleframework.vehicles.handlers.skins.VehicleSkin; +import net.tfminecraft.vehicleframework.vehicles.seat.Seat; +import net.tfminecraft.vehicleframework.vehicles.util.Fire; +import net.tfminecraft.vehicleframework.weapons.ActiveWeapon; + +class InventoryManagerCoverageTest { + private final List scopes = new ArrayList<>(); + private VehicleFramework previousPlugin; + private final Server server = mock(Server.class); + private final Player player = mock(Player.class); + private final ActiveVehicle vehicle = mock(ActiveVehicle.class); + private final SeatHandler seats = mock(SeatHandler.class); + private final WeaponHandler weapons = mock(WeaponHandler.class); + private final OwnerData owner = mock(OwnerData.class); + private final Map itemState = new IdentityHashMap<>(); + private final List created = new ArrayList<>(); + private MockedConstruction ownership; + private InventoryManager manager; + + @BeforeAll static void bootstrapRegistries() { + net.tfminecraft.vehicleframework.test.RegistryFixture.initialize(); + try (MockedStatic access = mockStatic(io.papermc.paper.registry.RegistryAccess.class)) { + Registry registry = mock(Registry.class, call -> call.getMethod().getName().equals("getOrThrow") + ? mock(Enchantment.class) : RETURNS_DEFAULTS.answer(call)); + var provider = mock(io.papermc.paper.registry.RegistryAccess.class, call -> + registry); + access.when(io.papermc.paper.registry.RegistryAccess::registryAccess).thenReturn(provider); + assertNotNull(Enchantment.UNBREAKING); + } + } + @BeforeEach void setup() { + previousPlugin = VehicleFramework.plugin; + VehicleFramework.plugin = mock(VehicleFramework.class); + when(VehicleFramework.plugin.getName()).thenReturn("VehicleFramework"); + when(VehicleFramework.plugin.namespace()).thenReturn("vehicleframework"); + when(VehicleFramework.plugin.getServer()).thenReturn(server); + keep(mockConstruction(ItemStack.class, (item, context) -> configure(item, (Material) context.arguments().get(0)))); + ownership = keep(mockConstruction(OwnershipGUIManager.class)); + when(server.createInventory(any(InventoryHolder.class), anyInt(), anyString())).thenAnswer(call -> { + Inventory inventory = inventory(call.getArgument(1), call.getArgument(0)); + created.add(inventory); return inventory; + }); + when(player.getName()).thenReturn("Alex"); + when(vehicle.getUUID()).thenReturn("vehicle-id"); when(vehicle.getOwnerData()).thenReturn(owner); + when(owner.getOwner()).thenReturn("player_Alex"); + when(vehicle.getSeatHandler()).thenReturn(seats); when(seats.getSeats()).thenReturn(List.of()); + when(vehicle.getComponents()).thenReturn(List.of()); when(vehicle.getWeaponHandler()).thenReturn(weapons); + when(weapons.getWeapons()).thenReturn(List.of()); + manager = new InventoryManager(); + ItemStack button = new ItemStack(Material.TRIPWIRE_HOOK); + when(ownership.constructed().get(0).createOwnershipButton()).thenReturn(button); + } + @AfterEach void cleanup() throws Exception { + Collections.reverse(scopes); for (AutoCloseable scope : scopes) scope.close(); + VehicleFramework.plugin = previousPlugin; + } + + @Test void seatSelectorShowsTypeOccupancyPassengerAndLinkedStorage() { + Seat empty = new Seat(SeatType.PASSENGER, "front_seat"), occupied = new Seat(SeatType.CAPTAIN, "driver"); + occupied.mount(mock(Player.class)); + Seat creature = new Seat(SeatType.ENTITY, "pet_seat"), freeCreature = new Seat(SeatType.ENTITY, "free_pet"); + Entity pig = mock(Entity.class); when(pig.getType()).thenReturn(EntityType.PIG); creature.mount(pig); + when(seats.getSeats()).thenReturn(List.of(empty, occupied, creature, freeCreature)); + when(vehicle.hasContainers()).thenReturn(true); ContainerHandler containers = mock(ContainerHandler.class); + when(vehicle.getContainerHandler()).thenReturn(containers); Container storage = mock(Container.class); + when(storage.getName()).thenReturn("Luggage"); when(containers.getBySeat("front_seat")).thenReturn(storage); + when(vehicle.isPassenger(player, false)).thenReturn(true); + manager.seatSelection(null, player, vehicle, true); Inventory inventory = created.get(0); + assertEquals(Material.GREEN_CONCRETE, inventory.getItem(0).getType()); + assertEquals(Material.YELLOW_CONCRETE, inventory.getItem(1).getType()); + assertEquals(Material.GRAY_CONCRETE, inventory.getItem(2).getType()); + assertEquals(Material.CYAN_CONCRETE, inventory.getItem(3).getType()); + assertEquals("§aFront seat", inventory.getItem(0).getItemMeta().getDisplayName()); + assertEquals("front_seat", tag(inventory.getItem(0), "vf_seat_id")); + assertTrue(inventory.getItem(0).getItemMeta().getLore().contains("§7Storage: §fLuggage")); + assertTrue(inventory.getItem(2).getItemMeta().getLore().contains("§7Passenger: §ePig")); + assertTrue(inventory.getItem(3).getItemMeta().getLore().contains("§aClick to choose a passenger to seat here")); + assertEquals(Material.TRIPWIRE_HOOK, inventory.getItem(25).getType()); + assertEquals("§cDismount", inventory.getItem(26).getItemMeta().getDisplayName()); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(24).getType()); + verify(player).openInventory(inventory); + assertEquals(VFGUI.SEAT_SELECTION, ((VFInventoryHolder) inventory.getHolder()).getType()); + } + + @Test void nonOwnerGetsNoOwnershipButtonAndRefreshDoesNotReopen() { + when(owner.getOwner()).thenReturn("player_SomeoneElse"); Inventory inventory = inventory(27, null); + manager.seatSelection(inventory, player, vehicle, false); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(25).getType()); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(26).getType()); + verify(player, never()).openInventory(any(Inventory.class)); + } + + @ParameterizedTest @ValueSource(booleans = {true, false}) + void manySeatsDoNotOverwriteOwnershipOrDismountControls(boolean open) { + List many = new ArrayList<>(); for (int i = 0; i < 26; i++) many.add(new Seat(SeatType.PASSENGER, "seat_" + i)); + when(seats.getSeats()).thenReturn(many); when(vehicle.isPassenger(player, false)).thenReturn(true); + manager.seatSelection(open ? null : inventory(27, null), player, vehicle, open); Inventory inventory = created.get(0); + long represented = Arrays.stream(inventory.getContents()).filter(Objects::nonNull) + .filter(item -> tag(item, "vf_seat_id") != null).count(); + assertEquals(26, represented, "Every configured seat must remain selectable"); + assertEquals(Material.TRIPWIRE_HOOK, inventory.getItem(25).getType()); + assertEquals(Material.BARRIER, inventory.getItem(26).getType()); + assertEquals(36, inventory.getSize()); verify(player).openInventory(inventory); + } + + @Test void seatRefreshRemovesButtonsThatAreNoLongerAvailable() { + Inventory inventory = inventory(27, null); when(vehicle.isPassenger(player, false)).thenReturn(true); + manager.seatSelection(inventory, player, vehicle, false); + when(vehicle.isPassenger(player, false)).thenReturn(false); when(owner.getOwner()).thenReturn("player_Other"); + manager.seatSelection(inventory, player, vehicle, false); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(25).getType()); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(26).getType()); + } + + @ParameterizedTest @ValueSource(strings = {"repair", "water"}) + void repairMenuDistinguishesComponentsWeaponsAndSelectedTool(String tool) { + List components = new ArrayList<>(); + components.add(component(Hull.class, Component.HULL)); components.add(component(Engine.class, Component.ENGINE)); + components.add(component(GearedEngine.class, Component.GEARED_ENGINE)); components.add(component(Pump.class, Component.PUMP)); + components.add(component(Wings.class, Component.WINGS)); components.add(component(Harness.class, Component.HARNESS)); + components.add(component(Balloon.class, Component.BALLOON)); + VehicleComponent burning = components.get(0); Fire fire = new Fire(); fire.setProgress(50); + when(burning.isOnFire()).thenReturn(true); when(burning.getFire()).thenReturn(fire); + burning.getHealthData().startRepair(); + when(vehicle.getComponents()).thenReturn(components); + List guns = new ArrayList<>(); for (int i = 0; i < 10; i++) guns.add(weapon("gun" + i)); + guns.get(0).getHealthData().startRepair(); when(weapons.getWeapons()).thenReturn(guns); + manager.repairWindow(null, player, vehicle, true, tool); Inventory inventory = created.get(0); + Material[] types = {Material.NETHERITE_BLOCK, Material.BLAST_FURNACE, Material.FURNACE, Material.BREWING_STAND, + Material.WHITE_WOOL, Material.LEAD, Material.GREEN_CONCRETE}; + String[] ids = {"hull", "engine", "engine", "pump", "wings", "harness", "balloon"}; + for (int i = 0; i < types.length; i++) { assertEquals(types[i], inventory.getItem(i).getType()); assertEquals(ids[i], tag(inventory.getItem(i), "vf_component_type")); } + assertTrue(inventory.getItem(0).getItemMeta().getLore().contains(fire.getFireString())); + assertTrue(inventory.getItem(0).getItemMeta().getLore().stream().anyMatch(line -> line.contains("Repairing"))); + assertEquals("gun0", tag(inventory.getItem(7), "vf_weapon_repair_type")); + assertEquals("gun9", tag(inventory.getItem(18), "vf_weapon_repair_type")); + assertEquals("repair", tag(inventory.getItem(8), "vf_tool_type")); assertEquals("water", tag(inventory.getItem(17), "vf_tool_type")); + ItemStack selected = inventory.getItem(tool.equals("repair") ? 8 : 17); + assertTrue(selected.getItemMeta().getLore().contains("§aSelected")); + verify(selected.getItemMeta()).addEnchant(Enchantment.UNBREAKING, 1, true); + verify(selected.getItemMeta()).addItemFlags(ItemFlag.HIDE_ENCHANTS); + verify(player).openInventory(inventory); + } + + @Test void repairMenuWithNoVehicleStillOffersBothToolsWhenRefreshing() { + Inventory inventory = inventory(27, null); manager.repairWindow(inventory, player, null, false, "none"); + assertEquals(Material.IRON_SHOVEL, inventory.getItem(8).getType()); + assertEquals(Material.WATER_BUCKET, inventory.getItem(17).getType()); + assertTrue(inventory.getItem(8).getItemMeta().getLore().contains("§eClick to Select")); + verify(player, never()).openInventory(any(Inventory.class)); + } + + @Test void repairComponentsSkipReservedToolSlots() { + List components = new ArrayList<>(); + for (int i = 0; i < 17; i++) components.add(component(Hull.class, Component.HULL)); + when(vehicle.getComponents()).thenReturn(components); Inventory inventory = inventory(27, null); + manager.repairWindow(inventory, player, vehicle, false, "repair"); + assertEquals("hull", tag(inventory.getItem(18), "vf_component_type")); + assertEquals("repair", tag(inventory.getItem(8), "vf_tool_type")); + } + + @Test void skinMenuKeepsActiveMissingModelAndFiltersOtherMissingModels() { + SkinHandler skins = mock(SkinHandler.class); when(vehicle.getSkinHandler()).thenReturn(skins); + VehicleSkin active = skin("current", "Current"), available = skin("new", "Available"), unavailable = skin("missing", "Missing"); + when(skins.getCurrentSkin()).thenReturn(active); + HashMap choices = new LinkedHashMap<>(); choices.put("current", active); choices.put("new", available); choices.put("missing", unavailable); + when(skins.getSkins()).thenReturn(choices); + MockedStatic access = keep(mockStatic(SkinHandler.class)); + access.when(() -> SkinHandler.isModelAvailable(available)).thenReturn(true); + manager.skinSelection(null, player, vehicle, true); Inventory inventory = created.get(0); + assertEquals(Material.YELLOW_CONCRETE, inventory.getItem(0).getType()); + assertEquals("§eCurrent", inventory.getItem(0).getItemMeta().getDisplayName()); + assertEquals("§aAvailable", inventory.getItem(1).getItemMeta().getDisplayName()); + assertEquals("new", tag(inventory.getItem(1), "vf_skin_id")); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(2).getType()); + manager.skinSelection(inventory, player, vehicle, false); verify(player, times(1)).openInventory(any(Inventory.class)); + } + + private VehicleComponent component(Class type, Component kind) { + VehicleComponent component = mock(type); HealthData health = new HealthData(100, 20, 60); + when(component.getType()).thenReturn(kind); when(component.getHealthData()).thenReturn(health); return component; + } + private ActiveWeapon weapon(String id) { + ActiveWeapon weapon = mock(ActiveWeapon.class); when(weapon.getId()).thenReturn(id); when(weapon.getName()).thenReturn(id); + HealthData health = new HealthData(100, 10, 40); when(weapon.getHealthData()).thenReturn(health); return weapon; + } + private VehicleSkin skin(String id, String name) { + VehicleSkin skin = mock(VehicleSkin.class); when(skin.getId()).thenReturn(id); when(skin.getName()).thenReturn(name); return skin; + } + private Inventory inventory(int size, InventoryHolder holder) { + Inventory inventory = mock(Inventory.class); ItemStack[] contents = new ItemStack[size]; + when(inventory.getSize()).thenReturn(size); when(inventory.getHolder()).thenReturn(holder); + when(inventory.getContents()).thenAnswer(call -> contents.clone()); + when(inventory.getItem(anyInt())).thenAnswer(call -> contents[call.getArgument(0)]); + doAnswer(call -> { contents[call.getArgument(0)] = call.getArgument(1); return null; }).when(inventory).setItem(anyInt(), nullable(ItemStack.class)); + doAnswer(call -> { Arrays.fill(contents, null); return null; }).when(inventory).clear(); return inventory; + } + private void configure(ItemStack item, Material type) { + ItemState state = new ItemState(); state.type = type; itemState.put(item, state); + ItemMeta meta = mock(ItemMeta.class); PersistentDataContainer data = mock(PersistentDataContainer.class); + Map values = new HashMap<>(); + when(item.getType()).thenAnswer(call -> state.type); + doAnswer(call -> { state.type = call.getArgument(0); return null; }).when(item).setType(any()); + when(item.getItemMeta()).thenReturn(meta); when(item.setItemMeta(meta)).thenReturn(true); + when(meta.getPersistentDataContainer()).thenReturn(data); + doAnswer(call -> { values.put(call.getArgument(0), call.getArgument(2)); return null; }).when(data).set(any(), eq(PersistentDataType.STRING), anyString()); + when(data.get(any(), eq(PersistentDataType.STRING))).thenAnswer(call -> values.get(call.getArgument(0))); + doAnswer(call -> { state.name = call.getArgument(0); return null; }).when(meta).setDisplayName(anyString()); + when(meta.getDisplayName()).thenAnswer(call -> state.name); + doAnswer(call -> { state.lore = List.copyOf(call.getArgument(0)); return null; }).when(meta).setLore(anyList()); + when(meta.getLore()).thenAnswer(call -> state.lore); + } + private String tag(ItemStack item, String name) { + return item.getItemMeta().getPersistentDataContainer().get(new NamespacedKey("vehicleframework", name), PersistentDataType.STRING); + } + private static final class ItemState { Material type; String name; List lore = List.of(); } + private T keep(T scope) { scopes.add(scope); return scope; } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/SpawnAndRepairCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/SpawnAndRepairCoverageTest.java new file mode 100644 index 0000000..44fa091 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/SpawnAndRepairCoverageTest.java @@ -0,0 +1,378 @@ +package net.tfminecraft.vehicleframework.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.*; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.world.*; +import org.bukkit.inventory.*; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.*; +import org.bukkit.plugin.PluginManager; +import org.bukkit.scheduler.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.vehicleframework.*; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.data.HealthData; +import net.tfminecraft.vehicleframework.database.*; +import net.tfminecraft.vehicleframework.enums.*; +import net.tfminecraft.vehicleframework.events.VehicleRepairStartEvent; +import net.tfminecraft.vehicleframework.loaders.VehicleLoader; +import net.tfminecraft.vehicleframework.managers.inventory.VFInventoryHolder; +import net.tfminecraft.vehicleframework.util.SpawnLocation; +import net.tfminecraft.vehicleframework.vehicles.*; +import net.tfminecraft.vehicleframework.vehicles.Vehicle; +import net.tfminecraft.vehicleframework.vehicles.component.VehicleComponent; +import net.tfminecraft.vehicleframework.vehicles.seat.Seat; +import net.tfminecraft.vehicleframework.vehicles.util.Fire; +import net.tfminecraft.vehicleframework.weapons.ActiveWeapon; + +class SpawnAndRepairCoverageTest { + @BeforeAll static void bootstrapRegistries() { net.tfminecraft.vehicleframework.test.RegistryFixture.initialize(); } + @TempDir Path temp; + private final List scopes = new ArrayList<>(); + private final List delayed = new ArrayList<>(), repeating = new ArrayList<>(); + private final VehicleManager manager = mock(VehicleManager.class); + private final Player player = mock(Player.class, RETURNS_DEEP_STUBS); + private final ActiveVehicle vehicle = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); + private final World world = mock(World.class); + private final BukkitScheduler scheduler = mock(BukkitScheduler.class); + private final PluginManager plugins = mock(PluginManager.class); + private final ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + private final Map previousVehicles = new HashMap<>(VehicleLoader.get()); + private VehicleFramework previousPlugin, plugin; + private String previousRepairItem; + private int previousDespawnDistance; + private MockedStatic bukkit; + private MockedStatic framework; + private MockedConstruction inventoryManagers; + private RepairManager repairs; + private SpawnManager spawns; + private VehicleRepository repository; + + @BeforeEach void setup() { + previousPlugin = VehicleFramework.plugin; plugin = mock(VehicleFramework.class); VehicleFramework.plugin = plugin; + when(plugin.getName()).thenReturn("VehicleFramework"); when(plugin.namespace()).thenReturn("vehicleframework"); + when(plugin.getDataFolder()).thenReturn(temp.toFile()); + bukkit = keep(mockStatic(Bukkit.class)); bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(world); + framework = keep(mockStatic(VehicleFramework.class)); framework.when(VehicleFramework::getVehicleRepository).thenAnswer(call -> repository); + keep(mockStatic(TLibs.class)).when(TLibs::getItemAPI).thenReturn(api); + keep(mockStatic(PersistenceLog.class)); keep(mockStatic(VFLogger.class)); + when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())).thenAnswer(call -> { delayed.add(call.getArgument(1)); return mock(BukkitTask.class); }); + when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong())).thenAnswer(call -> { repeating.add(call.getArgument(1)); return mock(BukkitTask.class); }); + inventoryManagers = keep(mockConstruction(InventoryManager.class)); + previousRepairItem = Cache.repairItem; Cache.repairItem = "custom.repair"; + previousDespawnDistance = Cache.despawnDistance; Cache.despawnDistance = 4096; + when(player.getWorld()).thenReturn(world); when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0)); + when(player.getOpenInventory().getTitle()).thenReturn("§7Repair Vehicle"); + when(api.getChecker().checkItemWithPath(any(), eq(Cache.repairItem))).thenReturn(true); + when(vehicle.getCurrentState().getType()).thenReturn(State.GROUND); when(vehicle.getSeat(any(Entity.class))).thenReturn(null); + when(manager.get("vehicle-id")).thenReturn(vehicle); + when(world.getName()).thenReturn("world"); when(manager.get()).thenReturn(new HashMap<>()); + repairs = new RepairManager(manager); spawns = new SpawnManager(manager); spawns.reload(); repeating.clear(); + VehicleLoader.get().clear(); + } + @AfterEach void cleanup() throws Exception { + bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); spawns.reload(); + if (repository != null) repository.close(); + Collections.reverse(scopes); for (AutoCloseable scope : scopes) scope.close(); + VehicleFramework.plugin = previousPlugin; Cache.repairItem = previousRepairItem; Cache.despawnDistance = previousDespawnDistance; + VehicleLoader.get().clear(); VehicleLoader.get().putAll(previousVehicles); + } + + @Test void componentRepairTracksItsVehicleSoClosingTheMenuCancelsIt() { + VehicleComponent hull = component(Component.HULL); startComponent(player, hull); + assertSame(vehicle, repairs.getRepairTarget(player)); + when(player.getOpenInventory().getTitle()).thenReturn("Closed"); repairs.tick(); + assertFalse(repairs.isBeingRepaired(hull)); assertFalse(hull.getHealthData().isUnderRepair()); + assertNull(repairs.getRepairTarget(player)); + } + + @Test void onePlayerCannotStartTwoComponentRepairsAtOnce() { + VehicleComponent hull = component(Component.HULL), pump = component(Component.PUMP); startComponent(player, hull); + repairs.repairComponent(player, tagged("vf_component_type", "pump"), key("vf_component_type"), vehicle); + assertFalse(pump.getHealthData().isUnderRepair()); assertTrue(repairs.isBeingRepaired(hull)); + verify(player).sendMessage("§cYou are already repairing something"); + } + + @Test void cancellingTwoClosedRepairMenusDoesNotModifyALiveMapIterator() { + Player second = mock(Player.class, RETURNS_DEEP_STUBS); when(second.getWorld()).thenReturn(world); + when(second.getLocation()).thenReturn(new Location(world, 1, 64, 0)); + ActiveWeapon firstGun = weapon("first"), secondGun = weapon("second"); + startWeapon(player, firstGun); startWeapon(second, secondGun); + when(player.getOpenInventory().getTitle()).thenReturn("Closed"); when(second.getOpenInventory().getTitle()).thenReturn("Closed"); + assertDoesNotThrow(repairs::tick); + assertFalse(repairs.isBeingRepaired(firstGun)); assertFalse(repairs.isBeingRepaired(secondGun)); + } + + @Test void reversingVehiclesMustStopBeforeAnOutsidePlayerCanRepair() { + when(vehicle.getAccessPanel().getSpeed()).thenReturn(-.3); repairs.repair(player, vehicle); + verify(player).sendMessage("§cCannot repair while moving"); verifyNoInteractions(inventoryManagers.constructed().get(0)); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void destroyedTargetsReleasePendingRepairState(boolean weapon) { + VehicleComponent hull = component(Component.HULL); ActiveWeapon gun = weapon("gun"); + if (weapon) startWeapon(player, gun); else startComponent(player, hull); + when(vehicle.isDestroyed()).thenReturn(true); delayed.get(0).run(); + assertFalse(weapon ? repairs.isBeingRepaired(gun) : repairs.isBeingRepaired(hull)); + assertNull(repairs.getRepairTarget(player)); + assertFalse((weapon ? gun.getHealthData() : hull.getHealthData()).isUnderRepair()); + } + + @Test void openingRepairsChecksCancellationItemFlyingMovementAndMechanicSeats() { + repairs.repair(null, vehicle); repairs.repair(player, null); verifyNoInteractions(plugins); + doAnswer(call -> { ((VehicleRepairStartEvent) call.getArgument(0)).setCancelled(true); return null; }).when(plugins).callEvent(any()); + repairs.repair(player, vehicle); verifyNoInteractions(inventoryManagers.constructed().get(0)); + doNothing().when(plugins).callEvent(any()); when(api.getChecker().checkItemWithPath(any(), eq(Cache.repairItem))).thenReturn(false); + repairs.repair(player, vehicle); assertNull(repairs.getTool(player)); + when(api.getChecker().checkItemWithPath(any(), eq(Cache.repairItem))).thenReturn(true); + when(vehicle.getCurrentState().getType()).thenReturn(State.FLYING); repairs.repair(player, vehicle); + verify(player).sendMessage("§cCannot repair while flying"); + when(vehicle.getCurrentState().getType()).thenReturn(State.GROUND); when(vehicle.getAccessPanel().getSpeed()).thenReturn(.3); + repairs.repair(player, vehicle); verify(player).sendMessage("§cCannot repair while moving"); + when(vehicle.getSeat(player)).thenReturn(new Seat(SeatType.PASSENGER, "seat")); repairs.repair(player, vehicle); + verify(player).sendMessage("§cYou are not in a mechanic seat"); + when(vehicle.getSeat(player)).thenReturn(new Seat(SeatType.MECHANIC, "mechanic")); repairs.repair(player, vehicle); + assertEquals("repair", repairs.getTool(player)); verify(inventoryManagers.constructed().get(0)).repairWindow(null, player, vehicle, true, "repair"); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void completedRepairsRefreshHealthAndReleaseTracking(boolean weapon) { + VehicleComponent hull = component(Component.HULL); ActiveWeapon gun = weapon("gun"); + if (weapon) startWeapon(player, gun); else startComponent(player, hull); + repairs.tick(); verify(inventoryManagers.constructed().get(0)).repairWindow(player.getOpenInventory().getTopInventory(), player, vehicle, false, "repair"); + HealthData health = weapon ? gun.getHealthData() : hull.getHealthData(); for (int tick = 0; tick < 20; tick++) health.tick(); + delayed.get(0).run(); assertFalse(weapon ? repairs.isBeingRepaired(gun) : repairs.isBeingRepaired(hull)); + assertNull(repairs.getRepairTarget(player)); assertTrue(health.getDamage() >= 6 && health.getDamage() <= 13); + verify(vehicle).updateBoard(); verify(player).sendMessage("§aRepaired §e" + (weapon ? "Cannon gun" : "Hull")); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void explicitCancellationPreventsDelayedSuccessMessages(boolean weapon) { + VehicleComponent hull = component(Component.HULL); ActiveWeapon gun = weapon("gun"); + if (weapon) startWeapon(player, gun); else startComponent(player, hull); + repairs.stop(player); delayed.get(0).run(); + assertNull(repairs.getTool(player)); assertFalse(weapon ? repairs.isBeingRepaired(gun) : repairs.isBeingRepaired(hull)); + verify(player).sendMessage("§cRepairing cancelled"); verify(vehicle, never()).updateBoard(); + } + + @Test void repairMenusDispatchToolsComponentsAndWeaponsAndIgnoreUnrelatedClicks() { + InventoryClickEvent unrelated = click(null, null); repairs.repairEvent(unrelated); verify(unrelated, never()).setCancelled(true); + InventoryClickEvent otherType = click(new VFInventoryHolder("vehicle-id", VFGUI.OWNERSHIP), null); repairs.repairEvent(otherType); verify(otherType, never()).setCancelled(true); + InventoryClickEvent empty = click(new VFInventoryHolder("vehicle-id", VFGUI.REPAIR), null); repairs.repairEvent(empty); verify(empty).setCancelled(true); + repairs.repairEvent(repairClick(tagged("unused", "unused"))); + repairs.repairEvent(repairClick(tagged("vf_tool_type", "water"))); assertEquals("water", repairs.getTool(player)); + verify(inventoryManagers.constructed().get(0)).repairWindow(null, player, vehicle, true, "water"); + VehicleComponent hull = component(Component.HULL); Fire fire = new Fire(); fire.setProgress(50); + when(hull.isOnFire()).thenReturn(true); when(hull.getFire()).thenReturn(fire); + repairs.repairEvent(repairClick(tagged("vf_component_type", "hull"))); assertTrue(fire.getProgress() >= 47 && fire.getProgress() <= 50); + verify(vehicle).updateBoard(); + repairs.repairEvent(repairClick(tagged("vf_tool_type", "repair"))); ActiveWeapon gun = weapon("gun"); + repairs.repairEvent(repairClick(tagged("vf_weapon_repair_type", "gun"))); assertTrue(repairs.isBeingRepaired(gun)); + } + + @Test void repairActionsRejectMissingHealthyOccupiedOrCancelledTargets() { + repairs.repair(player, vehicle); + when(vehicle.getComponent(Component.HULL)).thenReturn(null); + repairs.repairComponent(player, tagged("vf_component_type", "hull"), key("vf_component_type"), vehicle); + when(vehicle.getWeaponHandler().getWeapon("missing")).thenReturn(null); + repairs.repairWeapon(player, tagged("vf_weapon_repair_type", "missing"), key("vf_weapon_repair_type"), vehicle); + VehicleComponent hull = component(Component.HULL); hull.getHealthData().setDamage(0); + repairs.repairComponent(player, tagged("vf_component_type", "hull"), key("vf_component_type"), vehicle); + ActiveWeapon gun = weapon("gun"); gun.getHealthData().setDamage(0); + repairs.repairWeapon(player, tagged("vf_weapon_repair_type", "gun"), key("vf_weapon_repair_type"), vehicle); assertTrue(delayed.isEmpty()); + hull.getHealthData().setDamage(20); gun.getHealthData().setDamage(20); + doAnswer(call -> { VehicleRepairStartEvent event = call.getArgument(0); assertSame(vehicle, event.getVehicle()); + assertSame(VehicleRepairStartEvent.getHandlerList(), event.getHandlers()); event.setCancelled(true); return null; }).when(plugins).callEvent(any()); + repairs.repairComponent(player, tagged("vf_component_type", "hull"), key("vf_component_type"), vehicle); + repairs.repairWeapon(player, tagged("vf_weapon_repair_type", "gun"), key("vf_weapon_repair_type"), vehicle); assertTrue(delayed.isEmpty()); + repairs.repairEvent(repairClick(tagged("vf_tool_type", "water"))); Fire fire = new Fire(); fire.setProgress(50); + when(hull.isOnFire()).thenReturn(true); when(hull.getFire()).thenReturn(fire); + repairs.repairComponent(player, tagged("vf_component_type", "hull"), key("vf_component_type"), vehicle); assertEquals(50, fire.getProgress()); + repairs.repairWeapon(player, tagged("vf_weapon_repair_type", "gun"), key("vf_weapon_repair_type"), vehicle); assertTrue(delayed.isEmpty()); + } + + @Test void alreadyActiveTargetsAndPlayersCannotStartCompetingRepairs() { + VehicleComponent hull = component(Component.HULL); ActiveWeapon gun = weapon("gun"); startComponent(player, hull); + repairs.repairComponent(player, tagged("vf_component_type", "hull"), key("vf_component_type"), vehicle); + verify(player).sendMessage("§cComponent is already under repair"); + repairs.repairWeapon(player, tagged("vf_weapon_repair_type", "gun"), key("vf_weapon_repair_type"), vehicle); + verify(player).sendMessage("§cYou are already repairing something"); repairs.stop(player); startWeapon(player, gun); + repairs.repairWeapon(player, tagged("vf_weapon_repair_type", "gun"), key("vf_weapon_repair_type"), vehicle); + verify(player).sendMessage("§cWeapon is already under repair"); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void aCancelledAttemptCannotFinishANewerRepairOfTheSameTarget(boolean weapon) { + VehicleComponent hull = component(Component.HULL); ActiveWeapon gun = weapon("gun"); + if (weapon) startWeapon(player, gun); else startComponent(player, hull); + repairs.stop(player); + if (weapon) startWeapon(player, gun); else startComponent(player, hull); + delayed.get(0).run(); + assertTrue(weapon ? repairs.isBeingRepaired(gun) : repairs.isBeingRepaired(hull)); + assertSame(vehicle, repairs.getRepairTarget(player)); verify(vehicle, never()).updateBoard(); + delayed.get(1).run(); assertNull(repairs.getRepairTarget(player)); verify(vehicle).updateBoard(); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void anOldDestroyedVehicleCallbackCannotCancelRepairingADifferentVehicle(boolean weapon) { + VehicleComponent hull = component(Component.HULL); ActiveWeapon gun = weapon("gun"); + if (weapon) startWeapon(player, gun); else startComponent(player, hull); + repairs.stop(player); when(vehicle.isDestroyed()).thenReturn(true); + ActiveVehicle replacement = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); + when(replacement.getSeat(player)).thenReturn(null); when(replacement.getCurrentState().getType()).thenReturn(State.GROUND); + repairs.repair(player, replacement); + if (weapon) { + ActiveWeapon next = mock(ActiveWeapon.class); when(next.getHealthData()).thenReturn(new HealthData(100, 20, 20)); + when(replacement.getWeaponHandler().getWeapon("next")).thenReturn(next); + repairs.repairWeapon(player, tagged("vf_weapon_repair_type", "next"), key("vf_weapon_repair_type"), replacement); + } else { + VehicleComponent next = mock(VehicleComponent.class); when(next.getType()).thenReturn(Component.HULL); + when(next.getHealthData()).thenReturn(new HealthData(100, 20, 20)); when(replacement.getComponent(Component.HULL)).thenReturn(next); + repairs.repairComponent(player, tagged("vf_component_type", "hull"), key("vf_component_type"), replacement); + } + delayed.get(0).run(); assertSame(replacement, repairs.getRepairTarget(player)); + verify(replacement, never()).updateBoard(); delayed.get(1).run(); assertNull(repairs.getRepairTarget(player)); + } + + @Test void clickingAnItemInThePlayersInventoryCannotDispatchRepairMenuMetadata() { + repairs.repair(player, vehicle); VehicleComponent hull = component(Component.HULL); + InventoryClickEvent event = repairClick(tagged("vf_component_type", "hull")); + when(event.getRawSlot()).thenReturn(27); repairs.repairEvent(event); + verify(event).setCancelled(true); assertFalse(hull.getHealthData().isUnderRepair()); assertTrue(delayed.isEmpty()); + } + + @Test void staleRepairMenusIgnoreVehiclesThatHaveUnloaded() { + repairs.repair(player, vehicle); when(manager.get("vehicle-id")).thenReturn(null); + InventoryClickEvent event = repairClick(tagged("vf_component_type", "hull")); + assertDoesNotThrow(() -> repairs.repairEvent(event)); verify(event).setCancelled(true); assertTrue(delayed.isEmpty()); + } + + @Test void spawnQueueDeduplicatesFindsLegacyNamesAndRemovesCompletedEntries() { + Chunk chunk = chunk(0, 0); Location loc = new Location(world, 2, 64, 3); + SpawnLocation queued = new SpawnLocation(chunk, loc, "vehicle.json"); + assertSame(chunk, queued.getChunk()); + assertFalse(SpawnManager.exists(queued)); SpawnManager.remove(queued); SpawnManager.add(queued); + assertFalse(SpawnManager.exists(new SpawnLocation(chunk, loc, "another-vehicle"))); + SpawnManager.add(new SpawnLocation(chunk, new Location(world, 4, 64, 3), "VEHICLE.JSON")); + assertTrue(SpawnManager.exists(queued)); assertSame(loc, SpawnManager.findSpawnLocation("VEHICLE").orElseThrow()); + assertTrue(SpawnManager.findSpawnLocation(null).isEmpty()); assertTrue(SpawnManager.findSpawnLocation(" ").isEmpty()); + assertTrue(SpawnManager.findSpawnLocation("missing").isEmpty()); SpawnManager.remove(queued); assertFalse(SpawnManager.exists(queued)); + assertEquals("", SpawnManager.stripJson(null)); assertEquals("vehicle", SpawnManager.stripJson("vehicle.JSON")); + assertEquals("vehicle", SpawnManager.stripJson("vehicle")); spawns.save(); + } + + @Test void loadedChunkSnapshotsQueueKnownWorldsAndSkipInvalidDeletedAndAlreadyActiveRows() { + openRepository(); Chunk chunk = chunk(1, -1); when(world.getLoadedChunks()).thenReturn(new Chunk[]{chunk}); + when(world.getChunkAt(1, -1)).thenReturn(chunk); bukkit.when(Bukkit::getWorlds).thenReturn(List.of(world)); + repository.upsert(snapshot("queued", "world", false)); spawns.start(); + assertEquals(new Location(world, 20, 64, -2, 45, 0), SpawnManager.findSpawnLocation("queued").orElseThrow()); + spawns.enqueueSnapshot(null); spawns.enqueueSnapshot(snapshot(null, "world", false)); spawns.enqueueSnapshot(snapshot("deleted", "world", true)); + when(manager.getByUUID("active")).thenReturn(vehicle); spawns.enqueueSnapshot(snapshot("active", "world", false)); + spawns.enqueueSnapshot(snapshot("missing-world", "unloaded", false)); + assertTrue(SpawnManager.findSpawnLocation("active").isEmpty()); assertTrue(SpawnManager.findSpawnLocation("deleted").isEmpty()); + assertTrue(SpawnManager.findSpawnLocation("missing-world").isEmpty()); + spawns.chunkLoad(new ChunkLoadEvent(chunk(9, 9), false)); spawns.reload(); assertTrue(SpawnManager.findSpawnLocation("queued").isPresent()); + } + + @Test void queuedVehiclesRetryTransientFailuresAndAreRemovedOnlyAfterSuccessfulSpawn() { + openRepository(); Vehicle template = mock(Vehicle.class); VehicleLoader.get().put("cart", template); + repository.upsert(snapshot("queued", "world", false)); Chunk chunk = chunk(1, -1); Location loc = new Location(world, 20, 64, -2); + SpawnLocation queued = new SpawnLocation(chunk, loc, "queued.json"); SpawnManager.add(queued); + assertFalse(spawns.trySpawn(queued)); assertTrue(SpawnManager.exists(queued)); + ActiveVehicle loaded = mock(ActiveVehicle.class); when(manager.spawn(eq(loc), same(template), any(IncompleteVehicle.class))).thenReturn(loaded); + assertTrue(spawns.trySpawn(queued)); assertFalse(SpawnManager.exists(queued)); + SpawnManager.add(queued); when(manager.getByUUID("queued")).thenReturn(loaded); assertTrue(spawns.trySpawn(queued)); assertFalse(SpawnManager.exists(queued)); + } + + @Test void missingPersistencePayloadTypeOrWorldLeavesQueueEntriesAvailableForRetry() { + SpawnLocation queued = new SpawnLocation(chunk(1, -1), new Location(world, 20, 64, -2), "queued"); SpawnManager.add(queued); + assertFalse(spawns.trySpawn(queued)); spawns.chunkLoad(new ChunkLoadEvent(chunk(1, -1), false)); + openRepository(); assertFalse(spawns.trySpawn(queued)); repository.upsert(snapshot("queued", "world", false)); + assertFalse(spawns.trySpawn(queued)); VehicleLoader.get().put("cart", mock(Vehicle.class)); + assertFalse(spawns.trySpawn(new SpawnLocation(null, null, "queued"))); + assertFalse(spawns.trySpawn(new SpawnLocation(null, new Location(null, 0, 64, 0), "queued"))); + assertTrue(SpawnManager.exists(queued)); assertFalse(SpawnManager.isComplete(null)); + IncompleteVehicle blank = mock(IncompleteVehicle.class); when(blank.getId()).thenReturn(" "); assertFalse(SpawnManager.isComplete(blank)); + } + + @Test void scheduledSpawnCycleWaitsForNearbyPlayersAndRetriesAfterPlatformFailure() { + openRepository(); Vehicle template = mock(Vehicle.class); VehicleLoader.get().put("cart", template); + repository.upsert(snapshot("queued", "world", false)); Location loc = new Location(world, 20, 64, -2); + SpawnManager.add(new SpawnLocation(chunk(1, -1), loc, "queued")); spawns.start(); Runnable cycle = repeating.get(0); + cycle.run(); verify(manager, never()).spawn(any(Location.class), any(Vehicle.class), any(IncompleteVehicle.class)); + Player distant = mock(Player.class); when(distant.getWorld()).thenReturn(world); when(distant.getLocation()).thenReturn(new Location(world, 1000, 64, 0)); + Player otherWorld = mock(Player.class); when(otherWorld.getWorld()).thenReturn(mock(World.class)); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(distant, otherWorld)); cycle.run(); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + when(manager.spawn(eq(loc), same(template), any(IncompleteVehicle.class))).thenThrow(new IllegalStateException("Model unavailable")).thenReturn(vehicle); + assertThrows(IllegalStateException.class, cycle::run); assertTrue(SpawnManager.findSpawnLocation("queued").isPresent()); + cycle.run(); assertTrue(SpawnManager.findSpawnLocation("queued").isEmpty()); + } + + @Test void unloadEventsOnlyUnloadVehiclesInTheAffectedChunkWithoutLoadingOtherChunks() { + Chunk affected = chunk(-1, 2); Entity inside = mock(Entity.class), outside = mock(Entity.class), broken = mock(Entity.class); + when(inside.getLocation()).thenReturn(new Location(world, -.5, 64, 32)); when(outside.getLocation()).thenReturn(new Location(world, 0, 64, 32)); + when(broken.getLocation()).thenThrow(new IllegalStateException("Unloaded")); ActiveVehicle other = mock(ActiveVehicle.class); + manager.get().put(inside, vehicle); manager.get().put(outside, other); manager.get().put(broken, other); + spawns.chunkUnload(new ChunkUnloadEvent(affected)); verify(manager).unload(vehicle, "on chunk unload"); verify(manager, never()).unload(other, "on chunk unload"); + spawns.entitiesUnload(new EntitiesUnloadEvent(affected, List.of(inside, mock(Entity.class)))); verify(manager).unload(vehicle, "on entity unload"); + assertFalse(SpawnManager.entityInChunk(null, affected)); assertFalse(SpawnManager.entityInChunk(inside, null)); + assertFalse(SpawnManager.entityInChunk(broken, affected)); verify(world, never()).getChunkAt(any(Location.class)); + } + + private void openRepository() { repository = VehicleRepository.open(temp.resolve("vehicles.db").toFile()); } + private Chunk chunk(int x, int z) { + Chunk chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(world); when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z); return chunk; + } + private VehicleSnapshot snapshot(String id, String worldName, boolean deleted) { + return new VehicleSnapshot(id, "cart", worldName, 20, 64, -2, 45, 1, -1, + "{\"id\":\"cart\",\"name\":\"Cart\",\"skin\":\"default\",\"components\":{},\"rotators\":{}}", 2, 1, deleted, 1); + } + + private InventoryClickEvent repairClick(ItemMeta meta) { + ItemStack item = mock(ItemStack.class); when(item.getItemMeta()).thenReturn(meta); + return click(new VFInventoryHolder("vehicle-id", VFGUI.REPAIR), item); + } + private InventoryClickEvent click(InventoryHolder holder, ItemStack item) { + InventoryClickEvent event = mock(InventoryClickEvent.class, RETURNS_DEEP_STUBS); + when(event.getWhoClicked()).thenReturn(player); when(event.getView().getTopInventory().getHolder()).thenReturn(holder); + when(event.getView().getTopInventory().getSize()).thenReturn(27); when(event.getRawSlot()).thenReturn(0); + when(event.getCurrentItem()).thenReturn(item); return event; + } + + private VehicleComponent component(Component type) { + VehicleComponent component = mock(VehicleComponent.class); when(component.getType()).thenReturn(type); + when(component.getHealthData()).thenReturn(new HealthData(100, 20, 20)); when(vehicle.getComponent(type)).thenReturn(component); return component; + } + private ActiveWeapon weapon(String id) { + ActiveWeapon weapon = mock(ActiveWeapon.class); when(weapon.getId()).thenReturn(id); when(weapon.getName()).thenReturn("Cannon " + id); + when(weapon.getHealthData()).thenReturn(new HealthData(100, 20, 20)); when(vehicle.getWeaponHandler().getWeapon(id)).thenReturn(weapon); return weapon; + } + private void startComponent(Player who, VehicleComponent component) { + repairs.repair(who, vehicle); repairs.repairComponent(who, tagged("vf_component_type", component.getType().name().toLowerCase()), key("vf_component_type"), vehicle); + } + private void startWeapon(Player who, ActiveWeapon weapon) { + repairs.repair(who, vehicle); repairs.repairWeapon(who, tagged("vf_weapon_repair_type", weapon.getId()), key("vf_weapon_repair_type"), vehicle); + } + private ItemMeta tagged(String name, String value) { + ItemMeta meta = mock(ItemMeta.class, RETURNS_DEEP_STUBS); + when(meta.getPersistentDataContainer().get(key(name), PersistentDataType.STRING)).thenReturn(value); return meta; + } + private NamespacedKey key(String name) { return new NamespacedKey("vehicleframework", name); } + private T keep(T scope) { scopes.add(scope); return scope; } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java new file mode 100644 index 0000000..0fef0df --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java @@ -0,0 +1,931 @@ +package net.tfminecraft.vehicleframework.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.*; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.event.Event; +import org.bukkit.event.block.Action; +import org.bukkit.event.entity.*; +import org.bukkit.event.entity.EntityDamageEvent.DamageCause; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.*; +import org.bukkit.inventory.*; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.PluginManager; +import org.bukkit.scheduler.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import com.ticxo.modelengine.api.ModelEngineAPI; +import com.ticxo.modelengine.api.model.ModeledEntity; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.vehicleframework.*; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.data.*; +import net.tfminecraft.vehicleframework.database.*; +import net.tfminecraft.vehicleframework.enums.*; +import net.tfminecraft.vehicleframework.events.*; +import net.tfminecraft.vehicleframework.loaders.*; +import net.tfminecraft.vehicleframework.managers.inventory.VFInventoryHolder; +import net.tfminecraft.vehicleframework.managers.spawner.VehicleSpawner; +import net.tfminecraft.vehicleframework.tracks.*; +import net.tfminecraft.vehicleframework.util.Damager; +import net.tfminecraft.vehicleframework.vehicles.*; +import net.tfminecraft.vehicleframework.vehicles.Vehicle; +import net.tfminecraft.vehicleframework.vehicles.component.Harness; +import net.tfminecraft.vehicleframework.vehicles.handlers.*; +import net.tfminecraft.vehicleframework.vehicles.handlers.SeatHandler.MountResult; +import net.tfminecraft.vehicleframework.vehicles.handlers.container.Container; +import net.tfminecraft.vehicleframework.vehicles.handlers.train.*; +import net.tfminecraft.vehicleframework.vehicles.seat.Seat; + +class VehicleManagerCoverageTest { + @BeforeAll + static void initializeSounds() { + net.tfminecraft.vehicleframework.test.RegistryFixture.initialize(); + } + + private final List scopes = new ArrayList<>(); + private final List scheduled = new ArrayList<>(); + private final List timers = new ArrayList<>(); + private MockedStatic bukkit; + private MockedStatic storage; + private MockedStatic loader; + private MockedStatic spawning; + private MockedStatic decks; + private MockedStatic tickets; + private MockedStatic tapeInteractions; + private MockedStatic ticketInteractions; + private MockedConstruction inventories; + private MockedConstruction ownership; + private MockedConstruction spawners; + private VehicleManager manager; + private final ItemAPI items = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + private final PluginManager plugins = mock(PluginManager.class); + private final BukkitScheduler scheduler = mock(BukkitScheduler.class); + private final World world = mock(World.class); + private VehicleFramework oldPlugin; + private VehicleFramework plugin; + private String oldDestroyItem, oldRepairItem, oldSkinItem; + + @BeforeEach + void setup() { + oldPlugin = VehicleFramework.plugin; + oldDestroyItem = Cache.destroyItem; oldRepairItem = Cache.repairItem; oldSkinItem = Cache.skinItem; + Cache.destroyItem = "test:destroy"; Cache.repairItem = "test:repair"; Cache.skinItem = "test:skin"; + plugin = mock(VehicleFramework.class, RETURNS_DEEP_STUBS); + VehicleFramework.plugin = plugin; + when(plugin.getName()).thenReturn("VehicleFramework"); + when(plugin.namespace()).thenReturn("vehicleframework"); + when(plugin.getServer().getScheduler()).thenReturn(scheduler); + MockedStatic libs = keep(mockStatic(TLibs.class)); + libs.when(TLibs::getItemAPI).thenReturn(items); + bukkit = keep(mockStatic(Bukkit.class)); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); + keep(mockStatic(VFLogger.class)); + keep(mockStatic(PersistenceLog.class)); + keep(mockStatic(ConsistRelinker.class)); + keep(mockStatic(TrainCollision.class)); + keep(mockStatic(DeckRiders.class)); + tapeInteractions = keep(mockStatic(TrainTapeInteract.class)); + ticketInteractions = keep(mockStatic(VehicleTicketInteract.class)); + decks = keep(mockStatic(DeckBody.class)); + tickets = keep(mockStatic(VehicleTicketItems.class)); + storage = keep(mockStatic(VehiclePersistence.class)); + loader = keep(mockStatic(VehicleLoader.class)); + spawning = keep(mockStatic(SpawnManager.class)); + spawning.when(() -> SpawnManager.findSpawnLocation(anyString())).thenReturn(Optional.empty()); + inventories = keep(mockConstruction(InventoryManager.class)); + ownership = keep(mockConstruction(OwnershipGUIManager.class)); + spawners = keep(mockConstruction(VehicleSpawner.class)); + keep(mockConstruction(SpawnManager.class)); + keep(mockConstruction(RepairManager.class)); + when(scheduler.runTask(any(), any(Runnable.class))).thenAnswer(call -> task(call.getArgument(1), scheduled)); + when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())).thenAnswer(call -> task(call.getArgument(1), scheduled)); + when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong())).thenAnswer(call -> task(call.getArgument(1), timers)); + when(world.getName()).thenReturn("world"); + manager = new VehicleManager(); + } + + @AfterEach + void teardown() throws Exception { + Collections.reverse(scopes); + for (AutoCloseable scope : scopes) scope.close(); + VehicleFramework.plugin = oldPlugin; + Cache.destroyItem = oldDestroyItem; Cache.repairItem = oldRepairItem; Cache.skinItem = oldSkinItem; + } + + private BukkitTask task(Runnable runnable, List target) { + target.add(runnable); BukkitTask task = mock(BukkitTask.class); + when(task.getTaskId()).thenReturn(target.size()); return task; + } + private T keep(T scope) { scopes.add(scope); return scope; } + private Player player(String name) { + Player player = mock(Player.class, RETURNS_DEEP_STUBS); + when(player.getName()).thenReturn(name); + when(player.getUniqueId()).thenReturn(UUID.nameUUIDFromBytes(name.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0)); + when(player.isOnline()).thenReturn(true); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + ItemStack air = item(Material.AIR, null); + when(player.getInventory().getItemInMainHand()).thenReturn(air); + return player; + } + private ActiveVehicle vehicle(String id) { + ActiveVehicle v = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); + when(v.getUUID()).thenReturn(id); + when(v.getId()).thenReturn("cart"); + when(v.getName()).thenReturn("Cart " + id); + when(v.getEntity().isValid()).thenReturn(true); + when(v.getEntity().getLocation()).thenReturn(new Location(world, 0, 64, 0)); + when(v.getLocation()).thenAnswer(ignored -> v.getEntity().getLocation()); + when(v.getOwnerData()).thenReturn(new OwnerData()); + when(v.ticketSource()).thenReturn(v); + TrainHandler train = v.getBehaviourHandler().getTrainHandler(); + when(v.getTrainHandler()).thenReturn(train); + return v; + } + private void register(ActiveVehicle v) { manager.get().put(v.getEntity(), v); } + private Seat mount(Player player, ActiveVehicle vehicle) { + Seat seat = mock(Seat.class); when(seat.getBone()).thenReturn("captain"); when(seat.getType()).thenReturn(SeatType.CAPTAIN); + when(vehicle.getSeatHandler().getSeat("captain")).thenReturn(seat); + when(vehicle.addPassenger(player, seat)).thenReturn(MountResult.MOUNTED); + when(vehicle.changeSeat(player, seat)).thenReturn(MountResult.MOUNTED); + manager.mount(player, "captain", vehicle); + when(vehicle.isPassenger(player, false)).thenReturn(true); + when(vehicle.getSeatHandler().isMounted(player)).thenReturn(true); + return seat; + } + private ItemStack item(Material material, String value) { + ItemStack item = mock(ItemStack.class, RETURNS_DEEP_STUBS); + when(item.getType()).thenReturn(material); + when(item.getItemMeta().getPersistentDataContainer().get(any(NamespacedKey.class), eq(PersistentDataType.STRING))).thenReturn(value); + return item; + } + private InventoryClickEvent click(Player player, ActiveVehicle v, VFGUI type, int slot, ItemStack item) { + InventoryClickEvent event = mock(InventoryClickEvent.class, RETURNS_DEEP_STUBS); + Inventory top = mock(Inventory.class); + VFInventoryHolder holder = new VFInventoryHolder(v.getUUID(), type, v); + when(top.getHolder()).thenReturn(holder); + when(top.getSize()).thenReturn(27); + when(event.getView().getTopInventory()).thenReturn(top); + when(event.getClickedInventory()).thenReturn(top); + when(event.getWhoClicked()).thenReturn(player); + when(event.getCurrentItem()).thenReturn(item); + when(event.getSlot()).thenReturn(slot); + when(event.getRawSlot()).thenReturn(slot); + return event; + } + private PlayerInteractEntityEvent interact(Player player, Entity target) { + return new PlayerInteractEntityEvent(player, target); + } + private VehiclePersistence persistence() { + VehiclePersistence persistence = mock(VehiclePersistence.class); + storage.when(VehiclePersistence::current).thenReturn(persistence); + return persistence; + } + @SuppressWarnings("unchecked") + private Map map(String name) throws Exception { + Field field = VehicleManager.class.getDeclaredField(name); field.setAccessible(true); + return (Map) field.get(manager); + } + + @Test + void registryQueriesAndUnregisterClearOnlyTheRemovedVehicle() throws Exception { + Player player = player("Ryan"); ActiveVehicle v = vehicle("ID"), other = vehicle("other"); + register(v); register(other); + assertSame(v, manager.getByUUID("id")); assertSame(v, manager.get("id")); assertSame(v, manager.get(v.getEntity())); + assertNull(manager.getByUUID("missing")); assertNull(manager.get("missing")); assertNull(manager.get(player)); + assertNull(manager.getByPassenger(player)); assertFalse(manager.isPassenger(player)); + when(v.isPassenger(player, true)).thenReturn(true); + assertSame(v, manager.getByPassenger(player)); assertTrue(manager.isPassenger(player)); + map("activeVehicle").put(player, v); map("tempVehicle").put(player, v); map("tow").put(player, v); + manager.unregister(v.getEntity()); manager.unregister(v.getEntity()); + assertNull(manager.get(v.getEntity())); assertSame(other, manager.get(other.getEntity())); + assertTrue(map("activeVehicle").isEmpty()); assertTrue(map("tempVehicle").isEmpty()); assertTrue(map("tow").isEmpty()); + assertNotNull(manager.getRepairManager()); assertNotNull(manager.getSpawnManager()); + } + + @Test + void spawnPublishesOnlyInitializedVehiclesAndCleansFailedSpawns() { + Vehicle type = mock(Vehicle.class); when(type.getId()).thenReturn("cart"); + Location location = new Location(world, 0, 64, 0); + assertNull(manager.spawn(location, "missing")); + loader.when(() -> VehicleLoader.getByString("cart")).thenReturn(type); + assertNull(manager.spawn(location, "cart")); + ActiveVehicle invalid = mock(ActiveVehicle.class); + when(spawners.constructed().getFirst().spawn(location, type, manager, null)).thenReturn(invalid); + assertNull(manager.spawn(location, type)); + ActiveVehicle valid = vehicle("v"); + when(spawners.constructed().getFirst().spawn(location, type, manager, null)).thenReturn(valid); + assertSame(valid, manager.spawn(location, type)); + assertSame(valid, manager.get(valid.getEntity())); + verify(valid).restorePassengers(null); + verify(plugins).callEvent(isA(VehicleSpawnEvent.class)); + doThrow(new IllegalStateException("event failure")).when(plugins).callEvent(isA(VehicleSpawnEvent.class)); + doThrow(new IllegalStateException("cleanup failure")).when(valid).remove(VehicleRemoveReason.UNLOAD); + assertNull(manager.spawn(location, type)); + verify(valid).remove(VehicleRemoveReason.UNLOAD); + } + + @Test + void killIgnoresNonVehiclesAndTombstonesRemovedVehicles() { + Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); register(v); + Location location = v.getLocation(); + assertEquals(0, manager.kill(player, new Location(null, 0, 0, 0), 10)); + List nearby = List.of(player, v.getEntity()); + when(world.getNearbyEntities(location, 10, 10, 10)).thenReturn(nearby); + assertEquals(1, manager.kill(player, location, 10)); + verify(v).remove(VehicleRemoveReason.ADMIN_KILL); + VehiclePersistence persistence = persistence(); when(persistence.tombstone("v")).thenReturn(true); + assertEquals(1, manager.kill(null, location, 10)); verify(persistence).tombstone("v"); + } + + @Test + void swapAndMouseBindingsRequireAMountedPassenger() throws Exception { + Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); register(v); + PlayerSwapHandItemsEvent swap = mock(PlayerSwapHandItemsEvent.class); when(swap.getPlayer()).thenReturn(player); + manager.swap(swap); verify(v, never()).key(any(), any()); + mount(player, v); manager.swap(swap); verify(v).key(player, Keybind.SWAP); + PlayerInteractEvent click = mock(PlayerInteractEvent.class); when(click.getPlayer()).thenReturn(player); + when(click.getAction()).thenReturn(Action.RIGHT_CLICK_AIR); + manager.clickWhileMounted(click); verify(v).key(player, Keybind.RIGHT_CLICK); + manager.inputPacket(player, 0, 0, false, true); + manager.clickWhileMounted(click); verify(v).key(player, Keybind.SHIFT_RIGHT_CLICK); + when(click.getAction()).thenReturn(Action.LEFT_CLICK_BLOCK); + manager.clickWhileMounted(click); verify(v).key(player, Keybind.SHIFT_LEFT_CLICK); + map("mountedLeftClickAt").put(player.getUniqueId(), Long.MAX_VALUE); + PlayerAnimationEvent animation = mock(PlayerAnimationEvent.class); + when(animation.getPlayer()).thenReturn(player); when(animation.getAnimationType()).thenReturn(PlayerAnimationType.ARM_SWING); + manager.swingWhileMounted(animation); verify(v, times(1)).key(player, Keybind.SHIFT_LEFT_CLICK); + manager.dismount(player); manager.dismount(null); + manager.swap(swap); verify(v, times(1)).key(player, Keybind.SWAP); + } + + @Test + void inputPacketsRouteMovementAndCaptainJunctionRequests() { + Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); register(v); + manager.inputPacket(player, 1, 1, false, false); verify(v, never()).key(any(), any()); + mount(player, v); + when(v.getSeatHandler().isMounted(player)).thenReturn(false); + manager.inputPacket(player, 1, 1, false, false); verify(v, never()).key(any(), any()); + when(v.getSeatHandler().isMounted(player)).thenReturn(true); + when(v.isTrain()).thenReturn(true); when(v.getSeatHandler().isCaptain(player)).thenReturn(true); + manager.inputPacket(player, 1, 1, false, false); + manager.inputPacket(player, -1, -1, false, false); + verify(v.getTrainHandler()).holdJunction(TrackJunction.Side.LEFT); + verify(v.getTrainHandler()).holdJunction(TrackJunction.Side.RIGHT); + for (Keybind key : List.of(Keybind.W, Keybind.S, Keybind.A, Keybind.D)) verify(v).key(player, key); + } + + @Test + void leadInteractionOnlyUsesHarnessForSupportedAnimals() { + Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); + when(v.getComponent(Component.HARNESS)).thenReturn(null); + assertFalse(manager.leadInteract(player, v)); + LivingEntity cow = mock(LivingEntity.class); manager.leashedInteract(player, v, cow); verify(cow).setLeashHolder(null); + Harness harness = mock(Harness.class); when(v.getComponent(Component.HARNESS)).thenReturn(harness); + when(harness.dismount(player)).thenReturn(true); assertTrue(manager.leadInteract(player, v)); + for (Class type : List.of(Horse.class, Donkey.class, Mule.class)) { + LivingEntity animal = mock(type); manager.leashedInteract(player, v, animal); verify(harness).mount(player, animal); + } + } + + @Test + void towSelectionCanBeReplacedAndAttachmentChecksOccupancyAndDistance() { + Player player = player("Ryan"); ActiveVehicle v = vehicle("v"), trailer = vehicle("trailer"); + Location towLocation = v.getLocation(); + when(v.getTowHandler().getTowLocation()).thenReturn(towLocation); + manager.towSelect(player, trailer); manager.towSelect(player, trailer); + when(v.getTowHandler().isOccupied()).thenReturn(true); + manager.towAttach(player, v); verify(v.getTowHandler(), never()).attach(any()); + when(v.getTowHandler().isOccupied()).thenReturn(false); + when(trailer.getEntity().getLocation()).thenReturn(new Location(world, 10, 64, 0)); + manager.towAttach(player, v); verify(v.getTowHandler(), never()).attach(any()); + when(trailer.getEntity().getLocation()).thenReturn(new Location(world, 1, 64, 0)); + manager.towAttach(player, v); verify(v.getTowHandler()).attach(trailer); + } + + @Test + void persistenceFailuresPreserveLiveVehiclesAndSuccessfulUnloadRemovesThem() { + ActiveVehicle v = vehicle("v"); register(v); + assertFalse(manager.unload(null)); assertFalse(manager.persistDestroy(null)); + assertFalse(manager.persistDestroy(mock(ActiveVehicle.class))); + assertFalse(manager.unload(v)); verify(v, never()).remove(any(VehicleRemoveReason.class)); + VehiclePersistence persistence = persistence(); + when(persistence.saveLiveResult(v)).thenReturn(VehiclePersistResult.failed("disk full")); + assertFalse(manager.unload(v, " ")); verify(v, never()).remove(any(VehicleRemoveReason.class)); + when(persistence.saveLiveResult(v)).thenReturn(VehiclePersistResult.alreadyStored("chunk unloaded")); + assertTrue(manager.unload(v, null)); verify(v).remove(VehicleRemoveReason.UNLOAD); + when(persistence.saveLiveResult(v)).thenReturn(VehiclePersistResult.saved()); + manager.unloadAll(); verify(v, times(2)).remove(VehicleRemoveReason.UNLOAD); + when(v.isDestroyed()).thenReturn(true); + assertTrue(manager.unload(v)); verify(persistence).tombstone("v"); + } + + @Test + void storedAndLiveDamageOwnershipAndMetadataUseTheCurrentRepository() { + assertFalse(manager.unloadedDamage(null, .2, .03)); assertFalse(manager.unloadedDamage(" ", .2, .03)); + assertFalse(manager.unloadedDamage("offline", .2, .03)); + assertTrue(manager.readStoredVehicle("offline").isEmpty()); + manager.clearOwnership(null); manager.clearOwnership(" "); manager.clearOwnership("offline"); + ActiveVehicle v = vehicle("v"); register(v); v.getOwnerData().setOwner("player_Ryan"); + try (MockedStatic decay = mockStatic(VehicleHealthDecay.class)) { + manager.unloadedDamage(" v ", .2, .03); + decay.verify(() -> VehicleHealthDecay.applyToLive(v, .2, .03)); + } + VehiclePersistence persistence = persistence(); + when(persistence.applyStoredDecay("offline", .2, .03)).thenReturn(true); + assertTrue(manager.unloadedDamage(" offline ", .2, .03)); + StoredVehicleMeta meta = new StoredVehicleMeta("offline", "Old cart", "cart", "player_Ryan"); + when(persistence.readMeta("offline")).thenReturn(Optional.of(meta)); assertSame(meta, manager.readStoredVehicle("offline").orElseThrow()); + manager.clearOwnership("v"); assertEquals("none", v.getOwnerData().getOwner()); assertFalse(v.getOwnerData().isWhiteListed()); + verify(persistence).saveLive(v); + manager.clearOwnership("offline"); verify(persistence).clearOwnership("offline"); + } + + @Test + void ownerListingsPreferLiveStateAndDeduplicateStoredRows() { + ActiveVehicle v = vehicle("LIVE"), other = vehicle("other"); register(v); register(other); + v.getOwnerData().setOwner("player_Ryan"); other.getOwnerData().setOwner("npc_trader"); + assertTrue(manager.listOwnedVehicles(null).isEmpty()); assertTrue(manager.listOwnedVehicles(" ").isEmpty()); + assertEquals(1, manager.listOwnedVehicles("PLAYER_RYAN").size()); + assertEquals(1, manager.listAllPlayerOwnedVehicles().size()); + VehiclePersistence persistence = persistence(); + List stored = List.of(new StoredVehicleMeta("live", "Stale", "cart", "player_Ryan"), + new StoredVehicleMeta("offline", "Other cart", "cart", "player_Ryan")); + when(persistence.listByOwner("player_Ryan")).thenReturn(stored); + when(persistence.listPlayerOwned()).thenReturn(stored); + List result = manager.listOwnedVehicles("player_Ryan"); + assertEquals(2, result.size()); assertEquals("Cart LIVE", result.getFirst().getName()); assertTrue(result.getFirst().isSpawned()); + assertFalse(result.get(1).isSpawned()); assertEquals(2, manager.listAllPlayerOwnedVehicles().size()); + Vehicle type = mock(Vehicle.class); loader.when(() -> VehicleLoader.getByString("cart")).thenReturn(type); + when(persistence.countByOwner(eq("player_Ryan"), anySet())).thenReturn(Map.of("cart", 2, "unknown", 5)); + assertEquals(Map.of(type, 3), manager.getVehiclesByOwner("player_Ryan")); + verify(persistence).countByOwner("player_Ryan", Set.of("live")); + } + + @Test + void offlineLocationsUseLivePendingThenStoredCoordinatesWithoutLoadingChunks() { + assertTrue(manager.getOfflineLocation(null).isEmpty()); assertTrue(manager.getOfflineLocation(" ").isEmpty()); + assertTrue(manager.getOfflineLocation("missing").isEmpty()); + ActiveVehicle v = vehicle("live"); register(v); assertEquals(v.getLocation(), manager.getOfflineLocation("live").orElseThrow()); + Location pending = new Location(world, 10, 64, 20); + spawning.when(() -> SpawnManager.findSpawnLocation("pending")).thenReturn(Optional.of(pending)); + assertSame(pending, manager.getOfflineLocation("pending").orElseThrow()); + VehiclePersistence persistence = persistence(); VehicleSnapshot snapshot = mock(VehicleSnapshot.class); + when(persistence.findLive("stored")).thenReturn(Optional.of(snapshot)); + assertTrue(manager.getOfflineLocation("stored").isEmpty()); + when(snapshot.getWorld()).thenReturn("world"); assertTrue(manager.getOfflineLocation("stored").isEmpty()); + bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(world); + when(snapshot.getX()).thenReturn(12d); when(snapshot.getY()).thenReturn(65d); when(snapshot.getZ()).thenReturn(-3d); when(snapshot.getYaw()).thenReturn(45f); + assertEquals(new Location(world, 12, 65, -3, 45, 0), manager.getOfflineLocation("stored").orElseThrow()); + verify(world, never()).getChunkAt(anyInt(), anyInt()); + } + + @Test + void diagnosticDescriptionsHandleMissingAndInvalidEntities() { + assertEquals("unknown vehicle", VehicleManager.describeVehicle(null)); + ActiveVehicle empty = mock(ActiveVehicle.class); assertEquals("unknown (no-uuid)", VehicleManager.describeVehicle(empty)); + assertEquals("unknown location", VehicleManager.describeLocation(null)); assertEquals("unknown location", VehicleManager.describeLocation(empty)); + ActiveVehicle v = vehicle("v"); assertEquals("cart 'Cart v' (v)", VehicleManager.describeVehicle(v)); + assertEquals("world 0 64 0", VehicleManager.describeLocation(v)); + when(v.getEntity().getLocation()).thenThrow(new IllegalStateException("unloaded")); assertEquals("unknown location", VehicleManager.describeLocation(v)); + when(v.getEntity().isDead()).thenReturn(true); assertEquals("unknown location", VehicleManager.describeLocation(v)); + } + + @Test + void timersRunIndependentTicksAndSkipUnavailablePersistence() throws Exception { + ActiveVehicle v = vehicle("v"); register(v); Player player = player("Ryan"); + NamingData naming = mock(NamingData.class); when(naming.tick()).thenReturn(true); map("naming").put(player, naming); + manager.start(); assertEquals(3, timers.size()); verify(manager.getSpawnManager()).start(); + timers.get(0).run(); timers.get(1).run(); timers.get(2).run(); + verify(v).tick(); verify(v).slowTick(); verify(player).sendMessage("§cNaming timed out."); + doThrow(new IllegalStateException("broken tick")).when(v).tick(); + doThrow(new IllegalStateException("broken slow tick")).when(v).slowTick(); + manager.towSelect(player, v); when(v.getEntity().getLocation()).thenReturn(new Location(world, 100, 64, 0)); + assertDoesNotThrow(() -> timers.get(0).run()); assertDoesNotThrow(() -> timers.get(1).run()); + assertTrue(map("tow").isEmpty()); + VehiclePersistence persistence = persistence(); when(persistence.saveLiveResult(v)).thenReturn(VehiclePersistResult.failed("full")); + timers.get(2).run(); verify(persistence).checkpointWal(false); verify(persistence).vacuumIntoBackup(); + when(v.isDestroyed()).thenReturn(true); timers.get(2).run(); verify(persistence, times(1)).saveLiveResult(v); + manager.reload(); verify(manager.getSpawnManager()).reload(); + } + + @Test + void menuRefreshUpdatesOnlyMatchingLiveVehicleMenus() { + ActiveVehicle v = vehicle("v"); register(v); Player player = player("Ryan"); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + Inventory top = mock(Inventory.class); when(player.getOpenInventory().getTopInventory()).thenReturn(top); + manager.updateInventory(); + for (VFGUI type : List.of(VFGUI.SEAT_SELECTION, VFGUI.SKIN_SELECTION, VFGUI.REPAIR, VFGUI.OWNERSHIP)) { + when(top.getHolder()).thenReturn(new VFInventoryHolder("v", type)); manager.updateInventory(); + } + InventoryManager inv = inventories.constructed().getFirst(); + verify(inv).seatSelection(top, player, v, false); verify(inv).skinSelection(top, player, v, false); + verify(inv).repairWindow(top, player, v, false, null); + when(top.getHolder()).thenReturn(new VFInventoryHolder("missing", VFGUI.SEAT_SELECTION)); manager.updateInventory(); + when(player.getOpenInventory().getTopInventory()).thenReturn(null); manager.updateInventory(); + } + + @Test + void seatMenusAndMountsRespectTicketsAndExistingRiders() { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); register(v); + v.getOwnerData().setWhiteListed(true); v.getOwnerData().setOwner("player_Other"); + manager.seatInteract(player, v); verify(inventories.constructed().getFirst(), never()).seatSelection(any(), any(), any(), anyBoolean()); + v.getOwnerData().setTicketsEnabled(true); manager.seatInteract(player, v); + verify(player).sendMessage("§cYou need a ticket to board this vehicle."); + tickets.when(() -> VehicleTicketItems.inventoryHas(player, v.getOwnerData().getTicketId())).thenReturn(true); + manager.seatInteract(player, v); verify(inventories.constructed().getFirst()).seatSelection(null, player, v, true); + Seat seat = mock(Seat.class); when(seat.getType()).thenReturn(SeatType.PASSENGER); when(seat.getBone()).thenReturn("passenger"); + when(v.getSeatHandler().getSeat("passenger")).thenReturn(seat); + when(v.addPassenger(player, seat)).thenReturn(MountResult.MOUNTED); + manager.mount(player, "missing", v); manager.mount(player, "passenger", v); verify(v).addPassenger(player, seat); + manager.seatInteract(player, v); verify(inventories.constructed().getFirst(), times(1)).seatSelection(null, player, v, true); + } + + @Test + void ownershipMenuTogglesListsTicketsAndClearOwnership() { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); + v.getOwnerData().setOwner("player_Ryan"); + InventoryClickEvent event = click(player, v, VFGUI.OWNERSHIP, 0, item(Material.PAPER, null)); + manager.ownershipClick(event); assertTrue(v.getOwnerData().isWhiteListed()); verify(event).setCancelled(true); + when(event.getSlot()).thenReturn(8); manager.ownershipClick(event); assertTrue(v.getOwnerData().isTicketsEnabled()); + when(event.getSlot()).thenReturn(4); manager.ownershipClick(event); verify(ownership.constructed().getFirst()).whitelistGui(null, player, v, true); + when(event.getSlot()).thenReturn(6); manager.ownershipClick(event); assertEquals("none", v.getOwnerData().getOwner()); assertFalse(v.getOwnerData().isWhiteListed()); + } + + @Test + void chatAddsWhitelistNamesRejectsDuplicatesAndSupportsCancellation() { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); + InventoryClickEvent menu = click(player, v, VFGUI.OWNERSHIP, 2, item(Material.PAPER, null)); + for (String message : List.of(" Alice ", "Alice", "cancel")) { + manager.ownershipClick(menu); + AsyncPlayerChatEvent chat = new AsyncPlayerChatEvent(true, player, message, new HashSet<>()); + manager.nameVehicle(chat); assertTrue(chat.isCancelled()); + scheduled.removeFirst().run(); + } + assertEquals(List.of("player_Alice"), v.getOwnerData().getWhiteList()); + verify(player).sendMessage("§cAlice is already on the whitelist."); verify(player).sendMessage("§cCancelled."); + AsyncPlayerChatEvent normal = new AsyncPlayerChatEvent(true, player, "hello", new HashSet<>()); manager.nameVehicle(normal); assertFalse(normal.isCancelled()); + InventoryClickEvent remove = click(player, v, VFGUI.WHITELIST, 0, item(Material.PLAYER_HEAD, "player_Alice")); + manager.whitelistClick(remove); assertTrue(v.getOwnerData().getWhiteList().isEmpty()); + when(remove.getSlot()).thenReturn(26); manager.whitelistClick(remove); verify(ownership.constructed().getFirst()).ownershipGui(null, player, v, true); + } + + @Test + void takeoverCanExpireAndCanReplaceAnExistingOwner() { + Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); register(v); + manager.startTakeover(player); manager.startTakeover(player); verify(scheduler).cancelTask(1); + scheduled.removeLast().run(); verify(player).sendMessage("§cTakeover expired."); + manager.startTakeover(player); PlayerInteractEntityEvent event = interact(player, v.getEntity()); + manager.vehicleInteract(event); assertEquals("player_Ryan", v.getOwnerData().getOwner()); assertTrue(event.isCancelled()); + v.getOwnerData().setOwner("player_Other"); manager.startTakeover(player); manager.vehicleInteract(interact(player, v.getEntity())); + assertEquals("player_Ryan", v.getOwnerData().getOwner()); + } + + @Test + void interactionCancellationAndDeckMappingPrecedeOwnershipAndActions() { + Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); register(v); + Entity orphan = mock(Entity.class); decks.when(() -> DeckBody.isPart(orphan)).thenReturn(true); + PlayerInteractEntityEvent event = interact(player, orphan); manager.vehicleInteract(event); assertTrue(event.isCancelled()); + Entity deck = mock(Entity.class); decks.when(() -> DeckBody.owner(deck)).thenReturn(v); + doAnswer(call -> { if (call.getArgument(0) instanceof VehiclePreInteractEvent pre) pre.setCancelled(true); return null; }).when(plugins).callEvent(any(Event.class)); + event = interact(player, deck); manager.vehicleInteract(event); assertTrue(event.isCancelled()); assertEquals("none", v.getOwnerData().getOwner()); + Entity passenger = mock(Entity.class); when(v.isPassenger(passenger, true)).thenReturn(true); + event = interact(player, passenger); manager.vehicleInteract(event); assertTrue(event.isCancelled()); + } + + @Test + void interactionClaimsOwnershipAndRoutesToolsFuelSkinNamingAndSeatMenus() throws Exception { + Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); register(v); + when(v.getComponent(Component.HARNESS)).thenReturn(null); + manager.vehicleInteract(interact(player, v.getEntity())); + assertEquals("player_Ryan", v.getOwnerData().getOwner()); + verify(inventories.constructed().getFirst()).seatSelection(null, player, v, true); + map("cooldown").clear(); + when(items.getChecker().checkItemWithPath(any(), eq(Cache.repairItem))).thenReturn(true); + manager.vehicleInteract(interact(player, v.getEntity())); verify(manager.getRepairManager()).repair(player, v); + when(items.getChecker().checkItemWithPath(any(), eq(Cache.repairItem))).thenReturn(false); + map("cooldown").clear(); ItemStack nameTag = item(Material.NAME_TAG, null); when(player.getInventory().getItemInMainHand()).thenReturn(nameTag); + manager.vehicleInteract(interact(player, v.getEntity())); + AsyncPlayerChatEvent chat = new AsyncPlayerChatEvent(true, player, "Express_Cart", new HashSet<>()); + manager.nameVehicle(chat); assertTrue(chat.isCancelled()); scheduled.removeFirst().run(); verify(v).setName("Express Cart"); + map("cooldown").clear(); when(items.getChecker().checkItemWithPath(any(), eq(Cache.destroyItem))).thenReturn(true); + manager.vehicleInteract(interact(player, v.getEntity())); verify(v).remove(VehicleRemoveReason.PLAYER_DESTROY); + } + + @Test + void seatSelectionMountsEjectsAndEstablishesEntitySeatSelection() throws Exception { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"), rider = player("Rider"); register(v); + v.getOwnerData().setOwner("player_Ryan"); manager.seatInteract(player, v); + Seat seat = mock(Seat.class); when(seat.getBone()).thenReturn("seat"); when(seat.getType()).thenReturn(SeatType.PASSENGER); + when(v.getSeat("seat")).thenReturn(seat); when(v.addPassenger(player, seat)).thenReturn(MountResult.MOUNTED); + InventoryClickEvent event = click(player, v, VFGUI.SEAT_SELECTION, 0, item(Material.GREEN_CONCRETE, "seat")); + manager.seatSelect(event); verify(v).addPassenger(player, seat); + doReturn(item(Material.YELLOW_CONCRETE, "seat")).when(event).getCurrentItem(); when(seat.getEntity()).thenReturn(rider); + manager.seatSelect(event); verify(v).dismountPassenger(rider, false); + assertFalse(map("ejectCooldown").isEmpty()); + when(v.getSeatHandler().getSeat("seat")).thenReturn(seat); manager.mount(rider, "seat", v); verify(v, never()).addPassenger(rider, seat); + doReturn(item(Material.CYAN_CONCRETE, "seat")).when(event).getCurrentItem(); when(seat.getType()).thenReturn(SeatType.ENTITY); + manager.seatSelect(event); assertSame(v, map("pendingEntityVehicle").get(player)); + Entity cow = mock(Entity.class); when(cow.getType()).thenReturn(EntityType.COW); when(v.getEntitySeatWhitelist()).thenReturn(List.of("v.cow")); + when(v.addPassenger(cow, seat)).thenReturn(MountResult.MOUNTED); + manager.vehicleInteract(interact(player, cow)); verify(v).addPassenger(cow, seat); + doReturn(item(Material.GRAY_CONCRETE, "seat")).when(event).getCurrentItem(); when(seat.isOccupied()).thenReturn(true); when(seat.getEntity()).thenReturn(cow); + manager.seatSelect(event); verify(v).dismountPassenger(cow, false); verify(cow).teleport(player.getLocation()); + when(event.getSlot()).thenReturn(25); manager.seatSelect(event); verify(ownership.constructed().getFirst()).ownershipGui(null, player, v, true); + when(event.getSlot()).thenReturn(26); manager.seatSelect(event); verify(v).dismountPassenger(player, false); + } + + @Test + void skinSelectionValidatesModelAndRefreshesOnSuccessfulChange() { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); manager.skinInteract(player, v); + InventoryClickEvent event = click(player, v, VFGUI.SKIN_SELECTION, 0, item(Material.YELLOW_CONCRETE, "blue")); + manager.skinSelect(event); verify(player).sendMessage("§cAlready using this skin"); + doReturn(item(Material.GREEN_CONCRETE, "blue")).when(event).getCurrentItem(); + try (MockedStatic skins = mockStatic(SkinHandler.class)) { + manager.skinSelect(event); verify(player).sendMessage("§cThat skin is not available."); + skins.when(() -> SkinHandler.isModelAvailable(any(net.tfminecraft.vehicleframework.vehicles.handlers.skins.VehicleSkin.class))).thenReturn(true); + manager.skinSelect(event); verify(player).sendMessage("§cCould not change skin."); + when(v.changeSkin("blue")).thenReturn(true); manager.skinSelect(event); + verify(inventories.constructed().getFirst()).skinSelection(player.getOpenInventory().getTopInventory(), player, v, false); + } + } + + @ParameterizedTest + @EnumSource(value = InventoryAction.class, names = {"PLACE_ALL", "PLACE_ONE", "PLACE_SOME", "SWAP_WITH_CURSOR", "MOVE_TO_OTHER_INVENTORY", "HOTBAR_SWAP", "HOTBAR_MOVE_AND_READD"}) + void containerRejectsEveryIncomingTransferOfDisallowedItems(InventoryAction action) { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); when(v.hasContainers()).thenReturn(true); + Container container = mock(Container.class); when(v.getContainerHandler().get("v")).thenReturn(container); + ItemStack incoming = item(Material.STONE, null); + InventoryClickEvent event = click(player, v, VFGUI.CONTAINER, 0, incoming); + when(event.getAction()).thenReturn(action); when(event.getCursor()).thenReturn(incoming); + if (action == InventoryAction.MOVE_TO_OTHER_INVENTORY) { Inventory inventory = player.getInventory(); when(event.getClickedInventory()).thenReturn(inventory); } + when(event.getHotbarButton()).thenReturn(2); when(player.getInventory().getItem(2)).thenReturn(incoming); + manager.containerClick(event); verify(event).setCancelled(true); + when(container.allows(incoming)).thenReturn(true); clearInvocations(event); manager.containerClick(event); verify(event, never()).setCancelled(anyBoolean()); + } + + @Test + void containerDragsCheckTopSlotsAndCloseSanitizesSavedContents() { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); when(v.hasContainers()).thenReturn(true); + Container container = mock(Container.class); when(v.getContainerHandler().get("v")).thenReturn(container); + Inventory top = mock(Inventory.class); when(top.getSize()).thenReturn(27); when(top.getHolder()).thenReturn(new VFInventoryHolder("v", VFGUI.CONTAINER, v)); + InventoryDragEvent drag = mock(InventoryDragEvent.class, RETURNS_DEEP_STUBS); + when(drag.getView().getTopInventory()).thenReturn(top); when(drag.getWhoClicked()).thenReturn(player); + when(drag.getRawSlots()).thenReturn(Set.of(30)); manager.containerDrag(drag); verify(drag, never()).setCancelled(anyBoolean()); + when(drag.getRawSlots()).thenReturn(Set.of(1, 30)); manager.containerDrag(drag); verify(drag).setCancelled(true); + InventoryCloseEvent close = mock(InventoryCloseEvent.class, RETURNS_DEEP_STUBS); + when(close.getView().getTopInventory()).thenReturn(top); when(close.getPlayer()).thenReturn(player); + manager.saveContainer(close); verify(container).stripDisallowed(top, player); verify(container).close(top); + manager.closeInv(close); + } + + @Test + void harnessSuffocationCreativePassengersAndVehicleDamageHaveSeparateRules() { + Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); register(v); + EntityDamageEvent damage = mock(EntityDamageEvent.class); when(damage.getEntity()).thenReturn(player); when(damage.getCause()).thenReturn(DamageCause.SUFFOCATION); + when(v.isPassenger(player, SeatType.HARNESS)).thenReturn(true); manager.damagePassenger(damage); verify(damage).setCancelled(true); + VFEntityDamageEvent direct = new VFEntityDamageEvent(v.getEntity(), null, "FIRE", 12); manager.damageVehicle(direct); assertTrue(direct.isCancelled()); verify(v).damage("FIRE", 12); + when(player.getGameMode()).thenReturn(GameMode.CREATIVE); + VFEntityDamageEvent passenger = new VFEntityDamageEvent(player, null, "FIRE", 12); manager.damageVehicle(passenger); assertTrue(passenger.isCancelled()); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); when(v.isPassenger(player, false)).thenReturn(true); + passenger = new VFEntityDamageEvent(player, null, "FIRE", 12); manager.damageVehicle(passenger); assertEquals(6, passenger.getDamage()); + manager.setDamaged(player, true); clearInvocations(damage); manager.damagePassenger(damage); verify(damage, never()).setCancelled(anyBoolean()); manager.setDamaged(player, false); + } + + @Test + void mountedMeleeCannotDamageTheRidersVehicleOrItsModelParts() { + Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); register(v); mount(player, v); + EntityDamageByEntityEvent event = mock(EntityDamageByEntityEvent.class); + Entity chassis = v.getEntity(); + when(event.getDamager()).thenReturn(player); when(event.getEntity()).thenReturn(chassis); when(event.getCause()).thenReturn(DamageCause.ENTITY_ATTACK); + manager.damagePassenger(event); verify(event).setCancelled(true); verify(v).key(player, Keybind.LEFT_CLICK); + assertTrue(VehicleManager.isPassengerMeleeOnOwnVehicle(event, manager.get())); + Entity part = mock(Entity.class); when(event.getEntity()).thenReturn(part); + try (MockedStatic models = mockStatic(ModelEngineAPI.class)) { + ModeledEntity modeled = mock(ModeledEntity.class); + models.when(() -> ModelEngineAPI.getModeledEntity(part)).thenReturn(modeled); models.when(() -> ModelEngineAPI.getModeledEntity(v.getEntity())).thenReturn(modeled); + clearInvocations(event); manager.damagePassenger(event); verify(event).setCancelled(true); + } + when(event.getCause()).thenReturn(DamageCause.PROJECTILE); assertFalse(VehicleManager.isPassengerMeleeOnOwnVehicle(event, manager.get())); + assertFalse(VehicleManager.isPassengerMeleeOnOwnVehicle(mock(EntityDamageEvent.class), manager.get())); + } + + @Test + void damageBridgePublishesCustomEventAndDamagesOnlyUncancelledLivingEntities() { + Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); register(v); + when(v.isPassenger(player, true)).thenReturn(true); + EntityDamageEvent event = mock(EntityDamageEvent.class); when(event.getEntity()).thenReturn(player); when(event.getCause()).thenReturn(DamageCause.FIRE); when(event.getDamage()).thenReturn(8d); + try (MockedStatic damager = mockStatic(Damager.class)) { + manager.damagePassenger(event); verify(event).setCancelled(true); damager.verify(() -> Damager.damage(player, 8)); + } + verify(plugins).callEvent(isA(VFEntityDamageEvent.class)); + } + + @Test + void quitAndDeathDismountRidersAndCancelPendingTakeovers() { + Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); register(v); when(v.isPassenger(player, false)).thenReturn(true); + manager.startTakeover(player); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); when(quit.getPlayer()).thenReturn(player); + manager.playerLeave(quit); verify(scheduler).cancelTask(1); verify(v).dismountPassenger(player, false); + PlayerDeathEvent death = mock(PlayerDeathEvent.class); when(death.getEntity()).thenReturn(player); + manager.passengerDeath(death); verify(v, times(2)).dismountPassenger(player, false); + } + + @Test + void rejectedMountRecoveryDeduplicatesAndReleasesItsReservationWhenVehicleDisappears() { + Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); + manager.recoverRejectedMount(null, player, "seat", player); + manager.recoverRejectedMount(v, player, "seat", player); + manager.recoverRejectedMount(v, player, "seat", player); assertEquals(1, scheduled.size()); + bukkit.when(() -> Bukkit.getPlayer(player.getUniqueId())).thenReturn(player); + scheduled.removeFirst().run(); + manager.recoverRejectedMount(v, player, "seat", player); assertEquals(1, scheduled.size()); + when(v.isDestroyed()).thenReturn(true); manager.recoverRejectedMount(v, player, "seat", player); assertEquals(1, scheduled.size()); + } + + @ParameterizedTest + @ValueSource(strings = {"unload-failed", "storage-closed", "missing-save", "unknown-type", "spawn-failed", "missing-seat", "missing-rider", "player", "animal", "rejected-animal"}) + void rejectedMountReloadEitherRetriesTheSavedSeatOrReportsWhyItCannot(String outcome) { + Player player = player("Ryan"); ActiveVehicle old = vehicle("v"), fresh = vehicle("v"); register(old); + VehiclePersistence persistence = persistence(); + when(persistence.saveLiveResult(old)).thenReturn(outcome.equals("unload-failed") ? VehiclePersistResult.failed("locked") : VehiclePersistResult.saved()); + doAnswer(call -> { manager.unregister(old.getEntity()); return null; }).when(old).remove(VehicleRemoveReason.UNLOAD); + IncompleteVehicle saved = mock(IncompleteVehicle.class); when(saved.getId()).thenReturn("cart"); + when(persistence.loadIncomplete("v")).thenReturn(outcome.equals("missing-save") ? Optional.empty() : Optional.of(saved)); + Vehicle type = mock(Vehicle.class); + loader.when(() -> VehicleLoader.getByString("cart")).thenReturn(outcome.equals("unknown-type") ? null : type); + when(spawners.constructed().getFirst().spawn(any(), eq(type), eq(manager), eq(saved))) + .thenReturn(outcome.equals("spawn-failed") ? null : fresh); + Seat seat = mock(Seat.class); when(seat.getBone()).thenReturn("seat"); + when(fresh.getSeat("seat")).thenReturn(outcome.equals("missing-seat") ? null : seat); + Entity rider = outcome.contains("animal") ? mock(Entity.class) : player; + UUID riderId = UUID.randomUUID(); when(rider.getUniqueId()).thenReturn(riderId); when(rider.isValid()).thenReturn(true); + bukkit.when(() -> Bukkit.getEntity(riderId)).thenReturn(outcome.equals("missing-rider") ? null : rider); + UUID notifyId = player.getUniqueId(); bukkit.when(() -> Bukkit.getPlayer(notifyId)).thenReturn(player); + when(fresh.addPassenger(rider, seat)).thenReturn(outcome.equals("rejected-animal") ? MountResult.REJECTED : MountResult.MOUNTED); + if (outcome.equals("storage-closed")) storage.when(VehiclePersistence::current).thenReturn(persistence, (VehiclePersistence) null); + manager.recoverRejectedMount(old, rider, "seat", player); + assertEquals(1, scheduled.size()); scheduled.removeFirst().run(); + if (Set.of("player", "animal", "rejected-animal").contains(outcome)) { + verify(fresh).addPassenger(rider, seat); + assertSame(fresh, manager.get(fresh.getEntity())); + } else if (outcome.equals("unload-failed")) { + verify(old, never()).remove(any(VehicleRemoveReason.class)); + } else { + verify(old).remove(VehicleRemoveReason.UNLOAD); + } + assertTrue(scheduled.isEmpty(), "A rejected retry must not schedule an unbounded reload loop"); + } + + @ParameterizedTest + @ValueSource(strings = {"destroyed", "forbidden", "missing-seat", "occupied", "rejected", "unavailable", "mythic-absent"}) + void pendingEntitySeatSelectionChecksAvailabilityBeforeMounting(String outcome) throws Exception { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); Entity animal = mock(Entity.class); + when(animal.getType()).thenReturn(EntityType.COW); + when(v.getEntitySeatWhitelist()).thenReturn(outcome.equals("mythic-absent") ? List.of("mm.pet") : List.of("v.cow")); + if (outcome.equals("forbidden")) when(v.getEntitySeatWhitelist()).thenReturn(List.of("v.pig")); + when(v.isDestroyed()).thenReturn(outcome.equals("destroyed")); + Seat seat = mock(Seat.class); when(seat.getBone()).thenReturn("seat"); when(seat.isOccupied()).thenReturn(outcome.equals("occupied")); + when(v.getSeat("seat")).thenReturn(outcome.equals("missing-seat") ? null : seat); + when(v.addPassenger(animal, seat)).thenReturn(outcome.equals("rejected") ? MountResult.REJECTED : MountResult.UNAVAILABLE); + map("pendingEntityVehicle").put(player, v); map("pendingEntitySeat").put(player, "seat"); + PlayerInteractEntityEvent event = interact(player, animal); + manager.vehicleInteract(event); assertTrue(event.isCancelled()); + assertTrue(map("pendingEntityVehicle").isEmpty()); assertTrue(map("pendingEntitySeat").isEmpty()); + if (Set.of("rejected", "unavailable").contains(outcome)) verify(v).addPassenger(animal, seat); + else verify(v, never()).addPassenger(any(), any()); + } + + @Test + void interactionHonorsCancelledOwnershipCooldownAndItemIntegrations() throws Exception { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); register(v); + doAnswer(call -> { if (call.getArgument(0) instanceof VehicleOwnerClaimedEvent claim) claim.setCancelled(true); return null; }).when(plugins).callEvent(any(Event.class)); + manager.vehicleInteract(interact(player, v.getEntity())); assertEquals("none", v.getOwnerData().getOwner()); + map("cooldown").put(player, Long.MAX_VALUE); + manager.vehicleInteract(interact(player, v.getEntity())); verify(plugins, times(1)).callEvent(isA(VehicleOwnerClaimedEvent.class)); + map("cooldown").clear(); tapeInteractions.when(() -> TrainTapeInteract.handle(player, v)).thenReturn(true); + PlayerInteractEntityEvent event = interact(player, v.getEntity()); manager.vehicleInteract(event); assertTrue(event.isCancelled()); + map("cooldown").clear(); tapeInteractions.when(() -> TrainTapeInteract.handle(player, v)).thenReturn(false); + ticketInteractions.when(() -> VehicleTicketInteract.handle(player, v)).thenReturn(true); + event = interact(player, v.getEntity()); manager.vehicleInteract(event); assertTrue(event.isCancelled()); + map("cooldown").clear(); ticketInteractions.when(() -> VehicleTicketInteract.handle(player, v)).thenReturn(false); + when(v.hasContainers()).thenReturn(true); when(v.getContainerHandler().open(player)).thenReturn(true); + manager.vehicleInteract(interact(player, v.getEntity())); verify(v.getContainerHandler()).open(player); + map("cooldown").clear(); when(v.hasContainers()).thenReturn(false); + when(v.usesFuel()).thenReturn(true); when(items.getChecker().getAsStringPath(any())).thenReturn("fuel"); + try (MockedStatic fuel = mockStatic(FuelLoader.class)) { + fuel.when(() -> FuelLoader.itemIsFuel("fuel")).thenReturn(true); + manager.vehicleInteract(interact(player, v.getEntity())); verify(v).refuel(player, "fuel"); + } + } + + @ParameterizedTest + @ValueSource(strings = {"select", "attach", "train-attach", "cannot-tow", "detach", "attachable-train", "not-towable"}) + void sneakingInteractionRoutesTowSelectionAttachmentAndDetachment(String outcome) { + ActiveVehicle v = vehicle("v"), selected = vehicle("selected"); Player player = player("Ryan"); register(v); + when(player.isSneaking()).thenReturn(true); + when(v.isTowable()).thenReturn(outcome.equals("select")); + when(v.hasTowHandler()).thenReturn(Set.of("attach", "detach").contains(outcome)); + when(v.getTowHandler().isOccupied()).thenReturn(outcome.equals("detach")); + when(v.getTowHandler().getTowLocation()).thenReturn(new Location(world, 0, 64, 0)); + when(v.isTrain()).thenReturn(Set.of("train-attach", "attachable-train").contains(outcome)); + when(v.getTrainHandler().isAttachable()).thenReturn(true); + when(selected.getTrainHandler().attach(player, v)).thenReturn(true); + if (Set.of("attach", "train-attach", "cannot-tow").contains(outcome)) manager.towSelect(player, selected); + manager.vehicleInteract(interact(player, v.getEntity())); + switch (outcome) { + case "select", "attachable-train" -> verify(player).sendMessage(contains("Selected Cart v")); + case "attach" -> verify(v.getTowHandler()).attach(selected); + case "train-attach" -> verify(selected.getTrainHandler()).attach(player, v); + case "cannot-tow" -> verify(player).sendMessage("§cThis vehicle cannot tow anything"); + case "detach" -> verify(v.getTowHandler()).unattach(); + case "not-towable" -> verify(player).sendMessage("§cThis vehicle cannot be towed"); + } + } + + @Test + void interactionHandlesLeashedAnimalsLeadDismountSkinAndExistingSeat() throws Exception { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); register(v); + Harness harness = mock(Harness.class); when(v.getComponent(Component.HARNESS)).thenReturn(harness); + Horse horse = mock(Horse.class); when(horse.isLeashed()).thenReturn(true); when(horse.getLeashHolder()).thenReturn(player); + when(player.getNearbyEntities(10, 10, 10)).thenReturn(List.of(mock(Entity.class), horse)); + manager.vehicleInteract(interact(player, v.getEntity())); verify(harness).mount(player, horse); + map("cooldown").clear(); when(horse.getPassengers()).thenReturn(List.of(player)); + ItemStack lead = item(Material.LEAD, null); when(player.getInventory().getItemInMainHand()).thenReturn(lead); + when(harness.dismount(player)).thenReturn(true); manager.vehicleInteract(interact(player, v.getEntity())); verify(harness).dismount(player); + map("cooldown").clear(); when(harness.dismount(player)).thenReturn(false); when(v.getSeatHandler().isPassenger(player)).thenReturn(true); + manager.vehicleInteract(interact(player, v.getEntity())); verify(v).key(player, Keybind.RIGHT_CLICK); + map("cooldown").clear(); when(v.getSeatHandler().isPassenger(player)).thenReturn(false); + when(items.getChecker().checkItemWithPath(any(), eq(Cache.skinItem))).thenReturn(true); + manager.vehicleInteract(interact(player, v.getEntity())); verify(inventories.constructed().getFirst()).skinSelection(null, player, v, true); + } + + @Test + void seatActionsRejectSelfEjectionTicketsDestroyedVehiclesAndExpiredCooldowns() throws Exception { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); register(v); manager.seatInteract(player, v); + Seat seat = mock(Seat.class); when(seat.getType()).thenReturn(SeatType.PASSENGER); when(seat.getEntity()).thenReturn(player); + when(v.getSeat("seat")).thenReturn(seat); when(v.getSeatHandler().getSeat("seat")).thenReturn(seat); + InventoryClickEvent event = click(player, v, VFGUI.SEAT_SELECTION, 0, item(Material.YELLOW_CONCRETE, "seat")); + v.getOwnerData().setOwner("player_Ryan"); manager.seatSelect(event); verify(player).sendMessage("§cYou cannot eject yourself"); + v.getOwnerData().setOwner("player_Other"); manager.seatSelect(event); verify(player).sendMessage("§cSeat is occupied"); + v.getOwnerData().setTicketsEnabled(true); ItemStack green = item(Material.GREEN_CONCRETE, "seat"); when(event.getCurrentItem()).thenReturn(green); + manager.seatSelect(event); manager.mount(player, "seat", v); verify(player, times(2)).sendMessage("§cYou need a ticket for this vehicle."); + v.getOwnerData().setTicketsEnabled(false); + Map> cooldowns = map("ejectCooldown"); cooldowns.put(player, new HashMap<>(Map.of("v", System.currentTimeMillis() + 100000))); + manager.seatSelect(event); manager.seatInteract(player, v); verify(v, never()).addPassenger(any(), any()); + cooldowns.get(player).put("v", 0L); when(v.addPassenger(player, seat)).thenReturn(MountResult.UNAVAILABLE); + manager.seatSelect(event); assertTrue(cooldowns.isEmpty()); + when(v.isDestroyed()).thenReturn(true); manager.seatSelect(event); verify(player).sendMessage("Vehicle is destroyed"); + manager.skinInteract(player, v); InventoryClickEvent skin = click(player, v, VFGUI.SKIN_SELECTION, 0, green); + manager.skinSelect(skin); verify(player, times(2)).sendMessage("Vehicle is destroyed"); + manager.mount(null, "seat", v); + } + + @Test + void containerGuardsIgnoreUnrelatedMissingAndOutgoingInventories() { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); + InventoryClickEvent event = click(player, v, VFGUI.SEAT_SELECTION, 0, item(Material.STONE, null)); + Inventory top = event.getView().getTopInventory(); + manager.containerClick(event); + when(top.getHolder()).thenReturn(null); manager.containerClick(event); + when(top.getHolder()).thenReturn(new VFInventoryHolder("v", VFGUI.CONTAINER)); manager.containerClick(event); + when(top.getHolder()).thenReturn(new VFInventoryHolder("v", VFGUI.CONTAINER, v)); manager.containerClick(event); + when(v.hasContainers()).thenReturn(true); when(v.getContainerHandler().get("v")).thenReturn(null); manager.containerClick(event); + Container container = mock(Container.class); when(v.getContainerHandler().get("v")).thenReturn(container); + when(event.getAction()).thenReturn(InventoryAction.PICKUP_ALL); manager.containerClick(event); verify(event, never()).setCancelled(anyBoolean()); + InventoryDragEvent drag = mock(InventoryDragEvent.class, RETURNS_DEEP_STUBS); when(drag.getView().getTopInventory()).thenReturn(top); + when(top.getHolder()).thenReturn(null); manager.containerDrag(drag); + when(top.getHolder()).thenReturn(new VFInventoryHolder("v", VFGUI.SEAT_SELECTION)); manager.containerDrag(drag); + when(top.getHolder()).thenReturn(new VFInventoryHolder("v", VFGUI.CONTAINER)); manager.containerDrag(drag); + when(top.getHolder()).thenReturn(new VFInventoryHolder("v", VFGUI.CONTAINER, v)); when(v.getContainerHandler().get("v")).thenReturn(null); manager.containerDrag(drag); + verify(drag, never()).setCancelled(anyBoolean()); + } + + @Test + void initializationFailureStillReturnsNullWhenCleanupSucceeds() { + Vehicle type = mock(Vehicle.class); ActiveVehicle v = vehicle("v"); Location location = v.getLocation(); + when(spawners.constructed().getFirst().spawn(location, type, manager, null)).thenReturn(v); + doThrow(new IllegalStateException("failed restoration")).when(v).restorePassengers(null); + assertNull(manager.spawn(location, type)); verify(v).remove(VehicleRemoveReason.UNLOAD); + } + + @Test + void mythicWhitelistRecognizesOnlyTheConfiguredMobType() throws Exception { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); Entity pet = mock(Entity.class); + UUID id = UUID.randomUUID(); when(pet.getUniqueId()).thenReturn(id); + when(v.getEntitySeatWhitelist()).thenReturn(List.of("mm.other", "mm.pet")); + Seat seat = mock(Seat.class); when(v.getSeat("seat")).thenReturn(seat); + when(v.addPassenger(pet, seat)).thenReturn(MountResult.MOUNTED); + try (MockedStatic mythic = mockStatic(io.lumine.mythic.bukkit.MythicBukkit.class)) { + var plugin = mock(io.lumine.mythic.bukkit.MythicBukkit.class, RETURNS_DEEP_STUBS); + var mob = mock(io.lumine.mythic.core.mobs.ActiveMob.class, RETURNS_DEEP_STUBS); + when(mob.getType().getInternalName()).thenReturn("pet"); + when(plugin.getMobManager().getActiveMob(id)).thenReturn(Optional.of(mob)); + mythic.when(io.lumine.mythic.bukkit.MythicBukkit::inst).thenReturn(plugin); + map("pendingEntityVehicle").put(player, v); map("pendingEntitySeat").put(player, "seat"); + manager.vehicleInteract(interact(player, pet)); + } + verify(v).addPassenger(pet, seat); + } + + @Test + void passengerValidationHandlesStaleRidersAndRejectedMounts() throws Exception { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); register(v); + when(v.isPassenger(player, true)).thenReturn(true); + manager.seatInteract(player, v); verify(inventories.constructed().getFirst(), never()).seatSelection(any(), any(), any(), anyBoolean()); + when(v.isPassenger(player, true)).thenReturn(false); + Seat seat = mock(Seat.class); when(seat.getType()).thenReturn(SeatType.CAPTAIN); when(seat.getBone()).thenReturn("seat"); + when(v.getSeatHandler().getSeat("seat")).thenReturn(seat); when(v.addPassenger(player, seat)).thenReturn(MountResult.REJECTED); + manager.mount(player, "seat", v); verify(player).closeInventory(); assertEquals(1, scheduled.size()); + PlayerAnimationEvent event = mock(PlayerAnimationEvent.class); when(event.getPlayer()).thenReturn(player); + when(event.getAnimationType()).thenReturn(PlayerAnimationType.OFF_ARM_SWING); manager.swingWhileMounted(event); + when(event.getAnimationType()).thenReturn(PlayerAnimationType.ARM_SWING); manager.swingWhileMounted(event); + when(event.getPlayer()).thenReturn(null); manager.swingWhileMounted(event); + verify(v, never()).key(any(), any()); + Map> cooldowns = map("ejectCooldown"); cooldowns.put(player, new HashMap<>(Map.of("different-vehicle", Long.MAX_VALUE))); + manager.seatInteract(player, v); verify(inventories.constructed().getFirst()).seatSelection(null, player, v, true); + } + + @Test + void cleanupWithoutPassengersStillUnregistersPacketsAndPreservesUnrelatedDamage() { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); register(v); + try (MockedStatic framework = mockStatic(VehicleFramework.class)) { + var packets = mock(net.tfminecraft.vehicleframework.protocol.VehiclePacketListener.class); + framework.when(VehicleFramework::getPacketListener).thenReturn(packets); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); when(quit.getPlayer()).thenReturn(player); + manager.playerLeave(quit); verify(packets).unregisterPlayer(player); + } + PlayerDeathEvent death = mock(PlayerDeathEvent.class); when(death.getEntity()).thenReturn(player); + manager.passengerDeath(death); verify(v, never()).dismountPassenger(any(), anyBoolean()); + VFEntityDamageEvent damage = new VFEntityDamageEvent(player, null, "FIRE", 10); + manager.damageVehicle(damage); assertEquals(10, damage.getDamage()); assertFalse(damage.isCancelled()); + when(v.isTrain()).thenReturn(true); when(v.hasParent()).thenReturn(true); + ActiveVehicle locomotive = vehicle("locomotive"); when(v.getParent()).thenReturn(locomotive); when(locomotive.isLocomotive()).thenReturn(true); + assertEquals(5, VehicleManager.passengerDamage(v, 20)); + when(v.getEntity().getLocation()).thenReturn(null); assertEquals("unknown location", VehicleManager.describeLocation(v)); + } + + @Test + void meleeAliasesAreProtectedAndModelLookupErrorsDoNotCancelUnrelatedAttacks() { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); register(v); + Entity alias = mock(Entity.class); manager.get().put(alias, v); + EntityDamageByEntityEvent event = mock(EntityDamageByEntityEvent.class); + when(event.getDamager()).thenReturn(player); when(event.getEntity()).thenReturn(alias); when(event.getCause()).thenReturn(DamageCause.ENTITY_ATTACK); + manager.damagePassenger(event); verify(event, never()).setCancelled(anyBoolean()); + mount(player, v); manager.damagePassenger(event); verify(event).setCancelled(true); + manager.get().remove(alias); + try (MockedStatic models = mockStatic(ModelEngineAPI.class)) { + models.when(() -> ModelEngineAPI.getModeledEntity(alias)).thenThrow(new IllegalStateException("removed model")); + clearInvocations(event); manager.damagePassenger(event); verify(event, never()).setCancelled(anyBoolean()); + } + when(event.getDamager()).thenReturn(alias); + assertFalse(VehicleManager.isPassengerMeleeOnOwnVehicle(event, manager.get())); + } + + @Test + void damageAdapterFailureIsReportedWithoutEscapingTheEventHandler() { + ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); register(v); + when(v.isPassenger(player, true)).thenReturn(true); + EntityDamageEvent event = mock(EntityDamageEvent.class); when(event.getEntity()).thenReturn(player); + when(event.getCause()).thenReturn(DamageCause.FIRE); when(event.getDamage()).thenReturn(4d); + java.io.PrintStream previous = System.err; + java.io.ByteArrayOutputStream errors = new java.io.ByteArrayOutputStream(); + try (MockedStatic damage = mockStatic(Damager.class); + java.io.PrintStream output = new java.io.PrintStream(errors)) { + damage.when(() -> Damager.damage(player, 4)).thenThrow(new IllegalStateException("damage adapter unavailable")); + System.setErr(output); + assertDoesNotThrow(() -> manager.damagePassenger(event)); + } finally { System.setErr(previous); } + verify(event).setCancelled(true); + assertTrue(errors.toString(java.nio.charset.StandardCharsets.UTF_8).contains("damage adapter unavailable")); + } + + @Test + void ownershipAndInputDefensiveHelpersRejectMissingParticipants() throws Exception { + // These guards protect future event/API callers; assert their null contract without fabricated Bukkit events. + var claim = VehicleManager.class.getDeclaredMethod("claimOwnership", Player.class, ActiveVehicle.class); + claim.setAccessible(true); + assertEquals(false, claim.invoke(manager, null, vehicle("v"))); + assertEquals(false, claim.invoke(manager, player("Ryan"), null)); + var shifting = VehicleManager.class.getDeclaredMethod("mountedShifting", Player.class); + shifting.setAccessible(true); assertEquals(false, shifting.invoke(manager, new Object[]{null})); + var cooldown = VehicleManager.class.getDeclaredMethod("putEjectCooldown", Player.class, ActiveVehicle.class); + cooldown.setAccessible(true); cooldown.invoke(manager, null, vehicle("v")); + assertTrue(map("ejectCooldown").isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/spawner/VehicleSpawnerCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/spawner/VehicleSpawnerCoverageTest.java new file mode 100644 index 0000000..1b26985 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/spawner/VehicleSpawnerCoverageTest.java @@ -0,0 +1,165 @@ +package net.tfminecraft.vehicleframework.managers.spawner; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.logging.Logger; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.Entity; +import org.junit.jupiter.api.*; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import com.ticxo.modelengine.api.ModelEngineAPI; +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.ModeledEntity; +import com.ticxo.modelengine.api.model.bone.manager.BehaviorManager; +import com.ticxo.modelengine.api.model.bone.manager.MountManager; +import io.lumine.mythic.api.adapters.AbstractLocation; +import io.lumine.mythic.api.mobs.MythicMob; +import io.lumine.mythic.bukkit.BukkitAdapter; +import io.lumine.mythic.bukkit.MythicBukkit; +import io.lumine.mythic.core.mobs.ActiveMob; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.database.IncompleteVehicle; +import net.tfminecraft.vehicleframework.managers.VehicleManager; +import net.tfminecraft.vehicleframework.test.RegistryFixture; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.Vehicle; +import net.tfminecraft.vehicleframework.vehicles.handlers.SkinHandler; + +class VehicleSpawnerCoverageTest { + private final List scopes = new ArrayList<>(); + private final World world = mock(World.class); + private final Location location = new Location(world, 2, 65, 3); + private final ArmorStand stand = mock(ArmorStand.class); + private final Vehicle template = mock(Vehicle.class); + private final VehicleManager manager = mock(VehicleManager.class); + private final ActiveModel model = mock(ActiveModel.class, RETURNS_DEEP_STUBS); + private final ModeledEntity modeled = mock(ModeledEntity.class); + private final MountManager mounts = mock(MountManager.class, withSettings().extraInterfaces(BehaviorManager.class)); + private final Logger logger = mock(Logger.class); + private final List> constructorArguments = new ArrayList<>(); + private MockedStatic models; + private MockedConstruction vehicles; + private String previousMob; + + @BeforeAll static void registries() { RegistryFixture.initialize(); } + + @BeforeEach void setup() { + previousMob = Cache.mythicMob; Cache.mythicMob = "none"; + MockedStatic bukkit = keep(mockStatic(Bukkit.class)); + bukkit.when(Bukkit::getLogger).thenReturn(logger); + models = keep(mockStatic(ModelEngineAPI.class)); + var blueprint = model.getBlueprint(); + models.when(() -> ModelEngineAPI.getBlueprint(anyString())).thenReturn(blueprint); + YamlConfiguration skins = new YamlConfiguration(); + skins.set("red.model", "red_model"); skins.set("blue.model", "blue_model"); + SkinHandler skinHandler = new SkinHandler("red", skins); + when(template.getSkinHandler()).thenReturn(skinHandler); when(template.getId()).thenReturn("cart"); + when(world.spawn(location, ArmorStand.class)).thenReturn(stand); + doReturn(Optional.of(mounts)).when(model).getMountManager(); + models.when(() -> ModelEngineAPI.createModeledEntity(any(Entity.class))).thenReturn(modeled); + models.when(() -> ModelEngineAPI.createActiveModel(anyString())).thenReturn(model); + vehicles = keep(mockConstruction(ActiveVehicle.class, + (vehicle, context) -> constructorArguments.add(new ArrayList<>(context.arguments())))); + } + + @AfterEach void cleanup() throws Exception { + Collections.reverse(scopes); for (AutoCloseable scope : scopes) scope.close(); + Cache.mythicMob = previousMob; + } + + @Test void defaultSkinSpawnsInvisibleTransientMountAndPassesOwnershipToActiveVehicle() { + ActiveVehicle result = new VehicleSpawner().spawn(location, template, manager, null); + assertSame(vehicles.constructed().get(0), result); + assertEquals(Arrays.asList(template, stand, model, manager, null), constructorArguments.get(0)); + verify(stand).setVisible(false); verify(stand).setPersistent(false); + verify(modeled).setSaved(false); verify(modeled).addModel(model, true); verify(mounts).setCanRide(true); + models.verify(() -> ModelEngineAPI.createActiveModel("red_model")); verify(stand, never()).remove(); + } + + @Test void restoringUsesPersistedSkinAndPassesTheEntireSnapshotThrough() { + IncompleteVehicle saved = snapshot("blue"); + ActiveVehicle result = new VehicleSpawner().spawn(location, template, manager, saved); + assertNotNull(result); assertSame(saved, constructorArguments.get(0).get(4)); + models.verify(() -> ModelEngineAPI.createActiveModel("blue_model")); + } + + @Test void missingSavedSkinAndUnloadedModelAreRejectedBeforeAnyEntityExists() { + assertNull(new VehicleSpawner().spawn(location, template, manager, snapshot("removed"))); + models.when(() -> ModelEngineAPI.getBlueprint("red_model")).thenReturn(null); + assertNull(new VehicleSpawner().spawn(location, template, manager, null)); + verify(world, never()).spawn(any(Location.class), eq(ArmorStand.class)); + assertTrue(vehicles.constructed().isEmpty()); + verify(logger).warning(contains("Missing vehicle skin/model: removed")); + verify(logger).warning(contains("Missing vehicle skin/model: red")); + } + + @Test void missingMythicTemplateReportsTheConfiguredNameWithoutCreatingAFallbackEntity() { + Cache.mythicMob = "VehicleDummy"; + MythicBukkit mythic = mock(MythicBukkit.class, RETURNS_DEEP_STUBS); + MockedStatic api = keep(mockStatic(MythicBukkit.class)); api.when(MythicBukkit::inst).thenReturn(mythic); + when(mythic.getMobManager().getMythicMob("VehicleDummy")).thenReturn(Optional.empty()); + assertNull(new VehicleSpawner().spawn(location, template, manager, null)); + verify(world, never()).spawn(any(Location.class), eq(ArmorStand.class)); + verify(logger).warning(contains("could not find the VehicleDummy mythicmob")); + } + + @Test void mythicSpawnUsesItsBukkitEntityAndDisablesIndependentPersistence() { + Cache.mythicMob = "VehicleDummy"; + MythicBukkit mythic = mock(MythicBukkit.class, RETURNS_DEEP_STUBS); MythicMob mob = mock(MythicMob.class); + ActiveMob spawned = mock(ActiveMob.class, RETURNS_DEEP_STUBS); Entity base = mock(Entity.class); + AbstractLocation adapted = mock(AbstractLocation.class); + MockedStatic api = keep(mockStatic(MythicBukkit.class)); api.when(MythicBukkit::inst).thenReturn(mythic); + MockedStatic adapter = keep(mockStatic(BukkitAdapter.class)); adapter.when(() -> BukkitAdapter.adapt(location)).thenReturn(adapted); + when(mythic.getMobManager().getMythicMob("VehicleDummy")).thenReturn(Optional.of(mob)); + when(mob.spawn(adapted, 1)).thenReturn(spawned); when(spawned.getEntity().getBukkitEntity()).thenReturn(base); + assertNotNull(new VehicleSpawner().spawn(location, template, manager, null)); + verify(base).setPersistent(false); assertSame(base, constructorArguments.get(0).get(1)); + verify(world, never()).spawn(any(Location.class), eq(ArmorStand.class)); verify(base, never()).remove(); + } + + @Test void modelCreationFailureRemovesTheAlreadySpawnedBackingEntity() { + IllegalStateException failure = new IllegalStateException("model creation failed"); + models.when(() -> ModelEngineAPI.createActiveModel("red_model")).thenThrow(failure); + assertNull(new VehicleSpawner().spawn(location, template, manager, null)); + verify(stand).remove(); assertEquals(0, failure.getSuppressed().length); + verify(logger).warning(contains("model creation failed")); assertTrue(vehicles.constructed().isEmpty()); + } + + @Test void missingMountManagerUnregistersThePartialModelBeforeRemovingItsEntity() { + doReturn(Optional.empty()).when(model).getMountManager(); + ModelEngineAPI api = mock(ModelEngineAPI.class, RETURNS_DEEP_STUBS); models.when(ModelEngineAPI::getAPI).thenReturn(api); + models.when(() -> ModelEngineAPI.getModeledEntity(stand)).thenReturn(modeled); + assertNull(new VehicleSpawner().spawn(location, template, manager, null)); + var calls = inOrder(api.getModelUpdaters(), stand); + calls.verify(api.getModelUpdaters()).forceRemoveModeledEntity(modeled); calls.verify(stand).remove(); + } + + @Test void cleanupFailureIsSuppressedAndDoesNotPreventBackingEntityRemoval() { + IllegalStateException failure = new IllegalStateException("spawn failed"); + IllegalArgumentException cleanup = new IllegalArgumentException("model cleanup failed"); + models.when(() -> ModelEngineAPI.createActiveModel("red_model")).thenThrow(failure); + models.when(() -> ModelEngineAPI.getModeledEntity(stand)).thenThrow(cleanup); + assertNull(new VehicleSpawner().spawn(location, template, manager, null)); + verify(stand).remove(); assertArrayEquals(new Throwable[]{cleanup}, failure.getSuppressed()); + verify(logger).warning(contains("spawn failed")); + } + + @Test void failureConfiguringANewArmorStandStillRemovesThatEntity() { + doThrow(new IllegalStateException("entity configuration failed")).when(stand).setVisible(false); + assertNull(new VehicleSpawner().spawn(location, template, manager, null)); + verify(stand).remove(); + models.verify(() -> ModelEngineAPI.createModeledEntity(any(Entity.class)), never()); + } + + private IncompleteVehicle snapshot(String skin) { + return new IncompleteVehicle("uuid", "cart", "Name", skin, List.of(), List.of(), List.of(), List.of(), List.of(), + 0, 0, 0, 0, "none", false, List.of()); + } + private T keep(T scope) { scopes.add(scope); return scope; } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/projectiles/ProjectilesCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/projectiles/ProjectilesCoverageTest.java new file mode 100644 index 0000000..32e26bb --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/projectiles/ProjectilesCoverageTest.java @@ -0,0 +1,154 @@ +package net.tfminecraft.vehicleframework.projectiles; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Predicate; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Levelled; +import org.bukkit.block.data.Waterlogged; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.scheduler.*; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.RayTraceResult; +import org.bukkit.util.Vector; +import org.bukkit.util.VoxelShape; +import org.junit.jupiter.api.*; +import org.mockito.*; +import net.tfminecraft.vehicleframework.*; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.managers.VehicleManager; +import net.tfminecraft.vehicleframework.test.RegistryFixture; +import net.tfminecraft.vehicleframework.util.ExplosionCreator; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.handlers.train.DeckBody; +import net.tfminecraft.vehicleframework.weapons.*; +import net.tfminecraft.vehicleframework.weapons.ammunition.Bullet; + +public class ProjectilesCoverageTest { + @BeforeAll static void registries() { RegistryFixture.initialize(); assertTrue(Material.AIR.isAir()); assertTrue(Material.STONE.isSolid()); } + Rig r; + @BeforeEach void setup() { r = new Rig(); } + @AfterEach void cleanup() throws Exception { r.close(); } + + @Test void solidWallStopsBulletBeforeLivingTarget() { + r.block(0,64,2,Material.STONE); + LivingEntity target=r.entity(LivingEntity.class,new Location(r.world,0.5,64.5,4.5)); + r.rayTargets.add(target); + try(var explosions=mockStatic(ExplosionCreator.class)) { + assertTrue(segment(r.bullet(false),null,5)); + explosions.verifyNoInteractions(); + verify(r.world).playSound(any(Location.class),eq(Sound.BLOCK_STONE_BREAK),eq(1f),eq(2f)); + } + } + + @Test void weaponCanOverrideBulletExplosionPolicyAndBlastSize() throws Exception { + Bullet bullet=r.bullet(false); + YamlConfiguration config=Rig.yaml("fixed: true\nprojectile-explosive: true\nprojectile-yield: 4\nprojectile-radius: 9\nprojectile-damage: 25\nprojectile-damage-type: FIRE\n"); + ActiveWeapon weapon=new ActiveWeapon(mock(com.ticxo.modelengine.api.model.ActiveModel.class),r.vehicle(),new Weapon("gun",config),null); + try(var explosions=mockStatic(ExplosionCreator.class)) { + BulletRaycast.triggerExplosion(new Location(r.world,0,64,0),bullet,weapon); + explosions.verify(()->ExplosionCreator.triggerExplosion(any(Location.class),eq(4d),eq(9d),eq(25d),eq("FIRE"))); + } + } + + @Test void hitCheckerChecksEachNeighbourAndIgnoresLightAndWater() { + HitChecker checker=new HitChecker(); Entity projectile=r.entity(Entity.class,new Location(r.world,0,64,0)); + int[][] offsets={{0,0,0},{0,-1,0},{0,1,0},{0,0,-1},{0,0,1},{-1,0,0},{1,0,0}}; + for(int[] d:offsets) { + Block block=r.block(d[0],64+d[1],d[2],Material.STONE); + assertTrue(checker.hasHitLocation(projectile.getLocation())); assertTrue(checker.hasHit(projectile,List.of())); assertTrue(checker.hasHitIgnoreWater(projectile,List.of())); + block.setType(Material.LIGHT); assertFalse(checker.hasHitLocation(projectile.getLocation())); assertFalse(checker.hasHit(projectile,List.of())); assertFalse(checker.hasHitIgnoreWater(projectile,List.of())); block.setType(Material.AIR); + } + for(Material water:List.of(Material.WATER,Material.KELP,Material.KELP_PLANT,Material.SEAGRASS,Material.TALL_SEAGRASS)) { r.block(0,64,0,water); assertFalse(checker.hasHitIgnoreWater(projectile,List.of())); } + Block logged=r.block(0,64,0,Material.OAK_SLAB); Waterlogged data=mock(Waterlogged.class); when(data.isWaterlogged()).thenReturn(true);when(logged.getBlockData()).thenReturn(data);assertFalse(checker.hasHitIgnoreWater(projectile,List.of()));when(data.isWaterlogged()).thenReturn(false);assertTrue(checker.hasHitIgnoreWater(projectile,List.of())); + } + + @Test void hitCheckerUsesBoundsAndIgnoresSelfPassengersAndMarkers() { + HitChecker checker=new HitChecker();Entity projectile=r.entity(Entity.class,new Location(r.world,0,64,0));Entity ignored=r.entity(Entity.class,new Location(r.world,0,64,0));ArmorStand marker=r.entity(ArmorStand.class,new Location(r.world,0,64,0));when(marker.isMarker()).thenReturn(true); + Entity miss=r.entity(Entity.class,new Location(r.world,.45,64,0));Entity far=r.entity(Entity.class,new Location(r.world,6,64,0));when(far.getBoundingBox()).thenReturn(new BoundingBox(-.1,63.9,-.1,6.2,64.2,.2));Entity hit=r.entity(Entity.class,new Location(r.world,0.1,64,0)); + r.nearby.addAll(List.of(projectile,ignored,marker,miss,far));assertFalse(checker.hasHit(projectile,List.of(ignored)));r.nearby.remove(marker);assertFalse(checker.hasHitIgnoreWater(projectile,List.of(ignored)));assertTrue(checker.getHitEntities(projectile,List.of(ignored),0.1).isEmpty()); + r.nearby.add(hit);assertTrue(checker.hasHit(projectile,List.of(ignored)));assertTrue(checker.hasHitIgnoreWater(projectile,List.of(ignored)));assertEquals(List.of(hit),checker.getHitEntities(projectile,List.of(ignored),0.1)); + } + + @Test void collisionShapesRespectPassabilityLocalCoordinatesAndEmptyShapes() { + Block b=r.block(4,64,7,Material.STONE);assertTrue(BulletRaycast.intersectsCollision(b,new Location(r.world,4.5,64.5,7.5)));assertFalse(BulletRaycast.intersectsCollision(b,new Location(r.world,3.5,64.5,7.5))); + when(b.isPassable()).thenReturn(true);assertFalse(BulletRaycast.intersectsCollision(b,new Location(r.world,4.5,64.5,7.5)));when(b.isPassable()).thenReturn(false);VoxelShape empty=mock(VoxelShape.class);when(empty.getBoundingBoxes()).thenReturn(List.of());when(b.getCollisionShape()).thenReturn(empty);assertFalse(BulletRaycast.intersectsCollision(b,new Location(r.world,4.5,64.5,7.5))); + } + + @Test void bulletsFilterShooterDeckDeadAndNonVehicleEntities() { + Bullet bullet=r.bullet(false);Entity ignored=r.entity(Entity.class,new Location(r.world,0.5,64.5,1));Entity deck=r.entity(Entity.class,new Location(r.world,0.5,64.5,2));LivingEntity dead=r.entity(LivingEntity.class,new Location(r.world,0.5,64.5,3));when(dead.isDead()).thenReturn(true);Entity decoration=r.entity(Entity.class,new Location(r.world,0.5,64.5,4));r.rayTargets.addAll(List.of(ignored,deck,dead,decoration));r.decks.when(()->DeckBody.isPart(deck)).thenReturn(true); + assertFalse(BulletRaycast.handleSegment(new Location(r.world,0.5,64.5,0),new Location(r.world,0.5,64.5,5),Set.of(ignored),null,bullet.getData(),bullet,null,List.of())); + assertFalse(BulletRaycast.handleSegment(new Location(r.world,0,0,0),new Location(r.world,0,0,0),Set.of(),null,bullet.getData(),bullet,null,List.of())); + } + + @Test void fragmentsBurnVehiclesTrailAndStopOnImpactOrDeath() { + ActiveVehicle source=r.vehicle();Player observer=r.entity(Player.class,new Location(r.world,0,64,0));when(source.getNearbyPlayers()).thenReturn(List.of(observer));List projectiles=new ArrayList<>();new Fragment(source,r.world,new Location(r.world,0,64,0),projectiles);ArmorStand fragment=(ArmorStand)projectiles.getFirst(); + Vector initial=fragment.getVelocity();assertTrue(initial.getY()>=1.5&&initial.getY()<2);assertTrue(Math.abs(initial.getX())<=3&&Math.abs(initial.getZ())<=3);verify(fragment).setInvisible(true);verify(fragment).setSmall(true); + Entity target=r.entity(Entity.class,new Location(r.world,0.1,64,0));ActiveVehicle victim=r.vehicle();when(source.getVehicleManager().get(target)).thenReturn(victim);r.nearby.add(target);r.tick();verify(victim).randomFire();verify(observer).spawnParticle(eq(Particle.FLAME),any(Location.class),eq(0),eq(0d),eq(0d),eq(0d),eq(0.2d)); + r.nearby.clear();r.ticks(10);r.block(0,64,0,Material.STONE);r.tick();verify(fragment).remove();assertEquals(0,r.pending()); + new Fragment(source,r.world,new Location(r.world,5,64,0),new ArrayList<>());ArmorStand another=(ArmorStand)r.spawned.getLast();when(another.isDead()).thenReturn(true);r.tick();verify(another).remove(); + } + + @Test void livingTargetsReceiveDirectDamageOrConfiguredExplosions() { + LivingEntity target=r.entity(LivingEntity.class,new Location(r.world,.5,64.5,2));r.rayTargets.add(target); + try(var explosions=mockStatic(ExplosionCreator.class)){assertTrue(segment(r.bullet(false),null,5));explosions.verify(()->ExplosionCreator.applyDamage(target,8d,"PROJECTILE"));assertTrue(segment(r.bullet(true),null,5));explosions.verify(()->ExplosionCreator.triggerExplosion(any(Location.class),eq(1d),eq(5d),eq(8d),eq("PROJECTILE")));} + } + + @Test void vehicleTargetsReceiveDamageExceptForTheShooterItself() { + Entity target=r.entity(Entity.class,new Location(r.world,.5,64.5,2));ActiveVehicle victim=r.vehicle(),shooter=r.vehicle();when(r.manager.get(target)).thenReturn(victim);r.rayTargets.add(target); + try(var explosions=mockStatic(ExplosionCreator.class)){assertTrue(segment(r.bullet(false),null,5));verify(victim).damage("PROJECTILE",8);Bullet explosive=r.bullet(true);assertTrue(BulletRaycast.handleSegment(new Location(r.world,.5,64.5,0),new Location(r.world,.5,64.5,5),Set.of(),shooter,explosive.getData(),explosive,null,List.of()));explosions.verify(()->ExplosionCreator.triggerExplosion(any(Location.class),eq(1d),eq(5d),eq(8d),eq("PROJECTILE")));Bullet regular=r.bullet(false);String victimId=victim.getUUID().toUpperCase(Locale.ROOT);when(shooter.getUUID()).thenReturn(victimId);assertFalse(BulletRaycast.handleSegment(new Location(r.world,.5,64.5,0),new Location(r.world,.5,64.5,5),Set.of(),shooter,regular.getData(),regular,null,List.of()));verify(victim,times(2)).damage("PROJECTILE",8);} + } + + @Test void leavesAndGlassAllowBulletsThroughWhileSolidBlocksStopThem() { + r.block(0,64,1,Material.OAK_LEAVES);r.block(0,64,2,Material.GLASS);Block hollow=r.block(0,64,3,Material.STONE);when(hollow.isPassable()).thenReturn(true);assertFalse(segment(r.bullet(false),null,5));verify(r.world,atLeastOnce()).playSound(any(Location.class),eq(Sound.BLOCK_GLASS_BREAK),eq(.8f),eq(1.2f)); + r.block(0,64,4,Material.STONE);try(var explosions=mockStatic(ExplosionCreator.class)){assertTrue(segment(r.bullet(true),null,5));explosions.verify(()->ExplosionCreator.triggerExplosion(any(Location.class),eq(1d),eq(5d),eq(8d),eq("PROJECTILE")));} + assertTrue(BulletRaycast.isBulletPassable(Material.GLASS_PANE));assertFalse(BulletRaycast.isBulletPassable(Material.STONE)); + } + + @Test void blockSamplingStopsAtTheActualSegmentEndpoint() { + r.block(0,64,1,Material.STONE);Bullet bullet=r.bullet(false);assertFalse(BulletRaycast.handleSegment(new Location(r.world,.5,64.5,.6),new Location(r.world,.5,64.5,.95),Set.of(),null,bullet.getData(),bullet,null,List.of())); + } + + private boolean segment(Bullet bullet,ActiveWeapon weapon,double length) {return BulletRaycast.handleSegment(new Location(r.world,0.5,64.5,0),new Location(r.world,0.5,64.5,length),Set.of(),null,bullet.getData(),bullet,weapon,List.of());} + + /** Stateful external server boundary; production movement, collisions and timers run unchanged. */ + public static final class Rig implements AutoCloseable { + public final World world=mock(World.class); public final BukkitScheduler scheduler=mock(BukkitScheduler.class); + public final VehicleManager manager=mock(VehicleManager.class); public final List nearby=new ArrayList<>(),rayTargets=new ArrayList<>(),spawned=new ArrayList<>(); + public final MockedStatic bukkit; public final MockedStatic framework; public final MockedStatic decks; + private final MockedStatic logger; private final VehicleFramework oldPlugin; private final List oldProjectiles; + private final Map blocks=new HashMap<>(); private final List scheduled=new ArrayList<>();private long tick;private int nextId=1; + public Rig() { + RegistryFixture.initialize();bukkit=mockStatic(Bukkit.class);bukkit.when(Bukkit::getScheduler).thenReturn(scheduler);logger=mockStatic(VFLogger.class);oldPlugin=VehicleFramework.plugin;VehicleFramework.plugin=mock(VehicleFramework.class);framework=mockStatic(VehicleFramework.class);framework.when(VehicleFramework::getVehicleManager).thenReturn(manager);decks=mockStatic(DeckBody.class); + oldProjectiles=new ArrayList<>(Cache.projectiles);Cache.projectiles.clear();when(world.getName()).thenReturn("weapons");when(world.getUID()).thenReturn(UUID.randomUUID()); + when(world.getBlockAt(anyInt(),anyInt(),anyInt())).thenAnswer(c->block(c.getArgument(0),c.getArgument(1),c.getArgument(2))); + when(world.getBlockAt(any(Location.class))).thenAnswer(c->{Location l=c.getArgument(0);return block(l.getBlockX(),l.getBlockY(),l.getBlockZ());}); + when(world.getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble())).thenAnswer(c->{Location center=c.getArgument(0);double x=c.getArgument(1),y=c.getArgument(2),z=c.getArgument(3);BoundingBox query=new BoundingBox(center.getX()-x,center.getY()-y,center.getZ()-z,center.getX()+x,center.getY()+y,center.getZ()+z);return nearby.stream().filter(e->e.getBoundingBox().overlaps(query)).toList();}); + when(world.rayTraceEntities(any(Location.class),any(Vector.class),anyDouble(),anyDouble(),any())).thenAnswer(c->{Location from=c.getArgument(0);double range=c.getArgument(2);Predicate filter=c.getArgument(4);return rayTargets.stream().filter(filter).filter(e->e.getLocation().distance(from)<=range).min(Comparator.comparingDouble(e->e.getLocation().distanceSquared(from))).map(e->new RayTraceResult(e.getLocation().toVector(),e)).orElse(null);}); + when(world.spawn(any(Location.class),any(Class.class))).thenAnswer(c->{Entity e=entity(c.getArgument(1),c.getArgument(0));spawned.add(e);return e;}); + when(world.spawn(any(Location.class),any(Class.class),any(Consumer.class))).thenAnswer(c->{Entity e=entity(c.getArgument(1),c.getArgument(0));((Consumer)c.getArgument(2)).accept(e);spawned.add(e);return e;}); + when(scheduler.runTaskTimer(any(),any(Runnable.class),anyLong(),anyLong())).thenAnswer(c->schedule(c.getArgument(1),c.getArgument(2),c.getArgument(3))); + when(scheduler.runTaskLater(any(),any(Runnable.class),anyLong())).thenAnswer(c->schedule(c.getArgument(1),c.getArgument(2),0)); + doAnswer(c->{int id=c.getArgument(0);scheduled.stream().filter(s->s.id==id).forEach(s->s.cancelled=true);return null;}).when(scheduler).cancelTask(anyInt()); + } + private BukkitTask schedule(Runnable run,long delay,long period) {Scheduled s=new Scheduled(nextId++,run,tick+Math.max(1,delay),period);scheduled.add(s);BukkitTask task=mock(BukkitTask.class);when(task.getTaskId()).thenReturn(s.id);return task;} + public void tick(){tick++;for(Scheduled s:new ArrayList<>(scheduled))if(!s.cancelled&&s.due<=tick){s.run.run();if(s.period==0)s.cancelled=true;else s.due=tick+s.period;}} + public void ticks(int count){for(int i=0;i!s.cancelled).count();} + public Block block(int x,int y,int z,Material type){Block b=block(x,y,z);b.setType(type);return b;} + public Block block(int x,int y,int z){return blocks.computeIfAbsent(x+":"+y+":"+z,key->{Block b=mock(Block.class);AtomicReference type=new AtomicReference<>(Material.AIR);AtomicReference data=new AtomicReference<>(mock(BlockData.class));when(b.getType()).thenAnswer(c->type.get());doAnswer(c->{type.set(c.getArgument(0));if(type.get()==Material.LIGHT)data.set(mock(Levelled.class));return null;}).when(b).setType(any());when(b.getBlockData()).thenAnswer(c->data.get());doAnswer(c->{data.set(c.getArgument(0));return null;}).when(b).setBlockData(any(),anyBoolean());when(b.getLocation()).thenAnswer(c->new Location(world,x,y,z));when(b.getWorld()).thenReturn(world);when(b.isPassable()).thenAnswer(c->Set.of(Material.AIR,Material.LIGHT,Material.WATER,Material.KELP,Material.KELP_PLANT,Material.SEAGRASS,Material.TALL_SEAGRASS).contains(type.get()));VoxelShape shape=mock(VoxelShape.class);when(shape.getBoundingBoxes()).thenReturn(List.of(new BoundingBox(0,0,0,1,1,1)));when(b.getCollisionShape()).thenReturn(shape);return b;});} + public T entity(Class type,Location start){T e=mock(type,RETURNS_DEEP_STUBS);AtomicReference location=new AtomicReference<>(start.clone());AtomicReference velocity=new AtomicReference<>(new Vector());when(e.getLocation()).thenAnswer(c->location.get().clone());when(e.getWorld()).thenAnswer(c->location.get().getWorld());when(e.getUniqueId()).thenReturn(UUID.randomUUID());when(e.getBoundingBox()).thenAnswer(c->{Location l=location.get();return new BoundingBox(l.getX()-.2,l.getY()-.2,l.getZ()-.2,l.getX()+.2,l.getY()+.2,l.getZ()+.2);});when(e.getVelocity()).thenAnswer(c->velocity.get().clone());doAnswer(c->{velocity.set(((Vector)c.getArgument(0)).clone());return null;}).when(e).setVelocity(any());when(e.teleport(any(Location.class))).thenAnswer(c->{location.set(((Location)c.getArgument(0)).clone());return true;});when(e.isDead()).thenReturn(false);when(e.isOnGround()).thenReturn(false);when(e.isValid()).thenReturn(true);if(e instanceof Player p){when(p.getEyeLocation()).thenAnswer(c->location.get().clone());when(p.isOnline()).thenReturn(true);}return e;} + public ActiveVehicle vehicle(){ActiveVehicle v=mock(ActiveVehicle.class,RETURNS_DEEP_STUBS);Entity root=entity(Entity.class,new Location(world,0,64,0));when(v.getUUID()).thenReturn(UUID.randomUUID().toString());when(v.getEntity()).thenReturn(root);when(v.getSeatHandler().getPassengers()).thenReturn(List.of());when(v.getAccessPanel().getRotator(anyString())).thenReturn(null);return v;} + public Bullet bullet(boolean explosive){YamlConfiguration yaml=new YamlConfiguration();yaml.set("type","BULLET");yaml.set("explosive",explosive);return new Bullet("bullet",yaml);} + public static YamlConfiguration yaml(String text)throws Exception{YamlConfiguration y=new YamlConfiguration();y.loadFromString(text);return y;} + @Override public void close(){Cache.projectiles.clear();Cache.projectiles.addAll(oldProjectiles);decks.close();framework.close();VehicleFramework.plugin=oldPlugin;logger.close();bukkit.close();} + private static final class Scheduled {final int id;final Runnable run;long due;final long period;boolean cancelled;Scheduled(int id,Runnable run,long due,long period){this.id=id;this.run=run;this.due=due;this.period=period;}} + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/test/RegistryFixture.java b/src/test/java/net/tfminecraft/vehicleframework/test/RegistryFixture.java new file mode 100644 index 0000000..70e941d --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/test/RegistryFixture.java @@ -0,0 +1,98 @@ +package net.tfminecraft.vehicleframework.test; + +import static org.mockito.Mockito.*; +import io.papermc.paper.registry.RegistryAccess; +import io.papermc.paper.registry.RegistryKey; +import java.lang.reflect.Proxy; +import java.util.HashMap; +import java.util.Map; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.Sound; +import org.bukkit.attribute.Attribute; +import org.bukkit.potion.PotionEffectType; + +/** Supplies the server-owned sound and attribute registries used by unit fixtures. */ +public final class RegistryFixture { + private RegistryFixture() {} + private static final Map> registries = new HashMap<>(); + + public static void initialize() { + try (var accessScope = mockStatic(RegistryAccess.class)) { + RegistryAccess access = mock(RegistryAccess.class, call -> { + if (call.getMethod().getName().equals("getRegistry")) { + Object key = call.getArgument(0); + return registry(key instanceof RegistryKey named ? named.key().value() + : key == Attribute.class ? "attribute" : key == Sound.class ? "sound_event" + : key == PotionEffectType.class ? "mob_effect" + : ((Class) key).getName()); + } + return RETURNS_DEFAULTS.answer(call); + }); + accessScope.when(RegistryAccess::registryAccess).thenReturn(access); + java.util.Objects.requireNonNull(Sound.BLOCK_NOTE_BLOCK_BIT); + java.util.Objects.requireNonNull(Attribute.MOVEMENT_SPEED); + java.util.Objects.requireNonNull(PotionEffectType.SPEED); + java.util.Objects.requireNonNull(org.bukkit.block.BlockType.AIR); + } + } + + private static Registry registry(String id) { + Registry existing = registries.get(id); + if (existing != null) return existing; + Map values = new HashMap<>(); + Class type = id.equals("attribute") ? Attribute.class : Sound.class; + Registry created = mock(Registry.class, call -> { + if (id.equals("block") && (call.getMethod().getName().equals("get") + || call.getMethod().getName().equals("getOrThrow"))) { + NamespacedKey key = namespaced(call.getArgument(0)); + return values.computeIfAbsent(key, ignored -> Proxy.newProxyInstance( + org.bukkit.block.BlockType.class.getClassLoader(), new Class[]{org.bukkit.block.BlockType.Typed.class}, + (self, method, args) -> switch (method.getName()) { + case "getKey", "key" -> key; + case "typed" -> self; + case "isAir" -> java.util.Set.of("air", "cave_air", "void_air").contains(key.getKey()); + case "isSolid" -> !java.util.Set.of("air", "cave_air", "void_air", "water", "lava").contains(key.getKey()); + case "getMaterial" -> org.bukkit.Material.valueOf(key.getKey().toUpperCase(java.util.Locale.ROOT)); + case "getBlastResistance" -> switch (key.getKey()) { + case "stone" -> 6f; + case "obsidian" -> 1200f; + default -> 0f; + }; + case "equals" -> self == args[0]; + case "hashCode" -> key.hashCode(); + case "toString" -> key.toString(); + default -> throw new UnsupportedOperationException(method.getName()); + })); + } + if (call.getMethod().getName().equals("get")) return values.get(namespaced(call.getArgument(0))); + if (!call.getMethod().getName().equals("getOrThrow")) return RETURNS_DEFAULTS.answer(call); + NamespacedKey key = namespaced(call.getArgument(0)); + if (id.equals("mob_effect")) { + return values.computeIfAbsent(key, ignored -> mock(PotionEffectType.class, effect -> switch(effect.getMethod().getName()) { + case "getKey", "key" -> key; + case "getName" -> key.getKey().toUpperCase(java.util.Locale.ROOT); + case "getColor" -> org.bukkit.Color.BLUE; + case "getDurationModifier" -> 1.0; + default -> RETURNS_DEFAULTS.answer(effect); + })); + } + return values.computeIfAbsent(key, ignored -> Proxy.newProxyInstance(type.getClassLoader(),new Class[]{type}, + (self,method,args) -> switch (method.getName()) { + case "equals" -> self == args[0]; + case "hashCode" -> key.hashCode(); + case "getKey", "key" -> key; + case "toString" -> key.toString(); + default -> throw new UnsupportedOperationException(method.getName()); + })); + }); + registries.put(id,created); + return created; + } + private static NamespacedKey namespaced(Object value) { + if (value instanceof NamespacedKey key) return key; + net.kyori.adventure.key.Key key = (net.kyori.adventure.key.Key) value; + return new NamespacedKey(key.namespace(), key.value()); + } + +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackCommandsCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackCommandsCoverageTest.java new file mode 100644 index 0000000..a8c203a --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackCommandsCoverageTest.java @@ -0,0 +1,792 @@ +package net.tfminecraft.vehicleframework.tracks; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import net.kyori.adventure.text.Component; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.managers.VehicleManager; +import net.tfminecraft.vehicleframework.permissions.Permissions; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +class TrackCommandsCoverageTest { + private final List scopes = new ArrayList<>(); + private final TrackRegistry registry = mock(TrackRegistry.class); + private final TrackDisplayManager displays = mock(TrackDisplayManager.class); + private final VehicleManager vehicles = mock(VehicleManager.class); + private final HashMap fleet = new HashMap<>(); + private final World world = mock(World.class); + private final Player player = mock(Player.class); + private final Map tracks = new LinkedHashMap<>(); + private final List messages = new ArrayList<>(); + private MockedStatic framework; + private MockedStatic permissions; + private MockedStatic support; + private MockedStatic pieces; + private MockedStatic animation; + private MockedStatic fx; + private MockedStatic log; + private MockedStatic tools; + private MockedStatic highlights; + private MockedStatic collision; + private boolean oldDebug, oldSwing; + private long oldRetry, oldRemove; + + @BeforeEach + void setup() { + oldDebug = Cache.debugLogging; + oldSwing = Cache.trackBuildSwing; + oldRetry = Cache.trackLayRetryMs; + oldRemove = Cache.trackRemoveCooldownMs; + Cache.debugLogging = false; + Cache.trackBuildSwing = true; + Cache.trackLayRetryMs = 60_000; + Cache.trackRemoveCooldownMs = 60_000; + framework = keep(mockStatic(VehicleFramework.class)); + permissions = keep(mockStatic(Permissions.class)); + support = keep(mockStatic(TrackSupport.class)); + pieces = keep(mockStatic(TrackPieces.class)); + animation = keep(mockStatic(TrackBuildAnimator.class)); + fx = keep(mockStatic(TrackFx.class)); + log = keep(mockStatic(TrackLog.class)); + tools = keep(mockStatic(TrackTools.class)); + highlights = keep(mockStatic(TrainSpaceHighlight.class)); + collision = keep(mockStatic(TrainBlockCollision.class)); + framework.when(VehicleFramework::getTrackRegistry).thenReturn(registry); + framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(displays); + framework.when(VehicleFramework::getVehicleManager).thenReturn(vehicles); + permissions.when(() -> Permissions.isAdmin(any(CommandSender.class))).thenReturn(true); + support.when(() -> TrackSupport.snapY(any(World.class), anyDouble(), anyDouble(), anyDouble())).thenReturn(64.25); + support.when(() -> TrackSupport.firstSitY(any(World.class), anyDouble(), anyDouble(), anyDouble())).thenReturn(64d); + pieces.when(() -> TrackPieces.canAffordFirst(player)).thenReturn(true); + pieces.when(() -> TrackPieces.pays(player)).thenReturn(true); + pieces.when(() -> TrackPieces.count(player)).thenReturn(10_000); + pieces.when(() -> TrackPieces.cost(anyList())).thenCallRealMethod(); + pieces.when(() -> TrackPieces.consumeUpTo(eq(player), anyInt())).thenAnswer(call -> call.getArgument(1)); + pieces.when(() -> TrackPieces.persistPoints(anyList(), anyList(), anyBoolean(), anyInt())).thenCallRealMethod(); + when(world.getName()).thenReturn("world"); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getName()).thenReturn("Engineer"); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenAnswer(call -> start()); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + doAnswer(call -> { messages.add(call.getArgument(0)); return null; }).when(player).sendMessage(anyString()); + when(registry.get(any(UUID.class))).thenAnswer(call -> Optional.ofNullable(tracks.get(call.getArgument(0)))); + when(registry.inWorld(anyString())).thenAnswer(call -> tracks.values().stream() + .filter(s -> s.getWorld().equals(call.getArgument(0))).toList()); + when(registry.nearest(anyString(), anyDouble(), anyDouble(), anyDouble(), anyDouble())).thenReturn(Optional.empty()); + when(registry.digTarget(anyString(), anyDouble(), anyDouble(), anyDouble())).thenReturn(Optional.empty()); + when(vehicles.get()).thenReturn(fleet); + } + + @AfterEach + void teardown() throws Exception { + TrackAnchorSession.clear(player); + TrackJunctionSession.clear(player); + Collections.reverse(scopes); + for (AutoCloseable scope : scopes) scope.close(); + Cache.debugLogging = oldDebug; + Cache.trackBuildSwing = oldSwing; + Cache.trackLayRetryMs = oldRetry; + Cache.trackRemoveCooldownMs = oldRemove; + } + + private T keep(T scope) { scopes.add(scope); return scope; } + private Location start() { return new Location(world, 8, 64, 8); } + private Location end() { return new Location(world, 8, 64, 20); } + private TrackSpline rail() { return rail("world"); } + private TrackSpline rail(String worldName) { + TrackSpline rail = TrackSpline.fromPoints(UUID.randomUUID(), worldName, false, + List.of(new double[] {8, 64.25, 8}, new double[] {8, 64.25, 14}, new double[] {8, 64.25, 20})); + tracks.put(rail.getId(), rail); + return rail; + } + private void nearby(TrackSpline rail) { + when(registry.nearest(anyString(), anyDouble(), anyDouble(), anyDouble(), anyDouble())).thenReturn(Optional.of(rail)); + } + private boolean command(String... args) { + String[] full = new String[args.length + 1]; + full[0] = "track"; + System.arraycopy(args, 0, full, 1, args.length); + return TrackCommands.handle(player, full); + } + private boolean said(String part) { return messages.stream().anyMatch(message -> message.contains(part)); } + private void lays(TrackLayResult result) throws TrackLayException { + when(registry.lay(anyString(), any(World.class), anyDouble(), anyDouble(), anyDouble(), anyDouble(), anyDouble(), anyDouble(), anyInt())).thenReturn(result); + } + private void refuses(TrackLayException error) throws TrackLayException { + when(registry.lay(anyString(), any(World.class), anyDouble(), anyDouble(), anyDouble(), anyDouble(), anyDouble(), anyDouble(), anyInt())).thenThrow(error); + } + private TrackRegistry.DigTarget digTarget(TrackSpline rail, DigResult result) { + TrackRegistry.DigTarget target = new TrackRegistry.DigTarget(rail, 1, + List.of(new TrackRegistry.Span(rail.getId(), 6, 1))); + when(registry.digTarget("world", 8, 64, 8)).thenReturn(Optional.of(target)); + when(registry.digAt(rail, 1, world)).thenReturn(result); + return target; + } + private ActiveVehicle train(double x, World in) { + ActiveVehicle train = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); + when(train.isTrain()).thenReturn(true); + Entity entity = train.getEntity(); + when(entity.getWorld()).thenReturn(in); + when(entity.getLocation()).thenReturn(new Location(in, x, 64, 8)); + fleet.put(entity, train); + return train; + } + private void pending(TrackSpline stem) { + TrackJunctionSession.set(player, new TrackJunctionSession.Pending(stem.getId(), 6, 1)); + } + private AtomicInteger inventory(int amount) { + AtomicInteger available = new AtomicInteger(amount); + pieces.when(() -> TrackPieces.count(player)).thenAnswer(call -> available.get()); + pieces.when(() -> TrackPieces.consumeUpTo(eq(player), anyInt())).thenAnswer(call -> { + int consumed = Math.min(available.get(), call.getArgument(1)); + available.addAndGet(-consumed); + return consumed; + }); + return available; + } + private TrackSpline openRacetrack() { + List points = new ArrayList<>(); + for (int z = 10; z <= 40; z++) points.add(new double[] {0, 64, z}); + semicircle(points, 20, 40, Math.PI); + for (int z = 39; z >= 0; z--) points.add(new double[] {40, 64, z}); + semicircle(points, 20, 0, 0); + return TrackSpline.fromPoints(UUID.randomUUID(), "world", false, points); + } + private void semicircle(List points, double cx, double cz, double from) { + for (int i = 1; i < 63; i++) { + double angle = from - Math.PI * i / 63; + points.add(new double[] {cx + 20 * Math.cos(angle), 64, cz + 20 * Math.sin(angle)}); + } + } + + @Test + void checksPermissionAndPlayerRequirementBeforeDispatch() { + permissions.when(() -> Permissions.isAdmin(player)).thenReturn(false); + assertTrue(command("list")); + assertTrue(said("No permission")); + verifyNoInteractions(registry); + CommandSender console = mock(CommandSender.class); + assertTrue(TrackCommands.handle(console, new String[] {"track", "list"})); + verify(console).sendMessage("§cOnly players can use track commands."); + } + + @Test + void givesUsageAndSupportsConsoleResyncWithOrWithoutDisplayService() { + assertTrue(command()); + assertTrue(command("unrecognized")); + assertEquals(2, messages.stream().filter(m -> m.contains("/vf track start")).count()); + CommandSender console = mock(CommandSender.class); + assertTrue(TrackCommands.handle(console, new String[] {"track", "ReSyNc"})); + verify(displays).startRailResync(console); + framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(null); + assertTrue(command("resync")); + assertTrue(said("displays are not running")); + } + + @Test + void listsTracksAndReportsExplicitOrNearbyInformation() { + assertTrue(command("list")); + assertTrue(said("No tracks")); + TrackSpline rail = rail(); + assertTrue(command("list")); + assertTrue(said("§bTracks:")); + assertTrue(said(rail.getId() + " §7samples=3 length=12.0")); + assertTrue(command("info", rail.getId().toString())); + assertTrue(said("§bTrack " + rail.getId())); + assertTrue(said("samples=3 length=12.0 loop=false")); + nearby(rail); + assertTrue(command("info")); + verify(registry).nearest("world", 8, 64, 8, 8); + } + + @Test + void unknownAndMalformedTrackIdsHaveActionableMessages() { + assertTrue(command("info")); + assertTrue(command("info", "not-a-uuid")); + assertTrue(command("particles", UUID.randomUUID().toString())); + assertTrue(command("clearance", "not-a-uuid")); + assertEquals(4, messages.stream().filter(m -> m.contains("No track nearby (8 blocks) or unknown id")).count()); + } + + @Test + void particleCommandShowsEverySampleSlightlyAboveRail() { + TrackSpline rail = rail(); + assertTrue(command("particles", rail.getId().toString())); + for (TrackSample sample : rail.getSamples()) { + verify(player).spawnParticle(Particle.END_ROD, new Location(world, sample.x, sample.y + .2, sample.z), 1, 0, 0, 0, 0); + } + assertTrue(said("Showed 3 samples")); + } + + @Test + void clearanceRejectsDifferentWorldAndReportsClearLoadedSpace() { + TrackSpline elsewhere = rail("other-world"); + assertTrue(command("clearance", elsewhere.getId().toString())); + assertTrue(said("That track is in other-world")); + collision.verifyNoInteractions(); + TrackSpline rail = rail(); + collision.when(() -> TrainBlockCollision.scanLoaded(world, rail)).thenReturn(new TrainBlockCollision.Scan(List.of(), 12)); + assertTrue(command("clearance", rail.getId().toString())); + assertTrue(said("Nothing in the way of trains")); + assertTrue(said("Did not check 12 blocks of track in unloaded chunks")); + } + + @Test + void clearanceGroupsObstructionsShowsLargestFiveAndCountsRemainingStretches() { + TrackSpline rail = rail(); + List obstructions = new ArrayList<>(); + obstructions.add(new TrainBlockCollision.Obstruction(1, 65, 1, 0)); + obstructions.add(new TrainBlockCollision.Obstruction(2, 65, 1, 2)); + for (int i = 1; i <= 5; i++) obstructions.add(new TrainBlockCollision.Obstruction(i * 10, 65, 1, i * 10)); + collision.when(() -> TrainBlockCollision.scanLoaded(world, rail)).thenReturn(new TrainBlockCollision.Scan(obstructions, 0)); + highlights.when(() -> TrainSpaceHighlight.show(player, obstructions)).thenReturn(4); + assertTrue(command("clearance", rail.getId().toString())); + assertTrue(said("7 blocks in the way")); + assertTrue(said("Outlined the nearest 4")); + assertEquals(5, messages.stream().filter(m -> m.contains("blocks from")).count()); + assertTrue(messages.get(1).contains("2 blocks from 1, 65, 1 (0 to 2 along the track)")); + assertTrue(said("and 1 smaller stretches")); + } + + @Test + void dumpRequiresDebugLogging() { + assertTrue(command("dump")); + assertTrue(said("Enable debug-logging")); + verify(registry, never()).dumpToLog(); + Cache.debugLogging = true; + assertTrue(command("dump")); + verify(registry).dumpToLog(); + assertTrue(said("Wrote spline dump")); + } + + @Test + void startCommandSnapsToGroundReplacesJunctionAndEndCommandUsesSavedAnchor() throws Exception { + TrackSpline rail = rail(); + pending(rail); + assertFalse(TrackCommands.skipDuplicateToolUse(player)); + assertTrue(command("StArT")); + assertNull(TrackJunctionSession.get(player)); + Location snapped = new Location(world, 8, 64.25, 8); + assertEquals(snapped, TrackAnchorSession.getStart(player)); + log.verify(() -> TrackLog.start("Engineer", 8, 64.25, 8)); + assertTrue(said("Start location set")); + lays(TrackLayResult.of(TrackLayResult.Kind.NEW, rail, rail.xyz(), 0)); + when(player.getLocation()).thenReturn(end()); + assertTrue(command("end")); + verify(registry).lay("world", world, 8, 64.25, 8, 8, 64.25, 20, 10_000); + assertNull(TrackAnchorSession.getStart(player)); + verify(displays).refreshSpline(null, rail.getId()); + assertTrue(said("Laid track from start to end. Length 12.0")); + fx.verify(() -> TrackFx.hitNear(new Location(world, 8, 64.25, 20))); + } + + @Test + void startRejectsMissingClickAndUnsuitableGroundWithoutSettingAnchor() { + assertFalse(TrackCommands.markStart(player, null)); + support.when(() -> TrackSupport.firstSitY(world, 8, 64, 8)).thenReturn(null); + assertFalse(TrackCommands.markStart(player, start())); + assertNull(TrackAnchorSession.getStart(player)); + assertTrue(said("Click solid ground")); + } + + @Test + void endRequiresStartAndSkipsInvalidClickedGround() { + assertTrue(command("end")); + assertTrue(said("Set a start location first")); + TrackAnchorSession.setStart(player, start()); + support.when(() -> TrackSupport.firstSitY(world, 8, 64, 20)).thenReturn(null); + TrackCommands.markEnd(player, end()); + assertEquals(start(), TrackAnchorSession.getStart(player)); + fx.verifyNoInteractions(); + verifyNoInteractions(registry); + } + + @Test + void endUsesClickedBlockForFeedbackWhenSupplied() throws Exception { + TrackSpline rail = rail(); + TrackAnchorSession.setStart(player, start()); + lays(TrackLayResult.of(TrackLayResult.Kind.NEW, rail)); + Block block = mock(Block.class); + TrackCommands.markEnd(player, end(), block); + fx.verify(() -> TrackFx.hit(block)); + fx.verify(() -> TrackFx.place(eq(world), argThat(p -> p.x == 8 && p.y == 64.25 && p.z == 20 && p.yaw == 0))); + } + + @Test + void finishRejectsDifferentWorldsAndMissingInventoryWithoutMutatingTracks() { + TrackCommands.finish(player, start(), new Location(mock(World.class), 8, 64, 20)); + assertTrue(said("Start and end must be in the same world")); + log.verify(() -> TrackLog.append("LAY_FAIL player=Engineer different worlds")); + pieces.when(() -> TrackPieces.canAffordFirst(player)).thenReturn(false); + TrackCommands.finish(player, start(), end()); + assertTrue(said("You need track in your inventory")); + verifyNoInteractions(registry); + } + + @Test + void refusedLayHighlightsBlockingCoordinateAndPreventsImmediateRetry() throws Exception { + refuses(new TrackLayException("Wall in the way", 8, 65, 12)); + TrackAnchorSession.setStart(player, start()); + TrackCommands.markEnd(player, end()); + assertTrue(said("Wall in the way")); + highlights.verify(() -> TrainSpaceHighlight.show(player, List.of(new TrainBlockCollision.Obstruction(8, 65, 12, 0)))); + TrackCommands.markEnd(player, end()); + TrackCommands.startJunction(player, start()); + verify(player, times(2)).sendActionBar(any(Component.class)); + verify(registry, times(1)).lay(anyString(), any(World.class), anyDouble(), anyDouble(), anyDouble(), anyDouble(), anyDouble(), anyDouble(), anyInt()); + assertNotNull(TrackAnchorSession.getStart(player)); + } + + @Test + void refusedLayWithoutCoordinateStillReportsError() throws Exception { + refuses(new TrackLayException("Too tight a curve")); + TrackCommands.finish(player, start(), end()); + assertTrue(said("Too tight a curve")); + highlights.verifyNoInteractions(); + } + + @Test + void junctionStartRequiresExistingTrackAndClearsOrdinaryAnchorOnSuccess() throws Exception { + TrackCommands.startJunction(player, null); + TrackCommands.startJunction(player, new Location(null, 0, 0, 0)); + TrackCommands.startJunction(player, start()); + assertEquals(3, messages.size()); + TrackSpline rail = rail(); + nearby(rail); + TrackAnchorSession.setStart(player, start()); + TrackCommands.startJunction(player, new Location(world, 8, 64.25, 14, 180, 0)); + TrackJunctionSession.Pending pending = TrackJunctionSession.get(player); + assertNotNull(pending); + assertEquals(rail.getId(), pending.stemId); + assertEquals(6, pending.s); + assertEquals(-1, pending.facingSign); + assertNull(TrackAnchorSession.getStart(player)); + verify(registry).dropIncompleteJunctions(); + verify(registry).ensureFrogClear(rail, 6, null); + log.verify(() -> TrackLog.junctionStart("Engineer", rail.getId(), 6)); + } + + @Test + void junctionRefusalHighlightsAllTrainSpaceObstructionsAndLogsFailure() throws Exception { + TrackSpline rail = rail(); + nearby(rail); + List blocked = List.of(new TrainBlockCollision.Obstruction(8, 65, 14, 6)); + TrackLayException error = new TrackLayException("Train space blocked", blocked); + doThrow(error).when(registry).ensureFrogClear(eq(rail), anyDouble(), isNull()); + TrackCommands.startJunction(player, start()); + assertNull(TrackJunctionSession.get(player)); + assertTrue(said("Train space blocked")); + highlights.verify(() -> TrainSpaceHighlight.show(player, blocked)); + log.verify(() -> TrackLog.layFail("Train space blocked", error)); + } + + @Test + void branchEndRetainsPendingJunctionUntilAffordableAndSuccessful() throws Exception { + TrackSpline stem = rail(), branch = rail(); + pending(stem); + pieces.when(() -> TrackPieces.canAffordFirst(player)).thenReturn(false); + TrackCommands.markEnd(player, end()); + assertNotNull(TrackJunctionSession.get(player)); + assertTrue(said("You need track")); + pieces.when(() -> TrackPieces.canAffordFirst(player)).thenReturn(true); + when(registry.layBranch(stem.getId(), 6, 1, "world", world, 8, 64.25, 20)).thenReturn(branch); + TrackCommands.markEnd(player, end()); + assertNull(TrackJunctionSession.get(player)); + verify(displays).refreshSpline(null, branch.getId()); + assertTrue(said("Laid branch. Length 12.0")); + } + + @Test + void branchEndRefusesUnsuitableGroundAndReportsLayFailure() throws Exception { + TrackSpline stem = rail(); + pending(stem); + support.when(() -> TrackSupport.firstSitY(world, 8, 64, 20)).thenReturn(null); + TrackCommands.markEnd(player, end()); + assertNotNull(TrackJunctionSession.get(player)); + verifyNoInteractions(displays); + support.when(() -> TrackSupport.firstSitY(world, 8, 64, 20)).thenReturn(64d); + when(registry.layBranch(stem.getId(), 6, 1, "world", world, 8, 64.25, 20)) + .thenThrow(new TrackLayException("Branch too long")); + TrackCommands.markEnd(player, end()); + assertNotNull(TrackJunctionSession.get(player)); + assertTrue(said("Branch too long")); + } + + @Test + void branchEndWithoutWorldKeepsPendingJunctionAndDoesNotLayRail() { + TrackSpline stem = rail(); + pending(stem); + TrackCommands.markEnd(player, new Location(null, 8, 64, 20)); + assertNotNull(TrackJunctionSession.get(player)); + verifyNoInteractions(registry, displays); + } + + @Test + void sequentialLayStartsAnimatorWithoutImmediateConsumptionOrBurst() throws Exception { + TrackSpline rail = rail(); + TrackLayResult result = TrackLayResult.of(TrackLayResult.Kind.APPEND, rail, rail.xyz(), 2); + lays(result); + animation.when(() -> TrackBuildAnimator.sequential(player)).thenReturn(true); + animation.when(() -> TrackBuildAnimator.start(eq(player), eq(rail), anyList(), eq(result.stroke))).thenReturn(true); + TrackCommands.finish(player, start(), end()); + ArgumentCaptor> kept = ArgumentCaptor.forClass(List.class); + animation.verify(() -> TrackBuildAnimator.start(eq(player), eq(rail), kept.capture(), eq(result.stroke))); + assertEquals(2, kept.getValue().size()); + assertArrayEquals(new double[] {8, 64.25, 8}, kept.getValue().getFirst()); + assertArrayEquals(new double[] {8, 64.25, 14}, kept.getValue().getLast()); + pieces.verify(() -> TrackPieces.consumeUpTo(any(), anyInt()), never()); + verifyNoInteractions(displays); + fx.verifyNoInteractions(); + assertTrue(said("Laid track")); + } + + @Test + void failedSequentialStartDoesNotClaimMissingTrackWasLaid() throws Exception { + TrackSpline rail = rail(); + lays(TrackLayResult.of(TrackLayResult.Kind.NEW, rail, rail.xyz(), 0)); + animation.when(() -> TrackBuildAnimator.sequential(player)).thenReturn(true); + animation.when(() -> TrackBuildAnimator.start(eq(player), eq(rail), anyList(), anyList())) + .thenAnswer(call -> { tracks.remove(rail.getId()); return false; }); + TrackCommands.finish(player, start(), end()); + assertFalse(said("Laid track")); + verifyNoInteractions(displays); + } + + @Test + void fullyPaidConnectionAnnouncesJoinAndPreservesBothOriginalTrackSections() throws Exception { + TrackSpline rail = rail(); + lays(TrackLayResult.of(TrackLayResult.Kind.CONNECT, rail, rail.xyz(), 0)); + TrackCommands.finish(player, start(), end()); + assertTrue(said("Connected two tracks. Length 12.0")); + assertFalse(said("Not enough track to finish")); + verify(registry, never()).persistPoints(any(), anyList()); + } + + @Test + void connectingTracksRequiresFullPaymentBeforeChangingEitherSpline(@TempDir java.nio.file.Path directory) throws Exception { + TrackRegistry actual = new TrackRegistry(directory.toFile()); + keep(mockStatic(TrackClearance.class)); // Geometry is real; block clearance is independent of payment. + TrackSpline first = actual.lay("world", 0, 64, 0, 0, 64, 12).spline(); + TrackSpline second = actual.lay("world", 0, 64, 24, 0, 64, 36).spline(); + assertEquals(2, actual.inWorld("world").size()); + framework.when(VehicleFramework::getTrackRegistry).thenReturn(actual); + AtomicInteger available = inventory(1); + TrackCommands.finish(player, new Location(world, 0, 64, 12), new Location(world, 0, 64, 24)); + TrackRegistry reloaded = new TrackRegistry(directory.toFile()); + reloaded.loadFromDisk(); + assertAll("An unaffordable connection must preserve existing track and inventory", + () -> assertEquals(2, actual.inWorld("world").size()), + () -> assertSame(first, actual.get(first.getId()).orElse(null)), + () -> assertSame(second, actual.get(second.getId()).orElse(null)), + () -> assertEquals(first.toJson(), reloaded.get(first.getId()).map(TrackSpline::toJson).orElse(null)), + () -> assertEquals(second.toJson(), reloaded.get(second.getId()).map(TrackSpline::toJson).orElse(null)), + () -> assertEquals(1, available.get()), + () -> assertFalse(said("Connected two tracks"))); + } + + @Test + void affordableConnectionPaysForEveryNewEdgeAndSavesJoinedTrack(@TempDir java.nio.file.Path directory) throws Exception { + TrackRegistry actual = new TrackRegistry(directory.toFile()); + keep(mockStatic(TrackClearance.class)); + TrackSpline first = actual.lay("world", 0, 64, 0, 0, 64, 12).spline(); + TrackSpline second = actual.lay("world", 0, 64, 24, 0, 64, 36).spline(); + framework.when(VehicleFramework::getTrackRegistry).thenReturn(actual); + AtomicInteger available = inventory(1000); + TrackCommands.finish(player, new Location(world, 0, 64, 12), new Location(world, 0, 64, 24)); + TrackSpline joined = actual.get(first.getId()).orElseThrow(); + assertEquals(1, actual.inWorld("world").size()); + assertTrue(actual.get(second.getId()).isEmpty()); + assertEquals(0, joined.first().z); + assertEquals(36, joined.last().z); + int newEdges = joined.edgeCount() - first.edgeCount() - second.edgeCount(); + assertTrue(newEdges > 1); + assertEquals(1000 - newEdges, available.get()); + assertTrue(said("Connected two tracks")); + TrackRegistry reloaded = new TrackRegistry(directory.toFile()); + reloaded.loadFromDisk(); + assertEquals(joined.toJson(), reloaded.get(joined.getId()).orElseThrow().toJson()); + assertTrue(reloaded.get(second.getId()).isEmpty()); + } + + @ParameterizedTest + @ValueSource(ints = {1, 1000}) + void closingLoopRequiresFullPaymentBeforeChangingSavedTrack(int amount, @TempDir java.nio.file.Path directory) { + TrackRegistry actual = new TrackRegistry(directory.toFile()); + keep(mockStatic(TrackClearance.class)); + TrackSpline open = actual.replace(openRacetrack()); + assertFalse(open.isLoop()); + framework.when(VehicleFramework::getTrackRegistry).thenReturn(actual); + AtomicInteger available = inventory(amount); + TrackSample first = open.first(), last = open.last(); + TrackCommands.finish(player, new Location(world, first.x, first.y, first.z), + new Location(world, last.x, last.y, last.z)); + TrackSpline after = actual.get(open.getId()).orElseThrow(); + if (amount == 1) { + assertSame(open, after); + assertFalse(after.isLoop()); + assertEquals(1, available.get()); + assertTrue(said("complete this connection")); + } else { + assertTrue(after.isLoop()); + int newEdges = after.edgeCount() - open.edgeCount(); + assertTrue(newEdges > 1); + assertEquals(amount - newEdges, available.get()); + } + TrackRegistry reloaded = new TrackRegistry(directory.toFile()); + reloaded.loadFromDisk(); + assertEquals(after.toJson(), reloaded.get(open.getId()).orElseThrow().toJson()); + } + + @Test + void partiallyPaidAppendPersistsAffordablePointsAndReportsIncompleteTrack() throws Exception { + TrackSpline rail = rail(); + TrackLayResult result = TrackLayResult.of(TrackLayResult.Kind.APPEND, rail, rail.xyz(), 2).withBefore(rail); + lays(result); + pieces.when(() -> TrackPieces.consumeUpTo(player, 2)).thenReturn(1); + List kept = TrackPieces.persistPoints(result.keepPoints(), result.stroke, true, 1); + TrackCommands.finish(player, start(), end()); + ArgumentCaptor> saved = ArgumentCaptor.forClass(List.class); + verify(registry).persistPoints(eq(rail.getId()), saved.capture()); + assertEquals(kept.size(), saved.getValue().size()); + for (int i = 0; i < kept.size(); i++) assertArrayEquals(kept.get(i), saved.getValue().get(i)); + verify(displays).refreshSpline(rail, rail.getId()); + assertTrue(said("Not enough track to finish")); + } + + @Test + void depletedNewLayThatLeavesNoRailAsksForTrackInsteadOfAnnouncing() throws Exception { + TrackSpline rail = rail(); + lays(TrackLayResult.of(TrackLayResult.Kind.NEW, rail, rail.xyz(), 0)); + pieces.when(() -> TrackPieces.consumeUpTo(player, 2)).thenReturn(0); + doAnswer(call -> { tracks.remove(rail.getId()); return null; }).when(registry).persistPoints(eq(rail.getId()), anyList()); + TrackCommands.finish(player, start(), end()); + assertTrue(said("You need track in your inventory")); + assertFalse(said("Laid track")); + } + + @Test + void unchangedAppendCannotAnnounceSuccessWhenInventoryRunsOut() throws Exception { + TrackSpline rail = rail(); + lays(TrackLayResult.of(TrackLayResult.Kind.APPEND, rail, rail.xyz(), 3)); + pieces.when(() -> TrackPieces.consumeUpTo(player, 2)).thenReturn(0); + TrackCommands.finish(player, start(), end()); + assertTrue(said("You need track in your inventory")); + assertFalse(said("Laid track")); + } + + @Test + void singlePointBurstHasNeutralYawAndDisplayServiceCanBeAbsent() throws Exception { + TrackSpline rail = rail(); + lays(TrackLayResult.of(TrackLayResult.Kind.CONNECT, rail, List.of(new double[] {9, 64, 20}), 0)); + framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(null); + pieces.when(() -> TrackPieces.pays(player)).thenReturn(false); + TrackCommands.finish(player, start(), end()); + fx.verify(() -> TrackFx.place(eq(world), argThat(p -> p.x == 9 && p.y == 64 && p.z == 20 && p.yaw == 0))); + assertTrue(said("Connected two tracks")); + } + + @Test + void trainBindingAndUnbindingRequireNearbyTrain() { + assertTrue(command("bind")); + assertTrue(command("unbind")); + assertEquals(2, messages.stream().filter(m -> m.contains("No train")).count()); + } + + @Test + void bindingChoosesNearestEligibleTrainAndUsesTrackNearLocomotiveIfNeeded() { + TrackSpline rail = rail(); + ActiveVehicle notTrain = mock(ActiveVehicle.class); + fleet.put(mock(Entity.class), notTrain); + ActiveVehicle absentEntity = mock(ActiveVehicle.class); + when(absentEntity.isTrain()).thenReturn(true); + fleet.put(mock(Entity.class), absentEntity); + train(8, mock(World.class)); + train(30, world); + ActiveVehicle farther = train(14, world); + ActiveVehicle nearest = train(10, world); + when(vehicles.getByPassenger(player)).thenReturn(notTrain); + when(registry.nearest("world", 10, 64, 8, 8)).thenReturn(Optional.of(rail)); + when(nearest.getTrainHandler().bind(rail)).thenReturn(true); + assertTrue(command("bind", "bad-id")); + verify(nearest.getTrainHandler()).bind(rail); + verify(farther.getTrainHandler(), never()).bind(any()); + assertTrue(said("Bound to track " + rail.getId())); + assertTrue(command("unbind")); + verify(nearest.getTrainHandler()).unbind(); + assertTrue(said("Unbound from track")); + } + + @Test + void passengerBindingFollowsParentsAndReportsBindingFailureOrMissingRail() { + ActiveVehicle car = train(8, world), middle = train(12, world), loco = train(16, world); + when(vehicles.getByPassenger(player)).thenReturn(car); + when(car.hasParent()).thenReturn(true); + when(car.getParent()).thenReturn(middle); + when(middle.hasParent()).thenReturn(true); + when(middle.getParent()).thenReturn(loco); + assertTrue(command("bind")); + assertTrue(said("No track nearby or unknown id")); + TrackSpline rail = rail(); + assertTrue(command("bind", rail.getId().toString())); + verify(loco.getTrainHandler()).bind(rail); + assertTrue(said("Could not bind to track")); + assertTrue(command("unbind")); + verify(loco.getTrainHandler()).unbind(); + } + + @Test + void deletionValidatesUuidCancelsAnimationAndReportsStorageResult() { + assertTrue(command("delete")); + assertTrue(said("Usage: /vf track delete ")); + assertTrue(command("delete", "broken")); + assertTrue(said("Invalid uuid")); + UUID known = UUID.randomUUID(), missing = UUID.randomUUID(); + when(registry.delete(known)).thenReturn(true); + assertTrue(command("delete", known.toString())); + animation.verify(() -> TrackBuildAnimator.cancel(known)); + verify(displays).despawnSpline(known); + log.verify(() -> TrackLog.delete("Engineer", known, true)); + assertTrue(said("Deleted track " + known)); + framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(null); + assertTrue(command("delete", missing.toString())); + log.verify(() -> TrackLog.delete("Engineer", missing, false)); + assertTrue(said("Unknown track")); + } + + @Test + void diggingWithoutWorldOrTargetDoesNotChangeAnything() { + TrackCommands.digAt(player, new Location(null, 1, 2, 3)); + verifyNoInteractions(registry); + TrackCommands.digAt(player, start()); + log.verify(() -> TrackLog.dig(eq("Engineer"), argThat(r -> r.kind == DigResult.Kind.NONE))); + verifyNoInteractions(displays); + fx.verifyNoInteractions(); + } + + @Test + void trainOnAnyAffectedSpanBlocksDiggingBeforeRegistryMutation() { + TrackSpline rail = rail(); + TrackRegistry.DigTarget target = digTarget(rail, DigResult.deleted(rail.getId())); + ActiveVehicle train = train(8, world); + when(train.getTrainHandler().occupies(target.spans())).thenReturn(true); + TrackCommands.digAt(player, start()); + verify(registry, never()).digAt(any(), anyInt(), any()); + assertTrue(said("A train is on this track")); + verifyNoInteractions(displays); + } + + @Test + void digUpdatesAffectedSplineStartsSurvivalCooldownAndBlocksRepeat() { + TrackSpline rail = rail(); + TrackRegistry.DigTarget target = digTarget(rail, DigResult.updated(rail)); + ActiveVehicle child = train(8, world); + when(child.hasParent()).thenReturn(true); + ActiveVehicle clear = train(10, world); + ActiveVehicle ordinary = mock(ActiveVehicle.class); + fleet.put(mock(Entity.class), ordinary); + TrackCommands.digAt(player, start()); + verify(clear.getTrainHandler()).occupies(target.spans()); + verify(child.getTrainHandler(), never()).occupies(anyList()); + verify(registry).digAt(rail, 1, world); + verify(displays).refreshSpline(rail, rail.getId()); + tools.verify(() -> TrackTools.showRemoverCooldown(player, 60_000)); + TrackCommands.digAt(player, start()); + verify(registry, times(1)).digAt(rail, 1, world); + verify(player).swingMainHand(); + } + + @ParameterizedTest + @EnumSource(value = GameMode.class, names = {"CREATIVE", "SPECTATOR"}) + void creativeAndSpectatorDiggingSkipCooldown(GameMode mode) { + when(player.getGameMode()).thenReturn(mode); + TrackSpline rail = rail(); + digTarget(rail, DigResult.updated(rail)); + framework.when(VehicleFramework::getVehicleManager).thenReturn(null); + TrackCommands.digAt(player, start()); + TrackCommands.digAt(player, start()); + verify(registry, times(2)).digAt(rail, 1, world); + tools.verifyNoInteractions(); + } + + @Test + void unsuccessfulDigDoesNotStartCooldown() { + TrackSpline rail = rail(); + digTarget(rail, DigResult.none()); + TrackCommands.digAt(player, start()); + TrackCommands.digAt(player, start()); + verify(registry, times(2)).digAt(rail, 1, world); + tools.verifyNoInteractions(); + } + + @Test + void deletingJunctionTurnoutCancelsItsAnimationAndRemovesDisplays() { + UUID id = UUID.randomUUID(); + TrackCommands.applyDig(player, DigResult.deletedJunctionTurnout(id), start()); + assertTrue(said("Removed junction turnout.")); + animation.verify(() -> TrackBuildAnimator.cancel(id)); + verify(displays).despawnSpline(id); + fx.verify(() -> TrackFx.place(eq(world), argThat(p -> p.x == 8 && p.y == 64 && p.z == 8))); + verify(player).swingMainHand(); + } + + @Test + void updatedTurnoutKeepsBranchAndAnimatedTrackDiscardsBeforeSnapshot() { + TrackSpline rail = rail(); + animation.when(() -> TrackBuildAnimator.isBuilding(rail.getId())).thenReturn(true); + Cache.trackBuildSwing = false; + TrackCommands.applyDig(player, DigResult.removedJunctionTurnout(rail), start(), rail); + assertTrue(said("branch track kept beyond the frog")); + verify(displays).refreshSpline(null, rail.getId()); + verify(player, never()).swingMainHand(); + animation.verify(() -> TrackBuildAnimator.cancel(rail.getId()), times(2)); + } + + @Test + void replacementDigRemovesPreviousIdentityAndKeepsOriginalSnapshot() { + TrackSpline before = rail(), kept = rail(); + TrackCommands.applyDig(player, DigResult.updated(before.getId(), kept), null, before); + verify(displays).despawnSpline(before.getId()); + verify(displays).refreshSpline(before, kept.getId()); + fx.verifyNoInteractions(); + } + + @Test + void splitDigCancelsBothAnimationsAndReplacesBothDisplaySets() { + TrackSpline kept = rail(), tail = rail(); + TrackCommands.applyDig(player, DigResult.split(kept, tail), start()); + animation.verify(() -> TrackBuildAnimator.cancel(kept.getId())); + animation.verify(() -> TrackBuildAnimator.cancel(tail.getId())); + InOrder order = inOrder(displays); + order.verify(displays).despawnSpline(kept.getId()); + order.verify(displays).despawnSpline(tail.getId()); + order.verify(displays).spawnSpline(kept); + order.verify(displays).spawnSpline(tail); + } + + @Test + void digStillRecordsMutationWhenDisplaysAreUnavailable() { + framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(null); + TrackSpline rail = rail(); + TrackCommands.applyDig(player, DigResult.deleted(rail.getId()), null); + TrackCommands.applyDig(player, DigResult.updated(rail), new Location(null, 1, 2, 3)); + TrackCommands.applyDig(player, DigResult.split(rail, rail()), null); + verifyNoInteractions(displays); + fx.verifyNoInteractions(); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackDisplayManagerCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackDisplayManagerCoverageTest.java new file mode 100644 index 0000000..08ba457 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackDisplayManagerCoverageTest.java @@ -0,0 +1,811 @@ +package net.tfminecraft.vehicleframework.tracks; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.function.Consumer; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.*; +import org.bukkit.event.block.*; +import org.bukkit.event.entity.*; +import org.bukkit.event.player.*; +import org.bukkit.event.world.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.cache.Cache; + +class TrackDisplayManagerCoverageTest { + private final List scopes = new ArrayList<>(); + private final VehicleFramework plugin = mock(VehicleFramework.class, RETURNS_DEEP_STUBS); + private final TrackRegistry registry = mock(TrackRegistry.class); + private final World world = mock(World.class); + private final ItemAPI items = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + private final Map itemPaths = new HashMap<>(); + private final Map tracks = new LinkedHashMap<>(); + private final Map junctions = new LinkedHashMap<>(); + private final Map chunks = new LinkedHashMap<>(); + private final Set loaded = new HashSet<>(); + private final List spawned = new ArrayList<>(); + private final List entities = new ArrayList<>(); + private MockedStatic framework; + private MockedStatic bukkit; + private MockedStatic animation; + private MockedStatic commands; + private MockedStatic tools; + private MockedStatic fx; + private MockedStatic pieces; + private MockedStatic log; + private MockedStatic keepout; + private TrackDisplayManager manager; + private Runnable tick; + private SavedStyle old; + + @BeforeEach + void setup() { + old = new SavedStyle(); + Cache.trackItemSmall = Cache.appliedTrackItemSmall = "small"; + Cache.trackItemMedium = Cache.appliedTrackItemMedium = "medium"; + Cache.trackItemLarge = Cache.appliedTrackItemLarge = "large"; + Cache.trackSwitchItem = "switch"; + Cache.appliedTrackDisplayYOffset = .25; + Cache.trackDisplayYOffset = .5; + Cache.trackResyncChunksPerTick = 1; + framework = keep(mockStatic(VehicleFramework.class)); + bukkit = keep(mockStatic(Bukkit.class)); + animation = keep(mockStatic(TrackBuildAnimator.class)); + commands = keep(mockStatic(TrackCommands.class)); + tools = keep(mockStatic(TrackTools.class)); + fx = keep(mockStatic(TrackFx.class)); + pieces = keep(mockStatic(TrackPieces.class)); + log = keep(mockStatic(VFLogger.class)); + keepout = keep(mockStatic(TrackPlaceKeepout.class)); + MockedStatic libs = keep(mockStatic(TLibs.class)); + libs.when(TLibs::getItemAPI).thenReturn(items); + for (String path : List.of("small", "medium", "large", "switch")) { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(mock(Material.class)); + itemPaths.put(path, item); + } + when(items.getCreator().getItemFromPath(anyString())).thenAnswer(call -> itemPaths.get(call.getArgument(0))); + framework.when(VehicleFramework::getInstance).thenReturn(plugin); + framework.when(VehicleFramework::getTrackRegistry).thenReturn(registry); + when(plugin.namespace()).thenReturn("vehicleframework"); + when(plugin.getServer().getScheduler().runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(1L))) + .thenAnswer(call -> { tick = call.getArgument(1); return null; }); + when(world.getName()).thenReturn("world"); + bukkit.when(Bukkit::getWorlds).thenReturn(List.of(world)); + bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(world); + when(world.isChunkLoaded(anyInt(), anyInt())).thenAnswer(call -> loaded.contains(TrackChunks.key(call.getArgument(0), call.getArgument(1)))); + when(world.getChunkAt(anyInt(), anyInt())).thenAnswer(call -> chunk(call.getArgument(0), call.getArgument(1))); + when(world.getLoadedChunks()).thenAnswer(call -> chunks.entrySet().stream() + .filter(e -> loaded.contains(e.getKey())).map(Map.Entry::getValue).toArray(Chunk[]::new)); + when(world.spawn(any(Location.class), eq(ItemDisplay.class), any(Consumer.class))).thenAnswer(call -> { + DisplayState state = new DisplayState(call.getArgument(0)); + Consumer initialize = call.getArgument(2); + initialize.accept(state.entity); + spawned.add(state); + return state.entity; + }); + when(registry.get(any(UUID.class))).thenAnswer(call -> Optional.ofNullable(tracks.get(call.getArgument(0)))); + when(registry.inWorld(anyString())).thenAnswer(call -> tracks.values().stream() + .filter(s -> s.getWorld().equals(call.getArgument(0))).toList()); + when(registry.junctionsOn(any(UUID.class))).thenAnswer(call -> junctions.values().stream() + .filter(j -> j.stemSplineId.equals(call.getArgument(0))).toList()); + when(registry.getJunction(any(UUID.class))).thenAnswer(call -> Optional.ofNullable(junctions.get(call.getArgument(0)))); + doAnswer(call -> { TrackSpline s = call.getArgument(0); tracks.put(s.getId(), s); return null; }).when(registry).replace(any()); + load(0, 0); + manager = new TrackDisplayManager(); + } + + @AfterEach + void teardown() throws Exception { + Collections.reverse(scopes); + for (AutoCloseable scope : scopes) scope.close(); + old.restore(); + } + + private T keep(T scope) { scopes.add(scope); return scope; } + private Chunk load(int x, int z) { loaded.add(TrackChunks.key(x, z)); return chunk(x, z); } + private Chunk chunk(int x, int z) { + return chunks.computeIfAbsent(TrackChunks.key(x, z), ignored -> { + Chunk chunk = mock(Chunk.class); + when(chunk.getWorld()).thenReturn(world); + when(chunk.getX()).thenReturn(x); + when(chunk.getZ()).thenReturn(z); + when(chunk.getEntities()).thenAnswer(call -> entities.stream().filter(e -> !e.isDead()) + .filter(e -> TrackChunks.inChunk(e.getLocation().getX(), e.getLocation().getZ(), x, z)).toArray(Entity[]::new)); + return chunk; + }); + } + private TrackVisual visual(int edge, double x) { return visual(TrackVisual.Type.SMALL, edge, 1, x); } + private TrackVisual visual(TrackVisual.Type type, int edge, int span, double x) { + return new TrackVisual(type, edge, span, edge, span, x, 64, 8, 12, 3); + } + private TrackSpline spline(TrackVisual... visuals) { return spline(UUID.randomUUID(), visuals); } + private TrackSpline spline(UUID id, TrackVisual... visuals) { + TrackSpline spline = mock(TrackSpline.class); + when(spline.getId()).thenReturn(id); + when(spline.getWorld()).thenReturn("world"); + when(spline.visuals()).thenReturn(List.of(visuals)); + tracks.put(id, spline); + return spline; + } + private TrackSpline rail() { + TrackSpline spline = TrackSpline.fromPoints(UUID.randomUUID(), "world", false, + List.of(new double[] {8, 64, 8}, new double[] {8, 64, 9}, new double[] {8, 64, 10}, new double[] {8, 64, 11})); + tracks.put(spline.getId(), spline); + return spline; + } + private TrackJunction junction(TrackSpline stem) { + TrackJunction junction = new TrackJunction(UUID.randomUUID(), stem.getId(), 1, 1, + TrackJunction.Side.LEFT, UUID.randomUUID()); + junctions.put(junction.id, junction); + return junction; + } + private DisplayState spawn(UUID id, TrackVisual visual) { + manager.spawnVisuals(id, List.of(visual), world, null); + return spawned.getLast(); + } + private DisplayState external(String id, Integer edge, Integer span) { + DisplayState state = new DisplayState(new Location(world, 8, 64.25, 8)); + if (id != null) state.data.put(manager.idKey(), id); + if (edge != null) state.data.put(manager.edgeKey(), edge); + if (span != null) state.data.put(manager.spanKey(), span); + return state; + } + private Player player() { + Player player = mock(Player.class, RETURNS_DEEP_STUBS); + when(player.getLocation()).thenAnswer(call -> new Location(world, 8, 64, 8, 80, 25)); + return player; + } + private EntityDamageByEntityEvent punch(Entity damager, Entity victim) { + EntityDamageByEntityEvent event = mock(EntityDamageByEntityEvent.class); + when(event.getDamager()).thenReturn(damager); + when(event.getEntity()).thenReturn(victim); + manager.onPunch(event); + return event; + } + private PlayerInteractEntityEvent interact(Player player, Entity entity, EquipmentSlot hand) { + PlayerInteractEntityEvent event = new PlayerInteractEntityEvent(player, entity, hand); + manager.onInteract(event); + return event; + } + + @Test + void initializesStableMetadataKeysAndSchedulesServerTick() { + assertNotNull(tick); + assertEquals("vehicleframework:track_id", manager.idKey().toString()); + assertSame(manager.idKey(), manager.idKey()); + assertSame(manager.edgeKey(), manager.edgeKey()); + assertSame(manager.spanKey(), manager.spanKey()); + assertSame(manager.switchKey(), manager.switchKey()); + tick.run(); + animation.verify(TrackBuildAnimator::tick); + framework.when(VehicleFramework::getInstance).thenReturn(null); + assertDoesNotThrow(TrackDisplayManager::new); + } + + @Test + void spawnsConfiguredDisplayTypesWithMetadataAndCachesSuccessfulItems() { + UUID id = UUID.randomUUID(); + for (TrackVisual.Type type : TrackVisual.Type.values()) { + TrackVisual visual = visual(type, type.ordinal(), type.ordinal() + 1, 8 + type.ordinal()); + DisplayState display = spawn(id, visual); + assertEquals(id.toString(), display.data.get(manager.idKey())); + assertEquals(visual.fromEdge, display.data.get(manager.edgeKey())); + assertEquals(visual.span, display.data.get(manager.spanKey())); + assertEquals(64.25, display.location.getY()); + assertEquals(12, display.location.getYaw()); + assertEquals(3, display.location.getPitch()); + verify(display.entity).setPersistent(false); + verify(display.entity).setGravity(false); + verify(display.entity).setInvulnerable(false); + verify(display.entity).setBillboard(Display.Billboard.FIXED); + verify(display.entity).setItemStack(itemPaths.get(type.name().toLowerCase())); + } + spawn(id, visual(0, 9)); + verify(items.getCreator(), times(1)).getItemFromPath("small"); + } + + @Test + void missingLargerPieceFallsBackToSmallAndLogsOncePerType() { + itemPaths.remove("large"); + UUID id = UUID.randomUUID(); + DisplayState first = spawn(id, visual(TrackVisual.Type.LARGE, 0, 2, 8)); + spawn(id, visual(TrackVisual.Type.LARGE, 2, 2, 10)); + verify(first.entity).setItemStack(itemPaths.get("small")); + log.verify(() -> VFLogger.log("Track display item is missing: large"), times(1)); + verify(items.getCreator(), times(2)).getItemFromPath("large"); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "blank", "missing", "air", "exception"}) + void skipsUnusableItemsWithoutCachingFailure(String failure) { + switch (failure) { + case "null" -> Cache.appliedTrackItemSmall = null; + case "blank" -> Cache.appliedTrackItemSmall = " "; + case "missing" -> itemPaths.remove("small"); + case "air" -> when(itemPaths.get("small").getType().isAir()).thenReturn(true); + case "exception" -> when(items.getCreator().getItemFromPath("small")).thenThrow(new IllegalStateException("items loading")); + } + manager.spawnVisuals(UUID.randomUUID(), List.of(visual(0, 8)), world, null); + assertTrue(spawned.isEmpty()); + } + + @Test + void missingLargeAndSmallItemsProduceNoEntityAndCanRecoverWhenPluginLoads() { + itemPaths.clear(); + UUID id = UUID.randomUUID(); + manager.spawnVisuals(id, List.of(visual(TrackVisual.Type.LARGE, 0, 3, 8)), world, null); + assertTrue(spawned.isEmpty()); + log.verify(() -> VFLogger.log("Track display item is missing: small")); + ItemStack recovered = mock(ItemStack.class); + when(recovered.getType()).thenReturn(mock(Material.class)); + itemPaths.put("small", recovered); + DisplayState display = spawn(id, visual(0, 8)); + verify(display.entity).setItemStack(recovered); + } + + @Test + void splineSpawnsOnlyLoadedChunksAndAnimatedBuildSpawnsOncePerChunk() { + TrackSpline spline = spline(visual(0, 8), visual(1, 9), visual(2, 24), visual(3, 40)); + load(1, 0); + manager.spawnSpline(spline); + assertEquals(3, spawned.size()); + verify(world, times(1)).isChunkLoaded(0, 0); + manager.despawnAll(); + animation.when(() -> TrackBuildAnimator.isBuilding(spline.getId())).thenReturn(true); + manager.spawnSpline(spline); + animation.verify(() -> TrackBuildAnimator.spawnIntoChunk(spline, chunk(0, 0))); + animation.verify(() -> TrackBuildAnimator.spawnIntoChunk(spline, chunk(1, 0))); + assertEquals(3, spawned.size()); + bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(null); + manager.spawnSpline(spline); + assertEquals(3, spawned.size()); + } + + @Test + void refreshReplacesOnlyChangedChunksAndKeepsOtherSplines() { + load(1, 0); + UUID id = UUID.randomUUID(); + TrackSpline before = spline(id, visual(0, 8), visual(1, 24)); + manager.spawnSpline(before); + DisplayState changed = spawned.get(0), unchanged = spawned.get(1); + DisplayState other = spawn(UUID.randomUUID(), visual(0, 9)); + TrackSpline after = spline(id, visual(0, 10), visual(1, 24)); + manager.refreshSpline(before, id); + assertTrue(changed.removed); + assertFalse(unchanged.removed); + assertFalse(other.removed); + assertEquals(4, spawned.size()); + manager.refreshSpline(after, id); + assertEquals(4, spawned.size()); + bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(null); + manager.refreshSpline(after, id); + assertEquals(4, spawned.size()); + } + + @Test + void refreshRebakesUnknownBeforeAndRemovesDeletedTracks() { + TrackSpline after = spline(visual(0, 8)); + DisplayState previous = spawn(after.getId(), visual(0, 8)); + manager.refreshSpline(null, after.getId()); + assertTrue(previous.removed); + assertEquals(2, spawned.size()); + manager.refreshSpline(spline(visual(0, 8)), after.getId()); + assertEquals(3, spawned.size()); + animation.when(() -> TrackBuildAnimator.isBuilding(after.getId())).thenReturn(true); + manager.refreshSpline(after, after.getId()); + animation.verify(() -> TrackBuildAnimator.spawnIntoChunk(after, chunk(0, 0))); + tracks.remove(after.getId()); + manager.refreshSpline(after, after.getId()); + assertTrue(spawned.stream().allMatch(d -> d.removed)); + } + + @Test + void replacingAnimatedSuffixLeavesMatchingPrefixAndOtherEdgesAlone() { + UUID id = UUID.randomUUID(); + TrackVisual first = visual(0, 8), old = visual(1, 9), next = visual(1, 10); + DisplayState foreign = spawn(UUID.randomUUID(), old); + DisplayState prefix = spawn(id, first); + DisplayState remote = spawn(id, visual(1, 40)); + DisplayState changed = spawn(id, old); + manager.replaceFrom(id, List.of(first, old), List.of(first, next, visual(2, 24)), world, chunk(0, 0)); + assertFalse(foreign.removed); + assertFalse(prefix.removed); + assertFalse(remote.removed); + assertTrue(changed.removed); + assertEquals(5, spawned.size()); + assertEquals(10, spawned.getLast().location.getX()); + manager.replaceFrom(id, List.of(first, next), null, world, null); + assertTrue(prefix.removed); + assertTrue(spawned.getLast().removed); + manager.replaceFrom(id, null, List.of(first), world, null); + assertEquals(6, spawned.size()); + } + + @Test + void replacementAfterChunkUnloadRecreatesSuffixWithoutRemovingOtherTrack() { + UUID id = UUID.randomUUID(); + TrackVisual before = visual(0, 8), after = visual(0, 9); + DisplayState removed = spawn(id, before); + manager.onChunkUnload(new ChunkUnloadEvent(chunk(0, 0))); + assertTrue(removed.removed); + DisplayState other = spawn(UUID.randomUUID(), visual(0, 10)); + manager.replaceFrom(id, List.of(before), List.of(after), world, chunk(0, 0)); + assertFalse(other.removed); + assertEquals(id.toString(), spawned.getLast().data.get(manager.idKey())); + assertEquals(9, spawned.getLast().location.getX()); + } + + @Test + void replacingSuffixSkipsMissingPreviousPiecesAndSwitchTaggedMetadata() { + UUID id = UUID.randomUUID(); + TrackVisual before = visual(0, 8), after = visual(0, 9); + DisplayState switchTagged = spawn(id, before); + // Persisted entity metadata can contain tags from both display formats. + switchTagged.entity.getPersistentDataContainer().set(manager.switchKey(), + PersistentDataType.STRING, UUID.randomUUID().toString()); + manager.replaceFrom(id, Arrays.asList(null, before), List.of(after), world, null); + assertFalse(switchTagged.removed); + assertEquals(2, spawned.size()); + assertEquals(9, spawned.getLast().location.getX()); + } + + @Test + void ghostAndVisualSpawnsRespectChunksAndIgnoreIncompleteInput() { + UUID id = UUID.randomUUID(); + manager.spawnGhost(null, List.of(), world, null); + manager.spawnGhost(id, null, world, null); + manager.spawnGhost(id, List.of(new double[] {1, 2, 3}), world, null); + manager.spawnGhost(id, List.of(new double[] {1, 2, 3}, new double[] {2, 2, 3}), null, null); + manager.replaceFrom(null, null, null, world, null); + manager.replaceFrom(id, null, null, null, null); + manager.spawnVisuals(null, List.of(), world, null); + manager.spawnVisuals(id, null, world, null); + manager.spawnVisuals(id, List.of(), null, null); + assertTrue(spawned.isEmpty()); + manager.spawnGhost(id, rail().xyz(), world, chunk(0, 0)); + assertFalse(spawned.isEmpty()); + int count = spawned.size(); + manager.spawnVisuals(id, List.of(visual(0, 8), visual(1, 24)), world, chunk(0, 0)); + assertEquals(count + 1, spawned.size()); + } + + @Test + void chunkLifecycleCleansOnlyTaggedDisplaysAndSpawnsMatchingPieces() { + TrackSpline spline = spline(visual(0, 8), visual(1, 24)); + DisplayState stale = external(spline.getId().toString(), 0, 1); + DisplayState ordinary = external(null, null, null); + DisplayState staleSwitch = external(null, null, null); + staleSwitch.data.put(manager.switchKey(), UUID.randomUUID().toString()); + Entity unrelated = mock(Entity.class); + when(unrelated.getLocation()).thenReturn(new Location(world, 8, 64, 8)); + entities.add(unrelated); + manager.spawnLoadedChunks(); + assertTrue(stale.removed); + assertTrue(staleSwitch.removed); + assertFalse(ordinary.removed); + assertEquals(1, spawned.size()); + verify(unrelated, never()).remove(); + manager.onChunkUnload(new ChunkUnloadEvent(chunk(0, 0))); + assertTrue(spawned.getFirst().removed); + manager.onChunkLoad(new ChunkLoadEvent(chunk(0, 0), false)); + assertEquals(2, spawned.size()); + animation.when(() -> TrackBuildAnimator.isBuilding(spline.getId())).thenReturn(true); + manager.spawnChunk(chunk(0, 0)); + animation.verify(() -> TrackBuildAnimator.spawnIntoChunk(spline, chunk(0, 0))); + } + + @Test + void resyncWorksInBatchesKeepsSwitchesAndFinishesOnlyOnce() { + TrackSpline rail = rail(); + TrackJunction junction = junction(rail); + manager.refreshSwitch(junction); + DisplayState lever = spawned.getLast(); + spline(visual(0, 8), visual(1, 24)); + load(1, 0); + DisplayState stale = external(UUID.randomUUID().toString(), 0, 1); + DisplayState ordinary = external(null, null, null); + Entity unrelated = mock(Entity.class); + when(unrelated.getLocation()).thenReturn(new Location(world, 9, 64, 8)); + entities.add(unrelated); + CommandSender sender = mock(CommandSender.class); + manager.startRailResync(sender); + verify(registry).invalidateAllVisuals(); + verify(sender).sendMessage("§aResyncing track displays in 2 loaded chunks."); + assertEquals(.5, Cache.appliedTrackDisplayYOffset); + tick.run(); + assertTrue(stale.removed); + assertFalse(lever.removed); + assertFalse(ordinary.removed); + verify(sender, never()).sendMessage("§aTrack display resync finished."); + tick.run(); + tick.run(); + verify(sender, times(1)).sendMessage("§aTrack display resync finished."); + assertTrue(spawned.stream().anyMatch(d -> d.location.getX() == 24)); + } + + @Test + void resyncSkipsVanishedWorldsAndUnloadedChunksAndSupportsAnimatedRails() { + TrackSpline rail = spline(visual(0, 8)); + animation.when(() -> TrackBuildAnimator.isBuilding(rail.getId())).thenReturn(true); + CommandSender sender = mock(CommandSender.class); + manager.startRailResync(sender); + Cache.trackResyncChunksPerTick = 0; + tick.run(); + animation.verify(() -> TrackBuildAnimator.spawnIntoChunk(rail, chunk(0, 0))); + manager.startRailResync(sender); + loaded.clear(); + tick.run(); + load(0, 0); + manager.startRailResync(sender); + bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(null); + tick.run(); + verify(sender, times(3)).sendMessage("§aTrack display resync finished."); + bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + manager.startRailResync(sender); + verify(sender).sendMessage("§aTrack displays already match config (no loaded chunks)."); + } + + @Test + void despawnAllCancelsPendingResyncAndIgnoresAlreadyDeadEntities() { + DisplayState alive = spawn(UUID.randomUUID(), visual(0, 8)); + DisplayState dead = spawn(UUID.randomUUID(), visual(1, 9)); + dead.removed = true; + CommandSender sender = mock(CommandSender.class); + manager.startRailResync(sender); + manager.despawnAll(); + assertTrue(alive.removed); + verify(dead.entity, never()).remove(); + tick.run(); + verify(sender, never()).sendMessage("§aTrack display resync finished."); + assertEquals(2, spawned.size()); + } + + @Test + void switchesUseConfiguredItemPositionAndAnimateTowardChangedThrow() { + TrackSpline rail = rail(); + TrackJunction junction = junction(rail); + manager.spawnSwitchesForSpline(rail); + DisplayState lever = spawned.getLast(); + TrackSwitchPose pose = TrackSwitchPose.of(rail.sampleAt(junction.s), junction, + Cache.trackSwitchOffsetAlong, Cache.trackSwitchOffsetOut, Cache.trackSwitchOffsetY, + Cache.trackSwitchYawInward, Cache.trackSwitchThrowDegrees); + assertEquals(pose.x, lever.location.getX()); + assertEquals(pose.y + Cache.trackDisplayYOffset, lever.location.getY()); + assertEquals(pose.z, lever.location.getZ()); + assertEquals(junction.id.toString(), lever.data.get(manager.switchKey())); + verify(lever.entity).setItemStack(itemPaths.get("switch")); + verify(lever.entity).setPersistent(false); + verify(lever.entity).setGravity(false); + verify(lever.entity).setInvulnerable(false); + verify(lever.entity).setBillboard(Display.Billboard.FIXED); + junctions.put(junction.id, junction.withThrown(true)); + tick.run(); + assertEquals(pose.targetYaw - Cache.trackSwitchThrowDegreesPerSecond / 20, lever.location.getYaw()); + manager.refreshSwitch(junction.withThrown(true)); + assertTrue(lever.removed); + assertEquals(pose.divergeYaw, spawned.getLast().location.getYaw()); + manager.despawnSwitch(null); + manager.despawnSwitch(junction.id); + assertTrue(spawned.getLast().removed); + } + + @Test + void switchReloadPreservesRailsReplacesSwitchesAndClearsItemCache() { + TrackSpline rail = rail(); + TrackJunction junction = junction(rail); + DisplayState track = spawn(rail.getId(), visual(0, 8)); + manager.refreshSwitch(junction); + DisplayState first = spawned.getLast(); + manager.reloadSwitches(); + assertFalse(track.removed); + assertTrue(first.removed); + assertFalse(spawned.getLast().removed); + verify(items.getCreator(), times(2)).getItemFromPath("switch"); + manager.despawnTrackDisplays(rail.getId()); + assertTrue(track.removed); + assertFalse(spawned.getLast().removed); + manager.despawnSpline(rail.getId()); + assertTrue(spawned.getLast().removed); + } + + @Test + void switchCreationIgnoresUnloadedOrOtherChunksAndIncompleteJunctions() { + TrackSpline rail = rail(); + TrackJunction junction = junction(rail); + manager.spawnSwitchesForSpline(null); + manager.refreshSwitch(null); + junctions.put(junction.id, junction.withBranch(null)); + manager.spawnLoadedChunks(); + assertTrue(spawned.stream().noneMatch(d -> d.data.containsKey(manager.switchKey()))); + junctions.put(junction.id, junction); + load(1, 0); + manager.spawnChunk(chunk(1, 0)); + assertTrue(spawned.stream().noneMatch(d -> d.data.containsKey(manager.switchKey()))); + loaded.clear(); + manager.spawnSwitchesForSpline(rail); + manager.refreshSwitch(junction); + assertTrue(spawned.stream().noneMatch(d -> d.data.containsKey(manager.switchKey()))); + load(0, 0); + manager.refreshSwitch(junction); + DisplayState lever = spawned.getLast(); + manager.refreshSwitch(junction.withBranch(null)); + assertTrue(lever.removed); + } + + @Test + void missingSwitchItemIsLoggedOnceUntilReload() { + TrackSpline rail = rail(); + TrackJunction junction = junction(rail); + itemPaths.remove("switch"); + manager.refreshSwitch(junction); + manager.refreshSwitch(junction); + log.verify(() -> VFLogger.log("Track switch item is missing: switch"), times(1)); + assertTrue(spawned.isEmpty()); + manager.reloadSwitches(); + log.verify(() -> VFLogger.log("Track switch item is missing: switch"), times(2)); + } + + @Test + void orphanedSwitchesAreRemovedAndMissingStemIsSafeDuringTick() { + TrackSpline rail = rail(); + TrackJunction junction = junction(rail); + manager.refreshSwitch(junction); + DisplayState lever = spawned.getLast(); + tracks.clear(); + tick.run(); + assertFalse(lever.removed); + verify(lever.entity, never()).teleport(any(Location.class)); + manager.refreshSwitch(junction); + assertTrue(lever.removed); + tracks.put(rail.getId(), rail); + manager.refreshSwitch(junction); + DisplayState incomplete = spawned.getLast(); + junctions.put(junction.id, junction.withBranch(null)); + tick.run(); + assertTrue(incomplete.removed); + junctions.put(junction.id, junction); + manager.refreshSwitch(junction); + DisplayState orphan = spawned.getLast(); + junctions.clear(); + tick.run(); + assertTrue(orphan.removed); + manager.refreshSwitch(junction); + DisplayState invalid = spawned.getLast(); + invalid.valid = false; + tick.run(); + manager.despawnAll(); + verify(invalid.entity, never()).remove(); + } + + @Test + void unloadedWorldAndUnavailableRegistryLeaveNoNewDisplays() { + TrackSpline rail = rail(); + TrackJunction junction = junction(rail); + bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(null); + manager.spawnSwitchesForSpline(rail); + manager.refreshSwitch(junction); + assertTrue(spawned.isEmpty()); + bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(world); + framework.when(VehicleFramework::getTrackRegistry).thenReturn(null); + manager.spawnSwitchesForSpline(rail); + manager.refreshSwitch(junction); + manager.spawnChunk(chunk(0, 0)); + manager.reloadSwitches(); + manager.despawnSpline(rail.getId()); + manager.rebakeSpline(rail.getId()); + manager.refreshSpline(rail, rail.getId()); + manager.breakInRadius(new Location(world, 8, 64, 8), 2); + manager.startRailResync(mock(CommandSender.class)); + tick.run(); + assertTrue(spawned.isEmpty()); + } + + @Test + void malformedEntityTagsDoNotDeleteUnrelatedDisplaysOrCrashTick() { + DisplayState track = spawn(UUID.randomUUID(), visual(0, 8)); + track.data.put(manager.idKey(), "bad uuid"); + track.data.put(manager.switchKey(), "bad switch uuid"); + manager.despawnTrackDisplays(UUID.randomUUID()); + manager.despawnSwitch(UUID.randomUUID()); + tick.run(); + assertFalse(track.removed); + DisplayState dead = spawn(UUID.randomUUID(), visual(0, 9)); + dead.removed = true; + tick.run(); + manager.despawnAll(); + verify(dead.entity, never()).remove(); + } + + @Test + void explosionsBreakAndDropEachAffectedEdgeExactlyOnce() { + TrackSpline rail = rail(); + manager.breakInRadius(null, 2); + manager.breakInRadius(new Location(null, 8, 64, 8), 2); + manager.breakInRadius(new Location(world, 8, 64, 8), 0); + manager.breakInRadius(new Location(world, 8, 64, 8), .5); + TrackSpline after = tracks.get(rail.getId()); + assertTrue(after.segment(0).broken); + assertFalse(after.segment(1).broken); + pieces.verify(() -> TrackPieces.dropAt(world, 8, 64, 8)); + manager.breakInRadius(new Location(world, 8, 64, 8), .5); + pieces.verify(() -> TrackPieces.dropAt(world, 8, 64, 8), times(1)); + EntityExplodeEvent entity = mock(EntityExplodeEvent.class); + when(entity.getLocation()).thenReturn(new Location(world, 8, 64, 10)); + when(entity.getYield()).thenReturn(0f); + manager.onEntityExplode(entity); + assertTrue(tracks.get(rail.getId()).getSegments().stream().allMatch(s -> s.broken)); + BlockExplodeEvent block = mock(BlockExplodeEvent.class, RETURNS_DEEP_STUBS); + when(block.getBlock().getLocation()).thenReturn(new Location(world, 8, 64, 10)); + when(block.getYield()).thenReturn(5f); + manager.onBlockExplode(block); + verify(registry, times(2)).replace(any()); + } + + @Test + void blockAndBucketPlacementRespectTrackKeepout() { + BlockPlaceEvent place = mock(BlockPlaceEvent.class); + Block block = mock(Block.class); + when(place.getBlock()).thenReturn(block); + manager.onBlockPlace(place); + verify(place, never()).setCancelled(true); + keepout.when(() -> TrackPlaceKeepout.blocked(block)).thenReturn(true); + manager.onBlockPlace(place); + verify(place).setCancelled(true); + PlayerBucketEmptyEvent bucket = mock(PlayerBucketEmptyEvent.class); + Block clicked = mock(Block.class); + when(bucket.getBlockClicked()).thenReturn(clicked); + when(bucket.getBlockFace()).thenReturn(BlockFace.UP); + when(clicked.getRelative(BlockFace.UP)).thenReturn(block); + manager.onBucketEmpty(bucket); + verify(bucket).setCancelled(true); + } + + @Test + void ordinaryEntityInteractionsPassThroughAndSwitchInteractionsAreCancelled() { + Player player = player(); + Entity other = mock(Entity.class); + EntityDamageByEntityEvent nonPlayer = punch(other, other); + verify(nonPlayer, never()).setCancelled(true); + verify(punch(player, other), never()).setCancelled(true); + DisplayState ordinary = external(null, null, null); + verify(punch(player, ordinary.entity), never()).setCancelled(true); + assertFalse(interact(player, other, EquipmentSlot.HAND).isCancelled()); + assertFalse(interact(player, ordinary.entity, EquipmentSlot.HAND).isCancelled()); + DisplayState lever = external(null, null, null); + lever.data.put(manager.switchKey(), UUID.randomUUID().toString()); + verify(punch(player, lever.entity)).setCancelled(true); + assertTrue(interact(player, lever.entity, EquipmentSlot.HAND).isCancelled()); + assertFalse(interact(player, lever.entity, EquipmentSlot.OFF_HAND).isCancelled()); + commands.verifyNoInteractions(); + } + + @Test + void trackToolsUseAimedDisplayPositionAndAvoidDuplicatePunches() { + Player player = player(); + ItemStack hand = player.getInventory().getItemInMainHand(); + DisplayState display = external(UUID.randomUUID().toString(), 0, 1); + Location aimed = new Location(world, 8, 64.25, 8, 80, 25); + tools.when(() -> TrackTools.isLayer(hand)).thenReturn(true); + commands.when(() -> TrackCommands.markStart(player, aimed)).thenReturn(true); + verify(punch(player, display.entity)).setCancelled(true); + commands.verify(() -> TrackCommands.markStart(player, aimed)); + fx.verify(() -> TrackFx.hitNear(new Location(world, 8, 64.25, 8))); + assertTrue(interact(player, display.entity, EquipmentSlot.HAND).isCancelled()); + commands.verify(() -> TrackCommands.markEnd(player, aimed)); + commands.when(() -> TrackCommands.skipDuplicateToolUse(player)).thenReturn(true); + punch(player, display.entity); + commands.verify(() -> TrackCommands.markStart(player, aimed), times(1)); + tools.when(() -> TrackTools.isLayer(hand)).thenReturn(false); + tools.when(() -> TrackTools.isJunction(hand)).thenReturn(true); + punch(player, display.entity); + commands.when(() -> TrackCommands.skipDuplicateToolUse(player)).thenReturn(false); + interact(player, display.entity, EquipmentSlot.HAND); + commands.verify(() -> TrackCommands.startJunction(player, aimed)); + tools.when(() -> TrackTools.isJunction(hand)).thenReturn(false); + tools.when(() -> TrackTools.isRemover(hand)).thenReturn(true); + punch(player, display.entity); + commands.verify(() -> TrackCommands.digAt(player, new Location(world, 8, 64.25, 8))); + commands.when(() -> TrackCommands.skipDuplicateToolUse(player)).thenReturn(true); + punch(player, display.entity); + commands.verify(() -> TrackCommands.digAt(player, new Location(world, 8, 64.25, 8)), times(1)); + } + + @Test + void punchingMultiEdgeDisplayBreaksWrappedEdgesAndDropsOnlyNewDamage() { + TrackSpline rail = rail(); + DisplayState display = external(rail.getId().toString(), 2, 3); + Player player = player(); + pieces.when(() -> TrackPieces.pays(player)).thenReturn(true); + punch(player, display.entity); + assertTrue(tracks.get(rail.getId()).getSegments().stream().allMatch(s -> s.broken)); + pieces.verify(() -> TrackPieces.dropAt(world, 8, 64, 8)); + pieces.verify(() -> TrackPieces.dropAt(world, 8, 64, 9)); + pieces.verify(() -> TrackPieces.dropAt(world, 8, 64, 10)); + punch(player, display.entity); + verify(registry, times(1)).replace(any()); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1}) + void oldDisplayWithoutValidSpanBreaksOnlyOneEdge(int span) { + TrackSpline rail = rail(); + DisplayState display = external(rail.getId().toString(), 1, span == 0 ? null : span); + punch(player(), display.entity); + TrackSpline after = tracks.get(rail.getId()); + assertTrue(after.segment(1).broken); + assertFalse(after.segment(0).broken); + assertFalse(after.segment(2).broken); + pieces.verify(() -> TrackPieces.dropAt(any(World.class), anyDouble(), anyDouble(), anyDouble()), never()); + } + + @Test + void staleOrIncompleteTrackMetadataDoesNotApplyDamage() { + Player player = player(); + punch(player, external("bad uuid", 0, 1).entity); + punch(player, external(UUID.randomUUID().toString(), null, 1).entity); + UUID missing = UUID.randomUUID(); + DisplayState stale = spawn(missing, visual(0, 8)); + punch(player, stale.entity); + assertTrue(stale.removed); + framework.when(VehicleFramework::getTrackRegistry).thenReturn(null); + punch(player, external(UUID.randomUUID().toString(), 0, 1).entity); + verify(registry, never()).replace(any()); + } + + private final class DisplayState { + final ItemDisplay entity = mock(ItemDisplay.class); + final Map data = new HashMap<>(); + Location location; + boolean removed; + boolean valid = true; + @SuppressWarnings({"rawtypes", "unchecked"}) + DisplayState(Location location) { + this.location = location.clone(); + PersistentDataContainer pdc = mock(PersistentDataContainer.class); + when(entity.getPersistentDataContainer()).thenReturn(pdc); + when(pdc.get(any(NamespacedKey.class), any(PersistentDataType.class))) + .thenAnswer(call -> data.get(call.getArgument(0))); + when(pdc.has(any(NamespacedKey.class), any(PersistentDataType.class))) + .thenAnswer(call -> data.containsKey(call.getArgument(0))); + doAnswer(call -> { data.put(call.getArgument(0), call.getArgument(2)); return null; }) + .when(pdc).set(any(NamespacedKey.class), any(PersistentDataType.class), any()); + when(entity.getLocation()).thenAnswer(call -> this.location.clone()); + when(entity.isDead()).thenAnswer(call -> removed); + when(entity.isValid()).thenAnswer(call -> valid && !removed); + doAnswer(call -> { removed = true; return null; }).when(entity).remove(); + when(entity.teleport(any(Location.class))).thenAnswer(call -> { this.location = ((Location) call.getArgument(0)).clone(); return true; }); + entities.add(entity); + } + } + + private static final class SavedStyle { + final String small = Cache.trackItemSmall, medium = Cache.trackItemMedium, large = Cache.trackItemLarge; + final String appliedSmall = Cache.appliedTrackItemSmall, appliedMedium = Cache.appliedTrackItemMedium, + appliedLarge = Cache.appliedTrackItemLarge, switchItem = Cache.trackSwitchItem; + final double offset = Cache.trackDisplayYOffset, appliedOffset = Cache.appliedTrackDisplayYOffset; + final int budget = Cache.trackResyncChunksPerTick; + void restore() { + Cache.trackItemSmall = small; Cache.trackItemMedium = medium; Cache.trackItemLarge = large; + Cache.appliedTrackItemSmall = appliedSmall; Cache.appliedTrackItemMedium = appliedMedium; + Cache.appliedTrackItemLarge = appliedLarge; Cache.trackSwitchItem = switchItem; + Cache.trackDisplayYOffset = offset; Cache.appliedTrackDisplayYOffset = appliedOffset; + Cache.trackResyncChunksPerTick = budget; + } + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackGeometryRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackGeometryRuntimeCoverageTest.java new file mode 100644 index 0000000..557bf09 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackGeometryRuntimeCoverageTest.java @@ -0,0 +1,1014 @@ +package net.tfminecraft.vehicleframework.tracks; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.test.RegistryFixture; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Entity; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.VoxelShape; +import org.json.simple.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +class TrackGeometryRuntimeCoverageTest { + @BeforeAll + static void registries() { + RegistryFixture.initialize(); + } + + @Test + void reverseTravelStopsAtTheFirstBrokenEdgeBeforeWrapping() { + TrackSpline loop = square(); + loop = + new TrackSpline( + loop.getId(), + "world", + true, + loop.getSamples(), + List.of( + new TrackSegment(0, true, 0), + new TrackSegment(1, false, 1), + new TrackSegment(2, false, 1), + new TrackSegment(3, true, 0))); + TrackAdvance stop = loop.advance(25, -35); + assertTrue(stop.stoppedAtBreak); + assertEquals( + 10, + stop.s, + 1e-9, + "Travelling backwards reaches edge zero before wrapping to the final edge"); + } + + @Test + void junctionSideIdentifiersAreIndependentOfLocale() { + JSONObject json = + new TrackJunction( + UUID.randomUUID(), UUID.randomUUID(), 2, 1, TrackJunction.Side.RIGHT, null) + .toJson(); + json.put("side", "right"); + Locale old = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertEquals(TrackJunction.Side.RIGHT, TrackJunction.fromJson(json).side); + } finally { + Locale.setDefault(old); + } + } + + @Test + void splineConfigurationValidatesSamplesAndRecoversLegacyJsonValues() { + assertThrows( + IllegalArgumentException.class, () -> new TrackSpline(null, null, false, null, null)); + assertThrows( + IllegalArgumentException.class, () -> new TrackSpline(null, null, false, List.of(), null)); + assertThrows(IllegalArgumentException.class, () -> TrackSpline.fromJson(null)); + assertThrows(IllegalArgumentException.class, () -> TrackSpline.fromJson(new JSONObject())); + assertFalse(TrackSpline.shouldLoop(null, 1)); + assertFalse(TrackSpline.shouldLoop(List.of(), 1)); + assertFalse(TrackSpline.shouldLoop(points(0, 0, 1, 0, 0, 0), -1)); + TrackSpline stored = TrackSpline.fromPoints(null, null, false, points(0, 0, 10, 0, 20, 0)); + assertEquals("", stored.getWorld()); + TrackSpline trimmed = + new TrackSpline( + stored.getId(), + "world", + false, + stored.getSamples(), + List.of( + new TrackSegment(0, false, 1), + new TrackSegment(1, false, 1), + new TrackSegment(2, true, 0))); + assertEquals(2, trimmed.getSegments().size()); + assertFalse(trimmed.segment(-1).broken); + assertEquals(-1, trimmed.segment(-1).fromIndex); + assertFalse(trimmed.segment(100).broken); + JSONObject json = stored.toJson(); + json.remove("id"); + JSONArray rows = (JSONArray) json.get("samples"); + ((JSONObject) rows.get(0)).remove("s"); + ((JSONObject) rows.get(0)).remove("x"); + ((JSONObject) rows.get(1)).put("x", "10.0"); + TrackSpline loaded = TrackSpline.fromJson(json); + assertNotNull(loaded.getId()); + assertEquals(10, loaded.getSamples().get(1).s); + assertEquals(20, loaded.length()); + json.put("id", "invalid uuid"); + assertNotNull(TrackSpline.fromJson(json).getId()); + TrackSpline point = TrackSpline.fromPoints(null, "world", false, points(2, 3, 2, 3)); + TrackPose pose = point.sampleAt(999); + assertEquals(2, pose.x); + assertEquals(3, pose.z); + assertEquals(0, point.advance(99, 10).s); + assertEquals(0, point.nearestS(4, 64, 5)); + } + + @Test + void junctionJsonHandlesOptionalBranchesAndRejectsMissingIdentity() { + UUID id = UUID.randomUUID(), stem = UUID.randomUUID(); + assertThrows( + IllegalArgumentException.class, + () -> new TrackJunction(null, stem, 0, 1, TrackJunction.Side.LEFT, null)); + assertThrows( + IllegalArgumentException.class, + () -> new TrackJunction(id, null, 0, 1, TrackJunction.Side.LEFT, null)); + assertThrows( + IllegalArgumentException.class, () -> new TrackJunction(id, stem, 0, 1, null, null)); + assertThrows( + IllegalArgumentException.class, + () -> new TrackJunction(id, stem, 0, 1, TrackJunction.Side.LEFT, null, false, -1)); + assertThrows(IllegalArgumentException.class, () -> TrackJunction.fromJson(null)); + JSONObject json = new TrackJunction(id, stem, 2, 1, TrackJunction.Side.LEFT, null).toJson(); + json.put("s", "3.5"); + json.put("facing", "-1"); + json.put("thrown", "true"); + assertEquals(3.5, TrackJunction.fromJson(json).s); + assertEquals(-1, TrackJunction.fromJson(json).facingSign); + assertTrue(TrackJunction.fromJson(json).thrown); + for (String branch : List.of("", "null", "bad uuid")) { + json.put("branch", branch); + assertTrue(TrackJunction.fromJson(json).branchSplineId().isEmpty()); + } + json.put("side", "unknown"); + assertThrows(IllegalArgumentException.class, () -> TrackJunction.fromJson(json)); + json.remove("side"); + assertThrows(IllegalArgumentException.class, () -> TrackJunction.fromJson(json)); + json.put("side", "LEFT"); + json.remove("id"); + assertThrows(IllegalArgumentException.class, () -> TrackJunction.fromJson(json)); + json.put("id", "bad uuid"); + assertThrows(IllegalArgumentException.class, () -> TrackJunction.fromJson(json)); + assertEquals(0, TrackJunction.wrapS(10, 0, true)); + } + + @Test + void junctionTravelHandlesReverseLoopsAndSpacingAcrossBranchEnds() { + assertFalse(TrackJunctionTravel.diverge(null, 1f)); + assertTrue(TrackJunctionTravel.diverge(TrackJunction.Side.LEFT, 1f)); + assertEquals( + TrackJunctionTravel.Choice.THROUGH, + TrackJunctionTravel.choice(TrackJunction.Side.LEFT, -1f)); + assertEquals(Double.POSITIVE_INFINITY, TrackJunctionTravel.ahead(0, 0, 1, false, 0)); + assertEquals(5, TrackJunctionTravel.ahead(5, 10, 1, true, 40)); + assertEquals(5, TrackJunctionTravel.ahead(10, 5, -1, true, 40)); + assertEquals(10, TrackJunctionTravel.ahead(5, 35, -1, true, 40)); + assertFalse(TrackJunctionTravel.crosses(0, 1, 0, 1, false, 0)); + assertTrue(TrackJunctionTravel.crosses(20, 10, 15, -1, true, 40)); + assertTrue(TrackJunctionTravel.crosses(5, 35, 39, -1, true, 40)); + assertFalse(TrackJunctionTravel.crosses(5, 35, 20, -1, true, 40)); + UUID stem = UUID.randomUUID(), branch = UUID.randomUUID(); + TrackJunctionTravel.Pose none = + TrackJunctionTravel.rewind(null, 3, 1, 2, true, stem, branch, 10, 1, 40, false, 10); + assertNull(none.splineId); + assertEquals(3, none.s); + assertEquals( + 3, TrackJunctionTravel.rewind(stem, 3, 1, 0, true, stem, branch, 10, 1, 40, false, 10).s); + TrackJunctionTravel.Pose reverse = + TrackJunctionTravel.rewind(branch, 8, -1, 5, true, stem, branch, 10, 1, 40, false, 10); + assertEquals(stem, reverse.splineId); + assertEquals(13, reverse.s); + assertEquals(0, reverse.missingSpacing); + TrackJunctionTravel.Pose branchPose = + TrackJunctionTravel.rewind(branch, 3, -1, 2, true, stem, branch, 10, 1, 40, false, 10); + assertEquals(branch, branchPose.splineId); + assertEquals(5, branchPose.s); + TrackJunctionTravel.Pose beyond = + TrackJunctionTravel.rewind(stem, 2, 1, 5, false, null, null, 10, 1, 40, false, 10); + assertEquals(stem, beyond.splineId); + assertEquals(0, beyond.s); + assertEquals(3, beyond.missingSpacing); + } + + @Test + void gradesValidateEmptyVerticalAndRequiredHeightStrokes() throws Exception { + assertThrows(TrackLayException.class, () -> TrackGrade.apply(null, 0, 1, 10, 15)); + assertThrows(TrackLayException.class, () -> TrackGrade.apply(List.of(), 0, 1, 10, 15)); + assertThrows( + TrackLayException.class, () -> TrackGrade.apply(points(0, 0, 0, 0), 64, 65, 10, 15)); + List flat = points(0, 0, 4, 0); + TrackGrade.apply(flat, 64, 64, 10, 15); + assertEquals(64, flat.getLast()[1]); + TrackGrade.applyRequiredHeights(null, new double[] {1, 2}, 15); + TrackGrade.applyRequiredHeights(flat, null, 15); + TrackGrade.applyRequiredHeights(flat, new double[] {1}, 15); + TrackGrade.applyRequiredHeights(List.of(), new double[] {}, 15); + assertThrows( + TrackLayException.class, + () -> TrackGrade.applyRequiredHeights(points(0, 0, 1, 0), new double[] {64, 66}, 15)); + List falling = points(0, 0, 4, 0, 8, 0); + falling.get(1)[1] = 60; + falling.get(2)[1] = 50; + TrackGrade.applyRequiredHeights(falling, new double[] {64, 60, 50}, 15); + assertTrue(falling.get(1)[1] >= 64 - 4 * Math.tan(Math.toRadians(15)) - 1e-9); + assertTrue(falling.get(2)[1] >= 64 - 8 * Math.tan(Math.toRadians(15)) - 1e-9); + } + + @Test + void curvesRejectVerticalOrBackwardRequestsAndRetainStraightEndpoints() throws Exception { + assertThrows( + TrackLayException.class, () -> TrackCurve.between(0, 64, 0, 0, 70, 0, 1, 10, 15, 1)); + assertThrows(TrackLayException.class, () -> TrackCurve.lay(0, 64, 0, 0, 0, 70, 0, 1, 35, 1)); + assertThrows( + TrackLayException.class, + () -> TrackCurve.layAligned(0, 64, 0, 0, 0, 70, 0, 1, 35, 10, 15, 1)); + assertThrows( + TrackLayException.class, + () -> TrackCurve.join(0, 64, 0, 0, 0, 70, 0, 0, 1, 35, 32, 10, 15, 1)); + assertThrows(TrackLayException.class, () -> TrackCurve.lay(0, 64, 0, 0, 0, 64, -10, 1, 35, 1)); + assertEquals( + 0, TrackCurve.endYaw(null, new double[] {0, 64, 0}, new double[] {0, 64, 1}), 1e-9); + List forward = TrackCurve.join(0, 64, 0, 0, 0, 64, 10, 0, 1, 35, 32, 10, 15, 1); + assertArrayEquals(new double[] {0, 64, 10}, forward.getLast(), 1e-9); + assertArrayEquals(new double[] {0, 64, 0}, forward.getFirst(), 1e-9); + } + + @Test + void visualBakingHandlesMissingShortAndAppendedTracks() { + assertTrue(TrackVisualBake.bake(null).isEmpty()); + assertTrue(TrackVisualBake.bakeAppended(null, 0, null).isEmpty()); + assertTrue(TrackVisualBake.bakeAppended(List.of(), 0, List.of()).isEmpty()); + TrackSpline tiny = TrackSpline.fromPoints(null, "world", false, points(0, 0, 1, 0)); + assertFalse(TrackVisualBake.bake(tiny).isEmpty()); + List expanded = new ArrayList<>(); + for (int z = 0; z < 20; z++) expanded.add(new double[] {0, 64, z}); + List before = + TrackVisualBake.bake(TrackSpline.fromPoints(null, "world", false, expanded.subList(0, 10))); + List after = TrackVisualBake.bakeAppended(before, 10, expanded); + List whole = + TrackVisualBake.bake(TrackSpline.fromPoints(null, "world", false, expanded)); + assertEquals(whole.size(), after.size()); + for (int i = 0; i < whole.size(); i++) { + assertEquals(whole.get(i).startIndex, after.get(i).startIndex); + assertEquals(whole.get(i).length, after.get(i).length); + assertEquals(whole.get(i).z, after.get(i).z); + } + } + + @Test + void routeQueriesHandleMissingInputsDisconnectedJunctionsAndVerticalRuns() { + TrackSpline track = TrackSpline.fromPoints(null, "world", false, points(0, 0, 10, 0)); + for (double radius : List.of(-1d, Double.NaN)) + assertTrue( + TrackRouteQuery.shortestRouteLength( + List.of(track), List.of(), "world", 0, 0, radius, 10, 0, 0) + .isEmpty()); + assertTrue( + TrackRouteQuery.shortestRouteLength(null, List.of(), "world", 0, 0, 1, 10, 0, 1).isEmpty()); + assertTrue( + TrackRouteQuery.shortestRouteLength(List.of(track), null, "world", 0, 0, 1, 10, 0, 1) + .isEmpty()); + assertTrue( + TrackRouteQuery.shortestRouteLength(List.of(track), List.of(), null, 0, 0, 1, 10, 0, 1) + .isEmpty()); + TrackJunction + self = + new TrackJunction( + UUID.randomUUID(), track.getId(), 5, 1, TrackJunction.Side.LEFT, track.getId()), + missing = + new TrackJunction( + UUID.randomUUID(), UUID.randomUUID(), 5, 1, TrackJunction.Side.LEFT, track.getId()); + assertEquals( + 10, + TrackRouteQuery.shortestRouteLength( + List.of(track), Arrays.asList(null, self, missing), "world", 0, 0, 0, 10, 0, 0) + .orElseThrow()); + TrackSpline vertical = + TrackSpline.fromPoints( + null, + "world", + false, + List.of(new double[] {0, 64, 0}, new double[] {0, 68, 0}, new double[] {10, 68, 0})); + assertEquals( + 10, + TrackRouteQuery.shortestRouteLength( + List.of(vertical), List.of(), "world", 0, 0, 0, 10, 0, 0) + .orElseThrow(), + 1e-8); + } + + @Test + void clearanceAllowsEmptySpaceAndRaisesSingleStepsWithinGradeLimits( + @TempDir java.nio.file.Path folder) throws Exception { + Terrain terrain = new Terrain(); + double grade = Cache.trackMaxGradeDegrees, + width = Cache.trainClearanceWidth, + height = Cache.trainClearanceHeight, + offset = Cache.trackVehicleYOffset; + try { + Cache.trackMaxGradeDegrees = 15; + Cache.trainClearanceWidth = 3; + Cache.trainClearanceHeight = 3; + Cache.trackVehicleYOffset = .5; + assertDoesNotThrow(() -> TrackClearance.check(null, points(0, 0, 10, 0), null, null)); + assertDoesNotThrow(() -> TrackClearance.check(terrain.world, null, null, null)); + assertDoesNotThrow(() -> TrackClearance.check(terrain.world, List.of(), null, null)); + TrackClearance.liftOneBlockSteps(null, null); + List stroke = new ArrayList<>(); + for (int z = 0; z <= 10; z++) stroke.add(new double[] {.5, 64, z + .5}); + TrackClearance.check(terrain.world, stroke, null, null); + terrain.solid.add(new Pos(0, 64, 8)); + TrackClearance.check(terrain.world, stroke, null, null); + assertTrue(stroke.get(8)[1] >= 65); + assertEquals(64, stroke.getFirst()[1]); + TrackRegistry registry = new TrackRegistry(folder.toFile()); + try { + TrackSpline existing = TrackSpline.fromPoints(null, "world", false, points(20, 0, 30, 0)); + registry.replace(existing); + TrackClearance.check( + terrain.world, points(20, 0, 30, 0), registry, Set.of(existing.getId())); + assertThrows( + TrackLayException.class, + () -> TrackClearance.check(terrain.world, points(20, 0, 30, 0), registry, null)); + } finally { + registry.close(); + } + } finally { + Cache.trackMaxGradeDegrees = grade; + Cache.trainClearanceWidth = width; + Cache.trainClearanceHeight = height; + Cache.trackVehicleYOffset = offset; + } + } + + @Test + void clearanceReportsHighStepsAndRailHeadroomBeyondNarrowTrainBounds() { + Terrain terrain = new Terrain(); + double width = Cache.trainClearanceWidth, + height = Cache.trainClearanceHeight, + offset = Cache.trackVehicleYOffset; + try { + Cache.trainClearanceWidth = 3; + Cache.trainClearanceHeight = 3; + Cache.trackVehicleYOffset = 0; + terrain.solid.addAll(List.of(new Pos(0, 64, 4), new Pos(0, 65, 4), new Pos(0, 66, 7))); + List stroke = new ArrayList<>(); + for (int z = 0; z <= 10; z++) stroke.add(new double[] {.5, 64, z + .5}); + TrackLayException rejected = + assertThrows( + TrackLayException.class, + () -> TrackClearance.check(terrain.world, stroke, null, null)); + assertTrue(rejected.hasBlock()); + assertTrue(rejected.inTrainSpace.size() >= 2); + assertTrue(rejected.getMessage().contains("more blocks")); + TrackLayException generic = new TrackLayException("generic"); + assertSame(generic, TrackClearance.withTrainSpace(terrain.world, stroke, generic)); + assertEquals("3", TrackClearance.format(3)); + assertEquals("3.5", TrackClearance.format(3.5)); + terrain.solid.clear(); + terrain.solid.add(new Pos(0, 66, 4)); + Cache.trainClearanceWidth = .5; + Cache.trainClearanceHeight = 1; + TrackLayException overhead = + assertThrows( + TrackLayException.class, + () -> TrackClearance.check(terrain.world, stroke, null, null)); + assertEquals(66, overhead.blockY); + assertTrue(overhead.getMessage().contains("stone in the way")); + } finally { + Cache.trainClearanceWidth = width; + Cache.trainClearanceHeight = height; + Cache.trackVehicleYOffset = offset; + } + } + + @Test + void collisionScansHandleNoWorldTinyIntervalsAndLoopSeams() { + Terrain terrain = new Terrain(); + TrackSpline loop = square(); + Entity entity = mock(Entity.class); + when(entity.getWorld()).thenReturn(terrain.world); + assertFalse(TrainBlockCollision.blocked(entity, loop, 39.9, loop, .1, 2)); + assertTrue(TrainBlockCollision.obstructions(null, loop, 0, 1).isEmpty()); + assertTrue(TrainBlockCollision.obstructions(terrain.world, null, 0, 1).isEmpty()); + assertTrue(TrainBlockCollision.obstructions(terrain.world, loop, 1, 0).isEmpty()); + assertTrue(TrainBlockCollision.obstructions(terrain.world, loop, 0, 1e-8).isEmpty()); + } + + @Test + void longLoopAdvancesStillStopAtTheFirstBrokenEdge() { + TrackSpline loop = square(); + loop = + loop.withSegment(0, new TrackSegment(0, true, 0)) + .withSegment(3, new TrackSegment(3, true, 0)); + TrackAdvance forward = loop.advance(25, 40); + assertTrue(forward.stoppedAtBreak); + assertEquals(30, forward.s, 1e-9); + TrackAdvance reverse = loop.advance(25, -40); + assertTrue(reverse.stoppedAtBreak); + assertEquals(10, reverse.s, 1e-9); + } + + @Test + void joiningStraightBehindTheHeadingRejectsAnInstantReversal() { + assertThrows( + TrackLayException.class, + () -> TrackCurve.join(0, 64, 0, 0, 0, 64, -30, 0, 1, 35, 32, 10, 15, 1)); + } + + @Test + void curveFallbacksPreserveEndpointsAndRejectOvertightBends() throws Exception { + for (double[] c : + List.of( + new double[] {-.1, 10, -160, 1, 35}, + new double[] {-2, 30, -35, 1, 35}, + new double[] {0, 2, -20, 1, 35}, + new double[] {-30, 30, -90, 1, 90}, + new double[] {10, 30, -90, 16, 90})) { + List curve = + TrackCurve.join(0, 64, 0, 0, c[0], 64, c[1], (float) c[2], c[3], c[4], 32, 10, 15, 1); + assertArrayEquals(new double[] {0, 64, 0}, curve.getFirst(), 1e-9); + assertArrayEquals(new double[] {c[0], 64, c[1]}, curve.getLast(), 1e-9); + for (double[] p : curve) { + assertTrue(Double.isFinite(p[0]) && Double.isFinite(p[2])); + assertEquals(64, p[1], 1e-9); + } + assertTrue(TrackGrade.horizontalLength(curve) >= Math.hypot(c[0], c[1]) - 1e-8); + } + for (double[] c : + List.of( + new double[] {-30, -30, -35, 35}, + new double[] {-30, 30, -35, 35}, + new double[] {-30, -30, 0, 35}, + new double[] {2, 0, 90, 120})) + assertThrows( + TrackLayException.class, + () -> TrackCurve.join(0, 64, 0, 0, c[0], 64, c[1], (float) c[2], 1, c[3], 32, 10, 15, 1)); + assertThrows( + TrackLayException.class, + () -> TrackCurve.layAligned(0, 64, 0, 0, 0, 64, -10, 1, 35, 10, 15, 1)); + List degenerate = TrackCurve.lay(0, 64, 0, 0, 0, 64, 0, 0, 35, 1); + assertEquals(2, degenerate.size()); + assertArrayEquals(degenerate.getFirst(), degenerate.getLast()); + assertEquals(2, TrackCurve.between(0, 64, 0, 0, 64, 0, 0, 10, 15, 1).size()); + double angle = Math.toRadians(35), + x = -32 * (1 - Math.cos(angle)) - 10 * Math.sin(angle), + z = 32 * Math.sin(angle) + 10 * Math.cos(angle); + List tangent = TrackCurve.join(0, 64, 0, 0, x, 64, z, 35, 1, 35, 32, 10, 15, 1); + assertArrayEquals(new double[] {x, 64, z}, tangent.getLast(), 1e-9); + assertEquals( + 35, + TrackCurve.endYaw( + tangent.get(tangent.size() - 3), tangent.get(tangent.size() - 2), tangent.getLast()), + .1); + } + + @Test + void gradeRejectsSmallOverslopeEvenWithinStartPointTolerance() { + double target = 64 + Math.tan(Math.toRadians(15)) + .0005; + TrackLayException steep = + assertThrows( + TrackLayException.class, + () -> + TrackGrade.applyRequiredHeights(points(0, 0, 1, 0), new double[] {64, target}, 15)); + assertTrue(steep.getMessage().contains("Slope is too steep")); + } + + @Test + void splineCopiesCoordinatesAndPromotesOnlyNearbyEndpoints() { + List xyz = points(0, 0, 1, 0, 0, .1); + assertTrue(TrackSpline.shouldLoop(xyz, .2)); + assertFalse(TrackSpline.shouldLoop(xyz, .05)); + TrackSpline open = TrackSpline.fromPoints(null, "world", false, xyz); + assertSame(open, open.promotedLoop(.05)); + TrackSpline loop = open.promotedLoop(.2); + assertTrue(loop.isLoop()); + assertSame(loop, loop.promotedLoop(.2)); + List copied = loop.xyz(); + copied.getFirst()[0] = 999; + assertEquals(0, loop.first().x); + assertEquals(.1, loop.last().z); + assertSame(loop.visuals(), loop.visuals()); + TrackSpline broken = + square() + .withSegment(0, new TrackSegment(0, true, 0)) + .withSegment(3, new TrackSegment(3, true, 0)); + assertEquals(30, broken.advance(25, 20).s); + assertEquals(30, broken.advance(25, 10).s); + assertFalse(broken.advance(25, -2).stoppedAtBreak); + TrackSpline legacy = + new TrackSpline( + open.getId(), + "world", + false, + open.getSamples(), + List.of(new TrackSegment(-2, true, .3), new TrackSegment(1, false, 1))); + assertEquals(.3, legacy.segment(-2).health); + assertFalse(legacy.segment(0).broken); + } + + @Test + void junctionChangesPreserveOtherPersistedFields() { + UUID id = UUID.randomUUID(), + stem = UUID.randomUUID(), + other = UUID.randomUUID(), + branch = UUID.randomUUID(); + TrackJunction start = + new TrackJunction(id, stem, 2, 1, TrackJunction.Side.LEFT, branch, true, 5); + TrackJunction changed = + start + .withS(3) + .withStem(other, 4) + .withSide(TrackJunction.Side.RIGHT) + .withBranch(stem) + .withFacing(-1) + .withThrown(false) + .withTurnoutEndS(6); + assertEquals(id, changed.id); + assertEquals(other, changed.stemSplineId); + assertEquals(4, changed.s); + assertEquals(TrackJunction.Side.RIGHT, changed.side); + assertEquals(stem, changed.branchSplineId().orElseThrow()); + assertEquals(-1, changed.facingSign); + assertFalse(changed.thrown); + assertEquals(6, changed.turnoutEndS); + assertEquals(2, start.s); + assertEquals(TrackJunction.Side.LEFT, TrackJunction.sideFrom(0, 1, 0)); + assertEquals(TrackJunction.Side.RIGHT, TrackJunction.sideFrom(0, -1, 0)); + assertEquals(10, TrackJunctionTravel.ahead(35, 5, 1, true, 40)); + assertTrue(TrackJunctionTravel.crosses(35, 5, 0, 1, true, 40)); + } + + @Test + void alternatingGentleBendsAreNotMergedIntoAnOverturnedVisual() { + List xyz = new ArrayList<>(); + double x = 0, z = 0; + xyz.add(new double[] {x, 64, z}); + for (double yaw : List.of(0d, 7d, -7d, -7d)) { + x -= Math.sin(Math.toRadians(yaw)); + z += Math.cos(Math.toRadians(yaw)); + xyz.add(new double[] {x, 64, z}); + } + TrackSpline track = TrackSpline.fromPoints(null, "world", false, xyz); + List visuals = TrackVisualBake.bake(track); + assertEquals(2, visuals.getFirst().length); + assertEquals(xyz.size(), visuals.stream().mapToInt(v -> v.length).sum()); + assertFalse(TrackVisualBake.bake(square()).isEmpty()); + } + + @Test + void routesChooseEitherDirectionAroundALoopAndIgnoreZeroLengthBrokenEdges() { + TrackSpline loop = square(), + left = TrackSpline.fromPoints(null, "world", false, points(0, 0, -10, 0)), + right = TrackSpline.fromPoints(null, "world", false, points(10, 10, 10, 20)); + List junctions = + List.of( + new TrackJunction( + UUID.randomUUID(), loop.getId(), 0, 1, TrackJunction.Side.LEFT, left.getId()), + new TrackJunction( + UUID.randomUUID(), loop.getId(), 20, 1, TrackJunction.Side.RIGHT, right.getId())); + List tracks = List.of(loop, left, right); + assertEquals( + 40, + TrackRouteQuery.shortestRouteLength(tracks, junctions, "world", -10, 0, 0, 10, 20, 0) + .orElseThrow(), + 1e-8); + assertEquals( + 10, + TrackRouteQuery.shortestRouteLength(tracks, junctions, "world", 0, 0, 0, -10, 0, 0) + .orElseThrow(), + 1e-8); + assertEquals( + 13, + TrackRouteQuery.shortestRouteLength(tracks, junctions, "world", 0, 3, 0, -10, 0, 0) + .orElseThrow(), + 1e-8); + TrackSpline repeated = TrackSpline.fromPoints(null, "world", false, points(0, 0, 0, 0, 10, 0)); + repeated = repeated.withSegment(0, new TrackSegment(0, true, 0)); + assertEquals( + 10, + TrackRouteQuery.shortestRouteLength( + List.of(repeated), List.of(), "world", 0, 0, 0, 10, 0, 0) + .orElseThrow(), + 1e-8); + TrackSpline closed = square(); + for (int edge = 0; edge < 4; edge++) + closed = closed.withSegment(edge, new TrackSegment(edge, true, 0)); + assertTrue( + TrackRouteQuery.shortestRouteLength(List.of(closed), List.of(), "world", 0, 0, 0, 10, 10, 0) + .isEmpty()); + TrackJunction missing = + new TrackJunction( + UUID.randomUUID(), loop.getId(), 3, 1, TrackJunction.Side.RIGHT, UUID.randomUUID()); + assertEquals( + 10, + TrackRouteQuery.shortestRouteLength( + List.of(loop), List.of(missing), "world", 0, 0, 0, 10, 0, 0) + .orElseThrow()); + } + + @Test + void clearanceIgnoresTracksAtDifferentHeightsAndEmptyTrainVolume( + @TempDir java.nio.file.Path folder) throws Exception { + Terrain terrain = new Terrain(); + TrackRegistry registry = new TrackRegistry(folder.toFile()); + try { + registry.replace( + TrackSpline.fromPoints( + null, "world", false, List.of(new double[] {0, 70, 0}, new double[] {10, 70, 0}))); + TrackClearance.checkOverlap("world", points(0, 0, 10, 0), registry, null, null); + TrackClearance.checkOverlap(null, points(0, 0, 10, 0), registry, null, null); + TrackClearance.checkOverlap("world", null, registry, null, null); + TrackClearance.checkOverlap("world", points(0, 0, 10, 0), null, null, null); + } finally { + registry.close(); + } + double offset = Cache.trackVehicleYOffset, height = Cache.trainClearanceHeight; + try { + Cache.trackVehicleYOffset = .5; + Cache.trainClearanceHeight = .5; + terrain.solid.add(new Pos(0, 64, 2)); + assertTrue(TrainBlockCollision.obstructions(terrain.world, square(), 0, 10).isEmpty()); + } finally { + Cache.trackVehicleYOffset = offset; + Cache.trainClearanceHeight = height; + } + } + + @Test + void alignedRowsRoundTowardGridHeadingAndNumericSamplingNeverOvershoots() throws Exception { + List track = TrackCurve.layAligned(0, 64, 0, 20, 0, 64, 30, 1, 35, 10, 15, 1); + assertArrayEquals(new double[] {0, 64, 30}, track.getLast(), 1e-9); + assertTrue( + Math.abs( + TrackCurve.endYaw( + track.get(track.size() - 3), track.get(track.size() - 2), track.getLast())) + < 20); + List dense = TrackGenerate.densify(0, 64, 0, 3.9, 64, 0, 1.3); + assertEquals(4, dense.size()); + assertEquals(3.9, dense.getLast()[0]); + assertTrue(dense.stream().allMatch(p -> p[0] >= 0 && p[0] <= 3.9)); + assertEquals(0, TrackLap.wrapDelta(4, 3, 0)); + assertEquals(2, TrackLap.wrapDelta(1, 9, 10)); + assertEquals(new org.bukkit.util.Vector(0, 0, 1), TrackSplineMotion.tangentFromPose(null, 1)); + assertThrows( + IllegalArgumentException.class, () -> TrackSwitchPose.of(null, null, 0, 0, 0, 0, 0)); + } + + @Test + void halfBlockSupportAlreadyUnderTheRailNeedsNoLift() throws Exception { + Terrain terrain = new Terrain(); + Pos support = new Pos(0, 64, 0); + terrain.solid.add(support); + Block block = terrain.block(support); + org.bukkit.block.data.type.Slab slab = mock(org.bukkit.block.data.type.Slab.class); + when(slab.getType()).thenReturn(org.bukkit.block.data.type.Slab.Type.BOTTOM); + when(block.getBlockData()).thenReturn(slab); + List stroke = + new ArrayList<>(List.of(new double[] {.5, 64.5, .5}, new double[] {.5, 64.5, 1.5})); + TrackClearance.liftOneBlockSteps(terrain.world, stroke); + assertEquals(64.5, stroke.getFirst()[1]); + assertEquals(64.5, stroke.getLast()[1]); + } + + @Test + void layResultsKeepOnlyExistingTrackAndVisualDiffFindsNewChunks() { + TrackSpline track = + TrackSpline.fromPoints(null, "world", false, points(0, 0, 1, 0, 2, 0, 3, 0)); + TrackLayResult append = + TrackLayResult.of(TrackLayResult.Kind.APPEND, track, points(2, 0, 3, 0), 3); + assertEquals(3, append.keepPoints().size()); + assertEquals(2, append.keepPoints().getLast()[0]); + assertTrue( + TrackLayResult.of(TrackLayResult.Kind.APPEND, track, null, 1).keepPoints().isEmpty()); + TrackLayResult prepend = + TrackLayResult.of(TrackLayResult.Kind.PREPEND, track, points(0, 0, 1, 0), 3); + assertEquals(1, prepend.keepPoints().getFirst()[0]); + assertEquals(3, prepend.keepPoints().size()); + assertTrue( + TrackLayResult.of(TrackLayResult.Kind.PREPEND, track, track.xyz(), 2) + .keepPoints() + .isEmpty()); + assertTrue( + TrackLayResult.of(TrackLayResult.Kind.CONNECT, track, track.xyz(), 2) + .keepPoints() + .isEmpty()); + List keep = append.keepPoints(); + keep.getFirst()[0] = 99; + assertEquals(0, track.first().x); + assertEquals(0, append.keepPoints().getFirst()[0]); + TrackLayResult withBefore = append.withBefore(track); + assertSame(track, withBefore.before); + assertSame(track, withBefore.spline()); + assertTrue(TrackLayResult.of(TrackLayResult.Kind.NEW, track).sequential()); + assertFalse(TrackLayResult.of(TrackLayResult.Kind.CONNECT, track).sequential()); + assertTrue(TrackLayResult.of(TrackLayResult.Kind.NEW, null).keepPoints().isEmpty()); + assertEquals( + Set.of(TrackChunks.key(0, 0)), TrackVisualDiff.changedChunks(null, track.visuals())); + assertEquals( + Set.of(TrackChunks.key(0, 0)), TrackVisualDiff.changedChunks(track.visuals(), null)); + assertTrue(TrackVisualDiff.changedChunks(null, null).isEmpty()); + assertEquals(0, TrackVisualDiff.firstChange(null, track.visuals())); + assertEquals( + track.visuals().size(), TrackVisualDiff.firstChange(track.visuals(), track.visuals())); + assertTrue(TrackVisualDiff.same(track.visuals().getFirst(), track.visuals().getFirst())); + assertFalse(TrackVisualDiff.same(null, track.visuals().getFirst())); + assertFalse(TrackVisualDiff.same(track.visuals().getFirst(), null)); + } + + @Test + void routeQueriesHandleRoundoffAtCircleTangentsAndRepeatedGraphRelaxation() { + TrackSpline line = TrackSpline.fromPoints(null, "world", false, points(0, 0, 10, 0)); + assertEquals( + 5, + TrackRouteQuery.shortestRouteLength( + List.of(line), List.of(), "world", 5, 1 + 1e-10, 1, 10, 0, 0) + .orElseThrow(), + 1e-6); + TrackSpline loop = square(); + assertEquals( + 0, + TrackRouteQuery.shortestRouteLength(List.of(loop), List.of(), "world", 0, 0, 0, 0, 0, 0) + .orElseThrow()); + TrackSpline shortcut = TrackSpline.fromPoints(null, "world", false, points(0, 0, 10, 10)), + branch = TrackSpline.fromPoints(null, "world", false, points(10, 10, 10, 20)), + isolated = TrackSpline.fromPoints(null, "world", false, points(100, 100, 110, 100)); + List junctions = + List.of( + new TrackJunction( + UUID.randomUUID(), loop.getId(), 0, 1, TrackJunction.Side.LEFT, shortcut.getId()), + new TrackJunction( + UUID.randomUUID(), loop.getId(), 20, 1, TrackJunction.Side.RIGHT, branch.getId()), + new TrackJunction( + UUID.randomUUID(), + shortcut.getId(), + shortcut.length(), + 1, + TrackJunction.Side.LEFT, + branch.getId())); + List network = List.of(loop, shortcut, branch, isolated); + assertEquals( + Math.sqrt(200) + 10, + TrackRouteQuery.shortestRouteLength(network, junctions, "world", 0, 0, 0, 10, 20, 0) + .orElseThrow(), + 1e-8); + assertTrue( + TrackRouteQuery.shortestRouteLength(network, junctions, "world", 0, 0, 0, 110, 100, 0) + .isEmpty()); + assertEquals( + 27, + TrackRouteQuery.shortestRouteLength(network, junctions, "world", 10, 20, 0, 0, 3, 0) + .orElseThrow(), + 1e-8); + TrackSpline broken = + TrackSpline.fromPoints(null, "world", false, points(0, 0, 10, 0, 20, 0, 30, 0)); + broken = broken.withSegment(1, new TrackSegment(1, true, 0)); + TrackSpline spur = TrackSpline.fromPoints(null, "world", false, points(25, 0, 25, 10)); + TrackJunction far = + new TrackJunction( + UUID.randomUUID(), broken.getId(), 25, 1, TrackJunction.Side.LEFT, spur.getId()); + assertEquals( + 5, + TrackRouteQuery.shortestRouteLength( + List.of(broken, spur), List.of(far), "world", 0, 0, 0, 5, 0, 0) + .orElseThrow(), + 1e-8); + assertTrue(TrackJunctionTravel.crosses(5, 15, 10, 1, true, 40)); + assertEquals( + 8, + TrackRouteQuery.shortestRouteLength(List.of(loop), List.of(), "world", 5, 0, 1, 10, 5, 1) + .orElseThrow(), + 1e-8); + } + + @Test + void tapeDefaultsLegacyJsonAndCapacityRetainRecordedSemantics() { + UUID origin = UUID.randomUUID(); + ThrottleTape empty = new ThrottleTape(null, null); + assertEquals("", empty.getSplineId()); + assertFalse(empty.matchesSpline(null)); + assertFalse(empty.takesJunction(null)); + assertEquals(0, ThrottleTape.lookup(null, 1, 1)); + assertNull(ThrottleTape.fromJson(null)); + assertNull(ThrottleTape.fromJson(new JSONObject())); + JSONObject json = new JSONObject(); + json.put("splineId", " "); + assertNull(ThrottleTape.fromJson(json)); + json.put("splineId", origin.toString()); + JSONArray samples = new JSONArray(); + samples.add("ignored legacy marker"); + JSONObject sample = new JSONObject(); + sample.put("s", "2.5"); + sample.put("sign", "-1"); + sample.put("throttle", "bad value"); + sample.put("hold", "3"); + sample.put("junction", "null"); + samples.add(sample); + json.put("samples", samples); + ThrottleTape loaded = ThrottleTape.fromJson(json); + assertTrue(loaded.matchesSpline(origin)); + assertEquals(1, loaded.getSamples().size()); + assertEquals(0, loaded.lookup(2.5, -1, (UUID) null)); + assertEquals(3, loaded.holdAt(2.5, -1, (UUID) null)); + assertEquals(1, loaded.holdAt(2.5, 1, origin)); + assertEquals(0, loaded.targetWithDwell(2.5, -1, new ThrottleTape.DwellState(), (UUID) null)); + assertEquals(0, loaded.lookup(2.5, -1, " ")); + sample.remove("s"); + assertEquals(0, ThrottleTape.fromJson(json).getSamples().getFirst().s); + assertEquals(0, loaded.lookup(Double.NaN, -1)); + ThrottleTape defaults = + new ThrottleTape(origin.toString(), List.of(new ThrottleTape.Sample(3, 1, 25, 4))); + assertEquals(4, defaults.holdAt(3, 1)); + assertEquals(ThrottleTape.AppendResult.ADDED, defaults.tryAppend(5, 1, 25, " ", null)); + ThrottleTape blank = new ThrottleTape(""); + assertEquals(ThrottleTape.AppendResult.ADDED, blank.tryAppend(0, 1, 0, (String) null, null)); + assertEquals(ThrottleTape.AppendResult.HELD, blank.tryAppend(.1, 1, 0, "null", null)); + ThrottleTape capacity = new ThrottleTape(origin.toString()); + for (int i = 0; i < ThrottleTape.MAX_SAMPLES - 1; i++) + assertEquals(ThrottleTape.AppendResult.ADDED, capacity.tryAppend(i, 1, 50)); + assertEquals( + ThrottleTape.AppendResult.CAPPED, capacity.tryAppend(ThrottleTape.MAX_SAMPLES - 1, 1, 50)); + assertEquals( + ThrottleTape.AppendResult.CAPPED, capacity.tryAppend(ThrottleTape.MAX_SAMPLES, 1, 60)); + assertEquals(ThrottleTape.MAX_SAMPLES, capacity.getSamples().size()); + assertEquals( + ThrottleTape.AppendResult.HELD, capacity.tryAppend(ThrottleTape.MAX_SAMPLES - 1, 1, 50)); + assertEquals(2, capacity.getSamples().getLast().holdTicks); + } + + @Test + void trainRouteStopsAtBreaksAndHandlesMissingAndZeroLengthTracks( + @TempDir java.nio.file.Path folder) throws Exception { + TrackRegistry registry = new TrackRegistry(folder.toFile()); + try { + TrackSpline line = + TrackSpline.fromPoints(null, "world", false, points(0, 0, 10, 0, 20, 0)) + .withSegment(1, new TrackSegment(1, true, 0)); + registry.replace(line); + TrainRoute route = new TrainRoute(registry, Map.of()); + TrainRoute.Walk stopped = + route.walk(new TrainRoute.Position(line.getId(), 5, 1), 10, false, true); + assertTrue(stopped.broken()); + assertEquals(10, stopped.position().s()); + assertEquals(5, stopped.missing()); + UUID absent = UUID.randomUUID(); + TrainRoute.Walk missing = route.walk(new TrainRoute.Position(absent, 2, 1), 3); + assertEquals(3, missing.missing()); + assertNull(route.rail(new TrainRoute.Position(absent, 0, 1), 2)); + TrackSpline zero = TrackSpline.fromPoints(null, "world", false, points(40, 0, 40, 0)); + registry.replace(zero); + assertEquals(2, route.walk(new TrainRoute.Position(zero.getId(), 0, 1), 2).missing()); + TrackSpline longStem = TrackSpline.fromPoints(null, "world", false, points(0, 100, 100, 100)); + registry.replace(longStem); + TrackJunction incomplete = + registry.putJunction( + new TrackJunction( + UUID.randomUUID(), longStem.getId(), 50, 1, TrackJunction.Side.LEFT, null)); + assertEquals( + 60, route.walk(new TrainRoute.Position(longStem.getId(), 40, 1), 20).position().s()); + registry.putJunction(incomplete.withBranch(UUID.randomUUID())); + assertEquals( + 60, route.walk(new TrainRoute.Position(longStem.getId(), 40, 1), 20).position().s()); + TrackPose beyond = route.rail(new TrainRoute.Position(longStem.getId(), 95, 1), 10); + assertEquals(105, beyond.x, 1e-9); + assertEquals(100, beyond.z, 1e-9); + TrackPose reverse = route.rail(new TrainRoute.Position(longStem.getId(), 5, -1), 10); + assertEquals(-5, reverse.x, 1e-9); + assertEquals(100, reverse.z, 1e-9); + assertEquals(90, reverse.yaw, 1e-6); + } finally { + registry.close(); + } + } + + @Test + void constructorNormalizesStoredDistancesJustLikeJsonReload() { + for (double[] stored : + List.of( + new double[] {0, 10, 5, 30}, + new double[] {5, 15, 25, 35}, + new double[] {0, Double.NaN, 20, 30}, + new double[] {0, 10, Double.POSITIVE_INFINITY, 30}, + new double[] {0, 10, 20, Double.POSITIVE_INFINITY})) { + List samples = new ArrayList<>(); + for (int i = 0; i < stored.length; i++) + samples.add(new TrackSample(0, 64, i * 10, 0, 0, stored[i])); + TrackSpline direct = new TrackSpline(UUID.randomUUID(), "world", false, samples, null), + loaded = TrackSpline.fromJson(direct.toJson()); + assertEquals(30, direct.length(), 1e-9); + for (double at : List.of(0d, 7d, 12d, 17d, 25d, 30d)) { + assertEquals( + loaded.sampleAt(at).z, + direct.sampleAt(at).z, + 1e-9, + "Constructor and JSON loader must agree at s=" + at); + assertEquals(at, direct.sampleAt(at).z, 1e-9); + } + } + TrackSpline duplicate = + new TrackSpline( + null, + "world", + false, + List.of( + new TrackSample(0, 64, 0, 0, 0, 0), + new TrackSample(0, 64, 0, 0, 0, 0), + new TrackSample(0, 64, 10, 0, 0, 10)), + null); + assertEquals(5, duplicate.sampleAt(5).z, 1e-9); + } + + @Test + void nonfiniteRouteCoordinatesDoNotConnectToRealTracks() { + TrackSpline line = TrackSpline.fromPoints(null, "world", false, points(0, 0, 10, 0)); + for (double coordinate : + List.of(Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY)) { + assertTrue( + TrackRouteQuery.shortestRouteLength( + List.of(square()), List.of(), "world", coordinate, 0, 1, 10, 0, 1) + .isEmpty()); + assertTrue( + TrackRouteQuery.shortestRouteLength( + List.of(line), List.of(), "world", coordinate, 0, 1, 10, 0, 1) + .isEmpty()); + assertTrue( + TrackRouteQuery.shortestRouteLength( + List.of(line), List.of(), "world", 0, 0, 1, coordinate, 0, 1) + .isEmpty()); + } + } + + @Test + void invalidSampleDistancesReturnFinitePointsOnTheTrack() { + for (TrackSpline track : + List.of(square(), TrackSpline.fromPoints(null, "world", false, points(0, 0, 10, 0)))) + for (double at : List.of(Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY)) { + TrackPose p = track.sampleAt(at); + assertTrue(Double.isFinite(p.x) && Double.isFinite(p.y) && Double.isFinite(p.z)); + assertTrue(p.x >= 0 && p.x <= 10 && p.z >= 0 && p.z <= 10); + assertEquals(64, p.y); + } + } + + @Test + void finiteLargeCoordinateCancellationFallsBackWithoutDividingByZero() throws Exception { + // Beyond normal world bounds, finite doubles can still be supplied through this public geometry + // API. + // At this magnitude the biarc tangent points round to the same coordinate; its fallback must + // stay finite. + double origin = 1e12, end = origin + Math.ulp(origin); + List curve = + TrackCurve.join(origin, 64, origin, 135, end, 64, origin, -135, 0, 179, 32, 10, 15, 1); + assertEquals(2, curve.size()); + assertArrayEquals(new double[] {origin, 64, origin}, curve.getFirst(), 0); + assertArrayEquals(new double[] {end, 64, origin}, curve.getLast(), 0); + for (double[] p : curve) for (double coordinate : p) assertTrue(Double.isFinite(coordinate)); + } + + private static List points(double... xz) { + List p = new ArrayList<>(); + for (int i = 0; i < xz.length; i += 2) p.add(new double[] {xz[i], 64, xz[i + 1]}); + return p; + } + + private static TrackSpline square() { + return TrackSpline.fromPoints( + UUID.randomUUID(), "world", true, points(0, 0, 10, 0, 10, 10, 0, 10)); + } + + private record Pos(int x, int y, int z) {} + + private static final class Terrain { + final World world = mock(World.class); + final Set solid = new HashSet<>(); + final Map blocks = new HashMap<>(); + + Terrain() { + when(world.getName()).thenReturn("world"); + when(world.getUID()).thenReturn(UUID.randomUUID()); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())) + .thenAnswer(c -> block(new Pos(c.getArgument(0), c.getArgument(1), c.getArgument(2)))); + } + + Block block(Pos pos) { + return blocks.computeIfAbsent( + pos, + key -> { + Block b = mock(Block.class); + when(b.getX()).thenReturn(pos.x); + when(b.getY()).thenReturn(pos.y); + when(b.getZ()).thenReturn(pos.z); + when(b.getWorld()).thenReturn(world); + when(b.getType()).thenAnswer(c -> solid.contains(pos) ? Material.STONE : Material.AIR); + when(b.isPassable()).thenAnswer(c -> !solid.contains(pos)); + BlockData data = mock(BlockData.class); + when(b.getBlockData()).thenReturn(data); + VoxelShape shape = mock(VoxelShape.class); + when(shape.getBoundingBoxes()) + .thenAnswer( + c -> + solid.contains(pos) + ? List.of(new BoundingBox(0, 0, 0, 1, 1, 1)) + : List.of()); + when(b.getCollisionShape()).thenReturn(shape); + return b; + }); + } + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackLoggingCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackLoggingCoverageTest.java new file mode 100644 index 0000000..69e011b --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackLoggingCoverageTest.java @@ -0,0 +1,297 @@ +package net.tfminecraft.vehicleframework.tracks; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import java.util.logging.*; +import net.tfminecraft.vehicleframework.bones.ConvertedAngle; +import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import org.bukkit.Location; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +class TrackLoggingCoverageTest { + @TempDir Path folder; + + @Test + void trackLogStartsWithoutAFolderThenWritesEveryOperationAndWipesOnRequest() throws Exception { + try (FreshLogger log = new FreshLogger(TrackLog.class)) { + log.call("configure", true, true, null); + log.call("append", "no destination"); + log.call("configure", false, false, folder.toFile()); + log.call("append", "disabled"); + assertFalse(Files.exists(folder.resolve("logs/track.log"))); + log.call("configure", true, false, folder.toFile()); + log.call("append", (Object) null); + TrackSpline spline = line(), tail = line(); + UUID id = spline.getId(); + log.call("start", "alex", 1d, 64d, 2d); + log.call("layAttempt", "world", 0d, 64d, 0d, 0d, 64d, 10d, true, false); + log.call("layOk", null, "none"); + log.call("layOk", spline, "straight"); + log.call("layFail", "too short", null); + log.call("layFail", "shape", new TrackLayException("shape")); + log.call("layFail", "blocked", new TrackLayException("blocked", 1, 64, 2)); + log.call("dig", "alex", null); + log.call("dig", "alex", DigResult.none()); + log.call("dig", "alex", DigResult.deleted(id)); + log.call("dig", "alex", DigResult.updated(spline)); + log.call("dig", "alex", DigResult.split(spline, tail)); + log.call("delete", "alex", id, true); + log.call("spline", id, "world", false, 10d, 2, 0d, 64d, 0d, 0d, 64d, 10d); + log.call("pt", id, 5d, 0d, 64d, 5d); + log.call( + "junction", + id, + id, + 2d, + 0d, + 64d, + 2d, + true, + "LEFT", + 1, + tail.getId(), + 10d, + 1d, + 10d, + true, + false); + log.call( + "junction", id, id, 2d, 0d, 64d, 2d, false, "RIGHT", -1, null, 0d, 0d, 0d, false, true); + log.call("junctionDrop", id, id, "missing"); + log.call("junctionStart", "alex", id, 2d); + log.call("junctionBranch", null, id, id); + log.call("junctionBranch", tail, id, id); + String text = Files.readString(folder.resolve("logs/track.log")); + for (String marker : + List.of( + "START player=alex 1.000,64.000,2.000", + "LAY world=world", + "LAY_OK how=straight", + "LAY_FAIL blocked block=1,64,2", + "kind=NONE", + "kind=DELETED", + "kind=UPDATED", + "kind=SPLIT", + "DELETE player=alex", + "SPLINE id=", + "PT spline=", + "frog=-", + "branchLen=-", + "JUNCTION_DROP", + "JUNCTION_START", + "JUNCTION_BRANCH")) assertTrue(text.contains(marker), marker); + assertFalse(text.contains("disabled")); + log.call("configure", true, true, folder.toFile()); + assertFalse(Files.exists(folder.resolve("logs/track.log"))); + log.call("append", "after wipe"); + assertTrue(Files.readString(folder.resolve("logs/track.log")).contains("after wipe")); + } + } + + @Test + void trackLogReportsWriteAndWipeFailuresWithoutThrowing() throws Exception { + Path destination = folder.resolve("logs/track.log"); + Files.createDirectories(destination); + Files.writeString(destination.resolve("occupied"), "retain"); + List warnings = new ArrayList<>(); + Logger logger = Logger.getLogger(TrackLog.class.getName()); + Handler handler = capture(warnings); + logger.addHandler(handler); + try (FreshLogger log = new FreshLogger(TrackLog.class)) { + log.call("configure", true, true, folder.toFile()); + log.call("append", "cannot write over a directory"); + assertTrue(Files.exists(destination.resolve("occupied"))); + assertTrue(warnings.stream().anyMatch(s -> s.startsWith("Failed to wipe track.log:"))); + assertTrue(warnings.stream().anyMatch(s -> s.startsWith("Failed to write track.log:"))); + } finally { + logger.removeHandler(handler); + } + } + + @Test + void recorderDescribesAvailableTrainStateAndThrottlesRepeatedEvents() throws Exception { + try (FreshLogger log = new FreshLogger(RecorderLog.class); + Rig r = new Rig()) { + log.call("configure", true, null); + log.call("append", "unconfigured"); + log.call("configure", false, folder.toFile()); + assertEquals(false, log.call("isEnabled")); + log.call("append", "disabled"); + log.call("configure", true, folder.toFile()); + assertEquals(true, log.call("isEnabled")); + log.call("append", (Object) null); + assertEquals("train=null", log.call("train", (Object) null)); + ActiveVehicle vehicle = r.vehicle(); + assertEquals("train=null", log.call("train", vehicle)); + when(vehicle.isTrain()).thenReturn(true); + when(vehicle.getId()).thenReturn("wagon"); + when(vehicle.getTrainHandler().getS()).thenReturn(12.5); + when(vehicle.getTrainHandler().getTravelSign()).thenReturn(-1); + when(vehicle.getTrainHandler().getInstalledTape()).thenReturn(null); + when(vehicle.getThrottle()).thenReturn(null); + when(vehicle.getBehaviourHandler()).thenReturn(null); + when(vehicle.getSeatHandler()).thenReturn(null); + String sparse = (String) log.call("train", vehicle); + assertTrue(sparse.contains("thr=0")); + assertTrue(sparse.contains("dyaw=-")); + assertTrue(sparse.contains("tape=0")); + assertTrue(sparse.contains("captain=false")); + ActiveVehicle full = r.vehicle(); + when(full.isTrain()).thenReturn(true); + when(full.getId()).thenReturn("engine"); + when(full.getThrottle().getCurrent()).thenReturn(35); + when(full.getTrainHandler().getInstalledTape()) + .thenReturn(new ThrottleTape("line", List.of(new ThrottleTape.Sample(2, 1, 35)))); + when(full.getTrainHandler().recordingSampleCount()).thenReturn(3); + when(full.getTrainHandler().isRecording()).thenReturn(true); + when(full.getTrainHandler().isBound()).thenReturn(true); + when(full.getBehaviourHandler().getRotator().getDriveYaw()).thenReturn(45f); + when(full.getSeatHandler().hasCaptain()).thenReturn(true); + String rich = (String) log.call("train", full); + assertTrue(rich.contains("thr=35")); + assertTrue(rich.contains("dyaw=45.000")); + assertTrue(rich.contains("tape=1")); + assertTrue(rich.contains("recSamples=3")); + assertTrue(rich.contains("captain=true")); + when(vehicle.getEntity()).thenReturn(null); + assertTrue(((String) log.call("train", vehicle)).contains("eyaw=-")); + Location from = new Location(r.world, 0, 64, 0), to = new Location(r.world, 0, 64, 3); + log.call("pose", null, from, to, null, null); + log.call( + "pose", full, from, to, new TrackPose(0, 64, 3, 90, 5), new ConvertedAngle(90, 5, 0)); + log.call("pose", full, from, to, null, null); + log.call("pose", vehicle, null, null, null, null); + log.call("sample", full, ThrottleTape.AppendResult.HELD, 35, 2); + log.call("sample", full, ThrottleTape.AppendResult.HELD, 35, 20); + log.call("sample", full, ThrottleTape.AppendResult.ADDED, 35, 1); + log.call("playback", full, "moving", 35, null); + log.call("playback", full, "moving", 35, null); + ThrottleTape.DwellState dwell = new ThrottleTape.DwellState(); + dwell.left = 3; + dwell.atS = 12.5; + log.call("playback", null, "waiting", 0, dwell); + dwell.atS = null; + log.call("playback", null, "no-position", 0, dwell); + log.call("interact", "start", null); + log.call("interact", "stop", " "); + log.call("interact", "save", "samples=1"); + log.call("arm", full, "none", null, null, 0d); + TrackJunction frog = + new TrackJunction( + UUID.randomUUID(), UUID.randomUUID(), 5, 1, TrackJunction.Side.LEFT, null); + log.call("arm", full, "armed", TrackJunction.Side.LEFT, frog, 5d); + log.call("junction", full, false, frog.id); + log.call("junction", full, true, frog.id, "held left"); + log.call("junction", full, false, frog.id, (String) null); + assertEquals(true, log.call("throttle", "probe", 100000L)); + assertEquals(false, log.call("throttle", "probe", 100000L)); + assertEquals(true, log.call("throttle", "probe", 0L)); + String text = Files.readString(folder.resolve("logs/recorder.log")); + for (String marker : + List.of( + "SESSION plugin-folder=", + "POSE gap=3.000 myaw=90.000", + "POSE gap=0.000 myaw=-", + "SAMPLE result=HELD thr=35 hold=20", + "SAMPLE result=ADDED", + "PLAY reason=moving", + "dwellLeft=3 dwellS=12.500", + "INTERACT save samples=1", + "ARM status=armed hold=LEFT frogSide=LEFT", + "JUNCTION take=through", + "JUNCTION take=diverge", + "reason=held left")) assertTrue(text.contains(marker), marker); + assertFalse(text.contains("hold=2 ")); + assertFalse(text.contains("disabled")); + } + } + + @Test + void recorderReportsFilesystemFailures() throws Exception { + Files.writeString(folder.resolve("logs"), "not a directory"); + List warnings = new ArrayList<>(); + Logger logger = Logger.getLogger(RecorderLog.class.getName()); + Handler handler = capture(warnings); + logger.addHandler(handler); + try (FreshLogger log = new FreshLogger(RecorderLog.class)) { + log.call("configure", true, folder.toFile()); + assertTrue(warnings.stream().anyMatch(s -> s.startsWith("Failed to write recorder.log:"))); + assertEquals("not a directory", Files.readString(folder.resolve("logs"))); + } finally { + logger.removeHandler(handler); + } + } + + private static TrackSpline line() { + return TrackSpline.fromPoints( + UUID.randomUUID(), + "world", + false, + List.of(new double[] {0, 64, 0}, new double[] {0, 64, 10})); + } + + private static Handler capture(List messages) { + return new Handler() { + public void publish(LogRecord record) { + messages.add(record.getMessage()); + } + + public void flush() {} + + public void close() {} + }; + } + + /** + * Each plugin classloader starts with unconfigured loggers; preserve that real startup state + * without altering private fields. + */ + private static final class FreshLogger implements AutoCloseable { + private final java.net.URLClassLoader loader; + private final Class type; + + FreshLogger(Class original) throws Exception { + loader = + new java.net.URLClassLoader( + new java.net.URL[] {original.getProtectionDomain().getCodeSource().getLocation()}, + original.getClassLoader()) { + @Override + protected Class loadClass(String name, boolean resolve) + throws ClassNotFoundException { + if (name.equals(original.getName())) { + synchronized (getClassLoadingLock(name)) { + Class found = findLoadedClass(name); + if (found == null) found = findClass(name); + if (resolve) resolveClass(found); + return found; + } + } + return super.loadClass(name, resolve); + } + }; + type = loader.loadClass(original.getName()); + } + + Object call(String name, Object... args) throws Exception { + var method = + Arrays.stream(type.getMethods()) + .filter(m -> m.getName().equals(name) && m.getParameterCount() == args.length) + .findFirst() + .orElseThrow(); + try { + return method.invoke(null, args); + } catch (java.lang.reflect.InvocationTargetException e) { + throw new AssertionError("Logger API failed: " + name, e.getCause()); + } + } + + public void close() throws Exception { + loader.close(); + } + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackPersistenceCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackPersistenceCoverageTest.java new file mode 100644 index 0000000..5b22dea --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackPersistenceCoverageTest.java @@ -0,0 +1,281 @@ +package net.tfminecraft.vehicleframework.tracks; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.nio.file.attribute.PosixFilePermission; +import java.util.*; +import java.util.concurrent.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.cache.Cache; + +class TrackPersistenceCoverageTest { + @TempDir Path directory; + + @Test void storeSkipsBadFilesAndReportsFailedWritesAndDeletes() throws Exception { + try (var logger = mockStatic(VFLogger.class)) { + TrackStore store = new TrackStore(directory.toFile()); + assertTrue(store.loadAll().isEmpty()); assertTrue(store.loadAllJunctions().isEmpty()); + Path root = directory.resolve("data/tracks"); Files.createDirectories(root); + Files.writeString(root.resolve("not-a-world"), "text"); + Path world = root.resolve("world"); Files.createDirectories(world); + Files.writeString(world.resolve("invalid.json"), "{"); Files.writeString(world.resolve("array.json"), "[]"); + Path junctions = world.resolve("junctions"); Files.createDirectories(junctions); + Files.writeString(junctions.resolve("invalid.json"), "{"); Files.writeString(junctions.resolve("array.json"), "[]"); + assertTrue(store.loadAll().isEmpty()); assertTrue(store.loadAllJunctions().isEmpty()); + TrackSpline track = line(0, 20, 0); + Path blocked = store.fileFor(track).toPath(); Files.createDirectories(blocked); Files.writeString(blocked.resolve("child"), "keep"); + store.delete(track); assertTrue(Files.isDirectory(blocked)); + store.save(track); assertTrue(Files.isDirectory(blocked)); + TrackJunction junction = node(track, 8, null); + Path blockedJunction = store.junctionFileFor("world", junction.id).toPath(); + Files.createDirectories(blockedJunction); Files.writeString(blockedJunction.resolve("child"), "keep"); + store.deleteJunction("world", junction.id); store.saveJunction("world", junction); + assertTrue(Files.isDirectory(blockedJunction)); + logger.verify(() -> VFLogger.log(contains("Failed to save track"))); + logger.verify(() -> VFLogger.log(contains("Failed to save junction"))); + logger.verify(() -> VFLogger.log(contains("Failed to delete track"))); + logger.verify(() -> VFLogger.log(contains("Failed to delete junction"))); + } + } + + @Test void unreadableDirectoriesDoNotEraseOtherWorlds() throws Exception { + TrackStore store = new TrackStore(directory.toFile()); + TrackSpline track = line(0, 20, 0); store.save(track); + Path root = directory.resolve("data/tracks"), world = root.resolve("world"), junctions = world.resolve("junctions"); + Files.createDirectories(junctions); + for (Path unreadable : List.of(root, world, junctions)) { + Set old = Files.getPosixFilePermissions(unreadable); + try { + Files.setPosixFilePermissions(unreadable, Set.of()); + assertTrue(store.loadAllJunctions().isEmpty()); + if (!unreadable.equals(junctions)) assertTrue(store.loadAll().isEmpty()); + } finally { Files.setPosixFilePermissions(unreadable, old); } + } + assertEquals(track.getId(), store.loadAll().getFirst().getId()); + TrackStore notDirectory = new TrackStore(directory.resolve("file-root").toFile()); + Files.createDirectories(directory.resolve("file-root/data")); + Files.writeString(directory.resolve("file-root/data/tracks"), "file"); + assertTrue(notDirectory.loadAll().isEmpty()); assertTrue(notDirectory.loadAllJunctions().isEmpty()); + } + + @Test void closeWaitsThroughInterruptAndRestoresInterruptFlag() throws Exception { + ExecutorService executor = mock(ExecutorService.class); + when(executor.awaitTermination(10, TimeUnit.SECONDS)).thenThrow(new InterruptedException()).thenReturn(false, true); + try (var executors = mockStatic(Executors.class); var logger = mockStatic(VFLogger.class)) { + executors.when(() -> Executors.newSingleThreadExecutor(any(ThreadFactory.class))).thenReturn(executor); + TrackStore store = new TrackStore(directory.toFile()); store.writeInBackground(); store.writeInBackground(); + store.close(); + assertTrue(Thread.currentThread().isInterrupted()); verify(executor).shutdown(); + verify(executor, times(3)).awaitTermination(10, TimeUnit.SECONDS); + logger.verify(() -> VFLogger.log("Still waiting for track saves to finish")); + store.close(); + } finally { Thread.interrupted(); } + } + + @Test void registryPersistsPaidPrefixesRemovesEmptyTracksAndNotifiesDisplays() throws Exception { + TrackRegistry registry = new TrackRegistry(directory.toFile()); + TrackDisplayManager displays = mock(TrackDisplayManager.class); + try (var framework = mockStatic(VehicleFramework.class)) { + framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(displays); + TrackSpline original = registry.createBetween("world", 0, 64, 0, 0, 64, 40); + assertThrows(IllegalArgumentException.class, () -> registry.createBetween("other", 0, 64, 0, 0, 64, 0)); + registry.persistPoints(UUID.randomUUID(), null); + registry.persistPoints(original.getId(), List.of(new double[]{0,64,0}, new double[]{0,64,12})); + assertEquals(12, registry.get(original.getId()).orElseThrow().length()); + assertEquals(12, new TrackStore(directory.toFile()).loadAll().getFirst().length()); + registry.invalidateAllVisuals(); + assertEquals(12, registry.shortestRouteLength("world", 0, 0, 0, 0, 12, 0).orElseThrow(), 1e-8); + registry.persistPoints(original.getId(), null); + assertTrue(registry.get(original.getId()).isEmpty()); verify(displays).despawnSpline(original.getId()); + assertFalse(registry.delete(original.getId())); + } + } + + @Test void junctionValidationAndIncompleteCleanupKeepFilesAndDisplaysConsistent() throws Exception { + TrackRegistry registry = new TrackRegistry(directory.toFile()); + TrackDisplayManager displays = mock(TrackDisplayManager.class); + try (var framework = mockStatic(VehicleFramework.class)) { + framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(displays); + TrackSpline stem = registry.replace(line(0, 80, 0)); + TrackSpline other = registry.replace(line(0, 80, 100)); + assertThrows(TrackLayException.class, () -> registry.putJunction(null)); + assertThrows(TrackLayException.class, () -> registry.putJunction(node(line(0, 80, 200), 8, null))); + assertThrows(TrackLayException.class, () -> registry.ensureFrogClear(null, 0, null)); + assertThrows(TrackLayException.class, () -> registry.attachBranch(null, UUID.randomUUID())); + assertThrows(TrackLayException.class, () -> registry.attachBranch(UUID.randomUUID(), UUID.randomUUID())); + assertTrue(registry.junctionByBranch(null).isEmpty()); assertFalse(registry.setThrown(UUID.randomUUID(), true)); + TrackJunction empty = registry.putJunction(node(stem, 8, null)); + TrackJunction complete = registry.putJunction(node(stem, 30, UUID.randomUUID())); + registry.putJunction(node(other, 30, UUID.randomUUID())); + registry.ensureFrogClear(stem, 8, empty.id); + assertThrows(TrackLayException.class, () -> registry.ensureFrogClear(stem, 30, null)); + assertThrows(TrackLayException.class, () -> registry.ensureFrogClear(stem, 35, null)); + assertThrows(TrackLayException.class, () -> registry.putJunction(complete.withBranch(UUID.randomUUID()))); + assertThrows(TrackLayException.class, () -> registry.putJunction(node(stem, 31, UUID.randomUUID()))); + registry.dropIncompleteJunctions(); + assertTrue(registry.getJunction(empty.id).isEmpty()); assertTrue(registry.getJunction(complete.id).isPresent()); + assertFalse(new TrackStore(directory.toFile()).junctionFileFor("world", empty.id).exists()); + verify(displays).despawnSwitch(empty.id); + assertFalse(registry.deleteJunction(UUID.randomUUID())); + } + } + + @Test void diggingSparseTracksReturnsActualSurvivingPieces() { + TrackRegistry registry = new TrackRegistry(directory.toFile()); + TrackSpline pair = registry.replace(line(0, 10, 0)); + assertEquals(DigResult.Kind.NONE, registry.digAt(pair, -1).kind); + assertEquals(DigResult.Kind.DELETED, registry.digAt(pair, 0).kind); + TrackSpline three = registry.replace(TrackSpline.fromPoints(UUID.randomUUID(), "world", false, + List.of(new double[]{0,64,0}, new double[]{0,64,5}, new double[]{0,64,10}))); + assertEquals(DigResult.Kind.DELETED, registry.digAt(three, 1).kind); + TrackSpline four = registry.replace(TrackSpline.fromPoints(UUID.randomUUID(), "world", false, + List.of(new double[]{0,64,0}, new double[]{0,64,5}, new double[]{0,64,10}, new double[]{0,64,15}))); + DigResult tail = registry.digAt(four, 1); + assertEquals(DigResult.Kind.UPDATED, tail.kind); assertEquals(5, tail.kept.length()); + assertNotEquals(four.getId(), tail.kept.getId()); + assertTrue(registry.digTarget("absent", 0, 64, 0).isEmpty()); + } + + @Test void extendingAtSecondEndpointAndReverseJunctionsPersistTheResult() throws Exception { + TrackRegistry registry = new TrackRegistry(directory.toFile()); + TrackSpline stem = registry.replace(line(0, 40, 0)); + TrackLayResult extended = registry.lay("world", 0, 64, 60, 0, 64, 40); + assertEquals(TrackLayResult.Kind.APPEND, extended.kind); assertEquals(60, extended.spline().length(), 1e-6); + TrackLayResult prepended = registry.lay("world", 0,64,-20,0,64,0); + assertEquals(TrackLayResult.Kind.PREPEND, prepended.kind); assertEquals(-20,prepended.spline().first().z,1e-6); + assertThrows(TrackLayException.class, () -> registry.layBranch(UUID.randomUUID(), 10, 1, "world", null, 3, 64, 25)); + assertThrows(TrackLayException.class, () -> registry.layBranch(UUID.randomUUID(), "world", null, 3, 64, 25)); + TrackSpline branch = registry.layBranch(stem.getId(), 60, -1, "world", null, 3, 64, 20); + TrackJunction junction = registry.junctionByBranch(branch.getId()).orElseThrow(); + assertEquals(-1, junction.facingSign); + assertThrows(TrackLayException.class, () -> registry.layBranch(junction.id, "world", null, 3, 64, 20)); + assertTrue(registry.digTarget("world", branch.first().x, 64, branch.first().z).isPresent()); + } + + @Test void legacyClosedPolylinesArePromotedAndForwardLoopClosurePreservesDirection() throws Exception { + TrackStore store = new TrackStore(directory.toFile()); + TrackSpline closed = TrackSpline.fromPoints(UUID.randomUUID(), "legacy", false, + List.of(new double[]{0,64,0},new double[]{20,64,0},new double[]{20,64,20},new double[]{0,64,0})); + store.save(closed); + TrackRegistry registry = new TrackRegistry(directory.toFile()); registry.loadFromDisk(); + assertTrue(registry.get(closed.getId()).orElseThrow().isLoop()); + assertTrue(store.loadAll().getFirst().isLoop()); + TrackSpline arc = registry.replace(openArc()); + TrackDisplayManager displays = mock(TrackDisplayManager.class); + try (var framework = mockStatic(VehicleFramework.class)) { + framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(displays); + TrackSpline loop = registry.lay("world", arc.last().x,64,arc.last().z,arc.first().x,64,arc.first().z).spline(); + assertTrue(loop.isLoop()); assertEquals(arc.first().z, loop.first().z); + verify(displays).despawnSpline(arc.getId()); + assertTrue(registry.digTarget("world", loop.first().x,64,loop.first().z).orElseThrow().spans().getFirst().halfSpan() > 0); + } + } + + @Test void diggingEndpointsAndLargeGapsAccountsForJunctionProtection() throws Exception { + TrackRegistry registry = new TrackRegistry(directory.toFile()); + TrackSpline branch = registry.replace(line(0, 10, 100)); + TrackSpline pair = registry.replace(line(0, 40, 0)); + registry.putJunction(node(pair, 8, branch.getId())); + assertEquals(1, registry.digTarget("world",0,64,0).orElseThrow().spans().size()); + registry.delete(pair.getId()); + TrackSpline spaced = registry.replace(TrackSpline.fromPoints(UUID.randomUUID(), "world", false, + List.of(new double[]{0,64,0},new double[]{0,64,10},new double[]{0,64,40},new double[]{0,64,50}))); + TrackJunction middle = registry.putJunction(node(spaced, 20, branch.getId())); + assertEquals(1, registry.digTarget("world",0,64,0).orElseThrow().spans().size()); + assertEquals(1, registry.digTarget("world",0,64,50).orElseThrow().spans().size()); + registry.digAt(spaced, 1); + assertTrue(registry.getJunction(middle.id).isEmpty()); + } + + @Test void joiningAnExistingBranchClearsTheDroppedBranchReference() throws Exception { + TrackRegistry registry = new TrackRegistry(directory.toFile()); + TrackSpline stem = registry.replace(line(0,80,100)); + TrackSpline keep = registry.replace(line(0,20,0)); + TrackSpline drop = registry.replace(line(30,50,0)); + TrackJunction junction = registry.putJunction(node(stem,20,drop.getId())); + registry.lay("world",0,64,20,0,64,30); + assertTrue(registry.get(drop.getId()).isEmpty()); + assertTrue(registry.get(keep.getId()).isPresent()); + assertTrue(registry.getJunction(junction.id).isEmpty()); + assertFalse(new TrackStore(directory.toFile()).junctionFileFor("world",junction.id).exists()); + } + + @Test void aRejectedJunctionRegistrationRollsBackItsNewTrackFile() throws Exception { + TrackRegistry registry = spy(new TrackRegistry(directory.toFile())); + TrackSpline stem = registry.replace(TrackSpline.fromPoints(UUID.randomUUID(),"world",false, + List.of(new double[]{0,64,0},new double[]{0,64,20},new double[]{0,64,40},new double[]{0,64,80}))); + TrackJunction pending = registry.putJunction(new TrackJunction(UUID.randomUUID(),stem.getId(),40,-1,TrackJunction.Side.RIGHT,null)); + doThrow(new TrackLayException("registration refused")).when(registry).putJunction(any()); + assertThrows(TrackLayException.class, () -> registry.layBranch(pending.id,"world",null,3,64,20)); + assertEquals(1, registry.all().size()); assertEquals(1,new TrackStore(directory.toFile()).loadAll().size()); + assertThrows(TrackLayException.class, () -> registry.layBranch(stem.getId(),40,-1,"world",null,3,64,20)); + assertEquals(1, registry.all().size()); assertEquals(1,new TrackStore(directory.toFile()).loadAll().size()); + registry.digAt(stem,0); + assertTrue(registry.getJunction(pending.id).isEmpty()); + } + + @Test void steepLegacyEndpointsAndElevatedTracksRetainTheirOwnGeometry() throws Exception { + TrackRegistry registry = new TrackRegistry(directory.toFile()); + TrackSpline vertical = TrackSpline.fromPoints(UUID.randomUUID(),"vertical",false, + List.of(new double[]{0,64,0},new double[]{0,74,0})); + registry.replace(vertical); + assertTrue(registry.lay("vertical",0,74,0,0,74,20).spline().length()>20); + TrackSpline ground = registry.replace(line(0,40,100)); + TrackSpline bridge = TrackSpline.fromPoints(UUID.randomUUID(),"world",false, + List.of(new double[]{100,70,0},new double[]{100,70,10})); + registry.replace(bridge); registry.pruneNestedShortTracks(); + assertTrue(registry.get(ground.getId()).isPresent()); assertTrue(registry.get(bridge.getId()).isPresent()); + } + + @Test void branchBudgetMeasuresTheCurveAndWorldClearanceIsCheckedBeforeRegistration() throws Exception { + TrackRegistry registry = new TrackRegistry(directory.toFile()); + TrackSpline stem = registry.replace(line(0,80,0)); + List points = TrackCurve.lay(0,64,40,180,3,64,20,Cache.trackMinLayDistance, + Cache.trackMaxTurnDegrees,Cache.trackDesiredGradeDegrees,Cache.trackMaxGradeDegrees,TrackGenerate.STEP); + double chord=Math.hypot(3,20), length=TrackSpline.fromPoints(UUID.randomUUID(),"world",false,points).length(); + assertTrue(length>chord+1e-6); + double previous=Cache.trackMaxJunctionLength; + try { + Cache.trackMaxJunctionLength=(length+chord)/2; + assertTrue(assertThrows(TrackLayException.class,()->registry.layBranch(stem.getId(),40,-1,"world",null,3,64,20)).getMessage().contains("at most")); + assertEquals(1,registry.all().size()); + } finally { Cache.trackMaxJunctionLength=previous; } + org.bukkit.World world=mock(org.bukkit.World.class); + try (var clearance=mockStatic(TrackClearance.class)) { + registry.layBranch(stem.getId(),40,-1,"world",world,3,64,20); + clearance.verify(()->TrackClearance.check(eq(world),anyList(),eq(registry),anySet())); + } + } + + @Test void diggingLeavesNoJunctionOnASurvivingPieceTooShortToHoldIt() throws Exception { + TrackRegistry registry = new TrackRegistry(directory.toFile()); + TrackSpline stem=registry.replace(TrackSpline.fromPoints(UUID.randomUUID(),"world",false, + List.of(new double[]{0,64,0},new double[]{0,64,3},new double[]{0,64,8},new double[]{0,64,20}))); + TrackJunction pending=registry.putJunction(node(stem,1,null)); + registry.digAt(stem,2); + assertTrue(registry.getJunction(pending.id).isEmpty()); + assertFalse(new TrackStore(directory.toFile()).junctionFileFor("world",pending.id).exists()); + } + + static TrackSpline openArc() { + List points = new ArrayList<>(); + for (int z=10;z<=40;z++) points.add(new double[]{0,64,z}); + for (int i=1;i<63;i++) { double a=Math.PI-Math.PI*i/63; points.add(new double[]{20+20*Math.cos(a),64,40+20*Math.sin(a)}); } + for (int z=39;z>=0;z--) points.add(new double[]{40,64,z}); + for (int i=1;i<63;i++) { double a=-Math.PI*i/63; points.add(new double[]{20+20*Math.cos(a),64,20*Math.sin(a)}); } + return TrackSpline.fromPoints(UUID.randomUUID(),"world",false,points); + } + + static TrackSpline line(double start, double end, double x) { + return TrackSpline.fromPoints(UUID.randomUUID(), "world", false, List.of(new double[]{x,64,start}, new double[]{x,64,end})); + } + static TrackJunction node(TrackSpline stem, double s, UUID branch) { + return new TrackJunction(UUID.randomUUID(), stem.getId(), s, 1, TrackJunction.Side.RIGHT, branch); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackRuntimeCoverageTest.java new file mode 100644 index 0000000..6fe5053 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackRuntimeCoverageTest.java @@ -0,0 +1,397 @@ +package net.tfminecraft.vehicleframework.tracks; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.*; +import java.util.*; +import java.util.function.Consumer; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.*; +import org.bukkit.block.data.type.*; +import org.bukkit.entity.*; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.*; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.*; +import org.bukkit.scheduler.*; +import org.bukkit.util.BoundingBox; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import io.papermc.paper.datacomponent.DataComponentTypes; +import io.papermc.paper.datacomponent.DataComponentType; +import io.papermc.paper.datacomponent.item.UseCooldown; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.vehicleframework.*; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.database.PersistenceLog; +import net.tfminecraft.vehicleframework.enums.VehicleDeath; +import net.tfminecraft.vehicleframework.permissions.Permissions; +import net.tfminecraft.vehicleframework.test.RegistryFixture; +import net.tfminecraft.vehicleframework.util.ImpactVfx; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.handlers.TrainHandler; + +class TrackRuntimeCoverageTest { + final List scopes = new ArrayList<>(); + final Map savedConfig = new HashMap<>(); + final Map tracks = new HashMap<>(); + final Map templates = new HashMap<>(); + final World world = mock(World.class); + final Player player = mock(Player.class); + final PlayerInventory inventory = mock(PlayerInventory.class); + final TrackRegistry registry = mock(TrackRegistry.class); + final TrackDisplayManager displays = mock(TrackDisplayManager.class); + final ItemAPI items = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + ItemStack[] contents = new ItemStack[36]; ItemStack offhand, hand; + MockedStatic framework; + MockedStatic bukkit; + MockedStatic effects; + VehicleFramework previousPlugin; + + @BeforeAll static void registries() { + RegistryFixture.initialize(); + Map components = new HashMap<>(); + doAnswer(call -> components.computeIfAbsent(call.getArgument(0), key -> { + Class type = Set.of("unbreakable", "glider", "intangible_projectile", "creative_slot_lock").contains(key.getKey()) ? DataComponentType.NonValued.class : DataComponentType.Valued.class; + DataComponentType component = mock(type); when(component.getKey()).thenReturn(key); return component; + })).when(Registry.DATA_COMPONENT_TYPE).get(any(NamespacedKey.class)); + assertNotNull(DataComponentTypes.USE_COOLDOWN); + } + @BeforeEach void setup() throws Exception { + // Snapshot only public configuration so every test restores the process-wide cache. + for (Field field : Cache.class.getFields()) if (Modifier.isStatic(field.getModifiers()) && !Modifier.isFinal(field.getModifiers())) savedConfig.put(field, field.get(null)); + previousPlugin = VehicleFramework.plugin; VehicleFramework.plugin = mock(VehicleFramework.class); when(VehicleFramework.plugin.getName()).thenReturn("VehicleFramework"); + when(VehicleFramework.plugin.namespace()).thenReturn("vehicleframework"); + framework = keep(mockStatic(VehicleFramework.class)); framework.when(VehicleFramework::getTrackRegistry).thenReturn(registry); framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(displays); + bukkit = keep(mockStatic(Bukkit.class)); bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(world); + keep(mockStatic(VFLogger.class)); keep(mockStatic(TrackLog.class)); keep(mockStatic(RecorderLog.class)); keep(mockStatic(PersistenceLog.class)); effects = keep(mockStatic(ImpactVfx.class)); + var libs = keep(mockStatic(TLibs.class)); libs.when(TLibs::getItemAPI).thenReturn(items); + when(items.getCreator().getItemFromPath(anyString())).thenAnswer(call -> templates.get(call.getArgument(0))); + when(items.getChecker().checkItemWithPath(any(), anyString())).thenAnswer(call -> { + ItemStack item = call.getArgument(0); if (item == null) return false; + return item.getType() == switch (call.getArgument(1)) { case "track" -> Material.IRON_INGOT; case "recorder" -> Material.IRON_NUGGET; case "layer" -> Material.STICK; case "remover" -> Material.GOLD_NUGGET; case "junction" -> Material.REDSTONE; default -> Material.AIR; }; + }); + when(world.getName()).thenReturn("world"); when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + when(player.getWorld()).thenReturn(world); when(player.getLocation()).thenAnswer(call -> new Location(world, 0, 64, 0, 15, 10)); + when(player.getName()).thenReturn("Engineer"); when(player.getUniqueId()).thenReturn(UUID.randomUUID()); when(player.isOnline()).thenReturn(true); when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(player.getInventory()).thenReturn(inventory); when(inventory.getStorageContents()).thenAnswer(call -> contents); + when(inventory.getItemInOffHand()).thenAnswer(call -> offhand); when(inventory.getItemInMainHand()).thenAnswer(call -> hand); + doAnswer(call -> { contents[call.getArgument(0)] = call.getArgument(1); return null; }).when(inventory).setItem(anyInt(), any()); + doAnswer(call -> { offhand = call.getArgument(0); return null; }).when(inventory).setItemInOffHand(any()); + doAnswer(call -> { hand = call.getArgument(0); return null; }).when(inventory).setItemInMainHand(any()); + when(inventory.addItem(any(ItemStack.class))).thenReturn(new HashMap<>()); + bukkit.when(() -> Bukkit.getPlayer(player.getUniqueId())).thenReturn(player); + when(registry.get(any(UUID.class))).thenAnswer(call -> Optional.ofNullable(tracks.get(call.getArgument(0)))); + when(registry.inWorld("world")).thenAnswer(call -> new ArrayList<>(tracks.values())); + Cache.trackItem = "track"; Cache.trackRecorderItem = "recorder"; Cache.trackLayerItem = "layer"; Cache.trackRemoverItem = "remover"; Cache.trackJunctionItem = "junction"; + Cache.trackBuildIntervalTicks = 2; Cache.trackBuildSwing = true; Cache.trackPlaceKeepoutRadius = 1; + } + @AfterEach void cleanup() throws Exception { + TrackBuildAnimator.finishAll(); TrainSpaceHighlight.clearAll(); + Collections.reverse(scopes); for (AutoCloseable scope : scopes) scope.close(); + for (var entry : savedConfig.entrySet()) entry.getKey().set(null, entry.getValue()); VehicleFramework.plugin = previousPlugin; + } + T keep(T scope) { scopes.add(scope); return scope; } + ItemStack stack(Material material, int amount) { + ItemStack stack = mock(ItemStack.class); int[] count = {amount}; ItemMeta[] meta = {mock(ItemMeta.class)}; Map tags = new HashMap<>(); + PersistentDataContainer pdc = mock(PersistentDataContainer.class); when(meta[0].getPersistentDataContainer()).thenReturn(pdc); + when(pdc.get(any(), eq(PersistentDataType.STRING))).thenAnswer(call -> tags.get(call.getArgument(0))); + doAnswer(call -> { tags.put(call.getArgument(0), call.getArgument(2)); return null; }).when(pdc).set(any(), eq(PersistentDataType.STRING), anyString()); + doAnswer(call -> { tags.remove(call.getArgument(0)); return null; }).when(pdc).remove(any()); + when(stack.getType()).thenReturn(material); when(stack.getAmount()).thenAnswer(call -> count[0]); doAnswer(call -> { count[0] = call.getArgument(0); return null; }).when(stack).setAmount(anyInt()); + when(stack.clone()).thenAnswer(call -> stack(material, count[0])); when(stack.hasItemMeta()).thenAnswer(call -> meta[0] != null); when(stack.getItemMeta()).thenAnswer(call -> meta[0]); + when(stack.setItemMeta(any())).thenAnswer(call -> { meta[0] = call.getArgument(0); return true; }); return stack; + } + List points(double... z) { List points = new ArrayList<>(); for (double value : z) points.add(new double[]{0, 64, value}); return points; } + TrackSpline rail(List points) { TrackSpline spline = TrackSpline.fromPoints(UUID.randomUUID(), "world", false, points); tracks.put(spline.getId(), spline); return spline; } + Block block(Material material, int x, int y, int z, boolean passable, BlockData data) { + Block block = mock(Block.class); when(block.getWorld()).thenReturn(world); when(block.getType()).thenReturn(material); when(block.getX()).thenReturn(x); when(block.getY()).thenReturn(y); when(block.getZ()).thenReturn(z); + when(block.getLocation()).thenAnswer(call -> new Location(world, x, y, z)); when(block.isPassable()).thenReturn(passable); when(block.getBlockData()).thenReturn(data); return block; + } + + @Test void trackPiecesCountAndConsumeStorageBeforeOffhandWithoutTouchingOtherItems() { + contents[0] = stack(Material.STONE, 5); contents[1] = stack(Material.IRON_INGOT, 2); offhand = stack(Material.IRON_INGOT, 2); + assertEquals(4, TrackPieces.count(player)); assertEquals(0, TrackPieces.count(null)); assertTrue(TrackPieces.canAffordFirst(player)); assertEquals(0, TrackPieces.consumeUpTo(player, 0)); + assertTrue(TrackPieces.consumeOne(player)); assertEquals(1, contents[1].getAmount()); assertTrue(TrackPieces.consumeOne(player)); assertNull(contents[1]); + assertEquals(2, TrackPieces.consumeUpTo(player, 4)); assertNull(offhand); assertEquals(5, contents[0].getAmount()); assertFalse(TrackPieces.consumeOne(player)); assertFalse(TrackPieces.canAffordFirst(player)); + contents = null; assertFalse(TrackPieces.consumeOne(player)); contents = new ItemStack[0]; + when(player.getGameMode()).thenReturn(GameMode.CREATIVE); assertTrue(TrackPieces.consumeOne(player)); assertEquals(10, TrackPieces.consumeUpTo(player, 10)); + when(player.getGameMode()).thenReturn(GameMode.SPECTATOR); assertFalse(TrackPieces.pays(player)); assertFalse(TrackPieces.pays(null)); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); Cache.trackItem = null; assertFalse(TrackPieces.pays(player)); Cache.trackItem = " "; assertFalse(TrackPieces.pays(player)); + } + + @Test void reclaimedTrackDropsRequireALoadedChunkAndAUsableTemplate() { + TrackPieces.dropAt(null, 0, 64, 0); when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); TrackPieces.dropAt(world, -1, 64, 0); verify(world, never()).dropItemNaturally(any(), any()); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); TrackPieces.dropAt(world, 0, 64, 0); + templates.put("track", stack(Material.AIR, 1)); TrackPieces.dropAt(world, 0, 64, 0); + Cache.trackItem = null; TrackPieces.dropAt(world, 0, 64, 0); Cache.trackItem = " "; TrackPieces.dropAt(world, 0, 64, 0); Cache.trackItem = "track"; + ItemStack track = stack(Material.IRON_INGOT, 1); templates.put("track", track); TrackPieces.dropAt(world, -1, 64, 0); + verify(world).dropItemNaturally(argThat(at -> at.getX() == -1 && at.getY() == 64 + Cache.trackDisplayYOffset), same(track)); + when(items.getCreator().getItemFromPath("track")).thenThrow(new IllegalStateException("item plugin unavailable")); assertDoesNotThrow(() -> TrackPieces.dropAt(world, 0, 64, 0)); + } + + @Test void recorderMetadataRoundTripsAndRejectsMissingInvalidAndEmptyTapes() { + assertNull(ThrottleTapeItems.read(null)); ItemStack blank = stack(Material.IRON_NUGGET, 1); when(blank.hasItemMeta()).thenReturn(false); assertNull(ThrottleTapeItems.read(blank)); + when(blank.hasItemMeta()).thenReturn(true); assertNull(ThrottleTapeItems.read(blank)); + NamespacedKey key = new NamespacedKey(VehicleFramework.plugin, "throttle_tape"); + for (String raw : List.of(" ", "bad json", "[]")) { blank.getItemMeta().getPersistentDataContainer().set(key, PersistentDataType.STRING, raw); assertNull(ThrottleTapeItems.read(blank)); } + UUID spline = UUID.randomUUID(); ThrottleTape tape = new ThrottleTape(spline.toString(), List.of(new ThrottleTape.Sample(1, 1, 40))); + ThrottleTapeItems.write(blank, tape); assertEquals(tape.toJson(), ThrottleTapeItems.read(blank).toJson()); + ThrottleTapeItems.write(blank, null); assertNull(ThrottleTapeItems.read(blank)); ThrottleTapeItems.write(blank, new ThrottleTape(spline.toString(), List.of())); assertNull(ThrottleTapeItems.read(blank)); + ThrottleTapeItems.write(null, tape); ItemStack noMeta = stack(Material.AIR, 1); when(noMeta.getItemMeta()).thenReturn(null); assertDoesNotThrow(() -> ThrottleTapeItems.write(noMeta, tape)); + } + + @Test void toolMatchingAndRemoverCooldownUseTheDedicatedGroup() { + assertTrue(TrackTools.isLayer(stack(Material.STICK, 1))); assertTrue(TrackTools.isJunction(stack(Material.REDSTONE, 1))); assertTrue(TrackTools.isRecorder(stack(Material.IRON_NUGGET, 1))); assertFalse(TrackTools.isRemover(null)); + Cache.trackLayerItem = null; assertFalse(TrackTools.isLayer(stack(Material.STICK, 1))); Cache.trackLayerItem = " "; assertFalse(TrackTools.isLayer(stack(Material.STICK, 1))); + hand = stack(Material.GOLD_NUGGET, 1); TrackTools.showRemoverCooldown(player, 0); + UseCooldown.Builder builder = mock(UseCooldown.Builder.class, RETURNS_SELF); UseCooldown value = mock(UseCooldown.class); when(value.cooldownGroup()).thenReturn(TrackTools.REMOVER_COOLDOWN); when(builder.build()).thenReturn(value); + try (var cooldown = mockStatic(UseCooldown.class)) { + cooldown.when(() -> UseCooldown.useCooldown(anyFloat())).thenReturn(builder); + TrackTools.showRemoverCooldown(player, 101); verify(player).setCooldown(TrackTools.REMOVER_COOLDOWN, 3); verify(hand).setData(DataComponentTypes.USE_COOLDOWN, value); + when(hand.getData(DataComponentTypes.USE_COOLDOWN)).thenReturn(value); TrackTools.showRemoverCooldown(player, 150); verify(hand, times(1)).setData(DataComponentTypes.USE_COOLDOWN, value); + } + hand = stack(Material.STONE, 1); TrackTools.showRemoverCooldown(player, 100); verify(hand, never()).setData(eq(DataComponentTypes.USE_COOLDOWN), any(UseCooldown.class)); + } + + @Test void animatedTrackConstructionConsumesOnePiecePerRevealedEdgeAndReusesBakedChunks() { + contents[0] = stack(Material.IRON_INGOT, 4); List stroke = points(0, 1, 2, 3); TrackSpline spline = rail(stroke); + assertFalse(TrackBuildAnimator.isBuilding(null)); assertFalse(TrackBuildAnimator.start(player, null, null, stroke)); assertFalse(TrackBuildAnimator.sequential(null)); + assertTrue(TrackBuildAnimator.sequential(player)); when(player.getGameMode()).thenReturn(GameMode.CREATIVE); assertFalse(TrackBuildAnimator.sequential(player)); when(player.getGameMode()).thenReturn(GameMode.SPECTATOR); assertFalse(TrackBuildAnimator.sequential(player)); when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + assertTrue(TrackBuildAnimator.start(player, spline, null, stroke)); assertEquals(3, contents[0].getAmount()); assertTrue(TrackBuildAnimator.isBuilding(spline.getId())); + Chunk chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(world); TrackBuildAnimator.spawnIntoChunk(spline, chunk); verify(displays).spawnVisuals(eq(spline.getId()), anyList(), eq(world), eq(chunk)); + TrackBuildAnimator.tick(); assertEquals(3, contents[0].getAmount()); TrackBuildAnimator.tick(); assertEquals(2, contents[0].getAmount()); + TrackBuildAnimator.tick(); TrackBuildAnimator.tick(); assertFalse(TrackBuildAnimator.isBuilding(spline.getId())); assertEquals(1, contents[0].getAmount()); verify(player, times(3)).swingMainHand(); + TrackBuildAnimator.tick(); TrackBuildAnimator.spawnIntoChunk(spline, chunk); TrackBuildAnimator.cancel(null); + } + + @Test void failedPaymentPersistsOnlyThePaidPrefixAndHandlesAbsentServices() { + List keep = points(-2, -1, 0), stroke = points(0, 1, 2, 3); TrackSpline spline = rail(stroke); + assertFalse(TrackBuildAnimator.start(player, spline, keep, stroke)); verify(registry).persistPoints(eq(spline.getId()), argThat(p -> p.size() == keep.size())); + contents[0] = stack(Material.IRON_INGOT, 1); assertTrue(TrackBuildAnimator.start(player, spline, keep, stroke)); TrackBuildAnimator.tick(); TrackBuildAnimator.tick(); + assertFalse(TrackBuildAnimator.isBuilding(spline.getId())); verify(player).sendMessage("§cNot enough track to finish."); verify(registry).persistPoints(eq(spline.getId()), argThat(p -> p.size() == 4 && p.getLast()[2] == 1)); + assertFalse(TrackBuildAnimator.start(null, spline, null, null)); verify(displays).rebakeSpline(spline.getId()); + framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(null); assertFalse(TrackBuildAnimator.start(null, spline, null, points(0))); assertTrue(TrackBuildAnimator.start(null, spline, points(10, 11), points(0, 1))); TrackBuildAnimator.tick(); TrackBuildAnimator.tick(); assertFalse(TrackBuildAnimator.isBuilding(spline.getId())); + assertTrue(TrackBuildAnimator.start(null, spline, points(10, 11), stroke)); framework.when(VehicleFramework::getTrackRegistry).thenReturn(null); TrackBuildAnimator.tick(); TrackBuildAnimator.tick(); TrackBuildAnimator.finishAll(); assertFalse(TrackBuildAnimator.isBuilding(spline.getId())); + } + + @Test void disconnectingDuringPaidConstructionCannotBuildTheRemainingTrackForFree() { + contents[0] = stack(Material.IRON_INGOT, 1); TrackSpline spline = rail(points(0, 1, 2, 3)); + assertTrue(TrackBuildAnimator.start(player, spline, List.of(), points(0, 1, 2, 3))); assertNull(contents[0]); + bukkit.when(() -> Bukkit.getPlayer(player.getUniqueId())).thenReturn(null); TrackBuildAnimator.tick(); TrackBuildAnimator.tick(); + assertFalse(TrackBuildAnimator.isBuilding(spline.getId()), "Offline builders must retain only their paid track"); + verify(registry).persistPoints(eq(spline.getId()), argThat(p -> p.size() == 2 && p.getLast()[2] == 1)); + } + + @Test void animationRetainsOnlyTheStrokeWhenExtendingFromTheOppositeEnd() { + TrackSpline spline = rail(points(0, 1, 2, 3)); assertFalse(TrackBuildAnimator.start(player, spline, null, points(0, 1, 2, 3))); + verify(registry).persistPoints(eq(spline.getId()), argThat(List::isEmpty)); + Cache.trackBuildSwing = false; Cache.trackBuildIntervalTicks = 1; + assertTrue(TrackBuildAnimator.start(null, spline, points(10, 11), points(0, 1, 2, 3))); + verify(displays).replaceFrom(eq(spline.getId()), anyList(), argThat(visuals -> !visuals.isEmpty() && visuals.stream().allMatch(v -> v.z < 2)), eq(world), isNull()); + verify(player, never()).swingMainHand(); tracks.remove(spline.getId()); TrackBuildAnimator.tick(); TrackBuildAnimator.tick(); assertFalse(TrackBuildAnimator.isBuilding(spline.getId())); + } + + @Test void trackSupportRecognizesSolidSurfacesSlabsStairsTrapdoorsAndIgnoredVegetation() { + assertNull(TrackSupport.sitY(null)); assertFalse(TrackSupport.isIgnored((Block)null)); + Slab slab = mock(Slab.class); when(slab.getType()).thenReturn(Slab.Type.BOTTOM); assertEquals(64.5, TrackSupport.sitY(64, slab, Material.STONE_SLAB, false, true)); + when(slab.getType()).thenReturn(Slab.Type.TOP); assertEquals(65, TrackSupport.sitY(64, slab, Material.STONE_SLAB, false, true)); + Stairs stairs = mock(Stairs.class); when(stairs.getHalf()).thenReturn(Bisected.Half.BOTTOM); assertEquals(64.5, TrackSupport.sitY(64, stairs, Material.STONE_STAIRS, false, true)); + when(stairs.getHalf()).thenReturn(Bisected.Half.TOP); assertEquals(65, TrackSupport.sitY(64, stairs, Material.STONE_STAIRS, false, true)); + TrapDoor trap = mock(TrapDoor.class); when(trap.getHalf()).thenReturn(Bisected.Half.BOTTOM); assertEquals(64.5, TrackSupport.sitY(64, trap, Material.OAK_TRAPDOOR, true, true)); + when(trap.getHalf()).thenReturn(Bisected.Half.TOP); assertEquals(65, TrackSupport.sitY(64, trap, Material.OAK_TRAPDOOR, false, true)); when(trap.isOpen()).thenReturn(true); assertNull(TrackSupport.sitY(64, trap, Material.OAK_TRAPDOOR, false, true)); + assertNull(TrackSupport.sitY(64, mock(Snow.class), Material.SNOW, false, true)); assertNull(TrackSupport.sitY(64, null, Material.STONE, false, false)); assertNull(TrackSupport.sitY(64, null, Material.STONE, true, true)); + for (Material ignored : List.of(Material.AIR, Material.CAVE_AIR, Material.WATER, Material.LAVA, Material.MOSS_CARPET, Material.RED_CARPET)) assertTrue(TrackSupport.isIgnored(null, ignored, false)); + for (String plant : List.of("OAK_SAPLING", "FLOWER_POT", "RED_TULIP", "BLUE_ORCHID", "WHEAT", "CARROTS", "POTATOES", "BEETROOTS", "SWEET_BERRY_BUSH", "RED_MUSHROOM", "VINE", "LILY_PAD", "PINK_PETALS", "KELP", "SUGAR_CANE", "BAMBOO", "DANDELION", "POPPY", "ALLIUM", "AZURE_BLUET", "OXEYE_DAISY", "CORNFLOWER", "WITHER_ROSE", "SUNFLOWER", "LILAC", "ROSE_BUSH", "PEONY", "TORCHFLOWER", "PITCHER_PLANT", "WILDFLOWERS", "BUSH", "FIREFLY_BUSH", "LEAF_LITTER")) assertTrue(TrackSupport.isPlantName(plant), plant); + assertFalse(TrackSupport.isPlantName("MUSHROOM_STEM")); assertFalse(TrackSupport.isPlantName("MUSHROOM_BLOCK")); assertFalse(TrackSupport.isPlantName("STONE")); + Block stone = block(Material.STONE, 0, 63, 0, false, mock(BlockData.class)), air = block(Material.AIR, 0, 65, 0, true, mock(BlockData.class)); + assertTrue(TrackSupport.isValidClick(stone)); assertTrue(TrackSupport.isIgnored(air)); assertFalse(TrackSupport.blocksRail(air, 64)); assertTrue(TrackSupport.blocksRail(stone, 63.5)); assertFalse(TrackSupport.blocksRail(stone, 64)); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(call -> call.getArgument(1) == 63 ? stone : air); + assertEquals(64, TrackSupport.snapY(world, 0, 68, 0)); assertEquals(64, TrackSupport.floorY(world, 0, 68, 0)); assertNull(TrackSupport.firstSitY(null, 0, 64, 0)); assertEquals(80, TrackSupport.snapY(world, 0, 80, 0)); + } + + @Test void toolClicksRouteOnlySupportedHandsActionsAndSurfaces() { + TrackToolListener listener = new TrackToolListener(); Block stone = block(Material.STONE, 3, 63, 7, false, mock(BlockData.class)); Block grass = block(Material.SHORT_GRASS, 3, 64, 7, true, mock(BlockData.class)); + try (var commands = mockStatic(TrackCommands.class); var effects = mockStatic(TrackFx.class)) { + commands.when(() -> TrackCommands.markStart(eq(player), any())).thenReturn(true); + listener.onInteract(click(Action.LEFT_CLICK_BLOCK, stack(Material.STICK, 1), stone, EquipmentSlot.OFF_HAND)); listener.onInteract(click(Action.LEFT_CLICK_BLOCK, stack(Material.STONE, 1), stone, EquipmentSlot.HAND)); + listener.onInteract(click(Action.RIGHT_CLICK_AIR, stack(Material.STICK, 1), null, EquipmentSlot.HAND)); listener.onInteract(click(Action.LEFT_CLICK_BLOCK, stack(Material.STICK, 1), null, EquipmentSlot.HAND)); commands.verifyNoInteractions(); + PlayerInteractEvent start = click(Action.LEFT_CLICK_BLOCK, stack(Material.STICK, 1), stone, EquipmentSlot.HAND); listener.onInteract(start); assertTrue(start.isCancelled()); + commands.verify(() -> TrackCommands.markStart(eq(player), argThat(at -> at.getX() == 3.5 && at.getY() == 64 && at.getZ() == 7.5 && at.getYaw() == 15))); effects.verify(() -> TrackFx.hit(stone)); + listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, stack(Material.STICK, 1), stone, EquipmentSlot.HAND)); commands.verify(() -> TrackCommands.markEnd(eq(player), any(), eq(stone))); + listener.onInteract(click(Action.LEFT_CLICK_BLOCK, stack(Material.GOLD_NUGGET, 1), stone, EquipmentSlot.HAND)); commands.verify(() -> TrackCommands.digAt(eq(player), any())); + listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, stack(Material.GOLD_NUGGET, 1), stone, EquipmentSlot.HAND)); + listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, stack(Material.REDSTONE, 1), stone, EquipmentSlot.HAND)); commands.verify(() -> TrackCommands.startJunction(eq(player), any())); + listener.onInteract(click(Action.LEFT_CLICK_BLOCK, stack(Material.REDSTONE, 1), stone, EquipmentSlot.HAND)); + listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, stack(Material.REDSTONE, 1), grass, EquipmentSlot.HAND)); verify(player).sendMessage("§cClick existing track to start a junction."); + listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, stack(Material.STICK, 1), grass, EquipmentSlot.HAND)); verify(player).sendMessage("§cClick solid ground, not grass or plants."); + listener.onInteract(click(Action.LEFT_CLICK_BLOCK, stack(Material.GOLD_NUGGET, 1), grass, EquipmentSlot.HAND)); + commands.when(() -> TrackCommands.skipDuplicateToolUse(player)).thenReturn(true); + for (Material tool : List.of(Material.STICK, Material.GOLD_NUGGET, Material.REDSTONE)) listener.onInteract(click(tool == Material.REDSTONE ? Action.RIGHT_CLICK_BLOCK : Action.LEFT_CLICK_BLOCK, stack(tool, 1), stone, EquipmentSlot.HAND)); + effects.verify(() -> TrackFx.hit(stone), times(1)); + } + } + PlayerInteractEvent click(Action action, ItemStack item, Block block, EquipmentSlot hand) { return new PlayerInteractEvent(player, action, item, block, BlockFace.UP, hand); } + + @Test void resettlingAndKeepoutUseRealBlockHeightsAndWorldTrackSamples() { + Block stone = block(Material.STONE, 0, 63, 0, false, mock(BlockData.class)), air = block(Material.AIR, 0, 70, 0, true, mock(BlockData.class)); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(call -> call.getArgument(1) == 63 ? stone : air); + List points = points(0, 2, 4, 6); points.getFirst()[1] = 70; points.getLast()[1] = 70; + TrackResettle.resettle(world, points, true, true); assertEquals(64, points.getFirst()[1]); assertEquals(64, points.getLast()[1]); + TrackResettle.resettle(world, points, true, true); TrackResettle.resettle(world, null, true, true); TrackResettle.resettle(world, points(0), true, true); TrackResettle.resettle(null, points(0, 1), false, false); + assertFalse(TrackPlaceKeepout.blocked((Block)null)); assertFalse(TrackPlaceKeepout.blocked((List)null, 0, 64, 0, 1)); assertFalse(TrackPlaceKeepout.blocked(0, 64, 0, 0, 64, 0, 0)); + TrackSpline spline = rail(points(0, 1, 2)); Block inside = block(Material.STONE, 0, 64, 0, false, mock(BlockData.class)); assertTrue(TrackPlaceKeepout.blocked(inside)); + assertFalse(TrackPlaceKeepout.blocked(spline.getSamples(), 20, 64, 20, 1)); + assertFalse(TrackPlaceKeepout.blocked(stone)); framework.when(VehicleFramework::getTrackRegistry).thenReturn(null); assertFalse(TrackPlaceKeepout.blocked(inside)); + assertEquals(0, TrackChunks.edgeIndexForSample(0, 0, false)); assertEquals(0, TrackChunks.edgeIndexForSample(-1, 4, false)); + TrackVisual visual = new TrackVisual(TrackVisual.Type.SMALL, 0, 1, -1, 1, 0, 64, 0, 0, 0); assertEquals(-1, visual.coveredEdge(0, 0)); assertEquals(4, visual.coveredEdge(0, 5)); assertEquals(0, visual.coveredEdge(-4, 5)); + } + + ActiveVehicle train(String id, UUID spline) { + ActiveVehicle vehicle = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); when(vehicle.isTrain()).thenReturn(true); when(vehicle.getUUID()).thenReturn(id); + when(vehicle.getTrainHandler().isBound()).thenReturn(spline != null); when(vehicle.getTrainHandler().getSplineId()).thenReturn(spline); + when(vehicle.getEntity().getWorld()).thenReturn(world); when(vehicle.getEntity().getBoundingBox()).thenReturn(new BoundingBox(-1, 64, -1, 1, 67, 1)); + when(vehicle.getOwnerData().getOwner()).thenReturn("player_Engineer"); return vehicle; + } + @Test void recordingRequiresOwnershipBoundCircuitAndTogglesOnlyForTheRecorder() { + ActiveVehicle vehicle = train("loco", UUID.randomUUID()); TrainHandler train = vehicle.getTrainHandler(); + assertFalse(TrainTapeInteract.handle(null, vehicle)); assertFalse(TrainTapeInteract.handle(player, null)); when(vehicle.isTrain()).thenReturn(false); assertFalse(TrainTapeInteract.handle(player, vehicle)); when(vehicle.isTrain()).thenReturn(true); + when(vehicle.hasParent()).thenReturn(true); assertFalse(TrainTapeInteract.handle(player, vehicle)); when(vehicle.hasParent()).thenReturn(false); hand = stack(Material.STONE, 1); assertFalse(TrainTapeInteract.handle(player, vehicle)); + hand = stack(Material.IRON_NUGGET, 1); + try (var permissions = mockStatic(Permissions.class)) { + when(vehicle.getOwnerData().getOwner()).thenReturn(null); assertTrue(TrainTapeInteract.handle(player, vehicle)); verify(player).sendMessage("§cYou do not own this vehicle"); + permissions.when(() -> Permissions.isAdmin(player)).thenReturn(true); when(train.isBound()).thenReturn(false); assertTrue(TrainTapeInteract.handle(player, vehicle)); verify(player).sendMessage("§cThis locomotive is not on a track"); + when(train.isBound()).thenReturn(true); assertTrue(TrainTapeInteract.handle(player, vehicle)); verify(player).sendMessage("§cRecording only works on a circuit"); + when(train.canRecordCircuit()).thenReturn(true); assertTrue(TrainTapeInteract.handle(player, vehicle)); verify(train).startRecording(player); assertNull(ThrottleTapeItems.read(hand)); + when(train.isRecording()).thenReturn(true); assertTrue(TrainTapeInteract.handle(player, vehicle)); verify(train).stopRecording(hand); verify(player).sendMessage("§eRecording cancelled"); + } + } + + @Test void installedTapesMustMatchTheTrackAndConsumeOnlyOneRecorder() { + UUID spline = UUID.randomUUID(); ActiveVehicle vehicle = train("loco", spline); TrainHandler train = vehicle.getTrainHandler(); when(player.isSneaking()).thenReturn(true); hand = stack(Material.IRON_NUGGET, 2); + try (var permissions = mockStatic(Permissions.class)) { + assertTrue(TrainTapeInteract.handle(player, vehicle)); verify(player).sendMessage("§cThis recorder has no tape"); + ThrottleTape wrong = new ThrottleTape(UUID.randomUUID().toString(), List.of(new ThrottleTape.Sample(1, 1, 40))); ThrottleTapeItems.write(hand, wrong); + assertTrue(TrainTapeInteract.handle(player, vehicle)); verify(player).sendMessage("§cThat tape does not match this track"); assertEquals(2, hand.getAmount()); + ThrottleTape tape = new ThrottleTape(spline.toString(), List.of(new ThrottleTape.Sample(1, 1, 40))); ThrottleTapeItems.write(hand, tape); + when(train.hasInstalledTape()).thenReturn(true); assertTrue(TrainTapeInteract.handle(player, vehicle)); verify(player).sendMessage("§cRemove the current tape first"); assertEquals(2, hand.getAmount()); + when(train.hasInstalledTape()).thenReturn(false); assertTrue(TrainTapeInteract.handle(player, vehicle)); assertEquals(1, hand.getAmount()); verify(train).setInstalledTape(argThat(value -> value != null && value.toJson().equals(tape.toJson()))); + assertTrue(TrainTapeInteract.handle(player, vehicle)); assertNull(hand); + } + } + + @Test void ejectedTapesPreserveMetadataAndDropInventoryOverflow() { + UUID spline = UUID.randomUUID(); ActiveVehicle vehicle = train("loco", spline); TrainHandler train = vehicle.getTrainHandler(); when(player.isSneaking()).thenReturn(true); + ThrottleTape tape = new ThrottleTape(spline.toString(), List.of(new ThrottleTape.Sample(1, 1, 40))); when(train.getInstalledTape()).thenReturn(tape); when(train.hasInstalledTape()).thenReturn(true); + try (var permissions = mockStatic(Permissions.class)) { + assertTrue(TrainTapeInteract.handle(player, vehicle)); verify(player).sendMessage("§cCould not create a recorder item"); verify(train, never()).setInstalledTape(null); + ItemStack output = stack(Material.IRON_NUGGET, 4); templates.put("recorder", output); when(inventory.addItem(output)).thenReturn(new HashMap<>(Map.of(0, output))); + assertTrue(TrainTapeInteract.handle(player, vehicle)); assertEquals(1, output.getAmount()); assertEquals(tape.toJson(), ThrottleTapeItems.read(output).toJson()); verify(train).setInstalledTape(null); verify(world).dropItemNaturally(any(), same(output)); + when(player.getWorld()).thenReturn(null); assertTrue(TrainTapeInteract.handle(player, vehicle)); verify(world, times(1)).dropItemNaturally(any(), same(output)); + } + } + + @Test void anAirRecorderTemplateCannotDestroyTheInstalledTape() { + UUID spline = UUID.randomUUID(); ActiveVehicle vehicle = train("loco", spline); TrainHandler train = vehicle.getTrainHandler(); when(player.isSneaking()).thenReturn(true); hand = stack(Material.AIR, 1); + when(train.hasInstalledTape()).thenReturn(true); when(train.getInstalledTape()).thenReturn(new ThrottleTape(spline.toString(), List.of(new ThrottleTape.Sample(1, 1, 40)))); templates.put("recorder", stack(Material.AIR, 1)); + try (var permissions = mockStatic(Permissions.class)) { + assertTrue(TrainTapeInteract.handle(player, vehicle)); verify(train, never()).setInstalledTape(null); verify(inventory, never()).addItem(any(ItemStack.class)); + } + } + + @Test void collisionsExplodeOverlappingDifferentConsistsOnlyOnce() { + UUID spline = UUID.randomUUID(); ActiveVehicle first = train("a", spline), second = train("b", spline), third = train("c", spline); + when(first.hasDeathData(VehicleDeath.EXPLODE)).thenReturn(true); when(second.hasDeathData(VehicleDeath.EXPLODE)).thenReturn(true); when(third.hasDeathData(VehicleDeath.EXPLODE)).thenReturn(true); + TrainCollision.tick(null); TrainCollision.tick(List.of()); TrainCollision.tick(List.of(first, second, third)); verify(first).kill(VehicleDeath.EXPLODE); verify(second).kill(VehicleDeath.EXPLODE); verify(third, never()).kill(any()); + assertFalse(TrainCollision.sameConsist(null, first)); assertNull(TrainCollision.consistKey(null)); when(second.hasParent()).thenReturn(true); when(second.getParent()).thenReturn(first); assertTrue(TrainCollision.sameConsist(first, second)); + TrainCollision.tick(List.of(first, second)); verify(first, times(1)).kill(VehicleDeath.EXPLODE); verify(second, times(1)).kill(VehicleDeath.EXPLODE); + when(first.getTrainHandler().getPendingParent()).thenReturn("unloaded-head"); assertEquals("unloaded-head", TrainCollision.consistKey(second)); + } + + @Test void collisionChecksIgnoreDifferentTracksWorldsUnboundAndUnavailableEntities() { + UUID spline = UUID.randomUUID(); ActiveVehicle a = train("a", spline), b = train("b", UUID.randomUUID()); + TrainCollision.tick(List.of(a, b)); when(b.getTrainHandler().getSplineId()).thenReturn(spline); when(b.getEntity().getWorld()).thenReturn(mock(World.class)); TrainCollision.tick(List.of(a, b)); + when(b.getEntity().getWorld()).thenReturn(null); TrainCollision.tick(List.of(a, b)); when(b.getEntity().getWorld()).thenReturn(world); when(b.getEntity().getBoundingBox()).thenReturn(null); TrainCollision.tick(List.of(a, b)); + when(b.getEntity().getBoundingBox()).thenReturn(new BoundingBox(10, 64, 10, 11, 67, 11)); TrainCollision.tick(List.of(a, b)); + BoundingBox bounds = a.getEntity().getBoundingBox(); when(b.getEntity().getBoundingBox()).thenReturn(bounds); when(b.isDestroyed()).thenReturn(true); TrainCollision.tick(List.of(a, b)); + when(b.isDestroyed()).thenReturn(false); when(b.isTrain()).thenReturn(false); TrainCollision.tick(List.of(a, b)); when(b.isTrain()).thenReturn(true); when(b.getEntity()).thenReturn(null); TrainCollision.tick(List.of(a, b)); + verify(a, never()).kill(any()); verify(b, never()).kill(any()); + ActiveVehicle noDeath = train("no-death", spline); TrainCollision.tick(List.of(a, noDeath)); verify(noDeath, never()).kill(any()); + } + + @Test void trackEffectsUseConfiguredLanePositionsParticleDataAndSounds() { + List particles = new ArrayList<>(); List payloads = new ArrayList<>(); + effects.when(() -> ImpactVfx.spawn(any(), any(), anyInt(), anyDouble(), anyDouble(), anyDouble(), anyDouble(), any())).thenAnswer(call -> { particles.add(call.getArgument(0)); payloads.add(call.getArgument(7)); return null; }); + BlockData data = mock(BlockData.class); bukkit.when(() -> Bukkit.createBlockData(Material.STONE)).thenReturn(data); + Cache.trackFxParticle = Particle.BLOCK; Cache.trackFxBlock = Material.STONE; Cache.trackFxCount = 2; Cache.trackFxWidth = 3; Cache.trackFxYOffset = .2; + TrackPose pose = new TrackPose(1, 64, 3, 0, 0); TrackFx.crumbs(world, pose); assertEquals(3, particles.size()); assertEquals(List.of(0., 1., 2.), particles.stream().map(Location::getX).toList()); assertTrue(particles.stream().allMatch(at -> at.getY() == 64.2)); assertEquals(List.of(data, data, data), payloads); + TrackFx.crumbs(null, pose); TrackFx.crumbs(world, null); Cache.trackFxCount = 0; TrackFx.crumbs(world, pose); Cache.trackFxCount = 1; Cache.trackFxParticle = null; TrackFx.crumbs(world, pose); assertEquals(3, particles.size()); + Cache.trackFxParticle = Particle.FALLING_DUST; Cache.trackFxBlock = null; TrackFx.crumbs(world, pose); assertNull(payloads.getLast()); + Cache.trackFxBlock = Material.STONE; Cache.trackFxParticle = Particle.FLAME; TrackFx.crumbs(world, pose); assertNull(payloads.getLast()); + Cache.trackBuildParticle = Particle.FLAME; Cache.trackBuildCount = 1; Cache.trackBuildWidth = 1; Cache.trackBuildSound = "place-one"; Cache.trackBuildSound2 = "place-two"; + TrackFx.place(world, pose); verify(world).playSound(any(Location.class), eq("place-one"), eq(SoundCategory.BLOCKS), anyFloat(), anyFloat()); verify(world).playSound(any(Location.class), eq("place-two"), eq(SoundCategory.BLOCKS), anyFloat(), anyFloat()); + TrackFx.place(world, null); TrackFx.placeSound(null, 0, 64, 0); TrackFx.hit(null); Block orphan = mock(Block.class); TrackFx.hit(orphan); TrackFx.hitNear(null); TrackFx.hitNear(new Location(null, 0, 64, 0)); + Cache.trackBuildSound = null; Cache.trackBuildSound2 = " "; TrackFx.hit(block(Material.STONE, 1, 63, 3, false, data)); TrackFx.hitNear(new Location(world, 1, 64, 3)); + TrackFx.clack(null, pose); TrackFx.clack(world, null); Cache.trackFxSound = null; TrackFx.clack(world, pose); Cache.trackFxSound = " "; TrackFx.clack(world, pose); Cache.trackFxSound = "clack"; TrackFx.clack(world, pose); + verify(world).playSound(argThat((Location at) -> at.getX() == 1 && at.getY() == 64.2), eq("clack"), eq(SoundCategory.BLOCKS), anyFloat(), anyFloat()); + } + + @Test void obstructionHighlightsRefreshAndExpireWithoutClearingAReplacementSet() { + BukkitScheduler scheduler = mock(BukkitScheduler.class); bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + List refresh = new ArrayList<>(), expiry = new ArrayList<>(); List tasks = new ArrayList<>(); List outlined = new ArrayList<>(); + when(scheduler.runTaskTimer(eq(VehicleFramework.plugin), any(Runnable.class), anyLong(), anyLong())).thenAnswer(call -> { refresh.add(call.getArgument(1)); BukkitTask task = mock(BukkitTask.class); tasks.add(task); return task; }); + when(scheduler.runTaskLater(eq(VehicleFramework.plugin), any(Runnable.class), anyLong())).thenAnswer(call -> { expiry.add(call.getArgument(1)); return mock(BukkitTask.class); }); + BlockData stone = mock(BlockData.class), marker = mock(BlockData.class), changed = mock(BlockData.class); bukkit.when(() -> Bukkit.createBlockData(Material.RED_STAINED_GLASS)).thenReturn(marker); + Map blocks = new HashMap<>(); blocks.put(0, block(Material.STONE, 0, 64, 0, false, stone)); blocks.put(1, block(Material.AIR, 1, 64, 0, true, marker)); blocks.put(2, block(Material.STONE, 2, 64, 0, false, stone)); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(call -> blocks.get(call.getArgument(0))); + when(world.getBlockAt(any(Location.class))).thenAnswer(call -> blocks.get(call.getArgument(0).getBlockX())); + when(world.spawn(any(Location.class), eq(BlockDisplay.class), any(Consumer.class))).thenAnswer(call -> { + BlockDisplay display = mock(BlockDisplay.class); when(display.isValid()).thenReturn(true); Location location = call.getArgument(0); when(display.getLocation()).thenReturn(location); + BlockData[] shape = {null}; when(display.getBlock()).thenAnswer(invocation -> shape[0]); doAnswer(invocation -> { shape[0] = invocation.getArgument(0); return null; }).when(display).setBlock(any()); + ((Consumer)call.getArgument(2)).accept(display); outlined.add(display); return display; + }); + List obstructions = List.of(new TrainBlockCollision.Obstruction(2, 64, 0, 2), new TrainBlockCollision.Obstruction(0, 64, 0, 0), new TrainBlockCollision.Obstruction(1, 64, 0, 1)); + assertEquals(3, TrainSpaceHighlight.show(player, obstructions)); assertEquals(0, outlined.getFirst().getLocation().getBlockX()); verify(player, times(3)).showEntity(eq(VehicleFramework.plugin), any()); + when(blocks.get(0).getBlockData()).thenReturn(changed); refresh.getFirst().run(); assertSame(changed, outlined.getFirst().getBlock()); assertSame(marker, outlined.get(1).getBlock()); + when(blocks.get(0).isPassable()).thenReturn(true); when(outlined.get(2).isValid()).thenReturn(false); refresh.getFirst().run(); verify(outlined.getFirst()).remove(); verify(outlined.get(2), never()).remove(); + assertEquals(1, TrainSpaceHighlight.show(player, List.of(obstructions.get(2)))); verify(tasks.getFirst()).cancel(); expiry.getFirst().run(); verify(tasks.get(1), never()).cancel(); expiry.get(1).run(); verify(tasks.get(1)).cancel(); + assertEquals(0, TrainSpaceHighlight.show(player, List.of())); TrainSpaceHighlight.clear(player); TrainSpaceHighlight.clear(player); TrainSpaceHighlight.clearAll(); + } + + @Test void prependingPaidTrackPreservesPointOrderAndCopiesStoredCoordinates() { + List keep = points(0, 1, 2), stroke = points(0, -1, -2, -3); + List paid = TrackPieces.persistPoints(keep, stroke, false, 2); assertEquals(List.of(-2., -1., 0., 1., 2.), paid.stream().map(p -> p[2]).toList()); + paid.get(0)[2] = 100; paid.get(2)[2] = 200; assertEquals(-2, stroke.get(2)[2]); assertEquals(0, keep.getFirst()[2]); + contents[0] = stack(Material.IRON_INGOT, 2); Cache.trackItem = null; assertEquals(0, TrackPieces.count(player)); Cache.trackItem = " "; assertEquals(0, TrackPieces.count(player)); + } + + @Test void resettlingLowersOrLiftsTrackOnlyAsFarAsGradeAndGroundPermit() { + Cache.trackMaxGradeDegrees = 10; + List underGround = points(0, 1); TrackResettle.smoothInward(underGround, 0, 1, 1, "first", i -> 64.1); assertEquals(64.1, underGround.getLast()[1]); + List belowGrade = points(0, 1); belowGrade.getLast()[1] = 60; TrackResettle.smoothInward(belowGrade, 0, 1, 1, "first", null); assertEquals(64 - Math.tan(Math.toRadians(10)), belowGrade.getLast()[1], 1e-9); + List belowGround = points(0, 1); belowGround.getLast()[1] = 60; TrackResettle.smoothInward(belowGround, 0, 1, 1, "first", i -> 64.1); assertEquals(64.1, belowGround.getLast()[1]); + assertFalse(TrackPlaceKeepout.blocked(List.of(new TrackSample(0, 64, 0, 0, 0, 0)), 20, 64, 20, 1)); + } + + @Test void resettlingCannotTurnAFlatSegmentIntoAGradeAboveTheConfiguredLimit() { + Cache.trackMaxGradeDegrees = 10; List points = points(0, 1); + TrackResettle.smoothInward(points, 0, 1, 1, "first", i -> 65.); + assertEquals(64, points.getLast()[1], "A supporting surface above the allowed grade must stop smoothing before changing the segment"); + } + + @Test void nearbyTrainsOnOtherTracksAreNotExplodedAndChainDestructionIsNotDuplicated() { + ActiveVehicle first = train("a", UUID.randomUUID()), otherTrack = train("b", UUID.randomUUID()); + assertTrue(first.getTrainHandler().isBound()); assertNotEquals(first.getTrainHandler().getSplineId(), otherTrack.getTrainHandler().getSplineId()); TrainCollision.tick(List.of(first, otherTrack)); verify(first, never()).kill(any()); + UUID track = UUID.randomUUID(); ActiveVehicle left = train("c", track), right = train("d", track); when(left.hasDeathData(VehicleDeath.EXPLODE)).thenReturn(true); when(right.hasDeathData(VehicleDeath.EXPLODE)).thenReturn(true); + doAnswer(call -> { when(right.isDestroyed()).thenReturn(true); return null; }).when(left).kill(VehicleDeath.EXPLODE); + TrainCollision.tick(List.of(left, right)); verify(left).kill(VehicleDeath.EXPLODE); verify(right, never()).kill(any()); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/util/RuntimeUtilitiesCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/util/RuntimeUtilitiesCoverageTest.java new file mode 100644 index 0000000..b8883ee --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/util/RuntimeUtilitiesCoverageTest.java @@ -0,0 +1,363 @@ +package net.tfminecraft.vehicleframework.util; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.bukkit.*; +import org.bukkit.attribute.Attribute; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.*; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.*; +import org.bukkit.event.Event; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.components.CustomModelDataComponent; +import org.bukkit.plugin.PluginManager; +import org.bukkit.util.RayTraceResult; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.*; +import org.mockito.*; +import net.tfminecraft.tlibs.enums.NSEW; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.database.LogWriter; +import net.tfminecraft.vehicleframework.enums.*; +import net.tfminecraft.vehicleframework.enums.Input; +import net.tfminecraft.vehicleframework.events.*; +import net.tfminecraft.vehicleframework.loaders.VehicleLoader; +import net.tfminecraft.vehicleframework.permissions.Permissions; +import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.Vehicle; +import net.tfminecraft.vehicleframework.vehicles.component.Engine; +import net.tfminecraft.vehicleframework.vehicles.component.Wings; +import net.tfminecraft.vehicleframework.vehicles.component.propulsion.Throttle; + +class RuntimeUtilitiesCoverageTest { + Rig r; + final Map blocks = new HashMap<>(); + final List debris = new ArrayList<>(); + final List online = new ArrayList<>(); + final List otherWorlds = new ArrayList<>(); + Consumer onEvent = e -> {}; + MockedStatic logs; + List oldExplode, oldLands, oldGround; + Map oldConvert; + Set oldLights; + boolean oldDamage; + int oldDistance; + + @BeforeEach void setup() { + r = new Rig(); logs = mockStatic(LogWriter.class); + oldExplode = new ArrayList<>(Cache.ignoreExplode); oldLands = new ArrayList<>(Cache.ignoreLands); oldGround = new ArrayList<>(Cache.ignoreGround); + oldConvert = new HashMap<>(Cache.convertExplode); oldLights = new HashSet<>(Cache.lightLocations); oldDamage = Cache.blockDamage; oldDistance = Cache.despawnDistance; + Cache.ignoreExplode.clear(); Cache.ignoreLands.clear(); Cache.ignoreGround.clear(); Cache.convertExplode.clear(); Cache.lightLocations.clear(); Cache.blockDamage = false; + PluginManager plugins = mock(PluginManager.class); r.bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + doAnswer(c -> { onEvent.accept(c.getArgument(0)); return null; }).when(plugins).callEvent(any(Event.class)); + r.bukkit.when(Bukkit::getOnlinePlayers).thenReturn(online); + r.bukkit.when(() -> Bukkit.createBlockData(any(Material.class))).thenAnswer(c -> data(c.getArgument(0))); + doAnswer(c -> block(c.getArgument(0), c.getArgument(1), c.getArgument(2))).when(r.world).getBlockAt(anyInt(), anyInt(), anyInt()); + doAnswer(c -> { Location l = c.getArgument(0); return block(l.getBlockX(), l.getBlockY(), l.getBlockZ()); }).when(r.world).getBlockAt(any(Location.class)); + when(r.world.spawnFallingBlock(any(Location.class), any(BlockData.class))).thenAnswer(c -> { + FallingBlock falling = r.entity(FallingBlock.class, c.getArgument(0)); BlockData material = c.getArgument(1); + when(falling.getBlockData()).thenReturn(material); debris.add(falling); return falling; + }); + } + + @AfterEach void cleanup() { + Cache.ignoreExplode.clear(); Cache.ignoreExplode.addAll(oldExplode); Cache.ignoreLands.clear(); Cache.ignoreLands.addAll(oldLands); Cache.ignoreGround.clear(); Cache.ignoreGround.addAll(oldGround); + Cache.convertExplode.clear(); Cache.convertExplode.putAll(oldConvert); Cache.lightLocations.clear(); Cache.lightLocations.addAll(oldLights); Cache.blockDamage = oldDamage; Cache.despawnDistance = oldDistance; + logs.close(); r.close(); + } + + @Test void lavaDoesNotProvideWaterBuoyancy() { + Block lava = block(0, 64, 0, Material.LAVA); + assertTrue(lava.isLiquid()); + assertFalse(LocationChecker.isWaterBlock(lava), "Lava must not satisfy water-only flotation or wading checks"); + } + + @Test void temporaryLightPreservesReplacementAndReleasesItsReservation() { + Location location = new Location(r.world, 0, 64, 0); + new LightEffect().createTemporaryLight(location, 12); + block(0, 64, 0).setType(Material.STONE); r.ticks(2); + assertEquals(Material.STONE, location.getBlock().getType()); assertFalse(Cache.lightLocations.contains(location)); + } + + @Test void missingSeatCannotFulfilOccupiedSeatCondition() { + ActiveVehicle vehicle = r.vehicle(); when(vehicle.getAccessPanel().getSeat("missing")).thenReturn(null); + assertFalse(ConditionChecker.checkCondition(vehicle, "seat_filled", "missing")); + } + + @Test void invalidConditionsFailClosedWithoutCrashingStateUpdates() { + ActiveVehicle vehicle = r.vehicle(); + assertFalse(ConditionChecker.checkCondition(vehicle, "unknown", "true")); + assertFalse(ConditionChecker.checkConditions(vehicle, List.of("passengers"))); + assertFalse(ConditionChecker.checkCondition(vehicle, "OR", "has_fuel")); + assertFalse(ConditionChecker.checkCondition(vehicle, "AND", "has_fuel")); + assertFalse(ConditionChecker.checkConditions(vehicle, Arrays.asList((String)null))); + for(String condition:List.of("(true)","has_fuel(true","passengers(maybe)"))assertFalse(ConditionChecker.checkConditions(vehicle,List.of(condition))); + assertFalse(ConditionChecker.checkCondition(vehicle,null,"true")); + assertFalse(ConditionChecker.checkCondition(vehicle,"has_fuel",null)); + assertFalse(ConditionChecker.checkCondition(vehicle,"has_fuel","sometimes")); + assertFalse(ConditionChecker.checkCondition(vehicle,"throttle_less_than","many")); + assertFalse(ConditionChecker.checkCondition(vehicle,"throttle_more_than","many")); + when(vehicle.getAccessPanel().getSeat("missing")).thenReturn(null);assertFalse(ConditionChecker.checkCondition(vehicle,"seat_empty","missing")); + } + + @Test void healthConditionIdentifiersAreIndependentOfServerLocale() { + ActiveVehicle vehicle = r.vehicle(); when(vehicle.hasComponent(Component.WINGS)).thenReturn(true); when(vehicle.getComponent(Component.WINGS).getHealthData().getHealthPercentage()).thenReturn(50); + Locale saved = Locale.getDefault(); try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); assertTrue(ConditionChecker.checkCondition(vehicle, "health_percent", "wings;at_least=50")); } finally { Locale.setDefault(saved); } + } + + @Test void waterDepthWadingAirAndGroundUseActualNeighbourBlocks() { + assertFalse(LocationChecker.isWaterBlock(null)); assertFalse(LocationChecker.isMostlyShallowWadableWater(null, .5)); assertFalse(LocationChecker.isMostlyShallowWadableWater(Set.of(), .5)); + Block floor = block(0, 63, 0, Material.STONE), water = block(0, 64, 0, Material.WATER), above = block(0, 65, 0, Material.WATER); + assertTrue(LocationChecker.isInWater(water.getLocation())); assertTrue(LocationChecker.hasDeepWaterAtCentre(floor)); assertTrue(LocationChecker.hasDeepWaterAtCentre(water)); + assertTrue(LocationChecker.isShallowWadableBlock(water)); assertFalse(LocationChecker.isShallowWadableBlock(above)); assertFalse(LocationChecker.isShallowWadableBlock(floor)); + assertTrue(LocationChecker.isMostlyShallowWadableWater(Set.of(floor, water, above), .5)); assertFalse(LocationChecker.isMostlyShallowWadableWater(Set.of(water, above), .75)); assertFalse(LocationChecker.isMostlyShallowWadableWater(Set.of(floor), .5)); + above.setType(Material.AIR); assertFalse(LocationChecker.hasDeepWaterAtCentre(floor)); assertFalse(LocationChecker.hasDeepWaterAtCentre(water)); assertFalse(LocationChecker.hasDeepWaterAtCentre(above)); + assertTrue(LocationChecker.isOnGround(floor.getLocation())); assertFalse(LocationChecker.isOnGround(water.getLocation())); assertTrue(LocationChecker.isInAir(above.getLocation())); assertFalse(LocationChecker.isOnGround(above.getLocation())); + above.setType(Material.LIGHT); assertTrue(LocationChecker.isInAir(above.getLocation())); assertFalse(LocationChecker.isInAir(water.getLocation())); + Waterlogged logged = mock(Waterlogged.class); Block slab = block(1, 64, 0, Material.OAK_SLAB); slab.setBlockData(logged, false); when(logged.isWaterlogged()).thenReturn(true); assertTrue(LocationChecker.isWaterBlock(slab)); when(logged.isWaterlogged()).thenReturn(false); assertFalse(LocationChecker.isWaterBlock(slab)); + for (Material material : List.of(Material.KELP, Material.KELP_PLANT, Material.SEAGRASS, Material.TALL_SEAGRASS)) { water.setType(material); assertTrue(LocationChecker.isWaterBlock(water)); } + } + + @Test void railStepsFollowStraightSlopesAndEachCurveExit() { + record Route(Rail.Shape shape, NSEW direction, float yaw, int dx, int dy, int dz) {} + List routes = new ArrayList<>(); + routes.addAll(List.of(new Route(Rail.Shape.NORTH_SOUTH,NSEW.NORTH,0,0,0,-1),new Route(Rail.Shape.NORTH_SOUTH,NSEW.SOUTH,0,0,0,1),new Route(Rail.Shape.NORTH_SOUTH,NSEW.EAST,0,0,0,1),new Route(Rail.Shape.NORTH_SOUTH,NSEW.EAST,-90,0,0,-1),new Route(Rail.Shape.NORTH_SOUTH,NSEW.WEST,0,0,0,1),new Route(Rail.Shape.NORTH_SOUTH,NSEW.WEST,90,0,0,-1),new Route(Rail.Shape.EAST_WEST,NSEW.EAST,0,1,0,0),new Route(Rail.Shape.EAST_WEST,NSEW.WEST,0,-1,0,0),new Route(Rail.Shape.EAST_WEST,NSEW.NORTH,90,-1,0,0),new Route(Rail.Shape.EAST_WEST,NSEW.NORTH,0,1,0,0),new Route(Rail.Shape.EAST_WEST,NSEW.SOUTH,90,-1,0,0),new Route(Rail.Shape.EAST_WEST,NSEW.SOUTH,0,1,0,0))); + routes.addAll(List.of(new Route(Rail.Shape.ASCENDING_NORTH,NSEW.NORTH,0,0,1,-1),new Route(Rail.Shape.ASCENDING_NORTH,NSEW.SOUTH,0,0,0,1),new Route(Rail.Shape.ASCENDING_SOUTH,NSEW.NORTH,0,0,0,-1),new Route(Rail.Shape.ASCENDING_SOUTH,NSEW.SOUTH,0,0,1,1),new Route(Rail.Shape.ASCENDING_EAST,NSEW.EAST,0,1,1,0),new Route(Rail.Shape.ASCENDING_EAST,NSEW.WEST,0,-1,0,0),new Route(Rail.Shape.ASCENDING_WEST,NSEW.EAST,0,1,0,0),new Route(Rail.Shape.ASCENDING_WEST,NSEW.WEST,0,-1,1,0))); + for (Rail.Shape shape : List.of(Rail.Shape.NORTH_EAST,Rail.Shape.NORTH_WEST,Rail.Shape.SOUTH_EAST,Rail.Shape.SOUTH_WEST)) { + int x = shape == Rail.Shape.NORTH_EAST || shape == Rail.Shape.SOUTH_EAST ? 1 : -1, z = shape == Rail.Shape.NORTH_EAST || shape == Rail.Shape.NORTH_WEST ? -1 : 1; + for (NSEW dir : List.of(NSEW.NORTH,NSEW.SOUTH,NSEW.EAST,NSEW.WEST)) { + boolean alongZ = (z < 0 && dir == NSEW.NORTH) || (z > 0 && dir == NSEW.SOUTH) || (x > 0 && dir == NSEW.WEST) || (x < 0 && dir == NSEW.EAST); + routes.add(new Route(shape,dir,0,alongZ?0:x,0,alongZ?z:0)); + } + } + for (Route route : routes) { + blocks.clear(); rail(block(0,64,0,Material.RAIL), route.shape()); + Rail.Shape next = route.dx() != 0 ? Rail.Shape.EAST_WEST : Rail.Shape.NORTH_SOUTH; + // The first step selects the configured exit, then three aligned straight rails continue it. + for(int i=1;i<4;i++) rail(block(route.dx()*i,64+route.dy(),route.dz()*i,Material.RAIL),next); + float yaw = route.yaw(); + // For a turn the heading preference is chosen from the actual exit; test all curve arms separately below. + if (route.shape().name().contains("ASCENDING") || route.shape()==Rail.Shape.NORTH_SOUTH || route.shape()==Rail.Shape.EAST_WEST) { + Location result=LocationChecker.getNextTrackedLocation(new Location(r.world,0.2,64,0.2),List.of(route.direction()),yaw); + assertNotNull(result, route.toString()); assertEquals(64+route.dy(),result.getY(),route.toString()); assertEquals(route.dx()*4+.5,result.getX(),route.toString()); assertEquals(route.dz()*4+.5,result.getZ(),route.toString()); + } else { + // A single curve is followed by no rail: the observable result is a stopped route, with the exit sampled. + blocks.clear(); rail(block(0,64,0,Material.RAIL),route.shape()); assertNull(LocationChecker.getNextTrackedLocation(new Location(r.world,0,64,0),List.of(route.direction()),0)); verify(block(route.dx(),64,route.dz()),atLeastOnce()).getBlockData(); + } + } + blocks.clear(); assertNull(LocationChecker.getNextTrackedLocation(new Location(r.world,0,64,0),List.of(NSEW.NORTH),0)); + for(int z=0;z>-4;z--)rail(block(0,63,z,Material.RAIL),Rail.Shape.NORTH_SOUTH);assertEquals(63,LocationChecker.getNextTrackedLocation(new Location(r.world,0,64,0),List.of(NSEW.NORTH),0).getY()); + for(Rail.Shape shape:Rail.Shape.values()){ + blocks.clear();rail(block(0,64,0,Material.RAIL),shape); + assertEquals(new Location(r.world,.5,64,.5),LocationChecker.getNextTrackedLocation(new Location(r.world,0,64,0),List.of(NSEW.NORTH_EAST),0),"Unsupported diagonal preference must not invent rail movement"); + } + } + + @Test void conditionsGateStateLiftPassengersFuelSeatsAndThrottle() { + ActiveVehicle v=r.vehicle(); when(v.getStateHandler().getCurrentState().getType()).thenReturn(State.GROUND); + assertTrue(ConditionChecker.checkConditions(v,List.of())); assertTrue(ConditionChecker.checkConditions(v,List.of("state(ground)","passengers(false)"))); assertFalse(ConditionChecker.checkConditions(v,List.of("state(flying)","unknown()"))); + assertFalse(ConditionChecker.checkCondition(v,"lift","true")); Wings wings=mock(Wings.class); Engine engine=mock(Engine.class,RETURNS_DEEP_STUBS); when(v.hasComponent(Component.WINGS)).thenReturn(true); when(v.getComponent(Component.WINGS)).thenReturn(wings);assertFalse(ConditionChecker.checkCondition(v,"lift","true"));when(v.hasComponent(Component.ENGINE)).thenReturn(true);when(v.getComponent(Component.ENGINE)).thenReturn(engine);when(wings.getLift()).thenReturn(1d);when(engine.getSpeed()).thenReturn(1d);when(engine.getThrottle().getCurrent()).thenReturn(100);assertTrue(ConditionChecker.checkCondition(v,"lift","true"));assertFalse(ConditionChecker.checkCondition(v,"lift","false"));when(engine.getThrottle().getCurrent()).thenReturn(-10);assertFalse(ConditionChecker.checkCondition(v,"lift","true"));assertTrue(ConditionChecker.checkCondition(v,"lift","false")); + when(v.getSeatHandler().hasPassengers()).thenReturn(true);assertTrue(ConditionChecker.checkCondition(v,"passengers","true"));assertFalse(ConditionChecker.checkCondition(v,"passengers","false")); + when(v.getAccessPanel().getSeat("pilot").isOccupied()).thenReturn(true);assertTrue(ConditionChecker.checkCondition(v,"seat_filled","pilot"));assertFalse(ConditionChecker.checkCondition(v,"seat_empty","pilot"));when(v.getAccessPanel().getSeat("pilot").isOccupied()).thenReturn(false);assertFalse(ConditionChecker.checkCondition(v,"seat_filled","pilot"));assertTrue(ConditionChecker.checkCondition(v,"seat_empty","pilot")); + for(boolean flag:List.of(false,true)){when(v.hasParent()).thenReturn(flag);when(v.hasFuel()).thenReturn(flag);for(String type:List.of("is_passenger","has_fuel")){assertTrue(ConditionChecker.checkCondition(v,type,Boolean.toString(flag)));assertFalse(ConditionChecker.checkCondition(v,type,Boolean.toString(!flag)));}} + when(v.getThrottle()).thenReturn(null);assertFalse(ConditionChecker.checkCondition(v,"throttle_less_than","50"));assertFalse(ConditionChecker.checkCondition(v,"throttle_more_than","50"));Throttle throttle=mock(Throttle.class);when(v.getThrottle()).thenReturn(throttle);when(throttle.getCurrent()).thenReturn(50);assertTrue(ConditionChecker.checkCondition(v,"throttle_less_than","50"));assertFalse(ConditionChecker.checkCondition(v,"throttle_less_than","49"));assertTrue(ConditionChecker.checkCondition(v,"throttle_more_than","50"));assertFalse(ConditionChecker.checkCondition(v,"throttle_more_than","51")); + assertTrue(ConditionChecker.checkCondition(v,"OR","has_fuel=false;passengers=true;state=flying"));assertFalse(ConditionChecker.checkCondition(v,"OR","has_fuel=false;passengers=false"));assertTrue(ConditionChecker.checkCondition(v,"AND","has_fuel=true;passengers=true"));assertFalse(ConditionChecker.checkCondition(v,"AND","has_fuel=false;passengers=true")); + } + + @Test void healthComparisonsValidateConfigurationAndTheirBoundaries() { + ActiveVehicle v=r.vehicle();when(v.hasComponent(Component.HULL)).thenReturn(true);when(v.getComponent(Component.HULL).getHealthData().getHealthPercentage()).thenReturn(50); + for(String value:Arrays.asList(null,""," ","hull;wings;at_most=50","hull;at_most=50;more_than=20","hull;at_most=abc","hull;bogus=20","hull","at_least=50","absent;at_least=50","engine;at_least=50")) assertFalse(ConditionChecker.checkCondition(v,"health_percent",value),String.valueOf(value)); + assertTrue(ConditionChecker.checkCondition(v,"health_percent"," ;component=hull;;less_than=51"));assertFalse(ConditionChecker.checkCondition(v,"health_percent","hull;less_than=50"));assertTrue(ConditionChecker.checkCondition(v,"health_percent","hull;more_than=49"));assertFalse(ConditionChecker.checkCondition(v,"health_percent","hull;more_than=50"));assertTrue(ConditionChecker.checkCondition(v,"health_percent","hull;at_most=50"));assertFalse(ConditionChecker.checkCondition(v,"health_percent","hull;at_most=49"));assertTrue(ConditionChecker.checkCondition(v,"health_percent","hull;at_least=50"));assertFalse(ConditionChecker.checkCondition(v,"health_percent","hull;at_least=51")); + } + + @Test void textAndControlLabelsStayReadable() { + assertNull(Text.capitalize(null));assertEquals("",Text.capitalize(""));assertEquals(" Hello\tWorld\nAgain",Text.capitalize(" hello\tworld\nagain"));assertEquals("AlreadyUPPER",Text.capitalize("AlreadyUPPER")); + String[] inputLabels={"Speed Up","Speed Down","Turn Left","Turn Right","Turn Left","Turn Right","Junction Left","Junction Right","Seat Selection","Move","Forward","Backward","UP","DOWN","Pitch Down","Pitch Up","Roll Left","Roll Right","Weapon Aim Up","Weapon Aim Down","Weapon Aim Left","Weapon Aim Right","Weapon Reload","Weapon Shoot","Reload & Shoot","Switch Weapon","Toggle Lights","Horn","None"}; + for(int i=0;iLegacyModelData.get(meta));when(component.getFloats()).thenReturn(List.of(123f,99f));assertTrue(LegacyModelData.has(meta));assertEquals(123,LegacyModelData.get(meta));LegacyModelData.set(meta,42);verify(component).setFloats(List.of(42f));verify(component).setFlags(List.of());verify(component).setStrings(List.of());verify(component).setColors(List.of());verify(meta).setCustomModelDataComponent(component);LegacyModelData.set(meta,null);verify(meta).setCustomModelDataComponent(null); + } + + @Test void mergingConfigMakesIndependentNestedCopiesAndWritableYaml()throws Exception { + var source=Rig.yaml("engine:\n speed: 2\n sounds: [a,b]\nitems: [{id: stone}]\n");Map base=ConfigMerger.fromSection(source);Map overlay=Map.of("template","base","engine",Map.of("speed",4),"new",true);Map merged=ConfigMerger.overlay(base,overlay);var yaml=ConfigMerger.toConfiguration(merged);assertEquals(4,yaml.getInt("engine.speed"));assertEquals(List.of("a","b"),yaml.getStringList("engine.sounds"));assertFalse(yaml.contains("template"));assertTrue(yaml.getBoolean("new"));((List)((Map)merged.get("engine")).get("sounds")).clear();assertEquals(List.of("a","b"),source.getStringList("engine.sounds"));assertEquals(2,((Map)base.get("engine")).get("speed"));assertTrue(ConfigMerger.fromSection(null).isEmpty());assertTrue(ConfigMerger.overlay(null,null).isEmpty());assertTrue(ConfigMerger.toConfiguration(null).getKeys(false).isEmpty());assertEquals(base,ConfigMerger.overlay(base,null)); + } + + @Test void soundAndParticleListsReadConfigurationAndSkipMalformedParticleRows()throws Exception { + var sounds=SoundLoader.getSoundsFromConfig(Rig.yaml("one: {sound: test.horn, volume: 2, pitch: 0.8}\n"));assertEquals(1,sounds.size());assertEquals("test.horn",sounds.getFirst().getSound());assertEquals(2,sounds.getFirst().getVolume());var particles=ParticleLoader.getParticlesFromConfig(Rig.yaml("one: {particle: FLAME, amount: 3}\nbroken: scalar\n"));assertEquals(1,particles.size());assertEquals(Particle.FLAME,particles.getFirst().getParticle());assertEquals(3,particles.getFirst().getAmount()); + } + + @Test void temporaryLightsRespectSolidBlocksReservationsAndExpire() { + LightEffect effect=new LightEffect();Location location=new Location(r.world,0,64,0);block(0,64,0,Material.STONE);effect.createTemporaryLight(location,12);assertEquals(0,r.pending());block(0,64,0).setType(Material.AIR);Cache.lightLocations.add(location);effect.createTemporaryLight(location,12);assertEquals(0,r.pending());Cache.lightLocations.clear();effect.createTemporaryLight(location,12);assertEquals(Material.LIGHT,location.getBlock().getType());verify((Levelled)location.getBlock().getBlockData()).setLevel(12);assertTrue(Cache.lightLocations.contains(location));r.ticks(2);assertEquals(Material.AIR,location.getBlock().getType());assertTrue(Cache.lightLocations.isEmpty()); + } + + @Test void spawnPresenceUsesSameWorldAndConfiguredSquaredDistance() { + Chunk chunk=mock(Chunk.class);Location location=new Location(r.world,0,64,0);SpawnLocation spawn=new SpawnLocation(chunk,location,"wagon.yml");assertSame(chunk,spawn.getChunk());assertSame(location,spawn.getLoc());assertEquals("wagon.yml",spawn.getFile());Cache.despawnDistance=100;assertFalse(spawn.hasNearby());online.add(r.entity(Player.class,new Location(otherWorld(),0,64,0)));online.add(r.entity(Player.class,new Location(r.world,20,64,0)));assertFalse(spawn.hasNearby());online.add(r.entity(Player.class,new Location(r.world,8,64,0)));assertTrue(spawn.hasNearby()); + } + + @Test void disablingBlockDamageAlsoProtectsBlocksFromExplosionFire() { + block(0,63,0,Material.STONE); + var display=mock(net.tfminecraft.vehicleframework.tracks.TrackDisplayManager.class);r.framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(display); + onEvent=e->{if(e instanceof VFExplosionEvent explosion)explosion.setBlockDamage(false);}; + try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(0d))){ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,2,0,"test",true);} + assertEquals(Material.AIR,block(0,64,0).getType(),"A protected explosion must not place damaging fire"); + verifyNoInteractions(display); + } + + @Test void zeroRadiusExplosionNeverAppliesANonFiniteVelocity() { + LivingEntity entity=r.entity(LivingEntity.class,new Location(r.world,0,64,0));r.nearby.add(entity); + ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,0,4,"test"); + Vector velocity=entity.getVelocity();assertTrue(Double.isFinite(velocity.getX())&&Double.isFinite(velocity.getY())&&Double.isFinite(velocity.getZ()),"A zero-radius blast must not send NaN velocity to Bukkit"); + for(double radius:List.of(-1d,Double.NaN,Double.POSITIVE_INFINITY))assertDoesNotThrow(()->ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,radius,4,"test"));verify(entity,never()).setVelocity(any()); + } + + @Test void entityAtBlastCentreGetsFiniteVerticalLift() { + LivingEntity centered=r.entity(LivingEntity.class,new Location(r.world,0,64,0));r.nearby.add(centered);ExplosionCreator.triggerExplosion(centered.getLocation(),2,4,10,"test");assertEquals(new Vector(0,1,0),centered.getVelocity());verify(centered).damage(10); + } + + @Test void explosionsRespectCancellationObserversDamageFalloffArmourAndVehicleKnockback() { + Cache.blockDamage=true; + Location center=new Location(r.world,0,64,0);LivingEntity near=r.entity(LivingEntity.class,center.clone().add(0,0,1)),far=r.entity(LivingEntity.class,center.clone().add(0,0,3)),outside=r.entity(LivingEntity.class,center.clone().add(3.5,0,3.5)),deck=r.entity(LivingEntity.class,center.clone());Player creative=r.entity(Player.class,center.clone()),spectator=r.entity(Player.class,center.clone()),rider=r.entity(Player.class,center.clone()),survival=r.entity(Player.class,center.clone().add(0,0,1));when(creative.getGameMode()).thenReturn(GameMode.CREATIVE);when(spectator.getGameMode()).thenReturn(GameMode.SPECTATOR);when(rider.getGameMode()).thenReturn(GameMode.SURVIVAL);when(survival.getGameMode()).thenReturn(GameMode.SURVIVAL);when(survival.getAttribute(Attribute.ARMOR).getValue()).thenReturn(20d); + Entity vehicleEntity=r.entity(Entity.class,center.clone());ActiveVehicle vehicle=r.vehicle();when(r.manager.get(vehicleEntity)).thenReturn(vehicle);when(vehicle.getSeatHandler().getPassengers()).thenReturn(List.of(rider));r.decks.when(()->net.tfminecraft.vehicleframework.vehicles.handlers.train.DeckBody.isPart(deck)).thenReturn(true);r.nearby.addAll(List.of(near,far,outside,deck,creative,spectator,rider,survival,vehicleEntity)); + Player observer=r.entity(Player.class,center.clone().add(0,0,100)),distant=r.entity(Player.class,center.clone().add(0,0,321)),other=r.entity(Player.class,new Location(otherWorld(),0,64,0));online.addAll(List.of(observer,distant,other)); + var display=mock(net.tfminecraft.vehicleframework.tracks.TrackDisplayManager.class);r.framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(display); + onEvent=e->{if(e instanceof VFExplosionEvent explosion)explosion.setCancelled(true);};ExplosionCreator.triggerExplosion(center,2,4,20,"test");verify(near,never()).damage(anyDouble());verifyNoInteractions(display); + onEvent=e->{};ExplosionCreator.triggerExplosion(center,2,4,20,"test");verify(display).breakInRadius(center,4);verify(near).damage(20);verify(far).damage(10);verify(survival).damage(15);verify(outside,never()).damage(anyDouble());verify(deck,never()).damage(anyDouble());verify(creative,never()).setVelocity(any());verify(spectator,never()).setVelocity(any());verify(rider,never()).setVelocity(any());assertEquals(new Vector(0,1,2.5),near.getVelocity());verify(observer).spawnParticle(Particle.EXPLOSION_EMITTER,center,30,0,0,0,0);verify(distant,never()).spawnParticle(any(Particle.class),any(Location.class),anyInt(),anyDouble(),anyDouble(),anyDouble(),anyDouble());verify(other,never()).spawnParticle(any(Particle.class),any(Location.class),anyInt(),anyDouble(),anyDouble(),anyDouble(),anyDouble()); + } + + @Test void directDamageAllowsEventCancellationAndAlwaysClearsItsGuard() { + Entity decoration=r.entity(Entity.class,new Location(r.world,0,64,0));ExplosionCreator.applyDamage(decoration,10,"test");LivingEntity target=r.entity(LivingEntity.class,new Location(r.world,0,64,0));onEvent=e->{if(e instanceof VFEntityDamageEvent damage)damage.setCancelled(true);};ExplosionCreator.applyDamage(target,10,"test");verify(target,never()).damage(anyDouble());onEvent=e->{if(e instanceof VFEntityDamageEvent damage)damage.setDamage(3);};ExplosionCreator.applyDamage(target,10,"test");verify(target).damage(3);var order=inOrder(r.manager,target);order.verify(r.manager).setDamaged(target,true);order.verify(target).damage(3);order.verify(r.manager).setDamaged(target,false); + doThrow(new IllegalStateException("external entity rejected damage")).when(target).damage(8);assertDoesNotThrow(()->Damager.damage(target,8));verify(r.manager,times(2)).setDamaged(target,false); + } + + @Test void blastConvertsSoftGroundAndRespectfullySkipsProtectedBlocks() { + Cache.blockDamage=true;Cache.ignoreExplode.add(Material.GOLD_BLOCK);Cache.ignoreLands.add(Material.DIRT);Cache.convertExplode.put(Material.DIRT,Material.COARSE_DIRT); + List soils=List.of(Material.DIRT,Material.GRASS_BLOCK,Material.PODZOL,Material.COARSE_DIRT,Material.ROOTED_DIRT,Material.MYCELIUM,Material.FARMLAND,Material.SNOW,Material.SNOW_BLOCK); + int i=0;for(int x=-1;x<=1;x++)for(int z=-1;z<=1;z++)block(x,64,z,soils.get(i++));Block bedrock=block(2,64,0,Material.BEDROCK),protectedBlock=block(-2,64,0,Material.GOLD_BLOCK),stone=block(0,64,2,Material.STONE); + try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(.1d))){ + ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),2,4,0,"artillery"); + assertEquals(Material.BEDROCK,bedrock.getType());assertEquals(Material.GOLD_BLOCK,protectedBlock.getType());assertEquals(Material.AIR,stone.getType());assertEquals(10,debris.size());for(FallingBlock falling:debris){verify(falling).setDropItem(false);assertTrue(falling.getVelocity().length()>0);} + assertTrue(debris.stream().anyMatch(falling->falling.getBlockData().getMaterial()==Material.COARSE_DIRT));verify(debris.getFirst()).setCancelDrop(true);logs.verify(()->LogWriter.logBreak(eq("artillery"),any(Block.class)),times(10));r.tick();verify(r.world,times(10)).spawnParticle(eq(Particle.BLOCK),any(Location.class),eq(8),eq(.1d),eq(.1d),eq(.1d),eq(0d),any(BlockData.class));for(FallingBlock falling:debris)when(falling.isDead()).thenReturn(true);r.ticks(2);assertEquals(0,r.pending()); + } + } + + @Test void blastRandomnessCanLeaveHardBlocksAndSuppressDebris() { + Cache.blockDamage=true;Block stone=block(0,64,0,Material.STONE),dirt=block(1,64,0,Material.DIRT); + try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(.99d))){ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,2,0,"test");assertEquals(Material.STONE,stone.getType());assertEquals(Material.AIR,dirt.getType());assertTrue(debris.isEmpty());} + dirt.setType(Material.AIR); + try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(.1d,.9d))){ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,2,0,"test");assertEquals(Material.AIR,stone.getType());assertTrue(debris.isEmpty());} + } + + @Test void debrisLandingRejectsInvalidGroundPillarsAndDistantUnluckyDebris() { + Cache.blockDamage=true;Cache.ignoreGround.add(Material.WATER); + for(String landing:List.of("water","pillar","supported","far")){ + blocks.clear();debris.clear();block(0,64,0,Material.DIRT); + try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(.1d))){ + ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,2,0,"test");assertEquals(1,debris.size());FallingBlock falling=debris.getFirst();int x=landing.equals("far")?80:10;falling.teleport(new Location(r.world,x,64,0));Block floor=block(x,63,0,landing.equals("water")?Material.WATER:Material.STONE);if(landing.equals("supported")||landing.equals("far"))block(x+1,63,0,Material.STONE);when(falling.isOnGround()).thenReturn(true);if(landing.equals("far"))when(random.constructed().getFirst().nextDouble()).thenReturn(.99d);r.tick();assertEquals(0,r.pending()); + if(landing.equals("supported")){verify(falling,never()).remove();Block landed=block(x,64,0);logs.verify(()->LogWriter.logPlace("test",landed));}else verify(falling).remove(); + } + } + } + + @Test void incendiaryBlastFindsSupportedAirButHonoursRandomChanceAndRadius() { + Cache.blockDamage=true;block(0,63,0,Material.BEDROCK);block(1,63,0,Material.BEDROCK);block(1,64,0,Material.WATER);Cache.ignoreExplode.add(Material.WATER); + try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(0d))){ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,2,0,"test",true);assertEquals(Material.FIRE,block(0,64,0).getType());assertEquals(Material.WATER,block(1,64,0).getType());assertEquals(Material.AIR,block(2,64,0).getType());} + block(0,64,0).setType(Material.AIR);try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(.99d))){ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,2,0,"test",true);assertEquals(Material.AIR,block(0,64,0).getType());} + } + + @Test void impactEffectsUseSurfaceNormalsAndOnlyNearbyViewers() { + Location inside=new Location(r.world,1,64,1);Vector direction=new Vector(0,0,1);assertNull(ImpactVfx.onBlockSurface(null,direction,null));Location nowhere=new Location(null,0,0,0);assertSame(nowhere,ImpactVfx.onBlockSurface(nowhere,direction,null));assertSame(inside,ImpactVfx.onBlockSurface(inside,null,null));assertEquals(inside,ImpactVfx.onBlockSurface(inside,new Vector(),null));assertEquals(new Location(r.world,1,64,.94),ImpactVfx.onBlockSurface(inside,direction,null));assertEquals(new Location(r.world,1,64,.94),ImpactVfx.onBlockSurface(inside,direction,block(1,64,1))); + when(r.world.rayTraceBlocks(any(Location.class),any(Vector.class),eq(3d),eq(FluidCollisionMode.NEVER),eq(true))).thenReturn(new RayTraceResult(new Vector(1,64,.5),BlockFace.NORTH));assertEquals(new Location(r.world,1,64,.44),ImpactVfx.onBlockSurface(inside,direction,null));when(r.world.rayTraceBlocks(any(Location.class),any(Vector.class),eq(3d),eq(FluidCollisionMode.NEVER),eq(true))).thenReturn(new RayTraceResult(new Vector(1,64,.5)));assertEquals(new Location(r.world,1,64,.44),ImpactVfx.onBlockSurface(inside,direction,null));assertEquals(new Vector(0,0,-.06),ImpactVfx.surfaceOffset(direction,new Vector())); + Player near=r.entity(Player.class,inside.clone()),far=r.entity(Player.class,inside.clone().add(321,0,0));when(r.world.getPlayers()).thenReturn(List.of(near,far));ImpactVfx.spawn(null,Particle.FLAME,1,0,0,0,0,null);ImpactVfx.spawn(nowhere,Particle.FLAME,1,0,0,0,0,null);ImpactVfx.spawn(inside,null,1,0,0,0,0,null);ImpactVfx.spawn(inside,Particle.FLAME,2,.1,.2,.3,.4,null);BlockData data=data(Material.STONE);ImpactVfx.spawn(inside,Particle.BLOCK,3,.1,.2,.3,.4,data);verify(near).spawnParticle(Particle.FLAME,inside,2,.1,.2,.3,.4);verify(near).spawnParticle(Particle.BLOCK,inside,3,.1,.2,.3,.4,data);verify(far,never()).spawnParticle(any(Particle.class),any(Location.class),anyInt(),anyDouble(),anyDouble(),anyDouble(),anyDouble()); + } + + @Test void tabCompletionReflectsPermissionsVehiclesAndCurrentWorldTracks(@org.junit.jupiter.api.io.TempDir java.nio.file.Path folder)throws Exception { + TabCompletion tabs=new TabCompletion();Command command=mock(Command.class);when(command.getName()).thenReturn("vf");Player player=r.entity(Player.class,new Location(r.world,0,64,0));assertNull(tabs.onTabComplete(mock(CommandSender.class),command,"vf",new String[]{""}));assertEquals(List.of("findvehicles","keybinds"),tabs.onTabComplete(player,command,"vf",new String[]{""}));assertEquals(List.of(),tabs.onTabComplete(player,command,"vf",new String[]{"spawn",""}));when(player.hasPermission(Permissions.Permission_Spawn)).thenReturn(true);assertEquals(List.of("findvehicles","keybinds","spawn","kill","ammo"),tabs.onTabComplete(player,command,"vf",new String[]{""})); + try(var loader=mockStatic(VehicleLoader.class)){Vehicle template=mock(Vehicle.class);when(template.getId()).thenReturn("wagon");loader.when(VehicleLoader::get).thenReturn(new HashMap<>(Map.of("wagon",template)));assertEquals(List.of("wagon"),tabs.onTabComplete(player,command,"vf",new String[]{"spawn",""}));} + when(player.hasPermission(Permissions.Permission_Admin)).thenReturn(true);assertTrue(tabs.onTabComplete(player,command,"vf",new String[]{""}).containsAll(List.of("takeover","reload","track")));assertEquals(List.of("start","end","list","info","particles","clearance","dump","delete","bind","unbind","resync"),tabs.onTabComplete(player,command,"vf",new String[]{"track",""}));assertEquals(List.of(),tabs.onTabComplete(player,command,"vf",new String[]{"track","delete",""})); + var registry=new net.tfminecraft.vehicleframework.tracks.TrackRegistry(folder.toFile());var local=net.tfminecraft.vehicleframework.tracks.TrackSpline.fromPoints(UUID.randomUUID(),r.world.getName(),false,List.of(new double[]{0,64,0},new double[]{4,64,0}));var elsewhere=net.tfminecraft.vehicleframework.tracks.TrackSpline.fromPoints(UUID.randomUUID(),"elsewhere",false,List.of(new double[]{0,64,0},new double[]{4,64,0}));registry.replace(local);registry.replace(elsewhere);r.framework.when(VehicleFramework::getTrackRegistry).thenReturn(registry);try{for(String sub:List.of("delete","clearance"))assertEquals(List.of(local.getId().toString()),tabs.onTabComplete(player,command,"vf",new String[]{"track",sub,""}));}finally{registry.close();} + assertEquals(List.of("10","20","50"),tabs.onTabComplete(player,command,"vf",new String[]{"kill",""}));assertNull(tabs.onTabComplete(player,command,"vf",new String[]{"track","info",""}));assertNull(tabs.onTabComplete(player,command,"vf",new String[]{"unknown",""}));when(command.getName()).thenReturn("other");assertNull(tabs.onTabComplete(player,command,"other",new String[]{""})); + } + + @Test void mythicDespawnUnloadsVehiclesAndEntityCleanupAlwaysRemovesBase() { + var event=mock(io.lumine.mythic.bukkit.events.MythicMobDespawnEvent.class);Entity entity=r.entity(Entity.class,new Location(r.world,0,64,0));when(event.getEntity()).thenReturn(entity);MythicMobsIntegration listener=new MythicMobsIntegration();listener.despawnEvent(event);verify(r.manager,never()).unload(any());ActiveVehicle vehicle=r.vehicle();when(r.manager.get(entity)).thenReturn(vehicle);listener.despawnEvent(event);verify(r.manager).unload(vehicle); + try(var api=mockStatic(com.ticxo.modelengine.api.ModelEngineAPI.class)){ + VehicleEntityCleanup.remove(entity);verify(entity).remove();var modeled=mock(com.ticxo.modelengine.api.model.ModeledEntity.class);var engine=mock(com.ticxo.modelengine.api.ModelEngineAPI.class,RETURNS_DEEP_STUBS);api.when(com.ticxo.modelengine.api.ModelEngineAPI::getAPI).thenReturn(engine);api.when(()->com.ticxo.modelengine.api.ModelEngineAPI.getModeledEntity(entity)).thenReturn(modeled);VehicleEntityCleanup.remove(entity);verify(engine.getModelUpdaters()).forceRemoveModeledEntity(modeled);verify(entity,times(2)).remove();var updaters=engine.getModelUpdaters();doThrow(new IllegalStateException("model callback failed")).when(updaters).forceRemoveModeledEntity(modeled);assertThrows(IllegalStateException.class,()->VehicleEntityCleanup.remove(entity));verify(entity,times(3)).remove(); + } + } + + @Test void relativeMoveSendsAllRelativeFieldsAndDisablesItselfAfterConnectionFailure(@org.junit.jupiter.api.io.TempDir java.nio.file.Path folder)throws Exception { + try(var boundary=new MovementBoundary(folder)){ + assertTrue(boundary.send(1.5,-2,3,45));Object packet=boundary.connection.getClass().getField("last").get(boundary.connection);assertEquals(-1,packet.getClass().getMethod("id").invoke(packet));Object change=packet.getClass().getMethod("change").invoke(packet),position=change.getClass().getMethod("position").invoke(change),velocity=change.getClass().getMethod("deltaMovement").invoke(change);assertEquals(1.5,position.getClass().getMethod("x").invoke(position));assertEquals(-2d,position.getClass().getMethod("y").invoke(position));assertEquals(3d,position.getClass().getMethod("z").invoke(position));assertEquals(0d,velocity.getClass().getMethod("x").invoke(velocity));assertEquals(45f,change.getClass().getMethod("yRot").invoke(change));assertEquals(0f,change.getClass().getMethod("xRot").invoke(change));assertEquals(9,((Set)packet.getClass().getMethod("relatives").invoke(packet)).size()); + assertTrue(boundary.send(0,0,0,0));boundary.connection.getClass().getField("fail").setBoolean(boundary.connection,true);assertFalse(boundary.send(1,1,1,0));boundary.connection.getClass().getField("fail").setBoolean(boundary.connection,false);assertFalse(boundary.send(1,1,1,0),"Unsupported transport remains disabled instead of flooding logs every tick"); + } + } + + @Test void relativeMoveUnsupportedServerFailsOnceWithoutTeleporting(@org.junit.jupiter.api.io.TempDir java.nio.file.Path folder)throws Exception { + try(var boundary=new MovementBoundary(folder)){ + Player unsupported=mock(Player.class);assertEquals(false,boundary.method.invoke(null,unsupported,1d,2d,3d,0f));assertFalse(boundary.send(1,2,3,0));verifyNoInteractions(unsupported); + } + } + + @Test void simultaneousFirstMovesSafelyShareTheReflectiveTransport(@org.junit.jupiter.api.io.TempDir java.nio.file.Path folder)throws Exception { + try(var boundary=new MovementBoundary(folder)){ + boundary.loader.pause.set(true);AtomicReference failure=new AtomicReference<>();List results=Collections.synchronizedList(new ArrayList<>()); + Runnable send=()->{try{results.add(boundary.send(1,0,0,0));}catch(Throwable e){failure.compareAndSet(null,e);}}; + Thread first=new Thread(send,"relative-move-first"),second=new Thread(send,"relative-move-second");first.start();assertTrue(boundary.loader.entered.await(5,java.util.concurrent.TimeUnit.SECONDS));second.start(); + try{long deadline=System.nanoTime()+java.util.concurrent.TimeUnit.SECONDS.toNanos(5);while(second.getState()!=Thread.State.BLOCKED&&System.nanoTime() sources=Map.of( + "net.minecraft.world.phys.Vec3","package net.minecraft.world.phys; public record Vec3(double x,double y,double z) {}", + "net.minecraft.world.entity.PositionMoveRotation","package net.minecraft.world.entity; public record PositionMoveRotation(net.minecraft.world.phys.Vec3 position,net.minecraft.world.phys.Vec3 deltaMovement,float yRot,float xRot) {}", + "net.minecraft.world.entity.Relative","package net.minecraft.world.entity; public final class Relative { public static final java.util.Set ALL=java.util.Set.of(\"X\",\"Y\",\"Z\",\"Y_ROT\",\"X_ROT\",\"DELTA_X\",\"DELTA_Y\",\"DELTA_Z\",\"ROTATE_DELTA\"); }", + "net.minecraft.network.protocol.Packet","package net.minecraft.network.protocol; public interface Packet {}", + "net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket","package net.minecraft.network.protocol.game; public record ClientboundPlayerPositionPacket(int id,net.minecraft.world.entity.PositionMoveRotation change,java.util.Set relatives) implements net.minecraft.network.protocol.Packet {}", + "fixture.Connection","package fixture; public class Connection { public Object last; public boolean fail; public void send(net.minecraft.network.protocol.Packet p){ if(fail)throw new IllegalStateException(\"closed connection\"); last=p;} }", + "fixture.ServerPlayer","package fixture; public class ServerPlayer { public final Connection connection=new Connection(); }", + "fixture.CraftPlayer","package fixture; public interface CraftPlayer extends org.bukkit.entity.Player { ServerPlayer getHandle(); }"); + List arguments=new ArrayList<>(List.of("-classpath",System.getProperty("java.class.path"),"-d",directory.toString(),"-proc:none")); + for(var entry:sources.entrySet()){var path=directory.resolve(entry.getKey().replace('.','/')+".java");java.nio.file.Files.createDirectories(path.getParent());java.nio.file.Files.writeString(path,entry.getValue());arguments.add(path.toString());} + assertEquals(0,javax.tools.ToolProvider.getSystemJavaCompiler().run(null,null,null,arguments.toArray(String[]::new))); + loader=new MovementLoader(new java.net.URL[]{directory.toUri().toURL(),RelativeMove.class.getProtectionDomain().getCodeSource().getLocation()});Class server=loader.loadClass("fixture.ServerPlayer");Object handle=server.getConstructor().newInstance();connection=server.getField("connection").get(handle);Class craft=loader.loadClass("fixture.CraftPlayer");player=(Player)mock(craft,invocation->invocation.getMethod().getName().equals("getHandle")?handle:RETURNS_DEFAULTS.answer(invocation));method=loader.loadClass(RelativeMove.class.getName()).getMethod("send",Player.class,double.class,double.class,double.class,float.class); + } + boolean send(double x,double y,double z,float yaw)throws Exception{return (boolean)method.invoke(null,player,x,y,z,yaw);} + public void close()throws Exception{loader.release.countDown();loader.close();} + } + private static final class MovementLoader extends java.net.URLClassLoader { + final java.util.concurrent.atomic.AtomicBoolean pause=new java.util.concurrent.atomic.AtomicBoolean();final java.util.concurrent.CountDownLatch entered=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1); + MovementLoader(java.net.URL[] urls){super(urls,RuntimeUtilitiesCoverageTest.class.getClassLoader());} + @Override protected Class loadClass(String name,boolean resolve)throws ClassNotFoundException { + if(name.equals("net.minecraft.world.phys.Vec3")&&pause.compareAndSet(true,false)){entered.countDown();try{if(!release.await(5,java.util.concurrent.TimeUnit.SECONDS))throw new ClassNotFoundException("Fixture initialization timed out");}catch(InterruptedException interrupted){Thread.currentThread().interrupt();throw new ClassNotFoundException(name,interrupted);}} + if(name.equals(RelativeMove.class.getName())){synchronized(getClassLoadingLock(name)){Class type=findLoadedClass(name);if(type==null)type=findClass(name);if(resolve)resolveClass(type);return type;}} + return super.loadClass(name,resolve); + } + } + + private World otherWorld(){World world=mock(World.class);when(world.getUID()).thenReturn(UUID.randomUUID());otherWorlds.add(world);return world;} + private void rail(Block block,Rail.Shape shape){Rail rail=mock(Rail.class);when(rail.getShape()).thenReturn(shape);block.setBlockData(rail,false);} + private BlockData data(Material material){BlockData d=material==Material.LIGHT?mock(Levelled.class):mock(BlockData.class);when(d.getMaterial()).thenReturn(material);return d;} + private Block block(int x,int y,int z,Material type){Block b=block(x,y,z);b.setType(type);return b;} + private Block block(int x,int y,int z){return blocks.computeIfAbsent(x+":"+y+":"+z,key->{ + Block b=mock(Block.class);AtomicReference type=new AtomicReference<>(Material.AIR);AtomicReference state=new AtomicReference<>(data(Material.AIR));when(b.getType()).thenAnswer(c->type.get());when(b.getBlockData()).thenAnswer(c->state.get());doAnswer(c->{Material m=c.getArgument(0);type.set(m);state.set(data(m));return null;}).when(b).setType(any(Material.class));doAnswer(c->{state.set(c.getArgument(0));return null;}).when(b).setBlockData(any(),anyBoolean());when(b.getLocation()).thenAnswer(c->new Location(r.world,x,y,z));when(b.getWorld()).thenReturn(r.world);when(b.getX()).thenReturn(x);when(b.getY()).thenReturn(y);when(b.getZ()).thenReturn(z);when(b.isLiquid()).thenAnswer(c->type.get()==Material.WATER||type.get()==Material.LAVA);when(b.isPassable()).thenAnswer(c->Set.of(Material.AIR,Material.LIGHT,Material.WATER,Material.LAVA,Material.KELP,Material.KELP_PLANT,Material.SEAGRASS,Material.TALL_SEAGRASS).contains(type.get()));when(b.getRelative(any(BlockFace.class))).thenAnswer(c->{BlockFace face=c.getArgument(0);return block(x+face.getModX(),y+face.getModY(),z+face.getModZ());});return b;});} +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/ActiveVehicleTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/ActiveVehicleTest.java new file mode 100644 index 0000000..5b2c6b0 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/ActiveVehicleTest.java @@ -0,0 +1,572 @@ +package net.tfminecraft.vehicleframework.vehicles; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.function.Consumer; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.plugin.PluginManager; +import org.bukkit.util.Vector; +import org.joml.AxisAngle4d; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import com.google.gson.JsonObject; +import com.ticxo.modelengine.api.ModelEngineAPI; +import com.ticxo.modelengine.api.model.*; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.bones.BoneRotator; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.data.*; +import net.tfminecraft.vehicleframework.database.*; +import net.tfminecraft.vehicleframework.enums.*; +import net.tfminecraft.vehicleframework.events.VehicleRemoveEvent; +import net.tfminecraft.vehicleframework.loaders.FuelLoader; +import net.tfminecraft.vehicleframework.managers.VehicleManager; +import net.tfminecraft.vehicleframework.util.ConditionChecker; +import net.tfminecraft.vehicleframework.util.VehicleEntityCleanup; +import net.tfminecraft.vehicleframework.vehicles.component.*; +import net.tfminecraft.vehicleframework.vehicles.component.fuel.FuelTank; +import net.tfminecraft.vehicleframework.vehicles.component.gear.Gear; +import net.tfminecraft.vehicleframework.vehicles.component.propulsion.Throttle; +import net.tfminecraft.vehicleframework.vehicles.controller.ScoreboardController; +import net.tfminecraft.vehicleframework.vehicles.fuel.Fuel; +import net.tfminecraft.vehicleframework.vehicles.handlers.*; +import net.tfminecraft.vehicleframework.vehicles.handlers.SeatHandler.MountResult; +import net.tfminecraft.vehicleframework.vehicles.handlers.container.*; +import net.tfminecraft.vehicleframework.vehicles.handlers.train.ConsistRelinker; +import net.tfminecraft.vehicleframework.weapons.ActiveWeapon; +import net.tfminecraft.vehicleframework.vehicles.seat.Seat; + +class ActiveVehicleTest { + private final List scopes = new ArrayList<>(); + private MockedStatic bukkit; + private MockedStatic models; + private MockedStatic cleanup; + private MockedStatic relinker; + private final World world = mock(World.class); + private final Entity entity = mock(Entity.class); + private final ActiveModel model = mock(ActiveModel.class, RETURNS_DEEP_STUBS); + private final Vehicle stored = mock(Vehicle.class); + private final VehicleManager manager = mock(VehicleManager.class); + private final PluginManager plugins = mock(PluginManager.class); + private Consumer components = ignored -> {}; + private Consumer behaviour = ignored -> {}; + private Consumer weapons = ignored -> {}; + private Consumer containers = ignored -> {}; + private Consumer constructing = ignored -> {}; + private int previousDistance; + + @BeforeEach + void setup() { + previousDistance = Cache.despawnDistance; + Cache.despawnDistance = 100; + bukkit = keep(mockStatic(Bukkit.class)); + models = keep(mockStatic(ModelEngineAPI.class)); + cleanup = keep(mockStatic(VehicleEntityCleanup.class)); + relinker = keep(mockStatic(ConsistRelinker.class)); + keep(mockStatic(VFLogger.class)); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); + when(entity.getWorld()).thenReturn(world); + when(entity.getLocation()).thenReturn(new Location(world, 1, 64, 2, 35, 0)); + when(entity.getVelocity()).thenReturn(new Vector(1, 2, 3)); + when(stored.getId()).thenReturn("cart"); + when(stored.getName()).thenReturn("Cart"); + when(stored.getModel()).thenReturn("cart_model"); + keep(mockConstruction(SkinHandler.class)); + keep(mockConstruction(BehaviourHandler.class, withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (handler, context) -> { + when(handler.getRotator().getAngles()).thenReturn(new AxisAngle4d()); + behaviour.accept(handler); + })); + keep(mockConstruction(StateHandler.class, withSettings().defaultAnswer(RETURNS_DEEP_STUBS))); + keep(mockConstruction(ComponentHandler.class, (handler, context) -> { + components.accept(handler); + constructing.accept((ActiveVehicle) context.arguments().getFirst()); + })); + keep(mockConstruction(SeatHandler.class)); + keep(mockConstruction(WeaponHandler.class, (handler, context) -> weapons.accept(handler))); + keep(mockConstruction(EffectHandler.class)); + keep(mockConstruction(DeathHandler.class)); + keep(mockConstruction(ScoreboardController.class)); + keep(mockConstruction(ContainerHandler.class, (handler, context) -> containers.accept(handler))); + keep(mockConstruction(TowHandler.class)); + keep(mockConstruction(UtilityHandler.class)); + } + + @AfterEach + void cleanupScopes() throws Exception { + Collections.reverse(scopes); + for (AutoCloseable scope : scopes) scope.close(); + Cache.despawnDistance = previousDistance; + } + + private T keep(T scope) { scopes.add(scope); return scope; } + private ActiveVehicle create() { return create(null); } + private ActiveVehicle create(IncompleteVehicle saved) { return new ActiveVehicle(stored, entity, model, manager, saved); } + private Player player(double x, World in) { + Player player = mock(Player.class); + when(player.getWorld()).thenReturn(in); + when(player.getLocation()).thenReturn(new Location(in, x, 64, 2)); + return player; + } + + @Test + void constructorBindsIdentityHandlersAndInitialState() { + when(stored.isFixed()).thenReturn(true); + when(stored.isTowable()).thenReturn(true); + when(stored.getEntitySeatWhitelist()).thenReturn(List.of("v.cow")); + ActiveVehicle v = create(); + assertTrue(v.isInitialized()); + assertFalse(v.isDestroyed()); + assertTrue(v.isFixed()); + assertTrue(v.isTowable()); + assertEquals("cart", v.getId()); + assertEquals("Cart", v.getName()); + assertDoesNotThrow(() -> UUID.fromString(v.getUUID())); + assertTrue(v.getSpawnTime() <= System.currentTimeMillis()); + assertSame(manager, v.getVehicleManager()); + assertSame(model, v.getModel()); + assertSame(entity, v.getEntity()); + assertEquals(entity.getLocation(), v.getLocation()); + assertNotNull(v.getAccessPanel()); + assertNotNull(v.getOwnerData()); + assertNotNull(v.getComponentHandler()); + assertNotNull(v.getSeatHandler()); + assertNotNull(v.getStateHandler()); + assertNotNull(v.getEffectHandler()); + assertNotNull(v.getBehaviourHandler()); + assertNotNull(v.getDeathHandler()); + assertNotNull(v.getSkinHandler()); + assertNotNull(v.getWeaponHandler()); + assertFalse(v.hasTowHandler()); + assertFalse(v.hasUtilityHandler()); + assertFalse(v.hasContainers()); + assertEquals(List.of("v.cow"), v.getEntitySeatWhitelist()); + verify(v.getStateHandler()).tick(); + verify(v.getAnimationHandler()).animate(Animation.DEFAULT); + verify(entity).setRotation(35, 0); + v.setName("Express"); + assertEquals("Express", v.getName()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void incompleteWithoutAnIdentityKeepsFreshUuid(String uuid) { + IncompleteVehicle saved = mock(IncompleteVehicle.class); + when(saved.getUUID()).thenReturn(uuid); + assertDoesNotThrow(() -> UUID.fromString(create(saved).getUUID())); + } + + @Test + void incompleteRestoresIdentityOwnershipFuelEngineWeaponsAndComponentState() { + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + SinkableHull hull = mock(SinkableHull.class, RETURNS_DEEP_STUBS); + when(hull.getType()).thenReturn(Component.HULL); + ActiveWeapon cannon = mock(ActiveWeapon.class, RETURNS_DEEP_STUBS); + when(cannon.getId()).thenReturn("cannon"); + IncompleteWeapon savedWeapon = mock(IncompleteWeapon.class); + when(savedWeapon.getId()).thenReturn("CANNON"); + when(savedWeapon.getDamage()).thenReturn(14d); + when(savedWeapon.getCount()).thenReturn(5); + components = handler -> { + when(handler.hasComponent(Component.ENGINE)).thenReturn(true); + when(handler.getComponent(Component.ENGINE)).thenReturn(engine); + when(handler.getComponents()).thenReturn(List.of(hull)); + when(engine.getFuelTank().hasInput()).thenReturn(true); + }; + weapons = handler -> when(handler.getWeapons()).thenReturn(List.of(cannon)); + when(stored.getContainerHandler()).thenReturn(mock(ContainerHandler.class)); + Container container = mock(Container.class); + containers = handler -> when(handler.get("cargo")).thenReturn(container); + JsonObject json = new JsonObject(); json.addProperty("id", "cargo"); + IncompleteVehicle saved = new IncompleteVehicle("saved-uuid", "cart", "Saved cart", "blue", + List.of(new IncompleteComponent(Component.HULL, 25, 30, 40)), List.of(savedWeapon), + List.of(), List.of(), List.of(json), 20, 2, 90, 42, "player_A", true, List.of("player_B")); + ActiveVehicle v = create(saved); + assertEquals("saved-uuid", v.getUUID()); + assertEquals("Saved cart", v.getName()); + assertEquals("player_A", v.getOwnerData().getOwner()); + assertTrue(v.getOwnerData().isWhiteListed()); + assertEquals(List.of("player_B"), v.getOwnerData().getWhiteList()); + verify(engine).setStarted(true); + verify(engine.getThrottle()).setThrottle(20); + verify(engine.getFuelTank()).setFuel(42); + verify(hull.getHealthData()).setDamage(25); + verify(hull).setSinkProgress(40); + verify(hull).startFire(); + verify(hull.getFire()).setProgress(30); + verify(cannon.getHealthData()).setDamage(14); + verify(cannon.getAmmunitionHandler()).setAmmo(null, 5); + verify(container).loadFromJson(json); + assertSame(container, v.getContainerHandler().get("cargo")); + } + + @Test + void savedTrainRestoresGearConsistAndRotations() { + GearedEngine engine = mock(GearedEngine.class, RETURNS_DEEP_STUBS); + BoneRotator rotator = mock(BoneRotator.class); + when(rotator.getId()).thenReturn("rudder"); + components = handler -> { + when(handler.hasComponent(Component.GEARED_ENGINE)).thenReturn(true); + when(handler.getComponent(Component.GEARED_ENGINE)).thenReturn(engine); + }; + behaviour = handler -> when(handler.isTrain()).thenReturn(true); + IncompleteVehicle saved = mock(IncompleteVehicle.class); + when(saved.getUUID()).thenReturn("train"); + when(saved.getThrottle()).thenReturn(12); + when(saved.getGear()).thenReturn(3); + net.tfminecraft.vehicleframework.tracks.ThrottleTape tape = new net.tfminecraft.vehicleframework.tracks.ThrottleTape("line"); + when(saved.getThrottleTape()).thenReturn(tape); + constructing = v -> v.getAccessPanel().addRotator(rotator); + RotationData rotation = mock(RotationData.class); + when(rotation.getRotator()).thenReturn("RUDDER"); + when(rotation.getX()).thenReturn(1f); when(rotation.getY()).thenReturn(2f); + when(rotation.getZ()).thenReturn(3f); when(rotation.getW()).thenReturn(4f); + when(saved.getRotations()).thenReturn(List.of(rotation)); + ActiveVehicle v = create(saved); + verify(engine).setStarted(true); + verify(engine).setCurrentGear(3); + verify(engine.getGear().getThrottle()).setThrottle(12); + verify(v.getTrainHandler()).applyConsist(null); + verify(v.getTrainHandler().getOverdrive()).restore(null); + verify(v.getTrainHandler()).setInstalledTape(tape); + verify(rotator).rawSet(1, 2, 3, 4); + ActiveVehicle parent = mock(ActiveVehicle.class); + v.setParent(v); + assertFalse(v.hasParent()); + v.setParent(parent); + assertSame(parent, v.getParent()); + assertSame(parent, v.ticketSource()); + verify(v.getTrainHandler()).setPendingParent(null); + when(v.getTrainHandler().isLocomotive()).thenReturn(true); + assertTrue(v.isLocomotive()); + v.setParent(null); + assertSame(v, v.ticketSource()); + } + + @Test + void skinChangeRebindsAllModelConsumersOnlyAfterValidation() { + ActiveVehicle v = create(); + assertFalse(v.changeSkin("missing")); + verify(model, never()).destroy(); + when(v.getSkinHandler().canChangeSkin("blue", false)).thenReturn(true); + when(v.getSkinHandler().changeSkin("blue")).thenReturn("blue_model"); + when(model.getBlueprint().getName()).thenReturn("old_model"); + ActiveModel replacement = mock(ActiveModel.class, RETURNS_DEEP_STUBS); + var mounts = mock(com.ticxo.modelengine.api.model.bone.manager.MountManager.class, + withSettings().extraInterfaces(com.ticxo.modelengine.api.model.bone.manager.BehaviorManager.class)); + doReturn(Optional.of(mounts)).when(replacement).getMountManager(); + ModeledEntity modeled = mock(ModeledEntity.class); + models.when(() -> ModelEngineAPI.getModeledEntity(entity)).thenReturn(modeled); + models.when(() -> ModelEngineAPI.createActiveModel("blue_model")).thenReturn(replacement); + assertTrue(v.changeSkin("blue")); + assertSame(replacement, v.getModel()); + verify(model).destroy(); + verify(modeled).removeModel("old_model"); + verify(modeled).addModel(replacement, true); + verify(replacement.getMountManager().get()).setCanRide(true); + verify(v.getBehaviourHandler()).updateModel(replacement); + verify(v.getStateHandler()).updateModel(replacement); + verify(v.getComponentHandler()).updateModel(replacement); + verify(v.getSeatHandler()).updateModel(replacement); + verify(v.getWeaponHandler()).updateModel(replacement); + } + + @Test + void nearbyPlayersAndBoardsRespectWorldDistanceAndPassengerType() { + ActiveVehicle v = create(); + Player near = player(2, world), far = player(50, world), otherWorld = player(2, mock(World.class)); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(near, far, otherWorld)); + v.updateNearby(); + assertEquals(List.of(near), v.getNearbyPlayers()); + when(v.getSeatHandler().getPassengers()).thenReturn(List.of(near, entity)); + v.updateBoard(); + verify(v.sb).scoreboard(near); + v.removeBoard(near); + verify(v.sb).removeScoreboard(near); + } + + @Test + void riderInputDamageAndAnimationReachTheirHandlers() { + ActiveVehicle v = create(); + Player player = player(2, world); + v.key(player, Keybind.W); + verify(v.getStateHandler(), never()).key(any(), any()); + when(v.getSeatHandler().isMounted(player)).thenReturn(true); + v.key(player, Keybind.W); + verify(v.getStateHandler()).key(player, Keybind.W); + verify(v.getWeaponHandler()).input(v.getNearbyPlayers(), Keybind.W, player); + v.damage("SUFFOCATION", 7); + verify(v.getComponentHandler(), never()).damage(anyString(), anyDouble()); + v.damage("fire", 8); + verify(v.getComponentHandler()).damage("fire", 8); + verify(v.getWeaponHandler()).damage("fire", 8); + v.randomFire(); verify(v.getComponentHandler()).randomFire(); + v.animate(Animation.DEFAULT); v.stopAnimation(Animation.DEFAULT); v.setAnimationSpeed(Animation.DEFAULT, 2); + verify(v.getAnimationHandler(), times(2)).animate(Animation.DEFAULT); + verify(v.getAnimationHandler()).stop(Animation.DEFAULT); + verify(v.getAnimationHandler()).setSpeed(Animation.DEFAULT, 2); + assertSame(v.getStateHandler().getMoveControls(), v.getMoveControls()); + assertSame(v.getStateHandler().getCurrentState(), v.getCurrentState()); + assertFalse(v.shouldFloat()); + assertSame(v, v.ticketSource()); + assertFalse(v.isLocomotive()); + assertEquals(1, v.getParameterValue("vx")); + assertEquals(2, v.getParameterValue("vY")); + assertEquals(3, v.getParameterValue("vz")); + for (String parameter : List.of("yaw", "pitch", "roll", "unknown")) assertEquals(0, v.getParameterValue(parameter)); + when(v.getEffectHandler().hasEffect(CustomAction.values()[0])).thenReturn(true); + assertTrue(v.hasEffect(CustomAction.values()[0])); + v.playEffect(CustomAction.values()[0]); + verify(v.getEffectHandler()).playEffect(v.getNearbyPlayers(), v, CustomAction.values()[0]); + } + + @Test + void optionalUtilitiesAndTickHandlersAreInvoked() { + when(stored.getTowHandler()).thenReturn(mock(TowHandler.class)); + when(stored.getUtilityHandler()).thenReturn(mock(UtilityHandler.class)); + ActiveVehicle v = create(); + Player player = player(2, world); + v.toggleLights(player); v.honk(player); v.tick(); v.slowTick(); + assertTrue(v.hasTowHandler()); assertTrue(v.hasUtilityHandler()); + verify(v.getUtilityHandler()).toggleLights(player); + verify(v.getUtilityHandler()).honk(player, v.getLocation()); + verify(v.getUtilityHandler()).tick(v.getNearbyPlayers()); + verify(v.getTowHandler()).tick(); + verify(v.getBehaviourHandler()).tick(v); + verify(v.getWeaponHandler()).tick(); + verify(v.getComponentHandler()).fireSpread(); + verify(v.getSeatHandler()).slowTick(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void enginesExposeFuelThrottleAndBrakeWithoutCrossingMinimum(boolean geared) { + ActiveVehicle v = create(); + FuelTank tank = mock(FuelTank.class); + Throttle throttle = mock(Throttle.class); + if (geared) { + GearedEngine engine = mock(GearedEngine.class, RETURNS_DEEP_STUBS); + when(v.componentHandler.hasComponent(Component.GEARED_ENGINE)).thenReturn(true); + when(v.componentHandler.getComponent(Component.GEARED_ENGINE)).thenReturn(engine); + when(engine.getFuelTank()).thenReturn(tank); + when(engine.getGear().getThrottle()).thenReturn(throttle); + when(engine.getGear().getAcceleration()).thenReturn(3); + } else { + Engine engine = mock(Engine.class); + when(v.componentHandler.hasComponent(Component.ENGINE)).thenReturn(true); + when(v.componentHandler.getComponent(Component.ENGINE)).thenReturn(engine); + when(engine.getFuelTank()).thenReturn(tank); + when(engine.getThrottle()).thenReturn(throttle); + when(engine.getSpeed()).thenReturn(4.5); + assertEquals(4.5, v.getSpeed()); + assertTrue(v.shouldAutoMove()); + } + assertSame(throttle, v.getThrottle()); + assertFalse(v.usesFuel()); + v.setFuel(30); verify(tank, never()).setFuel(anyDouble()); + when(tank.hasInput()).thenReturn(true); + assertTrue(v.usesFuel()); + v.setFuel(30); verify(tank).setFuel(30); + assertFalse(v.hasFuel()); when(tank.getCurrent()).thenReturn(2d); assertTrue(v.hasFuel()); + when(throttle.getCurrent()).thenReturn(5); + when(throttle.getMin()).thenReturn(0); + v.applyBreakBraking(); + if (geared) verify(throttle).change(-3); else verify(throttle).decrease(); + clearInvocations(throttle); + when(throttle.getCurrent()).thenReturn(0); + v.applyBreakBraking(); + verify(throttle, never()).decrease(); verify(throttle, never()).change(anyInt()); + } + + @Test + void refuelingRejectsUnknownAndWrongFuelAndDelegatesMatchingInput() { + ActiveVehicle v = create(); Player player = player(1, world); + try (MockedStatic loader = mockStatic(FuelLoader.class)) { + assertFalse(v.hasFuel()); assertFalse(v.usesFuel()); assertNull(v.getThrottle()); + assertEquals(0, v.getSpeed()); assertFalse(v.shouldAutoMove()); + v.setFuel(1); v.refuel(player, "unknown"); v.applyBreakBraking(); v.honk(player); v.toggleLights(player); + Fuel fuel = mock(Fuel.class); when(fuel.getId()).thenReturn("coal"); when(fuel.getAmount()).thenReturn(5); + loader.when(() -> FuelLoader.getByInput("coal-item")).thenReturn(fuel); + v.refuel(player, "coal-item"); + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + when(v.componentHandler.hasComponent(Component.ENGINE)).thenReturn(true); + when(v.componentHandler.getComponent(Component.ENGINE)).thenReturn(engine); + v.refuel(player, "coal-item"); + FuelTank tank = engine.getFuelTank(); + when(tank.hasInput()).thenReturn(true); + when(tank.getInput().getId()).thenReturn("diesel"); + when(tank.getInput().getName()).thenReturn("Diesel"); + v.refuel(player, "coal-item"); + verify(player).sendMessage("§cThis vehicle only accepts Diesel §cas fuel"); + when(tank.getInput().getId()).thenReturn("COAL"); + v.refuel(player, "coal-item"); + verify(tank).refuel(player, v, 5); + } + } + + @Test + void gearedEngineRefuelsFromItsOwnTank() { + ActiveVehicle v = create(); Player player = player(1, world); + GearedEngine engine = mock(GearedEngine.class, RETURNS_DEEP_STUBS); + when(v.componentHandler.hasComponent(Component.GEARED_ENGINE)).thenReturn(true); + when(v.componentHandler.getComponent(Component.GEARED_ENGINE)).thenReturn(engine); + when(engine.getFuelTank().hasInput()).thenReturn(true); + when(engine.getFuelTank().getInput().getId()).thenReturn("coal"); + Fuel fuel = mock(Fuel.class); when(fuel.getId()).thenReturn("coal"); when(fuel.getAmount()).thenReturn(3); + try (MockedStatic loader = mockStatic(FuelLoader.class)) { + loader.when(() -> FuelLoader.getByInput("item")).thenReturn(fuel); + v.refuel(player, "item"); + } + verify(engine.getFuelTank()).refuel(player, v, 3); + } + + @Test + void seatsRequireOccupancyAndSuccessfulMountsBeforeUpdatingBoards() { + ActiveVehicle v = create(); Player player = player(1, world); + Seat seat = mock(Seat.class), empty = mock(Seat.class); + when(seat.getEntity()).thenReturn(player); when(seat.getType()).thenReturn(SeatType.ENTITY); + v.panel.addSeat(empty); v.panel.addSeat(seat); + assertFalse(v.isPassenger(player, true)); + when(seat.isOccupied()).thenReturn(true); + assertTrue(v.isPassenger(player, true)); assertFalse(v.isPassenger(entity, true)); + assertTrue(v.isPassenger(player, SeatType.ENTITY)); assertFalse(v.isPassenger(player, SeatType.HARNESS)); + assertFalse(v.isPassenger(entity, SeatType.ENTITY)); + when(v.seatHandler.isPassenger(player)).thenReturn(true); assertTrue(v.isPassenger(player, false)); + when(v.seatHandler.getSeat("entity")).thenReturn(seat); assertSame(seat, v.getSeat("entity")); + assertEquals(MountResult.UNAVAILABLE, v.changeSeat(player, seat)); + when(v.seatHandler.getSeat(player)).thenReturn(seat); assertSame(seat, v.getSeat(player)); + for (MountResult result : MountResult.values()) { + when(v.seatHandler.addPassenger(player, seat)).thenReturn(result); + when(v.seatHandler.changeSeat(player, seat)).thenReturn(result); + assertEquals(result, v.addPassenger(player, seat)); + assertEquals(result, v.changeSeat(player, seat)); + } + v.dismountPassenger(player, true); verify(v.seatHandler).dismountPassenger(player, true); + v.dismountAll(); verify(v.seatHandler).dismountAll(); verify(seat).dismount(); verify(empty, never()).dismount(); + VehicleComponent hull = mock(VehicleComponent.class); + List components = List.of(hull); + when(v.componentHandler.getComponents()).thenReturn(components); + when(v.componentHandler.getComponents(Component.HULL)).thenReturn(components); + assertSame(components, v.getComponents()); + assertSame(components, v.getComponents(Component.HULL)); + } + + @Test + void savedPassengersSkipMissingDeadOfflineAndOccupiedSeatsAndRecoverRejectedEntities() { + ActiveVehicle v = create(); + v.restorePassengers(null); + IncompleteVehicle saved = mock(IncompleteVehicle.class); + Entity animal = mock(Entity.class); UUID animalId = UUID.randomUUID(); + Entity dead = mock(Entity.class); UUID deadId = UUID.randomUUID(); when(dead.isDead()).thenReturn(true); + Player player = player(1, world), offline = player(1, world); when(player.isOnline()).thenReturn(true); + bukkit.when(() -> Bukkit.getEntity(animalId)).thenReturn(animal); + bukkit.when(() -> Bukkit.getEntity(deadId)).thenReturn(dead); + bukkit.when(() -> Bukkit.getPlayerExact("present")).thenReturn(player); + bukkit.when(() -> Bukkit.getPlayerExact("offline")).thenReturn(offline); + Seat seat = mock(Seat.class); when(seat.getBone()).thenReturn("seat"); + when(v.seatHandler.getSeat("seat")).thenReturn(seat); + when(v.seatHandler.addPassenger(animal, seat)).thenReturn(MountResult.REJECTED); + when(saved.getPassengers()).thenReturn(List.of(new PassengerData(animalId, "seat"), + new PassengerData(deadId, "seat"), new PassengerData(UUID.randomUUID(), "seat"), + new PassengerData("present", "seat"), new PassengerData("offline", "seat"), new PassengerData("absent", "seat"))); + v.restorePassengers(saved); + verify(manager).mount(player, "seat", v); + verify(manager).recoverRejectedMount(v, animal, "seat", null); + verify(v.seatHandler).addPassenger(animal, seat); + when(seat.isOccupied()).thenReturn(true); + v.restorePassengers(saved); + verify(v.seatHandler, times(1)).addPassenger(animal, seat); + } + + @ParameterizedTest + @EnumSource(VehicleDeath.class) + void eachConfiguredDeathPersistsDismountsAndDispatchesItsHandler(VehicleDeath cause) { + DeathData data = mock(DeathData.class); when(data.getType()).thenReturn(cause); + when(stored.getDeathData()).thenReturn(List.of(data)); + when(stored.getContainerHandler()).thenReturn(mock(ContainerHandler.class)); + ActiveVehicle v = create(); + assertTrue(v.hasDeathData(cause)); assertSame(data, v.getDeathData(cause)); + v.kill(cause); + assertTrue(v.isDestroyed()); + verify(manager).persistDestroy(v); + verify(v.seatHandler).dismountAll(); + switch (cause) { + case CRASH -> verify(v.deathHandler).crash(); + case EXPLODE -> verify(v.deathHandler).explode(true); + case SINK -> verify(v.deathHandler).sink(); + case DIE -> verify(v.deathHandler).die(); + } + verify(v.containerHandler).destroy(v.getLocation()); + v.remove(); + ArgumentCaptor event = ArgumentCaptor.forClass(VehicleRemoveEvent.class); + verify(plugins).callEvent(event.capture()); + assertEquals(Optional.of(cause), event.getValue().getPayload().getDeathCause()); + } + + @Test + void unconfiguredDeathIsIgnoredAndMatchingOverrideChoosesConfiguredDeath() { + ActiveVehicle v = create(); + assertNull(v.getDeathData(VehicleDeath.EXPLODE)); + v.kill(VehicleDeath.EXPLODE); assertFalse(v.isDestroyed()); + DeathData explosion = mock(DeathData.class), sink = mock(DeathData.class); + when(explosion.getType()).thenReturn(VehicleDeath.EXPLODE); when(sink.getType()).thenReturn(VehicleDeath.SINK); + DeathOverride skip = mock(DeathOverride.class), match = mock(DeathOverride.class); + when(skip.getConditions()).thenReturn(List.of("false")); when(match.getConditions()).thenReturn(List.of("true")); + when(match.getDeath()).thenReturn(VehicleDeath.SINK); + when(explosion.hasOverrides()).thenReturn(true); when(explosion.getOverrides()).thenReturn(List.of(skip, match)); + v.deathData = List.of(explosion, sink); + try (MockedStatic conditions = mockStatic(ConditionChecker.class)) { + conditions.when(() -> ConditionChecker.checkConditions(v, List.of("true"))).thenReturn(true); + v.kill(VehicleDeath.EXPLODE); + } + verify(v.deathHandler).sink(); verify(v.deathHandler, never()).explode(anyBoolean()); + } + + @Test + void componentTicksHandleFireFatalDamageAndSinking() { + ActiveVehicle v = create(); + SinkableHull hull = mock(SinkableHull.class, RETURNS_DEEP_STUBS); + when(v.componentHandler.getComponents()).thenReturn(List.of(hull)); + assertFalse(v.isOnFire()); when(hull.isOnFire()).thenReturn(true); assertTrue(v.isOnFire()); + when(hull.isFatal()).thenReturn(true); + when(hull.getFire().getProgress()).thenReturn(100); + when(hull.hasSinkProgress()).thenReturn(true); when(hull.getSinkProgress()).thenReturn(100); + v.tick(); v.slowTick(); + verify(hull).tick(v.nearby); verify(hull).slowTick(v.nearby); + v.forceDestroy(); v.tick(); assertTrue(v.isDestroyed()); + } + + @Test + void removalIsIdempotentAndRejectsSubsequentRiderInput() { + ActiveVehicle v = create(); Player player = player(1, world); + when(v.seatHandler.isMounted(player)).thenReturn(true); + v.remove((VehicleRemovePayload) null); v.remove(); v.key(player, Keybind.W); + verify(v.seatHandler).dismountAll(); verify(manager).unregister(entity); + cleanup.verify(() -> VehicleEntityCleanup.remove(entity)); + verify(v.stateHandler, never()).key(any(), any()); + ArgumentCaptor event = ArgumentCaptor.forClass(VehicleRemoveEvent.class); + verify(plugins).callEvent(event.capture()); + assertEquals(Optional.of(VehicleRemoveReason.GENERIC), event.getValue().getPayload().getRemoveReason()); + } + + @Test + void removalAlwaysCleansTrainDeckAndEntityEvenWhenEventsFail() { + behaviour = handler -> when(handler.isTrain()).thenReturn(true); + ActiveVehicle v = create(); v.forceDestroy(); + RuntimeException failedEvent = new IllegalStateException("listener failed"); + doThrow(failedEvent).when(plugins).callEvent(any()); + assertSame(failedEvent, assertThrows(RuntimeException.class, () -> v.remove((VehicleRemoveReason) null))); + verify(manager).unregister(entity); verify(v.getTrainHandler()).removeDeck(); + cleanup.verify(() -> VehicleEntityCleanup.remove(entity)); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/component/GearedEngineCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/component/GearedEngineCoverageTest.java new file mode 100644 index 0000000..12f7122 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/component/GearedEngineCoverageTest.java @@ -0,0 +1,484 @@ +package net.tfminecraft.vehicleframework.vehicles.component; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.scheduler.*; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.bone.ModelBone; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.database.PersistenceLog; +import net.tfminecraft.vehicleframework.data.ParticleData; +import net.tfminecraft.vehicleframework.effects.CustomEffect; +import net.tfminecraft.vehicleframework.enums.Animation; +import net.tfminecraft.vehicleframework.enums.CustomAction; +import net.tfminecraft.vehicleframework.enums.Input; +import net.tfminecraft.vehicleframework.enums.State; +import net.tfminecraft.vehicleframework.loaders.FuelLoader; +import net.tfminecraft.vehicleframework.util.ParticleLoader; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.component.propulsion.Throttle; +import net.tfminecraft.vehicleframework.vehicles.controller.VehicleMovementController; +import net.tfminecraft.vehicleframework.vehicles.fuel.Fuel; +import net.tfminecraft.vehicleframework.vehicles.handlers.*; +import net.tfminecraft.vehicleframework.vehicles.util.AccessPanel; + +class GearedEngineCoverageTest { + private final List scopes = new ArrayList<>(); + private final List timers = new ArrayList<>(), later = new ArrayList<>(); + private final BukkitScheduler scheduler = mock(BukkitScheduler.class); + private MockedStatic fuels; + private VehicleFramework previousPlugin; + private final World world = mock(World.class); + + @BeforeEach void setup() { + previousPlugin = VehicleFramework.plugin; + VehicleFramework.plugin = mock(VehicleFramework.class); + MockedStatic bukkit = keep(mockStatic(Bukkit.class)); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong())) + .thenAnswer(call -> task(call.getArgument(1), timers)); + when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())) + .thenAnswer(call -> task(call.getArgument(1), later)); + fuels = keep(mockStatic(FuelLoader.class)); + keep(mockStatic(PersistenceLog.class)); + } + + @AfterEach void cleanup() throws Exception { + Collections.reverse(scopes); + for (AutoCloseable scope : scopes) scope.close(); + VehicleFramework.plugin = previousPlugin; + } + + @Test void templateAndCopiesPreserveGearDefinitionsButHaveIndependentThrottle() { + YamlConfiguration config = config(true); + config.set("start-gear", 1); + GearedEngine template = new GearedEngine(config); + Fixture f = fixture(template); + assertTrue(f.engine.requiresStart()); + assertFalse(f.engine.isStarted()); + assertEquals(1, f.engine.getDefaultGear()); + assertEquals(1, f.engine.getCurrentGear()); + assertEquals(2.5, template.getTurnRate()); + assertEquals(3, f.engine.getGears().size()); + assertEquals("gear1", f.engine.getGear().getName()); + f.engine.getGear().getThrottle().setThrottle(25); + assertEquals(0, template.getGear().getThrottle().getCurrent()); + assertEquals(.5, f.engine.getSpeed(), 1e-9); + assertSame(template.getParticles(), f.engine.getParticles()); + assertSame(template.getParticleBones(), f.engine.getParticleBones()); + f.engine.setCurrentGear(-1); + f.engine.setCurrentGear(3); + assertEquals(1, f.engine.getCurrentGear()); + f.engine.setStarted(true); + assertTrue(f.engine.isStarted()); + } + + @Test void liveTurnRateCanScaleWithForwardOrReverseThrottle() { + Fixture f = fixture(false); + when(f.behaviour.turnScale()).thenReturn(true); + f.throttle().setThrottle(-20); + assertEquals(-.5, f.engine.getTurnRate()); + when(f.behaviour.turnScale()).thenReturn(false); + assertEquals(2.5, f.engine.getTurnRate()); + } + + @Test void acceleratorChangesByGearIncrementAndHonoursHardLimits() { + Fixture f = fixture(false); + f.engine.throttle(false); + assertEquals(5, f.throttle().getCurrent()); + f.engine.throttle(true); + assertEquals(0, f.throttle().getCurrent()); + f.throttle().setThrottle(-100); + f.engine.throttle(true); + assertEquals(-100, f.throttle().getCurrent()); + f.engine.setCurrentGear(2); + f.throttle().setThrottle(120); + f.engine.throttle(false); + assertEquals(120, f.throttle().getCurrent()); + verify(world, times(2)).playSound(any(Location.class), eq("accelerate"), eq(1f), eq(1f)); + later.forEach(Runnable::run); + f.engine.throttle(true); + assertEquals(115, f.throttle().getCurrent()); + } + + @Test void shiftingRampsDownAndIgnoresInputUntilTheShiftCompletes() { + Fixture f = fixture(false); + f.throttle().setThrottle(120); + f.engine.throttle(false); + assertTrue(f.engine.isShifting()); + assertEquals(1, timers.size()); + f.engine.throttle(true); + assertEquals(120, f.throttle().getCurrent()); + Runnable shift = timers.get(0); + for (int i = 0; i < 10; i++) shift.run(); + assertEquals(110, f.throttle().getCurrent()); + assertEquals(0, f.engine.getCurrentGear()); + shift.run(); + assertEquals(1, f.engine.getCurrentGear()); + assertFalse(f.engine.isShifting()); + f.throttle().setThrottle(-100); + f.engine.throttle(true); + for (int i = 0; i < 11; i++) timers.get(1).run(); + assertEquals(0, f.engine.getCurrentGear()); + assertFalse(f.engine.isShifting()); + } + + @ParameterizedTest @ValueSource(ints = {80, -80}) + void damagedEnginePublishesOnlyItsCurrentSafeSpeed(int demand) { + Fixture f = fixture(false); + when(f.behaviour.turnScale()).thenReturn(true); + f.engine.getHealthData().setDamage(60); + f.throttle().setThrottle(demand); + f.engine.tick(List.of()); + assertEquals(Integer.signum(demand) * 40, f.throttle().getCurrent()); + verify(f.panel).setSpeed(Integer.signum(demand) * .4); + verify(f.panel).setTurnRate(Integer.signum(demand)); + verify(f.panel).setReverse(demand < 0); + } + + @Test void tapePlaybackStartsAndChangesThrottleOneStepWithoutNormalizingOrRecording() { + Fixture f = fixture(true); + when(f.train.playbackThrottle(f.engine.getFuelTank())).thenReturn(40); + f.engine.tick(List.of()); + assertTrue(f.engine.isStarted()); + assertEquals(1, f.throttle().getCurrent()); + verify(f.train, never()).maybeRecordSample(anyInt()); + verify(f.vehicle).animate(Animation.ENGINE_ACTIVE); + verify(f.controls).input(null, Input.MOVE); + when(f.train.playbackThrottle(f.engine.getFuelTank())).thenReturn(-20); + f.engine.tick(List.of()); + assertEquals(0, f.throttle().getCurrent()); + } + + @Test void rootTrainRecordsThrottleAndDrainsFuelFromItsTender() { + Fixture f = fixture(false); + f.throttle().setThrottle(30); + f.engine.tick(List.of()); + verify(f.train).maybeRecordSample(30); + f.engine.getFuelTank().setFuel(10); + f.engine.slowTick(List.of()); + verify(f.train).drainFromChild(f.engine.getFuelTank()); + assertEquals(8, f.engine.getFuelTank().getCurrent()); + when(f.vehicle.hasParent()).thenReturn(true); + f.engine.slowTick(List.of()); + verify(f.train, times(1)).drainFromChild(any()); + } + + @Test void stoppedEngineDoesNotBurnFuelAndIdleEngineMakesNoSound() { + Fixture f = fixture(true); + f.engine.getFuelTank().setFuel(10); + f.engine.slowTick(List.of()); + assertEquals(10, f.engine.getFuelTank().getCurrent()); + verifyNoInteractions(world); + } + + @ParameterizedTest @ValueSource(ints = {-100, 50, 120}) + void enginePitchFollowsThrottleWithinAudibleLimits(int throttle) { + Fixture f = fixture(false); + Player listener = mock(Player.class); + f.throttle().setThrottle(throttle); + f.engine.playSound(List.of(listener)); + verify(listener).playSound(any(Location.class), eq("engine"), eq(1f), + eq(Math.max(.5f, Math.min(1.6f, .5f + throttle / 100f)))); + } + + @Test void healthyEngineStartsImmediatelyAndPlayerCooldownPreventsDuplicateStart() { + Fixture f = fixture(true); + Player driver = mock(Player.class); + f.engine.setCurrentGear(2); + f.engine.start(driver); + assertTrue(f.engine.isStarted()); + assertEquals(0, f.engine.getCurrentGear()); + assertEquals(1, f.throttle().getCurrent()); + f.engine.start(driver); + assertEquals(1, f.throttle().getCurrent()); + verify(driver).sendMessage("§e[Motor] §astarted!"); + f.engine.stop(); + assertFalse(f.engine.isStarted()); + assertEquals(0, f.throttle().getCurrent()); + verify(f.vehicle).stopAnimation(Animation.ENGINE_ACTIVE); + } + + @Test void customStartEffectKeepsEngineOffUntilFinishedAndIgnoresOtherStarts() { + Fixture f = fixture(true); + CustomEffect effect = mock(CustomEffect.class); + when(f.vehicle.hasEffect(CustomAction.ENGINE_START)).thenReturn(true); + when(f.vehicle.playEffect(CustomAction.ENGINE_START)).thenReturn(effect); + Player driver = mock(Player.class); + f.engine.start(driver); + f.engine.start(mock(Player.class)); + assertEquals(1, timers.size()); + timers.get(0).run(); + assertFalse(f.engine.isStarted()); + when(effect.isFinished()).thenReturn(true); + timers.get(0).run(); + assertTrue(f.engine.isStarted()); + assertEquals(1, f.throttle().getCurrent()); + } + + @Test void engineNeedingFuelCannotStartAnEmptyTank() { + fuelRequired(); + Fixture f = fixture(true); + Player driver = mock(Player.class); + f.engine.start(driver); + assertFalse(f.engine.isStarted()); + verify(driver).sendMessage("§e[Motor] §cNo fuel!"); + assertTrue(timers.isEmpty()); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void damagedStartFailureResolvesWithOrWithoutItsCustomEffect(boolean customEffect) { + Fixture f = fixture(true); + GearedEngine engine = spy(f.engine); + engine.getHealthData().setDamage(60); + doReturn(80.).when(engine).rollStartChance(); + Player driver = mock(Player.class); + CustomEffect effect = mock(CustomEffect.class); + when(f.vehicle.hasEffect(CustomAction.ENGINE_START_FAIL)).thenReturn(customEffect); + when(f.vehicle.playEffect(CustomAction.ENGINE_START_FAIL)).thenReturn(effect); + engine.start(driver); + assertFalse(engine.isStarted()); + if (customEffect) { + timers.get(0).run(); + verify(driver, never()).sendMessage(contains("failed")); + when(effect.isFinished()).thenReturn(true); + timers.get(0).run(); + } + verify(driver).sendMessage("§e[Motor] §cfailed!"); + assertEquals(0, engine.getGear().getThrottle().getCurrent()); + doReturn(0.).when(engine).rollStartChance(); + engine.start(mock(Player.class)); + assertTrue(engine.isStarted(), "A resolved failed effect must allow a later start"); + } + + @ParameterizedTest @ValueSource(ints = {-1, 3}) + void defaultGearMustReferToAnExistingGear(int invalid) { + YamlConfiguration config = config(true); config.set("start-gear", invalid); + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> new GearedEngine(config)); + assertTrue(error.getMessage().contains("start-gear")); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void configurationRequiresAtLeastOneGear(boolean emptySection) { + YamlConfiguration config = config(true); config.set("gears", null); + if (emptySection) config.createSection("gears"); + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> new GearedEngine(config)); + assertTrue(error.getMessage().toLowerCase(Locale.ROOT).contains("gear")); + } + + @ParameterizedTest @ValueSource(booleans = {true, false}) + void emptyTankStopsMotionEvenIfTheEngineWasAlreadyOff(boolean started) { + fuelRequired(); + Fixture f = fixture(true); + f.engine.setStarted(started); + f.throttle().setThrottle(30); + f.engine.tick(List.of()); + assertFalse(f.engine.isStarted()); + assertEquals(0, f.throttle().getCurrent()); + verify(f.panel).setSpeed(0); + } + + @Test void anAutomaticEngineAlsoStopsProducingPowerWhenItsFuelRunsOut() { + fuelRequired(); + Fixture f = fixture(false); + f.throttle().setThrottle(30); + f.engine.tick(List.of()); + assertEquals(0, f.throttle().getCurrent()); + verify(f.panel).setSpeed(0); + } + + @Test void emptyTankDoesNotRestartForRecordedThrottle() { + fuelRequired(); + Fixture f = fixture(true); + when(f.train.playbackThrottle(any())).thenReturn(50); + f.engine.tick(List.of()); + assertFalse(f.engine.isStarted()); + assertEquals(0, f.throttle().getCurrent()); + f.engine.getFuelTank().setFuel(1); + f.engine.tick(List.of()); + assertTrue(f.engine.isStarted()); + } + + @Test void destroyedEngineStopsInsteadOfRemainingStartedAtZeroThrottle() { + Fixture f = fixture(true); + f.engine.setStarted(true); + f.engine.getHealthData().setDamage(100); + f.throttle().setThrottle(40); + f.engine.tick(List.of()); + assertFalse(f.engine.isStarted()); + assertEquals(0, f.throttle().getCurrent()); + verify(f.panel).setSpeed(0); + verify(f.vehicle).stopAnimation(Animation.ENGINE_ACTIVE); + } + + @Test void stopLeavesAnAlwaysRunningEngineAvailable() { + Fixture f = fixture(false); + f.throttle().setThrottle(25); + f.engine.stop(); + assertTrue(f.engine.isStarted()); + assertEquals(25, f.throttle().getCurrent()); + } + + @Test void unattendedEngineNormalizesThenStopsWhenItReturnsToIdle() { + Fixture f = fixture(true); + when(f.train.isRecording()).thenReturn(false); + when(f.seats.hasPassengers()).thenReturn(false); + f.engine.setStarted(true); + f.throttle().setThrottle(2); + f.engine.tick(List.of()); + assertEquals(1, f.throttle().getCurrent()); + assertEquals(1, later.size()); + f.engine.tick(List.of()); + assertEquals(0, f.throttle().getCurrent()); + assertFalse(f.engine.isStarted()); + later.get(0).run(); + } + + @Test void delayedIdleCheckDoesNotStopAnEngineThatHasAccelerated() { + Fixture f = fixture(true); + when(f.vehicle.isTrain()).thenReturn(false); + f.engine.setStarted(true); + f.throttle().setThrottle(20); + f.engine.tick(List.of()); + f.engine.tick(List.of()); + assertEquals(1, later.size()); + later.get(0).run(); + assertTrue(f.engine.isStarted()); + f.engine.tick(List.of()); + assertEquals(2, later.size()); + } + + @ParameterizedTest @ValueSource(ints = {0, 2}) + void unattendedNeutralGearsReturnTowardTheDefaultGear(int selected) { + YamlConfiguration config = config(true); + config.set("start-gear", 1); + config.set("gears." + selected + ".min", 0); + config.set("gears." + selected + ".max", 0); + Fixture f = fixture(new GearedEngine(config)); + when(f.train.isRecording()).thenReturn(false); + when(f.seats.hasPassengers()).thenReturn(false); + f.engine.setCurrentGear(selected); + f.engine.tick(List.of()); + assertTrue(f.engine.isShifting()); + f.engine.tick(List.of()); + assertEquals(1, timers.size()); + for (int i = 0; i < 11; i++) timers.get(0).run(); + assertEquals(1, f.engine.getCurrentGear()); + assertFalse(f.engine.isShifting()); + } + + @Test void flyingUnattendedVehicleKeepsItsThrottle() { + Fixture f = fixture(true); + when(f.vehicle.isTrain()).thenReturn(false); + when(f.seats.hasPassengers()).thenReturn(false); + when(f.states.getCurrentState().getType()).thenReturn(State.FLYING); + f.throttle().setThrottle(-30); + f.engine.tick(List.of()); + assertEquals(-30, f.throttle().getCurrent()); + } + + @Test void activeEngineEmitsParticlesFromModelBonesAndSupportsModelReload() { + ParticleData particle = mock(ParticleData.class); + MockedStatic particles = keep(mockStatic(ParticleLoader.class)); + particles.when(() -> ParticleLoader.getParticlesFromConfig(any())).thenReturn(List.of(particle)); + YamlConfiguration config = config(false); + config.createSection("particles.exhaust"); + config.set("particle-bones", List.of("base.align")); + Fixture f = fixture(new GearedEngine(config)); + f.throttle().setThrottle(20); + f.engine.tick(List.of()); + verify(particle).spawnParticle(new Location(world, 0, 65, 0), new Vector(0, 1, 0)); + ActiveModel replacement = model(); + f.engine.updateModel(replacement); + f.engine.tick(List.of()); + verify(particle, times(2)).spawnParticle(any(), eq(new Vector(0, 1, 0))); + } + + private Fixture fixture(boolean requireStart) { return fixture(new GearedEngine(config(requireStart))); } + private Fixture fixture(GearedEngine template) { + ActiveVehicle vehicle = mock(ActiveVehicle.class); + Entity entity = mock(Entity.class); + BehaviourHandler behaviour = mock(BehaviourHandler.class); + SeatHandler seats = mock(SeatHandler.class); + StateHandler states = mock(StateHandler.class, RETURNS_DEEP_STUBS); + TrainHandler train = mock(TrainHandler.class); + AccessPanel panel = mock(AccessPanel.class); + VehicleMovementController controls = mock(VehicleMovementController.class); + when(vehicle.getEntity()).thenReturn(entity); + when(entity.getLocation()).thenAnswer(call -> new Location(world, 0, 64, 0)); + when(entity.getVelocity()).thenReturn(new Vector()); + when(vehicle.getBehaviourHandler()).thenReturn(behaviour); + when(vehicle.getSeatHandler()).thenReturn(seats); + when(seats.hasPassengers()).thenReturn(true); + when(vehicle.getStateHandler()).thenReturn(states); + when(states.getCurrentState().getType()).thenReturn(State.GROUND); + when(vehicle.getAccessPanel()).thenReturn(panel); + when(vehicle.getMoveControls()).thenReturn(controls); + when(vehicle.isTrain()).thenReturn(true); + when(vehicle.getTrainHandler()).thenReturn(train); + when(train.isRecording()).thenReturn(true); + when(train.playbackThrottle(any())).thenReturn(null); + GearedEngine engine = new GearedEngine(vehicle, template, entity, model(), null); + return new Fixture(vehicle, engine, behaviour, seats, states, train, panel, controls); + } + + private ActiveModel model() { + ActiveModel model = mock(ActiveModel.class); + ModelBone base = mock(ModelBone.class), align = mock(ModelBone.class); + when(base.getBoneId()).thenReturn("base"); + when(align.getBoneId()).thenReturn("align"); + when(base.getLocation()).thenAnswer(call -> new Location(world, 0, 65, 0)); + when(align.getLocation()).thenAnswer(call -> new Location(world, 0, 66, 0)); + when(model.getBone("base")).thenReturn(Optional.of(base)); + when(model.getBone("align")).thenReturn(Optional.of(align)); + return model; + } + + private void fuelRequired() { + Fuel fuel = mock(Fuel.class); + fuels.when(() -> FuelLoader.getByString("none")).thenReturn(fuel); + } + + private YamlConfiguration config(boolean requireStart) { + YamlConfiguration config = new YamlConfiguration(); + config.set("alias", "Motor"); + config.set("health", 100); + config.set("requires-start", requireStart); + config.set("turn-rate", 2.5); + for (int i = 0; i < 3; i++) { + String gear = "gears." + i + "."; + config.set(gear + "name", "gear" + i); + config.set(gear + "min", -100); + config.set(gear + "max", 120); + config.set(gear + "acceleration", 5); + config.set(gear + "speed", i + 1); + config.set(gear + "engine-sound.sound", "engine"); + config.set(gear + "engine-sound.pitched", true); + config.set(gear + "accelerate-sound.sound", "accelerate"); + } + return config; + } + private BukkitTask task(Runnable runnable, List target) { + target.add(runnable); + BukkitTask task = mock(BukkitTask.class); + when(task.getTaskId()).thenReturn(later.size() + timers.size()); + return task; + } + private T keep(T scope) { scopes.add(scope); return scope; } + private record Fixture(ActiveVehicle vehicle, GearedEngine engine, BehaviourHandler behaviour, + SeatHandler seats, StateHandler states, TrainHandler train, AccessPanel panel, + VehicleMovementController controls) { + Throttle throttle() { return engine.getGear().getThrottle(); } + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/component/VehicleComponentsCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/component/VehicleComponentsCoverageTest.java new file mode 100644 index 0000000..3fac82f --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/component/VehicleComponentsCoverageTest.java @@ -0,0 +1,729 @@ +package net.tfminecraft.vehicleframework.vehicles.component; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.inventory.*; +import org.bukkit.scheduler.*; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import com.google.gson.*; +import de.tr7zw.nbtapi.NBT; +import de.tr7zw.nbtapi.iface.ReadWriteNBT; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.bone.ModelBone; +import net.tfminecraft.vehicleframework.*; +import net.tfminecraft.vehicleframework.data.*; +import net.tfminecraft.vehicleframework.database.*; +import net.tfminecraft.vehicleframework.effects.CustomEffect; +import net.tfminecraft.vehicleframework.enums.Animation; +import net.tfminecraft.vehicleframework.enums.Component; +import net.tfminecraft.vehicleframework.enums.CustomAction; +import net.tfminecraft.vehicleframework.enums.Input; +import net.tfminecraft.vehicleframework.enums.State; +import net.tfminecraft.vehicleframework.loaders.FuelLoader; +import net.tfminecraft.vehicleframework.util.ParticleLoader; +import net.tfminecraft.vehicleframework.util.SoundLoader; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.component.propulsion.Throttle; +import net.tfminecraft.vehicleframework.vehicles.component.fuel.FuelTank; +import net.tfminecraft.vehicleframework.vehicles.controller.VehicleMovementController; +import net.tfminecraft.vehicleframework.vehicles.fuel.Fuel; +import net.tfminecraft.vehicleframework.vehicles.handlers.*; +import net.tfminecraft.vehicleframework.vehicles.handlers.container.Container; +import net.tfminecraft.vehicleframework.vehicles.handlers.container.ContainerHandler; +import net.tfminecraft.vehicleframework.vehicles.seat.Seat; +import net.tfminecraft.vehicleframework.enums.SeatType; +import net.tfminecraft.vehicleframework.vehicles.handlers.train.LocomotiveOverdrive; +import net.tfminecraft.vehicleframework.vehicles.util.AccessPanel; + +class VehicleComponentsCoverageTest { + @BeforeAll static void bootstrapRegistries() { + net.tfminecraft.vehicleframework.test.RegistryFixture.initialize(); + } + private final List scopes = new ArrayList<>(); + private final List later = new ArrayList<>(), timers = new ArrayList<>(); + private final BukkitScheduler scheduler = mock(BukkitScheduler.class); + private final World world = mock(World.class); + private MockedStatic bukkit; + private MockedStatic fuels; + private VehicleFramework previousPlugin; + private final Map itemPaths = new IdentityHashMap<>(); + + @BeforeEach void setup() { + previousPlugin = VehicleFramework.plugin; + VehicleFramework.plugin = mock(VehicleFramework.class); + bukkit = keep(mockStatic(Bukkit.class)); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())) + .thenAnswer(call -> task(call.getArgument(1), later)); + when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong())) + .thenAnswer(call -> task(call.getArgument(1), timers)); + fuels = keep(mockStatic(FuelLoader.class)); + keep(mockStatic(PersistenceLog.class)); keep(mockStatic(VFLogger.class)); + } + @AfterEach void cleanup() throws Exception { + Collections.reverse(scopes); for (AutoCloseable scope : scopes) scope.close(); + VehicleFramework.plugin = previousPlugin; + } + + @ParameterizedTest @ValueSource(ints = {-80, 80}) + void damagedEnginePublishesHealthLimitedSpeedAndSteering(int demand) { + EngineFixture f = engine(false); + when(f.behaviour.turnScale()).thenReturn(true); + f.engine.getHealthData().setDamage(60); f.throttle().setThrottle(demand); + f.engine.tick(List.of()); + assertEquals(Integer.signum(demand) * 40, f.throttle().getCurrent()); + verify(f.panel).setSpeed(Integer.signum(demand) * .4); + verify(f.panel).setTurnRate(Integer.signum(demand)); + verify(f.panel).setReverse(demand < 0); + } + + @Test void destroyedEngineStopsBeforeItsIdleEarlyReturn() { + EngineFixture f = engine(true); f.engine.setStarted(true); + f.engine.getHealthData().setDamage(100); f.throttle().setThrottle(40); + f.engine.tick(List.of()); + assertFalse(f.engine.isStarted()); assertEquals(0, f.throttle().getCurrent()); + verify(f.panel).setSpeed(0); verify(f.vehicle).stopAnimation(Animation.ENGINE_ACTIVE); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void fuelDepletionStopsPowerForManualAndAutomaticEngines(boolean requiresStart) { + fuelRequired(); EngineFixture f = engine(requiresStart); f.engine.setStarted(true); + f.throttle().setThrottle(40); f.engine.tick(List.of()); + assertEquals(0, f.throttle().getCurrent()); verify(f.panel).setSpeed(0); + if (requiresStart) assertFalse(f.engine.isStarted()); + } + + @Test void unnamedThrottleUsesItsDefaultDisplayName() { + assertEquals("Throttle", new Throttle(null, 100, -100, null).getName()); + } + + @Test void pumpCannotDrainFloodingBelowZero() { + YamlConfiguration config = config(false); config.set("power", 100); + EngineFixture f = engine(false); when(f.behaviour.shouldFloat()).thenReturn(true); + SinkableHull hull = new SinkableHull(f.vehicle, new SinkableHull(config), f.model, + new IncompleteComponent(Component.HULL, 0, 0, 1)); + hull.setPump(new Pump(config)); hull.slowTick(List.of()); + assertEquals(0, hull.getSinkProgress()); assertFalse(hull.hasSinkProgress()); + } + + @Test void componentModelReloadMovesFireParticlesToTheReplacementBones() { + YamlConfiguration config = config(false); config.set("vfx", List.of("base")); + EngineFixture f = engine(false); Player viewer = mock(Player.class); + Hull hull = new Hull(new Hull(config), f.vehicle, f.model, null); + hull.startFire(); hull.getFire().setProgress(10); + ActiveModel replacement = model(80); hull.updateModel(replacement); hull.tick(List.of(viewer)); + verify(viewer).spawnParticle(eq(Particle.FLAME), eq(new Location(world, 0, 80, 0)), eq(0), + anyDouble(), anyDouble(), anyDouble(), eq(.2)); + } + + @Test void engineCopiesConfigurationButNotLiveThrottleAndScalesTurnRate() { + YamlConfiguration config = config(true); Engine template = new Engine(config); EngineFixture f = engine(template); + assertTrue(f.engine.requiresStart()); assertFalse(f.engine.isStarted()); + assertEquals(1, template.getBaseSpeed()); assertEquals(2.5, template.getBaseTurnRate()); + assertEquals(2.5, template.getTurnRate()); + f.throttle().setThrottle(25); assertEquals(0, template.getThrottle().getCurrent()); + assertEquals(.25, f.engine.getSpeed()); assertEquals(2.5, f.engine.getTurnRate()); + when(f.behaviour.turnScale()).thenReturn(true); assertEquals(.625, f.engine.getTurnRate()); + assertSame(template.getSounds(), f.engine.getSounds()); + assertSame(template.getParticles(), f.engine.getParticles()); + assertSame(template.getParticleBones(), f.engine.getParticleBones()); + } + + @Test void tapePlaybackStartsEngineAndControlsThrottleWithoutRecording() { + EngineFixture f = engine(true); when(f.train.playbackThrottle(any())).thenReturn(30); + f.engine.tick(List.of()); assertTrue(f.engine.isStarted()); assertEquals(1, f.throttle().getCurrent()); + verify(f.controls).input(null, Input.MOVE); verify(f.train, never()).maybeRecordSample(anyInt()); + when(f.train.playbackThrottle(any())).thenReturn(-30); f.engine.tick(List.of()); + assertEquals(0, f.throttle().getCurrent()); + fuelRequired(); EngineFixture empty = engine(true); when(empty.train.playbackThrottle(any())).thenReturn(30); + empty.engine.tick(List.of()); assertFalse(empty.engine.isStarted()); + } + + @Test void recordingPreservesThrottleAndRootTrainCollectsTenderFuel() { + EngineFixture f = engine(false); f.throttle().setThrottle(30); f.engine.tick(List.of()); + verify(f.train).maybeRecordSample(30); + f.engine.getFuelTank().setFuel(20); f.engine.slowTick(List.of()); + assertEquals(18, f.engine.getFuelTank().getCurrent()); + verify(f.train).drainFromChild(f.engine.getFuelTank()); + when(f.vehicle.hasParent()).thenReturn(true); f.engine.slowTick(List.of()); + verify(f.train, times(1)).drainFromChild(any()); + } + + @Test void stoppedEngineConsumesNoFuelAndMakesNoSound() { + EngineFixture f = engine(true); f.engine.getFuelTank().setFuel(10); f.engine.slowTick(List.of()); + assertEquals(10, f.engine.getFuelTank().getCurrent()); verifyNoInteractions(world); + } + + @ParameterizedTest @ValueSource(ints = {-100, 50, 120}) + void engineSoundPitchIsClampedToItsAudibleRange(int throttle) { + SoundData sound = mock(SoundData.class); + MockedStatic sounds = keep(mockStatic(SoundLoader.class)); + sounds.when(() -> SoundLoader.getSoundsFromConfig(any())).thenReturn(List.of(sound)); + YamlConfiguration config = config(false); config.createSection("sounds.engine"); + EngineFixture f = engine(new Engine(config)); f.throttle().setThrottle(throttle); + Player listener = mock(Player.class); f.engine.playSound(List.of(listener)); + verify(sound).playSound(eq(List.of(listener)), eq(new Location(world, 0, 64, 0)), eq(new Vector()), + eq(Math.max(.5f, Math.min(1.6f, .5f + throttle / 100f)))); + } + + @Test void engineExhaustUsesVectorBonesAndUpdatesWhenTheModelChanges() { + ParticleData particle = mock(ParticleData.class); + MockedStatic particles = keep(mockStatic(ParticleLoader.class)); + particles.when(() -> ParticleLoader.getParticlesFromConfig(any())).thenReturn(List.of(particle)); + YamlConfiguration config = config(false); config.createSection("particles.exhaust"); + config.set("particle-bones", List.of("base.align")); + EngineFixture f = engine(new Engine(config)); f.throttle().setThrottle(30); f.engine.tick(List.of()); + verify(particle).spawnParticle(new Location(world, 0, 65, 0), new Vector(0, 1, 0)); + f.engine.updateModel(model(80)); f.engine.tick(List.of()); + verify(particle).spawnParticle(new Location(world, 0, 80, 0), new Vector(0, 1, 0)); + } + + @Test void unattendedGroundedEngineReturnsToIdleAndStops() { + EngineFixture f = engine(true); when(f.train.isRecording()).thenReturn(false); + when(f.seats.hasPassengers()).thenReturn(false); f.engine.setStarted(true); f.throttle().setThrottle(2); + f.engine.tick(List.of()); assertEquals(1, f.throttle().getCurrent()); + f.engine.tick(List.of()); assertEquals(0, f.throttle().getCurrent()); assertFalse(f.engine.isStarted()); + assertEquals(1, later.size()); later.get(0).run(); + } + + @Test void delayedIdleCheckStopsOnlyAnEngineThatRemainsIdle() { + EngineFixture f = engine(true); when(f.vehicle.isTrain()).thenReturn(false); f.engine.setStarted(true); + f.throttle().setThrottle(20); f.engine.tick(List.of()); f.engine.tick(List.of()); + assertEquals(1, later.size()); later.get(0).run(); assertTrue(f.engine.isStarted()); + f.engine.tick(List.of()); assertEquals(2, later.size()); + f.throttle().setThrottle(0); later.get(1).run(); assertFalse(f.engine.isStarted()); + } + + @Test void flyingEngineRetainsThrottleWithoutPassengers() { + EngineFixture f = engine(true); when(f.vehicle.isTrain()).thenReturn(false); + when(f.seats.hasPassengers()).thenReturn(false); when(f.states.getCurrentState().getType()).thenReturn(State.FLYING); + f.throttle().setThrottle(-20); f.engine.tick(List.of()); assertEquals(-20, f.throttle().getCurrent()); + } + + @Test void healthyEngineStartsOncePerCooldownAndStopsOnRequest() { + EngineFixture f = engine(true); Player player = mock(Player.class); + f.engine.start(player); f.engine.start(player); + assertTrue(f.engine.isStarted()); assertEquals(1, f.throttle().getCurrent()); + verify(player).sendMessage("§e[Motor] §astarted!"); f.engine.stop(); + assertFalse(f.engine.isStarted()); assertEquals(0, f.throttle().getCurrent()); + EngineFixture automatic = engine(false); automatic.throttle().setThrottle(20); automatic.engine.stop(); + assertTrue(automatic.engine.isStarted()); assertEquals(20, automatic.throttle().getCurrent()); + } + + @Test void startEffectCompletesBeforeTheEngineProducesPower() { + EngineFixture f = engine(true); CustomEffect effect = mock(CustomEffect.class); + when(f.vehicle.hasEffect(CustomAction.ENGINE_START)).thenReturn(true); + when(f.vehicle.playEffect(CustomAction.ENGINE_START)).thenReturn(effect); + f.engine.start(mock(Player.class)); f.engine.start(mock(Player.class)); + assertEquals(1, timers.size()); timers.get(0).run(); assertFalse(f.engine.isStarted()); + when(effect.isFinished()).thenReturn(true); timers.get(0).run(); assertTrue(f.engine.isStarted()); + } + + @ParameterizedTest @ValueSource(booleans = {true, false}) + void damagedEngineReportsFailedStartAndAllowsAnotherAttempt(boolean custom) { + EngineFixture f = engine(true); Engine engine = spy(f.engine); engine.getHealthData().setDamage(60); + doReturn(80.).when(engine).rollStartChance(); + CustomEffect effect = mock(CustomEffect.class); when(f.vehicle.hasEffect(CustomAction.ENGINE_START_FAIL)).thenReturn(custom); + when(f.vehicle.playEffect(CustomAction.ENGINE_START_FAIL)).thenReturn(effect); + Player player = mock(Player.class); engine.start(player); assertFalse(engine.isStarted()); + if (custom) { + timers.get(0).run(); verify(player, never()).sendMessage(contains("failed")); + when(effect.isFinished()).thenReturn(true); timers.get(0).run(); + } + verify(player).sendMessage("§e[Motor] §cfailed!"); + doReturn(0.).when(engine).rollStartChance(); engine.start(mock(Player.class)); assertTrue(engine.isStarted()); + } + + @Test void fuelRequiredForStartingAndAnAlreadyOffEmptyEngineStaysIdle() { + fuelRequired(); EngineFixture f = engine(true); Player player = mock(Player.class); + f.engine.start(player); assertFalse(f.engine.isStarted()); verify(player).sendMessage("§e[Motor] §cNo fuel!"); + f.throttle().setThrottle(20); f.engine.tick(List.of()); assertEquals(0, f.throttle().getCurrent()); + } + + @Test void hullDamageUsesConfiguredArmorAndLargeHitsIgniteTheVehicle() { + EngineFixture f = engine(false); YamlConfiguration config = config(false); + config.set("damage-chance", 1); config.set("damage", List.of("FALL(0.5)")); + Hull template = new Hull(config), hull = new Hull(template, f.vehicle, f.model, null); + assertTrue(hull.isFatal()); assertEquals(Component.HULL, hull.getType()); + assertEquals(1, hull.getDamageChance()); assertSame(template.getDamageData(), hull.getDamageData()); + hull.damage("FALL", 10); assertEquals(5, hull.getHealthData().getDamage()); + when(f.vehicle.hasEffect(CustomAction.FIRE_START)).thenReturn(true); + hull.damage("EXPLOSION", 10_000); + assertEquals(100, hull.getHealthData().getDamage()); assertTrue(hull.isOnFire()); + verify(f.vehicle).playEffect(CustomAction.FIRE_START); + hull.getFire().setProgress(100); hull.slowTick(List.of()); + assertEquals(100, hull.getFire().getProgress()); + hull.getFire().setProgress(0); hull.slowTick(List.of()); assertFalse(hull.isOnFire()); + } + + @Test void savedComponentRestoresDamageAndFireAndRepairsOnSchedule() { + EngineFixture f = engine(false); YamlConfiguration config = config(false); config.set("repair-time", 1); + Hull hull = new Hull(new Hull(config), f.vehicle, f.model, + new IncompleteComponent(Component.HULL, 20, 30, 0)); + assertEquals(20, hull.getHealthData().getDamage()); assertEquals(30, hull.getFire().getProgress()); + hull.getHealthData().startRepair(); hull.tick(List.of()); + assertFalse(hull.getHealthData().isUnderRepair()); + assertTrue(hull.getHealthData().getDamage() >= 6 && hull.getHealthData().getDamage() <= 13); + assertNotNull(hull.getVfx()); assertEquals("Motor", hull.getAlias()); + } + + @Test void fireEffectsBoundParticleDensityAndSurviveMissingSkinBones() { + EngineFixture f = engine(false); YamlConfiguration config = config(false); config.set("vfx", List.of("missing", "base")); + when(f.model.getBone("missing")).thenReturn(Optional.empty()); + Hull hull = new Hull(new Hull(config), f.vehicle, f.model, null); Player viewer = mock(Player.class); + assertEquals(1, hull.getVfx().getModelBones().size()); + hull.startFire(); hull.getFire().setProgress(100); hull.tick(List.of(viewer)); + verify(viewer, times(3)).spawnParticle(eq(Particle.FLAME), eq(new Location(world, 0, 65, 0)), eq(0), + anyDouble(), anyDouble(), anyDouble(), eq(.2)); + ActiveModel replacement = model(80); when(replacement.getBone("base")).thenReturn(Optional.empty()); + hull.updateModel(replacement); hull.getFire().setProgress(0); hull.tick(List.of(viewer)); + verify(viewer, times(4)).spawnParticle(eq(Particle.FLAME), eq(new Location(world, 0, 65, 0)), eq(0), + anyDouble(), anyDouble(), anyDouble(), eq(.2)); + hull.slowTick(List.of()); assertFalse(hull.isOnFire()); + } + + @Test void fireReportsSeverityAndBoundsDamageGrowthAndExtinguishing() { + EngineFixture f = engine(false); Hull hull = new Hull(new Hull(config(false)), f.vehicle, f.model, null); + hull.startFire(); var fire = hull.getFire(); + assertTrue(fire.getProgress() >= 1 && fire.getProgress() <= 7); + fire.setProgress(30); assertEquals("§eSmall Fire: §630%", fire.getFireString()); assertFalse(fire.engulfed()); + fire.setProgress(50); assertEquals("§cWidespread Fire!: §650%", fire.getFireString()); + fire.fight(); assertTrue(fire.getProgress() >= 47 && fire.getProgress() <= 50); + fire.setProgress(80); assertEquals("§4Massive Fire!!: §680%", fire.getFireString()); + fire.setProgress(110); fire.tick(); assertEquals(100, fire.getProgress()); assertTrue(fire.engulfed()); + fire.setProgress(-1); fire.fight(); assertEquals(0, fire.getProgress()); assertFalse(fire.engulfed()); + hull.slowTick(List.of()); assertFalse(hull.isOnFire()); + } + + @Test void floodingGrowsWithHullDamageAndRecoversWithAndWithoutAPump() { + EngineFixture f = engine(false); when(f.behaviour.shouldFloat()).thenReturn(true); + YamlConfiguration config = config(false); + SinkableHull hull = new SinkableHull(f.vehicle, new SinkableHull(config), f.model, + new IncompleteComponent(Component.HULL, 21, 0, 0)); + assertFalse(hull.hasPump()); assertFalse(hull.isSinking()); + hull.slowTick(List.of()); assertTrue(hull.isSinking()); assertEquals(1, hull.getSinkProgress()); + hull.getHealthData().setDamage(100); hull.setSinkProgress(99); hull.slowTick(List.of()); + assertEquals(100, hull.getSinkProgress()); + hull.getHealthData().setDamage(0); hull.slowTick(List.of()); + assertFalse(hull.isSinking()); assertEquals(99, hull.getSinkProgress()); + config.set("power", 40); Pump pump = new Pump(config); hull.setPump(pump); + assertTrue(hull.hasPump()); assertSame(pump, hull.getPump()); + hull.setSinkProgress(20); hull.slowTick(List.of()); assertEquals(16, hull.getSinkProgress()); + when(f.behaviour.shouldFloat()).thenReturn(false); hull.slowTick(List.of()); assertEquals(16, hull.getSinkProgress()); + when(f.behaviour.shouldFloat()).thenReturn(true); hull.setSinkProgress(0); hull.slowTick(List.of()); + assertFalse(hull.hasSinkProgress()); + config.set("power", 0); hull.setPump(new Pump(config)); hull.getHealthData().setDamage(30); + hull.slowTick(List.of()); assertEquals(2, hull.getSinkProgress()); + } + + @Test void damagedPumpAndWingsLosePowerWithoutNegativeLift() { + EngineFixture f = engine(false); YamlConfiguration config = config(false); + config.set("power", 80); config.set("lift", .8); config.set("turn-rate", .4); config.set("damage-factor", 2); + Pump pump = new Pump(new Pump(config), f.vehicle, f.model, new IncompleteComponent(Component.PUMP, 25, 0, 0)); + assertEquals(80, pump.getBasePower()); assertEquals(60, pump.getPower()); + Wings wings = new Wings(f.vehicle, new Wings(config), f.model, null); + when(f.vehicle.getSpeed()).thenReturn(.5); assertEquals(.2, wings.getTurnRate(), 1e-7); + assertEquals(.8, wings.getBaseLift()); assertEquals(2, wings.getDamageFactor()); + assertEquals(.8, wings.getLift()); wings.getHealthData().setDamage(75); assertEquals(0, wings.getLift()); + wings.slowTick(List.of()); + } + + @Test void balloonLiftRespondsToDamageAndItsControlDeltaDecaysSmoothly() { + EngineFixture f = engine(false); YamlConfiguration config = config(false); config.set("lift", .5); + Balloon balloon = new Balloon(new Balloon(config), f.vehicle, f.model, null); + assertEquals(.5, balloon.getBaseLift()); assertEquals(.5, balloon.getLift()); + balloon.setDelta(.08); assertEquals(.03, balloon.getDelta(), 1e-9); assertEquals(0, balloon.getDelta()); + balloon.setDelta(-.08); assertEquals(-.03, balloon.getDelta(), 1e-9); assertEquals(0, balloon.getDelta()); + balloon.getHealthData().setDamage(50); assertEquals(0, balloon.getLift()); + when(f.vehicle.isDestroyed()).thenReturn(true); assertEquals(-2, balloon.getLift()); + balloon.slowTick(List.of()); + } + + @Test void throttleLimitsInputNormalizesReverseAndHonoursDamagedAccelerationCap() { + EngineFixture f = engine(false); Throttle throttle = f.throttle(); + throttle.setThrottle(500); assertEquals(120, throttle.getCurrent()); + throttle.increase(); assertEquals(120, throttle.getCurrent()); + throttle.setThrottle(-500); assertEquals(-100, throttle.getCurrent()); + throttle.decrease(); assertEquals(-100, throttle.getCurrent()); + throttle.change(-100); assertEquals(-100, throttle.getCurrent()); + throttle.change(500); assertEquals(120, throttle.getCurrent()); + throttle.setThrottle(-2); throttle.normalize(); assertEquals(-1, throttle.getCurrent()); + throttle.normalize(); assertEquals(0, throttle.getCurrent()); + f.engine.getHealthData().setDamage(50); throttle.setThrottle(60); throttle.increase(); + assertEquals(60, throttle.getCurrent()); throttle.decrease(); throttle.increase(); assertEquals(60, throttle.getCurrent()); + throttle.stepToward(60); assertEquals(60, throttle.getCurrent()); + } + + @Test void containerCopyResolvesModelBonesAndKeepsItsSeatAndAllowList() { + EngineFixture f = engine(false); YamlConfiguration config = containerConfig(); + config.set("bones", List.of("base", "missing")); config.set("allow-items", List.of("coal", "", " ")); + when(f.model.getBone("missing")).thenReturn(Optional.empty()); + Container template = new Container("cargo", config), container = new Container(f.vehicle, template); + assertEquals("cargo", container.getId()); assertEquals("Cargo", container.getName()); + assertEquals(9, container.getSize()); assertEquals("passenger", container.getSeat()); + assertEquals(List.of("base", "missing"), template.getBoneList()); + assertEquals(List.of("coal"), container.getAllowItems()); + assertEquals(List.of(f.model.getBone("base").orElseThrow()), container.getBones()); + } + + @Test void containerFilterReturnsDisallowedItemsAndDropsInventoryOverflow() { + ItemAPI api = items(); Container container = container(List.of("coal")); + ItemStack coal = stack("coal", 3), rejected = stack("sand", 2), air = stack("air", 1); + Inventory inventory = inventory(9); inventory.setItem(0, coal); inventory.setItem(1, rejected); inventory.setItem(2, air); + Player player = mock(Player.class); PlayerInventory backpack = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(backpack); when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(new Location(world, 1, 64, 1)); + ItemStack leftover = stack("sand", 1); when(backpack.addItem(any(ItemStack.class))).thenReturn(new HashMap<>(Map.of(0, leftover))); + container.stripDisallowed(inventory, player); + assertSame(coal, inventory.getItem(0)); assertNull(inventory.getItem(1)); assertSame(air, inventory.getItem(2)); + verify(world).dropItemNaturally(any(Location.class), same(leftover)); + assertTrue(container.allows(null)); assertTrue(container.allows(air)); assertTrue(container.allows(coal)); + assertFalse(container.allows(rejected)); assertTrue(container(List.of()).allows(rejected)); + when(api.getChecker().checkItemWithPath(rejected, "coal")).thenThrow(new IllegalStateException("Unknown item template")); + assertFalse(container.allows(rejected)); + container.stripDisallowed(null, player); container(List.of()).stripDisallowed(inventory, player); + } + + @Test void givingContainerItemsHandlesEmptyStacksNullPlayersAndNoDropWorld() { + Container container = container(List.of()); Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); when(player.getInventory()).thenReturn(inventory); + ItemStack item = stack("coal", 3); container.giveOrDrop(null, item); container.giveOrDrop(player, null); + container.giveOrDrop(player, stack("air", 1)); verifyNoInteractions(player); + when(inventory.addItem(any(ItemStack.class))).thenReturn(new HashMap<>()); + container.giveOrDrop(player, item); verify(player, never()).getWorld(); + when(inventory.addItem(any(ItemStack.class))).thenReturn(new HashMap<>(Map.of(0, item))); + container.giveOrDrop(player, item); verify(player).getWorld(); verifyNoInteractions(world); + } + + @Test void closedContainerTakesOneMatchingFuelAndPreservesOtherSlots() { + ItemAPI api = items(); Container container = container(List.of()); + ItemStack wrong = stack("sand", 3), coal = stack("coal", 2), problematic = stack("broken", 1); + container.getItems().addAll(Arrays.asList(null, stack("air", 1), wrong, problematic, coal)); + when(api.getChecker().checkItemWithPath(problematic, "coal")).thenThrow(new IllegalStateException("unavailable")); + assertNull(container.takeOneMatching(null)); assertNull(container.takeOneMatching(" ")); + ItemStack one = container.takeOneMatching("coal"); assertEquals(1, one.getAmount()); assertNotSame(coal, one); + assertEquals(1, coal.getAmount()); assertSame(wrong, container.getItems().get(2)); + assertNotNull(container.takeOneMatching("coal")); assertNull(container.getItems().get(4)); + assertNull(container.takeOneMatching("coal")); + } + + @Test void openContainerSharesOneInventoryAndFuelDrainSynchronizesSavedContents() { + items(); Container container = container(List.of()); EngineFixture f = engine(false); + Inventory live = inventory(9); bukkit.when(() -> Bukkit.createInventory(any(InventoryHolder.class), eq(9), eq("Cargo"))).thenReturn(live); + ItemStack coal = stack("coal", 2); container.getItems().add(coal); + Player first = mock(Player.class), second = mock(Player.class); + container.open(f.vehicle, first); container.open(f.vehicle, second); + verify(first).openInventory(live); verify(second).openInventory(live); + assertSame(coal, live.getItem(0)); assertNull(live.getItem(1)); + ItemStack one = container.takeOneMatching("coal"); assertEquals(1, one.getAmount()); + assertEquals(1, live.getItem(0).getAmount()); assertEquals(1, container.getItems().get(0).getAmount()); + container.close(live); assertNotSame(live.getItem(0), container.getItems().get(0)); + Inventory external = inventory(9); ItemStack iron = stack("iron", 4); external.setItem(3, iron); + container.close(external); assertEquals(4, live.getItem(3).getAmount()); assertNull(live.getItem(0)); + assertNotSame(iron, container.getItems().get(3)); container.close(null); + } + + @Test void loadingAnEmptyContainerSnapshotClearsItsOpenInventory() { + Container container = container(List.of()); EngineFixture f = engine(false); + Inventory live = inventory(9); bukkit.when(() -> Bukkit.createInventory(any(InventoryHolder.class), eq(9), eq("Cargo"))).thenReturn(live); + container.getItems().add(stack("coal", 2)); container.open(f.vehicle, mock(Player.class)); + container.loadFromJson(new JsonObject()); + assertNull(live.getItem(0), "Loading empty contents must replace the current live contents"); + assertTrue(container.getItems().stream().allMatch(Objects::isNull)); + } + + @Test void containerSnapshotRoundTripKeepsSnbtAndFindsFreeSlotsForDuplicates() { + NbtFixture nbt = nbt(); Container container = container(List.of()); + nbt.decoded.put("coal:3", stack("coal", 3)); nbt.decoded.put("iron:2", stack("iron", 2)); + nbt.decoded.put("sand:1", stack("sand", 1)); + JsonObject json = payload(entry(3, new JsonPrimitive("coal:3")), entry(3, new JsonPrimitive("iron:2")), + entry("bad-slot", new JsonPrimitive("sand:1"))); + container.loadFromJson(json); + assertEquals("coal", itemPaths.get(container.getItems().get(3))); + assertEquals("iron", itemPaths.get(container.getItems().get(0))); + assertEquals("sand", itemPaths.get(container.getItems().get(1))); + JsonObject saved = container.getAsJson(); assertEquals("cargo", saved.get("id").getAsString()); + assertEquals(3, saved.getAsJsonArray("items").size()); + assertEquals("coal:3", saved.getAsJsonArray("items").get(2).getAsJsonArray().get(1).getAsString()); + } + + @Test void failedAndExcessContainerEntriesArePreservedWithoutLosingLoadedItems() { + NbtFixture nbt = nbt(); YamlConfiguration config = containerConfig(); config.set("size", 1); + Container container = new Container("tiny", config); + nbt.decoded.put("coal:3", stack("coal", 3)); + JsonArray valid = entry(0, new JsonPrimitive("coal:3")); + JsonArray excess = entry(9, new JsonPrimitive("coal:3")); + JsonArray broken = entry(4, new JsonPrimitive("broken-snbt")); + JsonArray empty = entry(5, JsonNull.INSTANCE); + JsonObject json = payload(valid, excess, broken, empty); + json.getAsJsonArray("items").add("not-an-entry"); JsonArray malformed = new JsonArray(); malformed.add(7); json.getAsJsonArray("items").add(malformed); + container.loadFromJson(json); JsonArray saved = container.getAsJson().getAsJsonArray("items"); + assertEquals(4, saved.size()); assertEquals(excess, saved.get(1)); assertEquals(broken, saved.get(2)); assertEquals(empty, saved.get(3)); + Location location = new Location(world, 0, 64, 0); container.destroy(location); + verify(world).dropItem(location, container.getItems().get(0)); + } + + @Test void legacyContainerItemFallsBackToOriginalEncodingWhenRestoredTypesFail() { + NbtFixture nbt = nbt(); Container container = container(List.of()); + JsonObject legacy = JsonParser.parseString("{count:'1b',id:'coal'}").getAsJsonObject(); + nbt.decoded.put(legacy.toString(), stack("coal", 1)); + container.loadFromJson(payload(entry(0, legacy))); + assertEquals("coal", itemPaths.get(container.getItems().get(0))); + assertEquals(1, container.getAsJson().getAsJsonArray("items").size()); + } + + @Test void destroyingAnOpenContainerUsesItsCurrentLiveStacksOnly() { + Container container = container(List.of()); EngineFixture f = engine(false); + Inventory live = inventory(9); bukkit.when(() -> Bukkit.createInventory(any(InventoryHolder.class), eq(9), eq("Cargo"))).thenReturn(live); + container.open(f.vehicle, mock(Player.class)); ItemStack item = stack("coal", 5); live.setItem(4, item); live.setItem(5, stack("air", 1)); + container.destroy(new Location(world, 0, 64, 0)); + verify(world).dropItem(any(Location.class), argThat(stack -> "coal".equals(itemPaths.get(stack)) && stack.getAmount() == 5)); + } + + @Test void containerHandlerOpensOnlyTheRidersAssignedStorageAndDestroysItsCargo() { + EngineFixture f = engine(false); YamlConfiguration config = new YamlConfiguration(); + config.set("cargo.name", "Cargo"); config.set("cargo.size", 9); config.set("cargo.seat", "passenger"); + ContainerHandler handler = new ContainerHandler(f.vehicle, f.model, new ContainerHandler(config)); + assertEquals(1, handler.getContainers().size()); assertSame(handler.get("CARGO"), handler.getBySeat("PASSENGER")); + assertNull(handler.get("missing")); assertNull(handler.getBySeat("missing")); + Player player = mock(Player.class); assertFalse(handler.open(player)); + when(f.vehicle.getSeat(player)).thenReturn(new Seat(SeatType.PASSENGER, "missing")); assertFalse(handler.open(player)); + when(f.vehicle.getSeat(player)).thenReturn(new Seat(SeatType.PASSENGER, "passenger")); + Inventory live = inventory(9); bukkit.when(() -> Bukkit.createInventory(any(InventoryHolder.class), eq(9), eq("Cargo"))).thenReturn(live); + assertTrue(handler.open(player)); verify(player).openInventory(live); + ItemStack coal = stack("coal", 2); live.setItem(0, coal); handler.destroy(new Location(world, 0, 64, 0)); + verify(world).dropItem(any(Location.class), argThat(item -> item.getAmount() == 2)); + assertFalse(new ContainerHandler(new YamlConfiguration()).open(player)); + } + + @Test void harnessUsesHorseSpeedAndRefusesMoreMountsThanConfiguredSeats() { + EngineFixture f = engine(false); Harness harness = harness(f, "base", "align"); + assertFalse(harness.hasMounts()); assertTrue(harness.getMountedEntities().isEmpty()); + Player driver = mock(Player.class); AbstractHorse horse = horse(.4); LivingEntity creature = mock(LivingEntity.class); + harness.mount(driver, horse); harness.mount(driver, creature); harness.mount(driver, mock(Entity.class)); + assertTrue(harness.hasMounts()); assertEquals(.2, harness.getSpeed()); + assertEquals(List.of(horse, creature), harness.getMountedEntities()); + verify(driver).sendMessage("§cThere is no room to hitch the creature"); + harness.tick(List.of()); verify(f.panel).setSpeed(.2); verify(f.panel).setTurnRate(2.5); + harness.slowTick(List.of()); + } + + @Test void harnessDismountDropsLeadReturnsHorseToPlayerAndPreventsNegativeSpeed() { + EngineFixture f = engine(false); Harness harness = harness(f, "base", "align"); + keep(mockConstruction(ItemStack.class)); Player driver = mock(Player.class); AbstractHorse horse = horse(.4); + assertFalse(harness.dismount(driver)); harness.mount(driver, horse); + when(horse.getAttribute(org.bukkit.attribute.Attribute.MOVEMENT_SPEED).getBaseValue()).thenReturn(.8); + assertTrue(harness.dismount(driver)); assertEquals(0, harness.getSpeed()); assertFalse(harness.hasMounts()); + verify(horse).teleport(driver); verify(world).dropItem(any(Location.class), any(ItemStack.class)); + } + + @Test void mountedEntitiesFollowTheModelBoneAndDeadMountsAreRemoved() { + EngineFixture f = engine(false); Harness harness = harness(f, "base", "align"); + keep(mockConstruction(ItemStack.class)); + net.tfminecraft.vehicleframework.bones.VectorBone vector = mock(net.tfminecraft.vehicleframework.bones.VectorBone.class); + when(f.behaviour.getVector()).thenReturn(vector); when(vector.getVector()).thenReturn(new Vector(1, 0, 0)); + Entity cargo = mock(Entity.class); when(cargo.getWorld()).thenReturn(world); when(cargo.getLocation()).thenReturn(new Location(world, 0, 64, 0)); + AbstractHorse horse = horse(.4); harness.mount(mock(Player.class), cargo); harness.mount(mock(Player.class), horse); + harness.syncMountedEntities(); verify(cargo).teleport(new Location(world, 0, 65, 0, -90, 0)); + verify(horse).teleport(new Location(world, 0, 66, 0, -90, 0)); + assertEquals(List.of(horse), harness.getMountedEntities()); + when(cargo.isDead()).thenReturn(true); harness.syncMountedEntities(); + assertFalse(harness.getMounts().get(0).isOccupied()); assertTrue(harness.hasMounts()); + harness.syncMountedEntities(); + verify(cargo).teleport(new Location(world, 0, 65, 0, -90, 0)); + verify(horse, times(3)).teleport(new Location(world, 0, 66, 0, -90, 0)); + } + + @Test void harnessWaitsForMissingModelDirectionAndMountBones() { + YamlConfiguration config = config(false); Harness template = new Harness(config); template.syncMountedEntities(); + assertTrue(template.getMounts().isEmpty()); assertEquals(2.5, template.getTurnRate()); + EngineFixture f = engine(false); Harness harness = harness(f, "missing"); Entity cargo = mock(Entity.class); + harness.mount(mock(Player.class), cargo); + when(f.vehicle.getModel()).thenReturn(null); harness.syncMountedEntities(); + when(f.vehicle.getModel()).thenReturn(f.model); when(f.vehicle.getBehaviourHandler()).thenReturn(null); harness.syncMountedEntities(); + when(f.vehicle.getBehaviourHandler()).thenReturn(f.behaviour); harness.syncMountedEntities(); + net.tfminecraft.vehicleframework.bones.VectorBone vector = mock(net.tfminecraft.vehicleframework.bones.VectorBone.class); + when(f.behaviour.getVector()).thenReturn(vector); harness.syncMountedEntities(); + when(vector.getVector()).thenReturn(new Vector(0, 0, 1)); when(f.model.getBone("missing")).thenReturn(Optional.empty()); + harness.syncMountedEntities(); verify(cargo, never()).teleport(any(Location.class)); + } + + @Test void harnessSteeringScalesWithVelocityAndStopsBelowItsMovementThreshold() { + EngineFixture f = engine(false); Harness harness = harness(f, "base"); + assertEquals(2.5, harness.getTurnRate()); when(f.behaviour.turnScale()).thenReturn(true); + when(f.entity.getVelocity()).thenReturn(new Vector(.01, 0, 0)); assertEquals(0, harness.getTurnRate()); + when(f.entity.getVelocity()).thenReturn(new Vector(.5, 0, 0)); assertEquals(1.25, harness.getTurnRate()); + when(f.entity.getVelocity()).thenReturn(new Vector(2, 0, 0)); assertEquals(2.5, harness.getTurnRate()); + } + + @Test void configuredFuelTankAcceptsOnlyPermittedStateAndIdleEngine() { + Fuel fuel = mock(Fuel.class); fuels.when(() -> FuelLoader.getByString("coal")).thenReturn(fuel); + YamlConfiguration config = config(false); config.set("fuel", "coal"); config.set("fuel-capacity", 100); + config.set("refuel-states", List.of("ground", "not-a-state")); FuelTank tank = new FuelTank(config); + assertTrue(tank.hasInput()); assertTrue(tank.useFuel()); assertEquals(List.of(State.GROUND), tank.getStates()); + EngineFixture f = engine(false); Player player = mock(Player.class); + var currentState = f.states.getCurrentState(); when(f.vehicle.getCurrentState()).thenReturn(currentState); + when(f.states.getCurrentState().getType()).thenReturn(State.FLYING); tank.refuel(player, f.vehicle, 10); + verify(player).sendMessage(contains("Cannot refuel in this state")); assertEquals(0, tank.getCurrent()); + when(f.states.getCurrentState().getType()).thenReturn(State.GROUND); when(f.vehicle.getThrottle()).thenReturn(f.throttle()); + f.throttle().setThrottle(10); tank.refuel(player, f.vehicle, 10); + verify(player).sendMessage("§cCannot refuel while the engine is on"); + tank.setFuel(100); tank.refuel(player, f.vehicle, 10); verify(player).sendMessage("§cFuel tank is full"); + tank.setFuel(0); f.throttle().setThrottle(0); tank.refuel(player, f.vehicle, -1); assertEquals(0, tank.getCurrent()); + } + + @ParameterizedTest @ValueSource(booleans = {true, false}) + void successfulRefuelConsumesOneHeldItemAndPlaysConfiguredOrDefaultSound(boolean custom) { + EngineFixture f = engine(false); Player player = mock(Player.class); PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack can = stack("fuel", 3); when(player.getInventory()).thenReturn(inventory); when(inventory.getItemInMainHand()).thenReturn(can); + when(player.getWorld()).thenReturn(world); + Fuel fuel = mock(Fuel.class); when(fuel.getSound()).thenReturn(custom ? "refuel" : ""); + when(fuel.getSoundVolume()).thenReturn(.7f); when(fuel.getSoundPitch()).thenReturn(.9f); + FuelTank tank = new FuelTank(90, 100, 2, List.of(), fuel); tank.refuel(player, f.vehicle, 20); + assertEquals(100, tank.getCurrent()); assertEquals(100, tank.getPercentage()); assertEquals(2, can.getAmount()); + verify(player).sendMessage("§aFuel: §e100/100"); + if (custom) verify(world).playSound(any(Location.class), eq("refuel"), eq(SoundCategory.BLOCKS), eq(.7f), eq(.9f)); + else verify(world).playSound(any(Location.class), eq(Sound.ITEM_BUCKET_FILL), eq(1f), eq(.8f)); + } + + @Test void refuelWithoutWorldStillUpdatesFuelAndBurningCannotMakeFuelNegative() { + EngineFixture f = engine(false); Player player = mock(Player.class); PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = stack("fuel", 2); when(player.getInventory()).thenReturn(inventory); when(inventory.getItemInMainHand()).thenReturn(item); + FuelTank tank = new FuelTank(0, 100, 2, List.of(), null); tank.refuel(player, f.vehicle, 1); + assertEquals(1, tank.getCurrent()); assertEquals(1, item.getAmount()); + f.throttle().setThrottle(-100); tank.tick(f.throttle()); assertEquals(0, tank.getCurrent()); + assertFalse(tank.addFuel(0)); assertTrue(tank.addFuel(500)); assertEquals(100, tank.getCurrent()); + assertFalse(tank.addFuel(1)); tank.setFuel(-5); assertEquals(0, tank.getCurrent()); + tank.tick(f.throttle()); assertEquals(0, tank.getCurrent()); + } + + private EngineFixture engine(boolean requiresStart) { return engine(new Engine(config(requiresStart))); } + private EngineFixture engine(Engine template) { + ActiveVehicle vehicle = mock(ActiveVehicle.class); Entity entity = mock(Entity.class); + ActiveModel model = model(65); + when(vehicle.getEntity()).thenReturn(entity); when(vehicle.getModel()).thenReturn(model); + when(entity.getLocation()).thenAnswer(call -> new Location(world, 0, 64, 0)); + when(entity.getVelocity()).thenReturn(new Vector()); + BehaviourHandler behaviour = mock(BehaviourHandler.class); when(vehicle.getBehaviourHandler()).thenReturn(behaviour); + SeatHandler seats = mock(SeatHandler.class); when(vehicle.getSeatHandler()).thenReturn(seats); + when(seats.hasPassengers()).thenReturn(true); + StateHandler states = mock(StateHandler.class, RETURNS_DEEP_STUBS); when(vehicle.getStateHandler()).thenReturn(states); + when(states.getCurrentState().getType()).thenReturn(State.GROUND); + TrainHandler train = mock(TrainHandler.class); when(vehicle.isTrain()).thenReturn(true); + when(vehicle.getTrainHandler()).thenReturn(train); when(train.playbackThrottle(any())).thenReturn(null); + when(train.isRecording()).thenReturn(true); when(train.getOverdrive()).thenReturn(new LocomotiveOverdrive()); + AccessPanel panel = mock(AccessPanel.class); when(vehicle.getAccessPanel()).thenReturn(panel); + VehicleMovementController controls = mock(VehicleMovementController.class); when(vehicle.getMoveControls()).thenReturn(controls); + Engine engine = new Engine(vehicle, template, entity, model, null); + return new EngineFixture(vehicle, entity, model, engine, behaviour, seats, states, train, panel, controls); + } + private ActiveModel model(double y) { + ActiveModel model = mock(ActiveModel.class, RETURNS_DEEP_STUBS); ModelBone base = mock(ModelBone.class), align = mock(ModelBone.class); + when(base.getBoneId()).thenReturn("base"); when(align.getBoneId()).thenReturn("align"); + when(base.getLocation()).thenAnswer(call -> new Location(world, 0, y, 0)); + when(align.getLocation()).thenAnswer(call -> new Location(world, 0, y + 1, 0)); + when(model.getBone("base")).thenReturn(Optional.of(base)); when(model.getBone("align")).thenReturn(Optional.of(align)); + return model; + } + private YamlConfiguration config(boolean requiresStart) { + YamlConfiguration config = new YamlConfiguration(); config.set("alias", "Motor"); config.set("health", 100); + config.set("requires-start", requiresStart); config.set("speed", 1); config.set("turn-rate", 2.5); + config.set("min", -100); config.set("max", 120); return config; + } + private void fuelRequired() { + Fuel fuel = mock(Fuel.class); fuels.when(() -> FuelLoader.getByString("none")).thenReturn(fuel); + } + private YamlConfiguration containerConfig() { + YamlConfiguration config = new YamlConfiguration(); config.set("name", "Cargo"); config.set("size", 9); + config.set("seat", "passenger"); return config; + } + private Harness harness(EngineFixture f, String... mounts) { + YamlConfiguration config = config(false); config.set("mount-bones", List.of(mounts)); + return new Harness(new Harness(config), f.vehicle, f.model, null); + } + private AbstractHorse horse(double speed) { + AbstractHorse horse = mock(AbstractHorse.class); + org.bukkit.attribute.AttributeInstance attribute = mock(org.bukkit.attribute.AttributeInstance.class); + when(attribute.getBaseValue()).thenReturn(speed); + when(horse.getAttribute(org.bukkit.attribute.Attribute.MOVEMENT_SPEED)).thenReturn(attribute); + when(horse.getWorld()).thenReturn(world); when(horse.getLocation()).thenReturn(new Location(world, 0, 64, 0)); return horse; + } + private Container container(List allow) { + YamlConfiguration config = containerConfig(); config.set("allow-items", allow); return new Container("cargo", config); + } + private ItemAPI items() { + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + MockedStatic libs = keep(mockStatic(TLibs.class)); libs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getChecker().checkItemWithPath(nullable(ItemStack.class), anyString())).thenAnswer(call -> + call.getArgument(1).equals(itemPaths.get(call.getArgument(0)))); + return api; + } + private ItemStack stack(String path, int amount) { + ItemStack item = mock(ItemStack.class); Material material = mock(Material.class); + when(material.isAir()).thenReturn(path.equals("air")); when(item.getType()).thenReturn(material); + int[] count = {amount}; when(item.getAmount()).thenAnswer(call -> count[0]); + doAnswer(call -> { count[0] = call.getArgument(0); return null; }).when(item).setAmount(anyInt()); + when(item.clone()).thenAnswer(call -> stack(path, count[0])); itemPaths.put(item, path); return item; + } + private Inventory inventory(int size) { + Inventory inventory = mock(Inventory.class); ItemStack[] contents = new ItemStack[size]; + when(inventory.getSize()).thenReturn(size); when(inventory.getContents()).thenAnswer(call -> contents.clone()); + when(inventory.getItem(anyInt())).thenAnswer(call -> contents[call.getArgument(0)]); + doAnswer(call -> { contents[call.getArgument(0)] = call.getArgument(1); return null; }).when(inventory).setItem(anyInt(), nullable(ItemStack.class)); + return inventory; + } + private JsonArray entry(Object slot, JsonElement encoded) { + JsonArray entry = new JsonArray(); if (slot instanceof Number number) entry.add(number); else entry.add(slot.toString()); + entry.add(encoded); return entry; + } + private JsonObject payload(JsonArray... entries) { + JsonArray items = new JsonArray(); for (JsonArray entry : entries) items.add(entry); + JsonObject json = new JsonObject(); json.add("items", items); return json; + } + private NbtFixture nbt() { + NbtFixture f = new NbtFixture(); + keep(mockStatic(NBT.class, call -> { + String method = call.getMethod().getName(); + if (method.equals("parseNBT")) { + String encoded = call.getArgument(0); + if (encoded.startsWith("broken")) throw new IllegalArgumentException("bad item"); + return f.compound(encoded); + } + if (method.equals("itemStackFromNBT")) return f.decoded.get(f.encoded.get(call.getArgument(0))); + if (method.equals("itemStackToNBT")) { + ItemStack item = call.getArgument(0); return f.compound(itemPaths.get(item) + ":" + item.getAmount()); + } + return RETURNS_DEFAULTS.answer(call); + })); + return f; + } + private static final class NbtFixture { + final Map decoded = new HashMap<>(); + final Map encoded = new IdentityHashMap<>(); + ReadWriteNBT compound(String text) { + ReadWriteNBT value = mock(ReadWriteNBT.class); when(value.toString()).thenReturn(text); encoded.put(value, text); return value; + } + } + private BukkitTask task(Runnable runnable, List target) { + target.add(runnable); BukkitTask task = mock(BukkitTask.class); + when(task.getTaskId()).thenReturn(later.size() + timers.size()); return task; + } + private T keep(T scope) { scopes.add(scope); return scope; } + private record EngineFixture(ActiveVehicle vehicle, Entity entity, ActiveModel model, Engine engine, + BehaviourHandler behaviour, SeatHandler seats, StateHandler states, TrainHandler train, + AccessPanel panel, VehicleMovementController controls) { + Throttle throttle() { return engine.getThrottle(); } + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/RuntimeSupportCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/RuntimeSupportCoverageTest.java new file mode 100644 index 0000000..d2af248 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/RuntimeSupportCoverageTest.java @@ -0,0 +1,183 @@ +package net.tfminecraft.vehicleframework.vehicles.controller; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import java.util.logging.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import com.ticxo.modelengine.api.model.ActiveModel; +import net.tfminecraft.vehicleframework.bones.BoneRotator; +import net.tfminecraft.vehicleframework.data.OwnerData; +import net.tfminecraft.vehicleframework.enums.*; +import net.tfminecraft.vehicleframework.managers.inventory.VFInventoryHolder; +import net.tfminecraft.vehicleframework.test.RegistryFixture; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.component.Engine; +import net.tfminecraft.vehicleframework.vehicles.component.GearedEngine; +import net.tfminecraft.vehicleframework.vehicles.component.propulsion.Throttle; +import net.tfminecraft.vehicleframework.vehicles.handlers.*; +import net.tfminecraft.vehicleframework.vehicles.handlers.state.InputHandler; +import net.tfminecraft.vehicleframework.vehicles.seat.Seat; +import net.tfminecraft.vehicleframework.vehicles.util.AccessPanel; +import net.tfminecraft.vehicleframework.weapons.ActiveWeapon; +import net.tfminecraft.vehicleframework.weapons.Weapon; + +class RuntimeSupportCoverageTest { + @TempDir Path folder; + + @BeforeAll static void initialize() { + RegistryFixture.initialize(); + // A disabled logger can be bootstrapped before a plugin data folder is available. + assertDoesNotThrow(() -> GroundEngineLog.configure(false, true, null)); + assertFalse(GroundEngineLog.isEnabled()); + } + + @AfterEach void resetLog() { GroundEngineLog.configure(false, false, folder.toFile()); } + + @Test void inputMappingsAreCompleteAndCopiesDoNotShareMutableBindings() { + InputHandler defaults = new InputHandler(); + assertEquals(Keybind.values().length, defaults.getMappings().size()); + assertEquals(Input.SEAT_SELECTION, defaults.getInput(Keybind.SHIFT)); + assertTrue(defaults.containsInput(Input.SEAT_SELECTION)); assertFalse(defaults.containsInput(Input.LIGHTS)); + YamlConfiguration yaml = new YamlConfiguration(); yaml.set("W", "throttle_up"); yaml.set("RIGHT_CLICK", "weapon_shoot"); + InputHandler configured = new InputHandler(yaml); InputHandler copied = new InputHandler(configured); + configured.getMappings().put(Keybind.W, Input.NONE); + assertEquals(Input.THROTTLE_UP, copied.getInput(Keybind.W)); + assertEquals(Input.WEAPON_SHOOT, copied.getInput(Keybind.RIGHT_CLICK)); assertEquals(Input.NONE, copied.getInput(Keybind.S)); + assertEquals(Keybind.values().length, copied.getMappings().size()); + } + + @Test void configuredInputNamesAreIndependentOfTheServersDefaultLocale() { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + YamlConfiguration yaml = new YamlConfiguration(); yaml.set("RIGHT_CLICK", "lights"); + assertEquals(Input.LIGHTS, new InputHandler(yaml).getInput(Keybind.RIGHT_CLICK)); + } finally { Locale.setDefault(previous); } + } + + @Test void accessPanelConnectsLiveSeatsAndOnlyTurnsWhileMoving() { + AccessPanel panel = new AccessPanel(); ActiveVehicle vehicle = mock(ActiveVehicle.class); when(vehicle.getAccessPanel()).thenReturn(panel); + Seat driver = new Seat(vehicle, new Seat(SeatType.CAPTAIN, "captain")); + Seat passenger = new Seat(vehicle, new Seat(SeatType.PASSENGER, "bench")); + BoneRotator front = mock(BoneRotator.class), rear = mock(BoneRotator.class); + when(front.getId()).thenReturn("front"); when(rear.getId()).thenReturn("rear"); panel.addRotator(front); panel.addRotator(rear); + assertEquals(List.of(driver, passenger), panel.getSeats()); assertSame(passenger, panel.getSeat("BENCH")); assertNull(panel.getSeat("missing")); + assertEquals(List.of(front, rear), panel.getRotators()); assertSame(rear, panel.getRotator("REAR")); assertNull(panel.getRotator("missing")); + panel.setTurnRate(3); assertEquals(0, panel.getTurnRate()); panel.setSpeed(.4); assertEquals(.4, panel.getSpeed()); assertEquals(3, panel.getTurnRate()); + panel.setReverse(true); assertTrue(panel.isReverse()); panel.setSpeed(0); assertEquals(0, panel.getTurnRate()); + } + + @Test void weaponRoutingConnectsMatchingSeatsAndOnlyDispatchesTheCurrentPlayersInputs() { + ActiveModel model = mock(ActiveModel.class), replacement = mock(ActiveModel.class); ActiveVehicle vehicle = mock(ActiveVehicle.class); + Weapon cannon = mock(Weapon.class), rearGun = mock(Weapon.class), idleGun = mock(Weapon.class); + Seat driver = new Seat(SeatType.CAPTAIN, "captain"), rear = new Seat(SeatType.GUNNER, "rear"), passenger = new Seat(SeatType.PASSENGER, "bench"); + SeatHandler seats = mock(SeatHandler.class); when(seats.getSeats()).thenReturn(List.of(driver, rear, passenger)); + Player operator = mock(Player.class), other = mock(Player.class); List nearby = List.of(operator, other); + List> arguments = new ArrayList<>(); + try (var constructed = mockConstruction(ActiveWeapon.class, (weapon, context) -> { + arguments.add(new ArrayList<>(context.arguments())); int index = context.getCount(); + when(weapon.getId()).thenReturn(index == 1 ? "cannon" : index == 2 ? "rear_gun" : "unused"); + when(weapon.getSeat()).thenReturn(index == 1 ? "CAPTAIN" : index == 2 ? "rear" : "unassigned"); + when(weapon.isControlled()).thenReturn(index != 3); when(weapon.getController()).thenReturn(index == 1 ? operator : other); + })) { + WeaponHandler handler = new WeaponHandler(model, vehicle, List.of(cannon, rearGun, idleGun), seats); + ActiveWeapon first = constructed.constructed().get(0), second = constructed.constructed().get(1), third = constructed.constructed().get(2); + assertEquals(Arrays.asList(model, vehicle, cannon, null), arguments.get(0)); + assertSame(first, driver.getWeapon()); assertSame(second, rear.getWeapon()); assertFalse(passenger.hasWeapon()); + assertEquals(constructed.constructed(), handler.getWeapons()); assertSame(second, handler.getWeapon("REAR_GUN")); assertNull(handler.getWeapon("missing")); + handler.input(nearby, Keybind.LEFT_CLICK, operator); + verify(first).input(nearby, Keybind.LEFT_CLICK); verify(second, never()).input(anyList(), any()); verify(third, never()).input(anyList(), any()); + handler.updateModel(replacement); handler.damage("explosion", 4.5); handler.tick(); + for (ActiveWeapon weapon : constructed.constructed()) { verify(weapon).updateModel(replacement); verify(weapon).damage("explosion", 4.5); verify(weapon).tick(); } + } + } + + @Test void accessRulesHandleMissingLegacyWhitelistEntriesWithoutGrantingStrangersAccess() { + assertFalse(VehicleTicketRules.ownerOrWhitelisted(null, "Guest")); + OwnerData access = new OwnerData(); access.setOwner("player_Owner"); access.setWhiteListed(true); + assertFalse(VehicleTicketRules.ownerOrWhitelisted(access, null)); assertTrue(VehicleTicketRules.ownerOrWhitelisted(access, "OWNER")); + access.setWhiteList(null); assertFalse(VehicleTicketRules.ownerOrWhitelisted(access, "Guest")); + access.setWhiteList(Arrays.asList(null, "player_Alice", "Bob")); + assertTrue(VehicleTicketRules.ownerOrWhitelisted(access, "ALICE")); assertTrue(VehicleTicketRules.ownerOrWhitelisted(access, "bob")); + assertFalse(VehicleTicketRules.ownerOrWhitelisted(access, "Guest")); + assertTrue(VehicleTicketRules.mayOpenSeatMenu(null, "Guest", false)); + assertFalse(VehicleTicketRules.mayOpenSeatMenu(access, null, "Guest", true)); + OwnerData locomotive = new OwnerData(); locomotive.setTicketsEnabled(true); + assertTrue(VehicleTicketRules.mayOpenSeatMenu(access, locomotive, "Guest", true)); + assertFalse(VehicleTicketRules.mayEnter(access, locomotive, "Guest", SeatType.PASSENGER, false)); + assertTrue(VehicleTicketRules.mayEnter(access, locomotive, "Guest", null, false)); + access.setOwner(null); assertTrue(VehicleTicketRules.mayOpenSeatMenu(access, "Guest", false)); + access.setOwner("none"); assertTrue(VehicleTicketRules.mayOpenSeatMenu(access, "Guest", false)); + } + + @Test void inventoryHoldersCarryRoutingContextWithoutOwningTheBukkitInventory() { + VFInventoryHolder selection = new VFInventoryHolder("select", VFGUI.SEAT_SELECTION); + assertEquals("select", selection.getId()); assertEquals(VFGUI.SEAT_SELECTION, selection.getType()); assertTrue(selection.getVehicle().isEmpty()); + ActiveVehicle vehicle = mock(ActiveVehicle.class); VFInventoryHolder repair = new VFInventoryHolder("repair", VFGUI.REPAIR, vehicle); + assertSame(vehicle, repair.getVehicle().orElseThrow()); assertEquals(VFGUI.REPAIR, repair.getType()); assertNull(repair.getInventory()); + } + + @Test void groundLogAppendsImmediatelyAndCanWipeOrRetainTheConfiguredDestination() throws Exception { + GroundEngineLog.configure(true, false, folder.toFile()); assertTrue(GroundEngineLog.isEnabled()); + GroundEngineLog.append("first"); GroundEngineLog.append(null); GroundEngineLog.append("second"); + Path log = folder.resolve("logs/ground_engine.log"); List lines = Files.readAllLines(log); + assertEquals(2, lines.size()); assertTrue(lines.get(0).matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} first")); assertTrue(lines.get(1).endsWith(" second")); + GroundEngineLog.configure(false, false, null); GroundEngineLog.append("disabled"); assertEquals(lines, Files.readAllLines(log)); + GroundEngineLog.configure(true, true, null); assertFalse(Files.exists(log)); GroundEngineLog.append("fresh"); assertEquals(1, Files.readAllLines(log).size()); + } + + @Test void groundLogReportsUnwritableOutputAndFailedWipesWithoutEscapingToTheTickLoop() throws Exception { + Logger logger = Logger.getLogger(GroundEngineLog.class.getName()); List records = new ArrayList<>(); + Handler capture = new Handler() { public void publish(LogRecord record) { records.add(record); } public void flush() {} public void close() {} }; + logger.addHandler(capture); + try { + Path log = folder.resolve("logs/ground_engine.log"); Files.createDirectories(log); Path original = Files.writeString(log.resolve("keep"), "data"); + GroundEngineLog.configure(true, true, folder.toFile()); assertEquals("data", Files.readString(original)); + GroundEngineLog.append("cannot write to directory"); + assertTrue(records.stream().anyMatch(record -> record.getMessage().startsWith("Failed to wipe ground_engine.log:"))); + assertTrue(records.stream().anyMatch(record -> record.getMessage().startsWith("Failed to write ground_engine.log:"))); + } finally { logger.removeHandler(capture); } + } + + @Test void groundLogFormatsActualEngineThrottleGearAndStateTransitions() throws Exception { + assertEquals("", GroundEngineLog.formatThrottle(null)); assertEquals("", GroundEngineLog.formatGearedEngine(null)); assertEquals("", GroundEngineLog.formatEngineFragment(null)); + Throttle independent = new Throttle("Throttle", 100, -30, null); independent.setThrottle(-10); + assertEquals("thr=-10/-30/100", GroundEngineLog.formatThrottle(independent)); + YamlConfiguration config = new YamlConfiguration(); config.loadFromString("max: 100\nmin: -30\nhealth: 100\ngears:\n first:\n name: First\n engine-sound: {}\n accelerate-sound: {}\n max: 100\n min: -30\n"); + Engine engine = new Engine(config); engine.getThrottle().setThrottle(20); + GearedEngine geared = new GearedEngine(config); geared.getGear().getThrottle().setThrottle(40); geared.setStarted(true); + String expected = "gear=0 gearName=First thr=40/-30/100 shifting=false started=true"; + assertEquals(expected, GroundEngineLog.formatGearedEngine(geared)); + ActiveVehicle vehicle = mock(ActiveVehicle.class); assertEquals("", GroundEngineLog.formatEngineFragment(vehicle)); + when(vehicle.hasComponent(Component.ENGINE)).thenReturn(true); assertEquals("", GroundEngineLog.formatEngineFragment(vehicle)); + when(vehicle.getComponent(Component.ENGINE)).thenReturn(engine); assertEquals("thr=20/-30/100", GroundEngineLog.formatEngineFragment(vehicle)); + when(vehicle.hasComponent(Component.GEARED_ENGINE)).thenReturn(true); when(vehicle.getComponent(Component.GEARED_ENGINE)).thenReturn(geared); + assertEquals(expected, GroundEngineLog.formatEngineFragment(vehicle)); + assertEquals("id=null state=none->null reason=unknown isDefault=false", GroundEngineLog.formatStateSwap(null, null, null, null, false)); + assertEquals("id=cart state=GROUND->FLYING reason=input isDefault=true", GroundEngineLog.formatStateSwap("cart", "GROUND", "FLYING", "input", true)); + } + + @Test void levelObstacleAllowsASafeBackoffWithoutTryingToRaiseTheVehicle() { + var move = TerrainFollowMath.raiseThenSlide(.4, 0, 64, 64, 64, 64, .25, + (x, z, y) -> x > 0); + assertTrue(move.offsetX <= 1e-9); assertEquals(0, move.offsetZ); assertEquals(64, move.y); + assertEquals("back+slidePartial", move.path); assertTrue(move.aabbBlocked); + } + + @Test void shortMovementBesideABlockCornerCanBackOffThenSlideAlongOneAxis() { + TerrainFollowMath.OffsetYBlocked corners = (x, z, y) -> + (x > 0 && z > 0) || (x < -.062 && x > -1.062 && z > -.067 && z < .933); + var move = TerrainFollowMath.raiseThenSlide(.007, .007, 64, 64, 64, 64, .25, corners); + assertEquals(-.1 / Math.sqrt(2) + .007, move.offsetX, 1e-9); + assertEquals(-.1 / Math.sqrt(2), move.offsetZ, 1e-9); + assertFalse(corners.test(move.offsetX, move.offsetZ, move.y)); + assertEquals("back+slideX", move.path); assertTrue(move.aabbBlocked); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowEngineCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowEngineCoverageTest.java new file mode 100644 index 0000000..0905eab --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowEngineCoverageTest.java @@ -0,0 +1,587 @@ +package net.tfminecraft.vehicleframework.vehicles.controller; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.Waterlogged; +import org.bukkit.entity.*; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.RayTraceResult; +import org.bukkit.util.Vector; +import org.bukkit.util.VoxelShape; +import org.junit.jupiter.api.*; +import org.mockito.*; +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.bone.ModelBone; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.bones.VectorBone; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.enums.Component; +import net.tfminecraft.vehicleframework.enums.Direction; +import net.tfminecraft.vehicleframework.enums.State; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.component.Harness; +import net.tfminecraft.vehicleframework.vehicles.handlers.state.TerrainFollowConfig; + +class TerrainFollowEngineCoverageTest { + private final World world = mock(World.class); + private final LivingEntity entity = mock(LivingEntity.class); + private final ActiveVehicle vehicle = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); + private final ActiveModel model = mock(ActiveModel.class); + private final VectorBone vector = mock(VectorBone.class); + private final BaseController base = new BaseController(); + private final Map blocks = new LinkedHashMap<>(); + private final Map bones = new HashMap<>(); + private final List messages = new ArrayList<>(); + private MockedStatic engineLog; + private MockedStatic log; + private Location location; + private Vector velocity; + private double locationOffset; + private double halfWidth = .4; + private double height = 1; + private boolean oldDebug; + private TerrainFollowConfig config; + + @BeforeEach + void setup() { + oldDebug = Cache.terrainFollowDebug; + Cache.terrainFollowDebug = false; + engineLog = mockStatic(GroundEngineLog.class); + log = mockStatic(VFLogger.class); + engineLog.when(() -> GroundEngineLog.formatEngineFragment(vehicle)).thenReturn(""); + engineLog.when(() -> GroundEngineLog.append(anyString())).thenAnswer(call -> { messages.add(call.getArgument(0)); return null; }); + when(world.getName()).thenReturn("terrain"); + location = new Location(world, .5, 64, .5, 25, 6); + velocity = new Vector(); + config = config(List.of()); + when(vehicle.getEntity()).thenReturn(entity); + when(vehicle.getUUID()).thenReturn(UUID.randomUUID().toString()); + when(vehicle.getId()).thenReturn("test_car"); + when(vehicle.getModel()).thenReturn(model); + when(vehicle.getCurrentState().getType()).thenReturn(State.GROUND); + when(entity.isValid()).thenReturn(true); + when(entity.getWorld()).thenReturn(world); + when(entity.getLocation()).thenAnswer(call -> location.clone()); + when(entity.getVelocity()).thenAnswer(call -> velocity.clone()); + when(entity.getBoundingBox()).thenAnswer(call -> new BoundingBox(location.getX() - halfWidth, + location.getY() - locationOffset, location.getZ() - halfWidth, + location.getX() + halfWidth, location.getY() - locationOffset + height, location.getZ() + halfWidth)); + when(vector.getVector()).thenAnswer(call -> new Vector(1, 0, 0)); + when(model.getBone(anyString())).thenAnswer(call -> Optional.ofNullable(bones.get(call.getArgument(0)))); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(call -> terrain(call.getArgument(0), call.getArgument(1), call.getArgument(2)).block); + when(world.rayTraceBlocks(any(Location.class), any(Vector.class), anyDouble(), any(FluidCollisionMode.class), anyBoolean())) + .thenAnswer(call -> trace(call.getArgument(0), call.getArgument(1), call.getArgument(2), call.getArgument(3))); + } + + @AfterEach + void teardown() { + when(entity.isValid()).thenReturn(false); + TerrainFollowEngine.step(vehicle, vector, Direction.STILL, base, config); + log.close(); + engineLog.close(); + Cache.terrainFollowDebug = oldDebug; + } + + private TerrainFollowConfig config(List probes) { + return new TerrainFollowConfig(true, 1, .25, 3, 1, .08, .98, probes); + } + private TerrainFollowEngine.Result step() { return step(Direction.FORWARD); } + private TerrainFollowEngine.Result step(Direction direction) { + return TerrainFollowEngine.step(vehicle, vector, direction, base, config); + } + private void apply(TerrainFollowEngine.Result result) { + location = result.location.clone(); + velocity = result.velocity.clone(); + } + private TerrainBlock terrain(int x, int y, int z) { + return blocks.computeIfAbsent(new Cell(x, y, z), TerrainBlock::new); + } + private TerrainBlock solid(int x, int y, int z) { return solid(x, y, z, new BoundingBox(0, 0, 0, 1, 1, 1)); } + private TerrainBlock solid(int x, int y, int z, BoundingBox... shapes) { + TerrainBlock block = terrain(x, y, z); + block.boxes = List.of(shapes); + return block; + } + private void floor(int y) { + for (int x = -3; x <= 15; x++) for (int z = -3; z <= 3; z++) solid(x, y, z); + } + private void bone(String id, Location at) { + ModelBone bone = mock(ModelBone.class); + when(bone.getLocation()).thenAnswer(call -> at == null ? null : at.clone()); + bones.put(id, bone); + } + private Player viewer(World in, boolean online) { + Player player = mock(Player.class); + when(player.getWorld()).thenReturn(in); + when(player.isOnline()).thenReturn(online); + return player; + } + private RayTraceResult hit(Block block, double x, double y, double z) { + return new RayTraceResult(new Vector(x, y, z), block, BlockFace.UP); + } + private RayTraceResult trace(Location start, Vector direction, double maxDistance, FluidCollisionMode fluids) { + RayTraceResult nearest = null; + double best = Double.POSITIVE_INFINITY; + for (TerrainBlock block : blocks.values()) { + List boxes = block.boxes; + if (fluids == FluidCollisionMode.ALWAYS && block.liquid && boxes.isEmpty()) + boxes = List.of(new BoundingBox(0, 0, 0, 1, 1, 1)); + for (BoundingBox local : boxes) { + BoundingBox shape = local.clone().shift(block.cell.x, block.cell.y, block.cell.z); + RayTraceResult result = shape.rayTrace(start.toVector(), direction, maxDistance); + if (result == null) continue; + double distance = result.getHitPosition().distanceSquared(start.toVector()); + if (distance < best) { + best = distance; + nearest = new RayTraceResult(result.getHitPosition(), block.block, result.getHitBlockFace()); + } + } + } + return nearest; + } + + @Test + void invalidEntitiesReturnNoMovementAndDiscardAirborneMomentum() { + when(entity.isValid()).thenReturn(false); + assertNull(step()); + when(vehicle.getEntity()).thenReturn(null); + when(vehicle.getUUID()).thenReturn(null); + assertNull(step()); + } + + @Test + void flatGroundKeepsFootOffsetYawAndPitchAndDoesNotMutateEntityVelocity() { + floor(63); + locationOffset = .3; + location.setY(64.3); + velocity = new Vector(.6, -.4, .2); + TerrainFollowEngine.Result result = step(); + assertEquals(1.1, result.location.getX(), 1e-9); + assertEquals(64.3, result.location.getY(), 1e-9); + assertEquals(.7, result.location.getZ(), 1e-9); + assertEquals(25, result.location.getYaw()); + assertEquals(6, result.location.getPitch()); + assertEquals(new Vector(.6, 0, .2), result.velocity); + assertEquals(new Vector(.6, -.4, .2), velocity); + assertNull(result.tilt); + } + + @Test + void stationaryAndFastVehiclesRemainOnGroundWithoutVerticalInterpolation() { + floor(63); + TerrainFollowEngine.Result stationary = step(); + assertEquals(location, stationary.location); + assertEquals(new Vector(), stationary.velocity); + velocity = new Vector(2, 0, 0); + TerrainFollowEngine.Result fast = step(); + assertEquals(2.5, fast.location.getX()); + assertEquals(new Vector(2, 0, 0), fast.velocity); + } + + @Test + void downhillDropsDirectlyToSupportInsteadOfLimitingByClimbSnap() { + floor(63); + location.setY(65.5); + velocity = new Vector(.4, 0, 0); + TerrainFollowEngine.Result result = step(); + assertEquals(64, result.location.getY()); + assertEquals(.9, result.location.getX(), 1e-9); + assertEquals(0, result.velocity.getY()); + } + + @Test + void shallowStepRaisesThenContinuesForwardWithoutPenetratingItsShape() { + floor(63); + solid(1, 64, 0, new BoundingBox(0, 0, 0, 1, .5, 1)); + velocity = new Vector(.6, 0, 0); + TerrainFollowEngine.Result result = step(); + assertEquals(64.5, result.location.getY(), 1e-9); + assertEquals(1.1, result.location.getX(), 1e-9); + assertTrue(result.location.getY() >= 64.5); + } + + @Test + void tallObstacleStopsForwardMotionAndRetainsHorizontalMomentumForLaterFall() { + floor(63); + solid(1, 64, 0); + solid(1, 65, 0); + config = new TerrainFollowConfig(true, .5, .25, 0, 0, .08, .98, List.of()); + velocity = new Vector(.6, 0, 0); + when(vehicle.hasComponent(Component.ENGINE)).thenReturn(true); + when(vehicle.getAccessPanel().getSpeed()).thenReturn(.6); + TerrainFollowEngine.Result result = step(); + assertEquals(.5, result.location.getX()); + assertEquals(new Vector(), result.velocity); + blocks.clear(); + apply(result); + step(); + TerrainFollowEngine.Result airborne = step(); + assertTrue(airborne.velocity.getX() > 0, "Stored drive momentum should survive leaving blocked ground"); + } + + @Test + void reportedForwardHitWithoutCollisionShapeConservativelyStopsAtBlockTop() { + floor(63); + Block reported = terrain(1, 64, 0).block; + RayTraceResult hit = hit(reported, 1, 64.08, .5); + when(world.rayTraceBlocks(any(Location.class), any(Vector.class), anyDouble(), + eq(FluidCollisionMode.NEVER), eq(true))).thenReturn(hit); + config = new TerrainFollowConfig(true, .5, .25, 0, 0, .08, .98, List.of()); + velocity = new Vector(.6, 0, 0); + TerrainFollowEngine.Result result = step(); + assertEquals(.5, result.location.getX()); + assertEquals(64, result.location.getY()); + assertEquals(new Vector(), result.velocity); + } + + @Test + void lookaheadRaisesSupportBeforeWheelsReachHigherGround() { + floor(63); + solid(2, 64, 0, new BoundingBox(0, 0, 0, 1, .5, 1)); + velocity = new Vector(.5, 0, 0); + config = new TerrainFollowConfig(true, 1, .1, 4, 0, .08, .98, List.of()); + TerrainFollowEngine.Result result = step(); + assertEquals(64.1, result.location.getY(), 1e-9); + assertEquals(1, result.location.getX(), 1e-9); + } + + @Test + void fourWheelContactsDeterminePitchRollAndHighestSupport() { + location = new Location(world, 1, 65, 1); + bone("fl", new Location(world, .5, 65, 1.5)); + bone("fr", new Location(world, 1.5, 65, 1.5)); + bone("bl", new Location(world, .5, 65, .5)); + bone("br", new Location(world, 1.5, 65, .5)); + solid(0, 64, 1, new BoundingBox(0, 0, 0, 1, .4, 1)); + solid(1, 64, 1, new BoundingBox(0, 0, 0, 1, .6, 1)); + solid(0, 63, 0); + solid(1, 64, 0, new BoundingBox(0, 0, 0, 1, .2, 1)); + config = config(List.of("fl", "fr", "bl", "br")); + TerrainFollowEngine.Result result = step(); + assertEquals(64.6, result.location.getY(), 1e-9); + assertNotNull(result.tilt); + assertEquals(-21.8014, result.tilt.pitchDeg, .001); + assertEquals(-11.3099, result.tilt.rollDeg, .001); + } + + @Test + void missingWheelContactKeepsSupportButDoesNotInventTilt() { + floor(63); + config = config(List.of("fl", "fr", "bl", "br")); + bone("fl", new Location(world, .1, 64, .1)); + bone("fr", new Location(world, .9, 64, .1)); + bone("bl", new Location(world, .1, 64, .9)); + bone("br", new Location(world, 50, 64, 50)); + TerrainFollowEngine.Result result = step(); + assertEquals(64, result.location.getY()); + assertNull(result.tilt); + velocity = new Vector(.2, 0, 0); + assertEquals(.7, step().location.getX(), 1e-9); + } + + @Test + void twoMissedSupportTicksStartGravityAndAirDragThenLandingResetsFall() { + floor(63); + velocity = new Vector(.5, 0, 0); + apply(step()); + blocks.clear(); + TerrainFollowEngine.Result firstMiss = step(); + assertEquals(63.92, firstMiss.location.getY(), 1e-9); + assertEquals(0, firstMiss.velocity.getY()); + apply(firstMiss); + TerrainFollowEngine.Result airborne = step(); + assertEquals(-.08, airborne.velocity.getY(), 1e-9); + assertEquals(.49, airborne.velocity.getX(), 1e-9); + apply(airborne); + TerrainFollowEngine.Result next = step(); + assertEquals(-.16, next.velocity.getY(), 1e-9); + assertEquals(.4802, next.velocity.getX(), 1e-9); + apply(next); + floor(62); + TerrainFollowEngine.Result landed = step(); + assertEquals(63, landed.location.getY()); + assertEquals(0, landed.velocity.getY()); + assertNull(landed.tilt); + } + + @Test + void fallingWithoutHorizontalMomentumAndBlockedDownwardMotionRemainFinite() { + step(); + apply(step()); + TerrainFollowEngine.Result falling = step(); + assertEquals(0, falling.velocity.getX()); + assertEquals(-.16, falling.velocity.getY(), 1e-9); + // Side support lies under the chassis but outside the central downward ray. + location = new Location(world, .95, 64, .5); + solid(1, 63, 0); + TerrainFollowEngine.Result blocked = step(); + assertTrue(blocked.location.getY() >= 63.92 - 1e-9); + assertTrue(blocked.location.getY() <= 64); + assertEquals(0, blocked.velocity.getY()); + } + + @Test + void airborneMotionCannotTunnelThroughWallWhenEndpointIsClear() { + floor(63); + velocity = new Vector(3, 0, 0); + apply(step()); + blocks.clear(); + apply(step()); + assertEquals(6.5, location.getX(), 1e-9); + solid(8, 63, 0); + solid(8, 64, 0); + solid(8, 65, 0); + TerrainFollowEngine.Result result = step(); + assertTrue(result.location.getX() <= 7.65 + 1e-9, + "A wall between start and endpoint must stop airborne travel; x=" + result.location.getX()); + assertTrue(result.velocity.getX() < 2.94); + } + + @Test + void airborneMovementStopsBeforeWallWhoseEndpointWouldOverlap() { + floor(63); + velocity = new Vector(.5, 0, 0); + apply(step()); + blocks.clear(); + apply(step()); + solid(2, 63, 0); + solid(2, 64, 0); + TerrainFollowEngine.Result result = step(); + assertTrue(result.location.getX() <= 1.65 + 1e-9); + assertTrue(result.velocity.getX() < .49); + } + + @Test + void fastFallCannotTunnelThroughPlatformBeyondTheShortGroundProbe() { + location.setY(300); + floor(155); + for (int i = 0; i < 60; i++) apply(step()); + assertEquals(158.32, location.getY(), 1e-8); + TerrainFollowEngine.Result result = step(); + assertTrue(result.location.getY() >= 155.92 - 1e-9, + "A falling chassis must stay above the platform crossed this tick; y=" + result.location.getY()); + } + + @Test + void alreadyOverlappingAirborneVehicleDoesNotMoveFurtherIntoObstacle() { + step(); + step(); + solid(0, 64, 0); + bone("body", new Location(world, 20, 64, 20)); + TerrainFollowEngine.Result result = step(); + assertEquals(location, result.location); + assertEquals(new Vector(), result.velocity); + } + + @Test + void climbUnsticksShallowEmbeddingAndPrefersResolvedMovementWhenClear() { + solid(0, 64, 0, new BoundingBox(0, 0, 0, 1, .25, 1)); + engineLog.when(GroundEngineLog::isEnabled).thenReturn(true); + TerrainFollowEngine.Result result = step(); + assertEquals(64.25, result.location.getY()); + assertTrue(messages.stream().anyMatch(m -> m.contains("resolve=unstick+up"))); + } + + @Test + void climbUnstickCanRecoverWhenNormalSnapStillIntersectsGround() { + solid(0, 64, 0, new BoundingBox(0, 0, 0, 1, .5, 1)); + bone("body", new Location(world, .5, 64.5, .5)); + engineLog.when(GroundEngineLog::isEnabled).thenReturn(true); + TerrainFollowEngine.Result result = step(); + assertEquals(64.45, result.location.getY(), 1e-9); + assertTrue(messages.stream().anyMatch(m -> m.contains("resolve=unstick "))); + } + + @Test + void reverseDoesNotUnstickIntoHigherGround() { + solid(0, 64, 0, new BoundingBox(0, 0, 0, 1, .5, 1)); + bone("body", new Location(world, .5, 64.5, .5)); + TerrainFollowEngine.Result result = step(Direction.BACKWARD); + assertEquals(64, result.location.getY()); + } + + @Test + void probesIgnoreMissingEmptyAndWorldlessBonesAndLogMissingNameOnlyOnce() { + config = config(Arrays.asList(null, " ", "missing", "no-location", "no-world", "wheel")); + bone("no-location", null); + bone("no-world", new Location(null, 0, 0, 0)); + Location wheel = new Location(world, 1, 64, 2); + bone("wheel", wheel); + List starts = TerrainFollowEngine.resolveProbeStarts(vehicle, config); + assertEquals(List.of(new Location(world, 1, 64.2, 2)), starts); + assertEquals(64, wheel.getY()); + TerrainFollowEngine.resolveProbeStarts(vehicle, config); + log.verify(() -> VFLogger.log("Vehicle test_car ground-probe bone 'missing' is missing, skipping"), times(1)); + assertTrue(TerrainFollowEngine.resolveProbeStarts(vehicle, null).isEmpty()); + assertTrue(TerrainFollowEngine.resolveProbeStarts(vehicle, config(List.of())).isEmpty()); + when(vehicle.getModel()).thenReturn(null); + assertTrue(TerrainFollowEngine.resolveProbeStarts(vehicle, config).isEmpty()); + } + + @Test + void bodySamplingUsesUsableBodyBoneAndFallsBackToEntityLocation() { + Location fallback = TerrainFollowEngine.bodyDownStart(vehicle, location, config); + assertEquals(new Location(world, .5, 64.5, .5, 25, 6), fallback); + bone("body", new Location(world, .7, 64.7, .8)); + assertEquals(new Location(world, .7, 64.9, .8), TerrainFollowEngine.bodyDownStart(vehicle, location, config)); + bone("body", null); + assertEquals(fallback, TerrainFollowEngine.bodyDownStart(vehicle, location, config)); + bone("body", new Location(null, 0, 0, 0)); + assertEquals(fallback, TerrainFollowEngine.bodyDownStart(vehicle, location, config)); + when(vehicle.getModel()).thenReturn(null); + assertEquals(fallback, TerrainFollowEngine.bodyDownStart(vehicle, location, config)); + } + + @Test + void liquidsAndWaterloggedBlocksNeverSupportGroundVehicles() { + TerrainBlock water = terrain(0, 63, 0); + water.liquid = true; + assertFalse(TerrainFollowEngine.isValidGroundHit(hit(water.block, .5, 64, .5))); + assertEquals(63.92, step().location.getY(), 1e-9); + water.liquid = false; + Waterlogged data = mock(Waterlogged.class); + when(water.block.getBlockData()).thenReturn(data); + when(data.isWaterlogged()).thenReturn(true); + assertFalse(TerrainFollowEngine.isValidGroundHit(hit(water.block, .5, 64, .5))); + when(data.isWaterlogged()).thenReturn(false); + assertTrue(TerrainFollowEngine.isValidGroundHit(hit(water.block, .5, 64, .5))); + assertFalse(TerrainFollowEngine.isValidGroundHit(null)); + assertFalse(TerrainFollowEngine.isValidGroundHit(new RayTraceResult(new Vector()))); + RayTraceResult incomplete = mock(RayTraceResult.class); + when(incomplete.getHitBlock()).thenReturn(water.block); + assertFalse(TerrainFollowEngine.isValidGroundHit(incomplete)); + } + + @Test + void debugParticlesReachOnlyOnlinePlayersInTheSameWorld() { + floor(63); + Cache.terrainFollowDebug = true; + when(vehicle.getNearbyPlayers()).thenReturn(List.of()); + step(); + when(vehicle.getNearbyPlayers()).thenReturn(null); + step(); + Player online = viewer(world, true), offline = viewer(world, false); + Player otherWorld = viewer(mock(World.class), true), noWorld = viewer(null, true); + when(vehicle.getNearbyPlayers()).thenReturn(Arrays.asList(null, offline, otherWorld, noWorld, online)); + step(); + verify(online).spawnParticle(Particle.END_ROD, new Location(world, .5, 64.5, .5, 25, 6), 1, 0, 0, 0, 0); + verify(online).spawnParticle(Particle.END_ROD, new Location(world, .5, 64, .5), 1, 0, 0, 0, 0); + verify(offline, never()).spawnParticle(any(Particle.class), any(Location.class), anyInt(), anyDouble(), anyDouble(), anyDouble(), anyDouble()); + verify(otherWorld, never()).spawnParticle(any(Particle.class), any(Location.class), anyInt(), anyDouble(), anyDouble(), anyDouble(), anyDouble()); + } + + @Test + void loggingIncludesStateDirectionSupportAndEngineFragment() { + floor(63); + engineLog.when(GroundEngineLog::isEnabled).thenReturn(true); + engineLog.when(() -> GroundEngineLog.formatEngineFragment(vehicle)).thenReturn("thr=42/0/100"); + velocity = new Vector(.2, 0, 0); + step(); + assertTrue(messages.getLast().contains("state=GROUND dir=FORWARD")); + assertTrue(messages.getLast().contains("lookaheadY=64.000")); + assertTrue(messages.getLast().endsWith("thr=42/0/100")); + when(vehicle.getCurrentState()).thenReturn(null); + velocity = new Vector(); + step(null); + assertTrue(messages.getLast().contains("state=null dir=null")); + assertTrue(messages.getLast().contains("lookaheadY=nan")); + } + + @Test + void realBaseControllerSuppliesEngineAndHarnessHorizontalDrive() { + floor(63); + when(vehicle.hasComponent(Component.ENGINE)).thenReturn(true); + when(vehicle.getAccessPanel().getSpeed()).thenReturn(.7); + assertEquals(1.2, step().location.getX(), 1e-9); + when(vehicle.hasComponent(Component.ENGINE)).thenReturn(false); + when(vehicle.hasComponent(Component.HARNESS)).thenReturn(true); + Harness harness = mock(Harness.class); + when(vehicle.getComponent(Component.HARNESS)).thenReturn(harness); + when(harness.hasMounts()).thenReturn(true); + assertEquals(Direction.MOVING, base.getDirection(vehicle)); + when(harness.getSpeed()).thenReturn(1d); + assertEquals(1.5, step(Direction.FORWARD).location.getX(), 1e-9); + assertEquals(.2, step(Direction.BACKWARD).location.getX(), 1e-9); + when(harness.hasMounts()).thenReturn(false); + assertEquals(Direction.STILL, base.getDirection(vehicle)); + assertEquals(.5, step().location.getX()); + } + + @Test + void optionalPhysicsInputsHaveNeutralResults() { + assertNull(TerrainFollowMath.bodySampleOrigin(List.of(), null)); + assertEquals(0, TerrainFollowMath.farthestUnblocked(0, .02, distance -> false)); + assertEquals(0, TerrainFollowMath.farthestUnblocked(1, .02, null)); + assertEquals(new Vector(), TerrainFollowMath.flattenHorizontal(null)); + assertTrue(Double.isNaN(TerrainFollowMath.supportY(null, new boolean[] {true}))); + assertTrue(Double.isNaN(TerrainFollowMath.supportY(new double[] {64}, null))); + TerrainFollowMath.Tilt tilt = TerrainFollowMath.tiltFromWorldHits(null, new Vector(), new Vector(), new Vector()); + assertEquals(0, tilt.pitchDeg); + assertEquals(0, tilt.rollDeg); + } + + @Test + void movementWithoutCollisionProviderCanSlideAndClimb() { + TerrainFollowMath.KinematicMove flat = TerrainFollowMath.raiseThenSlide(.5, .2, 64, 64, 64, + Double.NaN, .25, null); + assertEquals(.5, flat.offsetX); + assertEquals(.2, flat.offsetZ); + assertEquals("slide", flat.path); + assertFalse(flat.aabbBlocked); + TerrainFollowMath.KinematicMove climb = TerrainFollowMath.raiseThenSlide(.5, .2, 64, 64.25, 64.25, + Double.NaN, .25, null); + assertEquals(64.25, climb.y); + assertEquals("up+slide", climb.path); + } + + @Test + void collisionAtDiagonalCornerUsesClearAxesWithoutCrossingObstacle() { + TerrainFollowMath.OffsetYBlocked corner = (x, z, y) -> x > 0 && x < .4 && z > 0 && z < .4; + TerrainFollowMath.KinematicMove flat = TerrainFollowMath.raiseThenSlide(1, 1, 64, 64, 64, + Double.NaN, .25, corner); + assertEquals(1, flat.offsetX); + assertEquals(1, flat.offsetZ); + assertEquals("slideX+slideZ", flat.path); + assertFalse(flat.aabbBlocked); + TerrainFollowMath.KinematicMove raised = TerrainFollowMath.raiseThenSlide(1, 1, 64, 64.25, 64.25, + 64.25, .25, corner); + assertEquals(64.25, raised.y); + assertEquals("up+slideX+slideZ", raised.path); + assertFalse(raised.aabbBlocked); + } + + @Test + void crampedBackingRecoveryUsesOnlyAvailableClearDistance() { + TerrainFollowMath.OffsetYBlocked cramped = (x, z, y) -> y > 64 || x > 0 || x < -.07; + TerrainFollowMath.KinematicMove move = TerrainFollowMath.raiseThenSlide(.5, 0, 64, 64, 64, + 65, .25, cramped); + assertEquals(-.06, move.offsetX, 1e-9); + assertEquals(64, move.y); + assertEquals("back", move.path); + assertTrue(move.aabbBlocked); + } + + private record Cell(int x, int y, int z) {} + private final class TerrainBlock { + final Cell cell; + final Block block = mock(Block.class); + List boxes = List.of(); + boolean liquid; + TerrainBlock(Cell cell) { + this.cell = cell; + VoxelShape shape = mock(VoxelShape.class); + when(shape.getBoundingBoxes()).thenAnswer(call -> boxes); + when(block.getCollisionShape()).thenReturn(shape); + when(block.isPassable()).thenAnswer(call -> boxes.isEmpty()); + when(block.isLiquid()).thenAnswer(call -> liquid); + when(block.getY()).thenReturn(cell.y); + when(block.getLocation()).thenAnswer(call -> new Location(world, cell.x, cell.y, cell.z)); + } + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowMathTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowMathTest.java index d32471d..007bc3d 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowMathTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowMathTest.java @@ -268,10 +268,8 @@ void airborneTick_appliesDragAndGravityAccel() { @Test void raiseThenSlide_raiseBlocked_backsThenRaises() { TerrainFollowMath.OffsetYBlocked lip = (ox, oz, y) -> { - if (Math.abs(ox) < 1e-6) { - return y > 160.0 + 1e-6; - } - return ox > -0.09; + // Backing away is clear at ground height; the lip blocks lifting until backed 0.1. + return y > 160.0 + 1e-6 ? ox > -0.09 : ox > 1e-6; }; TerrainFollowMath.KinematicMove move = TerrainFollowMath.raiseThenSlide( 0.65, 0.0, 160.0, 160.0, 160.0, 161.0, 0.25, lip, false); diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleControllersCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleControllersCoverageTest.java new file mode 100644 index 0000000..221de2d --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleControllersCoverageTest.java @@ -0,0 +1,327 @@ +package net.tfminecraft.vehicleframework.vehicles.controller; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.function.Function; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.scoreboard.*; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import com.ticxo.modelengine.api.ModelEngineAPI; +import com.ticxo.modelengine.api.animation.BlueprintAnimation; +import com.ticxo.modelengine.api.animation.property.IAnimationProperty; +import com.ticxo.modelengine.api.generator.blueprint.ModelBlueprint; +import com.ticxo.modelengine.api.model.ActiveModel; +import net.tfminecraft.vehicleframework.enums.Animation; +import net.tfminecraft.vehicleframework.enums.Component; +import net.tfminecraft.vehicleframework.enums.Direction; +import net.tfminecraft.vehicleframework.enums.Keybind; +import net.tfminecraft.vehicleframework.enums.State; +import net.tfminecraft.vehicleframework.enums.VehicleDeath; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.component.*; +import net.tfminecraft.vehicleframework.vehicles.handlers.StateHandler; +import net.tfminecraft.vehicleframework.vehicles.handlers.state.*; +import net.tfminecraft.vehicleframework.vehicles.state.*; +import net.tfminecraft.vehicleframework.weapons.ActiveWeapon; + +class VehicleControllersCoverageTest { + @BeforeAll static void registry() { net.tfminecraft.vehicleframework.test.RegistryFixture.initialize(); } + private final List scopes = new ArrayList<>(); + @AfterEach void closeScopes() throws Exception { + Collections.reverse(scopes); for (AutoCloseable scope : scopes) scope.close(); + } + private T keep(T scope) { scopes.add(scope); return scope; } + + @Test void floatingRequiresAValidLivingEntityInEnoughWater() { + Water f = new Water(); Vector motion = new Vector(1, -.2, 3); FloatController controller = new FloatController(); + when(f.vehicle.shouldFloat()).thenReturn(false); + assertSame(motion, controller.calculateFloat(f.vehicle, motion)); assertEquals(-.2, motion.getY()); + when(f.vehicle.shouldFloat()).thenReturn(true); when(f.vehicle.getEntity()).thenReturn(null); + assertEquals(-.2, controller.calculateFloat(f.vehicle, motion).getY()); + when(f.vehicle.getEntity()).thenReturn(f.entity); when(f.entity.isValid()).thenReturn(false); + assertEquals(-.2, controller.calculateFloat(f.vehicle, motion).getY()); + Entity other = mock(Entity.class); when(other.isValid()).thenReturn(true); when(f.vehicle.getEntity()).thenReturn(other); + assertEquals(-.2, controller.calculateFloat(f.vehicle, motion).getY()); + when(f.vehicle.getEntity()).thenReturn(f.entity); when(f.entity.isValid()).thenReturn(true); + assertEquals(-.2, controller.calculateFloat(f.vehicle, motion).getY()); + f.grid.fill = p -> p.y == 64 ? Material.WATER : Material.AIR; f.location.setY(64.5); + assertEquals(-FloatController.BOB_SPEED / 2, controller.calculateFloat(f.vehicle, motion).getY(), 1e-9); + assertEquals(1, motion.getX()); assertEquals(3, motion.getZ()); + } + + @Test void deeplySubmergedBoatsRiseEvenWhenTheSurfaceIsOutsideTheScan() { + Water f = new Water(); f.grid.fill = p -> Material.WATER; + assertEquals(FloatController.BOB_SPEED, new FloatController().calculateFloat(f.vehicle, new Vector()).getY()); + } + + @Test void partialEdgeWaterWithoutACentreSurfacePreservesVerticalMotion() { + Water f = new Water(); when(f.entity.getBoundingBox()).thenReturn(new BoundingBox(0, 64, 0, 2, 65, 2)); + f.grid.fill = p -> p.x > 0 && p.y == 64 ? Material.WATER : Material.AIR; + assertEquals(.25, new FloatController().calculateFloat(f.vehicle, new Vector(0, .25, 0)).getY()); + } + + @Test void buoyancyFlipsAtTheDepthBoundsAndCapsGravityDrivenDescent() { + Water f = new Water(); f.grid.fill = p -> p.y <= 64 ? Material.WATER : Material.AIR; + FloatController controller = new FloatController(); f.location.setY(64); + assertEquals(FloatController.BOB_SPEED, controller.calculateFloat(f.vehicle, new Vector()).getY()); + f.location.setY(64.5); when(f.entity.hasGravity()).thenReturn(true); when(f.entity.getVelocity()).thenReturn(new Vector(0, -2, 0)); + assertEquals(-FloatController.BOB_SPEED / 2, controller.calculateFloat(f.vehicle, new Vector()).getY()); + f.location.setY(64.2); when(f.entity.getVelocity()).thenReturn(new Vector(0, .2, 0)); + assertEquals(0, controller.calculateFloat(f.vehicle, new Vector()).getY()); + when(f.entity.hasGravity()).thenReturn(false); + assertEquals(-FloatController.BOB_SPEED / 2, controller.calculateFloat(f.vehicle, new Vector()).getY()); + f.location.setY(64); assertEquals(FloatController.BOB_SPEED, controller.calculateFloat(f.vehicle, new Vector()).getY()); + f.location.setY(64.2); assertEquals(FloatController.BOB_SPEED, controller.calculateFloat(f.vehicle, new Vector()).getY()); + } + + @Test void forwardWaterRiseAddsBoundedLiftButLevelAndMissingWaterDoNot() { + Water f = new Water(); when(f.vehicle.getAccessPanel().getSpeed()).thenReturn(.2); + f.grid.fill = p -> p.y <= (p.z >= 1 ? 65 : 64) ? Material.WATER : Material.AIR; + FloatController c = new FloatController(); assertEquals(FloatController.BOB_SPEED + .3, c.calculateFloat(f.vehicle, new Vector()).getY()); + f.grid.fill = p -> p.y <= 64 ? Material.WATER : Material.AIR; + assertEquals(FloatController.BOB_SPEED, c.calculateFloat(f.vehicle, new Vector()).getY()); + f.grid.fill = p -> p.z >= 1 ? Material.AIR : p.y <= 64 ? Material.WATER : Material.AIR; + assertEquals(FloatController.BOB_SPEED, c.calculateFloat(f.vehicle, new Vector()).getY()); + f.grid.fill = p -> Material.WATER; + assertEquals(FloatController.BOB_SPEED, c.calculateFloat(f.vehicle, new Vector()).getY()); + f.location.setPitch(90); assertEquals(FloatController.BOB_SPEED, c.calculateFloat(f.vehicle, new Vector()).getY()); + } + + @Test void destroyedAndFloodedHullsDescendWhileSubmergedOrDrainedHullsRecover() { + Water f = new Water(); f.grid.fill = p -> p.y <= 64 ? Material.WATER : Material.AIR; + FloatController c = new FloatController(); when(f.vehicle.isDestroyed()).thenReturn(true); + assertEquals(-.03, c.calculateFloat(f.vehicle, new Vector()).getY()); + when(f.vehicle.isDestroyed()).thenReturn(false); + SinkableHull hull = mock(SinkableHull.class); when(f.vehicle.getComponent(Component.HULL)).thenReturn(hull); + when(hull.hasSinkProgress()).thenReturn(true); when(hull.isSinking()).thenReturn(true); when(hull.getSinkProgress()).thenReturn(40); + assertEquals(-.004, c.calculateFloat(f.vehicle, new Vector()).getY()); + f.grid.fill = p -> Material.WATER; assertEquals(.006, c.calculateFloat(f.vehicle, new Vector()).getY()); + when(hull.isSinking()).thenReturn(false); assertEquals(.006, c.calculateFloat(f.vehicle, new Vector()).getY()); + } + + @Test void noGravityArmorStandUsesCollisionMovementAndBreaksNearbyLilyPads() { + Water f = new Water(); ArmorStand stand = mock(ArmorStand.class); f.bind(stand); + f.grid.fill = p -> p.equals(new Cell(1, 64, 0)) ? Material.LILY_PAD : p.y <= 64 ? Material.WATER : Material.AIR; + var api = keep(mockStatic(ModelEngineAPI.class, RETURNS_DEEP_STUBS)); var handler = ModelEngineAPI.getEntityHandler(); + assertEquals(0, new FloatController().calculateFloat(f.vehicle, new Vector()).getY()); + verify(handler).move(stand, 0, FloatController.BOB_SPEED, 0); + verify(f.grid.block(1, 64, 0)).breakNaturally(); + verify(f.grid.world).playSound(eq(new Location(f.grid.world, 1, 64, 0)), eq(Sound.BLOCK_GRASS_BREAK), eq(1f), eq(1f)); + } + + @Test void animationStopSkipsAbsentAndIdleEntriesAndStillStopsLaterPlayingEntries() { + ActiveModel model = mock(ActiveModel.class, RETURNS_DEEP_STUBS); + BlueprintAnimation idle = mock(BlueprintAnimation.class), active = mock(BlueprintAnimation.class); + when(model.getBlueprint().getAnimations()).thenReturn(Map.of("idle", idle, "active", active)); + when(model.getAnimationHandler().isPlayingAnimation("active")).thenReturn(true); + YamlConfiguration config = yaml(); config.set("forward", List.of("missing", "idle", "active")); + AnimationHandler handler = new AnimationHandler(model, new AnimationHandler(config)); + handler.stop(Animation.FORWARD); verify(model.getAnimationHandler()).stopAnimation("active"); + verify(model.getAnimationHandler(), never()).stopAnimation("missing"); verify(model.getAnimationHandler(), never()).stopAnimation("idle"); + } + + @Test void animationCommandsHonorConfiguredListsIndicesCurrentPlaybackAndSpeed() { + keep(mockStatic(ModelEngineAPI.class, RETURNS_DEEP_STUBS)); + ActiveModel model = mock(ActiveModel.class, RETURNS_DEEP_STUBS); ModelBlueprint blueprint = mock(ModelBlueprint.class); + when(model.getBlueprint()).thenReturn(blueprint); Map clips = new HashMap<>(); + Map playing = new HashMap<>(); when(blueprint.getAnimations()).thenReturn(clips); + YamlConfiguration config = yaml(); + for (Animation animation : Animation.values()) { + String name = animation.name().toLowerCase(); BlueprintAnimation clip = new BlueprintAnimation(blueprint, name); clip.setLength(1); + clip.setLoopMode(BlueprintAnimation.LoopMode.LOOP); clips.put(name, clip); config.set(name, List.of(name)); + } + var engine = model.getAnimationHandler(); + when(engine.isPlayingAnimation(anyString())).thenAnswer(c -> playing.containsKey(c.getArgument(0))); + when(engine.getAnimation(anyString())).thenAnswer(c -> playing.get(c.getArgument(0))); + when(engine.playAnimation(any(IAnimationProperty.class), eq(true))).thenAnswer(c -> { IAnimationProperty property = c.getArgument(0); playing.put(property.getName(), property); return true; }); + doAnswer(c -> { playing.remove(c.getArgument(0)); return null; }).when(engine).stopAnimation(anyString()); + AnimationHandler template = new AnimationHandler(config), handler = new AnimationHandler(model, template); + assertEquals(List.of("forward"), handler.get(Animation.FORWARD)); assertTrue(handler.hasModel()); + for (Animation animation : Animation.values()) handler.animate(animation); + assertTrue(playing.containsKey("die")); handler.animate("die"); handler.animate("missing"); + verify(engine, times(Animation.values().length)).playAnimation(any(IAnimationProperty.class), eq(true)); + handler.animate(Animation.FORWARD, 5); handler.animate(Animation.FORWARD, 0); assertTrue(playing.containsKey("forward")); + IAnimationProperty forward = mock(IAnimationProperty.class); playing.put("forward", forward); + handler.setSpeed(Animation.FORWARD, 2); verify(forward).setSpeed(2); playing.remove("forward"); handler.setSpeed(Animation.FORWARD, 3); + handler.stopAllAnimations(); assertTrue(playing.isEmpty()); + AnimationHandler unbound = new AnimationHandler(); unbound.setSpeed(Animation.FORWARD, 1); unbound.animateWheels(Direction.FORWARD, 1); + assertFalse(unbound.hasModel()); handler.updateModel(model); assertTrue(handler.hasModel()); + } + + @Test void wheelAnimationSkipsMissingClipsAndStartsTheRemainingConfiguredLoop() { + keep(mockStatic(ModelEngineAPI.class, RETURNS_DEEP_STUBS)); + ActiveModel model = mock(ActiveModel.class, RETURNS_DEEP_STUBS); ModelBlueprint blueprint = mock(ModelBlueprint.class); + when(model.getBlueprint()).thenReturn(blueprint); BlueprintAnimation clip = new BlueprintAnimation(blueprint, "wheels"); + clip.setLength(2); clip.setLoopMode(BlueprintAnimation.LoopMode.LOOP); when(blueprint.getAnimations()).thenReturn(Map.of("wheels", clip)); + when(model.getAnimationHandler().getAnimation(anyString())).thenReturn(null); + YamlConfiguration config = yaml(); config.set("forward", List.of("missing", "wheels")); + AnimationHandler handler = new AnimationHandler(model, new AnimationHandler(config)); handler.animateWheels(Direction.FORWARD, 1); + ArgumentCaptor started = ArgumentCaptor.forClass(IAnimationProperty.class); + verify(model.getAnimationHandler()).playAnimation(started.capture(), eq(true)); assertEquals("wheels", started.getValue().getName()); + assertEquals(2, started.getValue().getSpeed()); + } + + @Test void terrainSettingsUseDefaultsAndCopyConfiguredGroundProbes() { + TerrainFollowConfig defaults = TerrainFollowConfig.from(null); assertFalse(defaults.isEnabled()); + assertEquals(1, defaults.getStepHeight()); assertEquals(.25, defaults.getSnapSpeed()); assertEquals(3, defaults.getClimbLeadTicks()); + assertEquals(1, defaults.getClimbLeadFactor()); assertEquals(.08, defaults.getAirGravity()); assertEquals(.98, defaults.getAirDrag()); + assertFalse(defaults.hasGroundProbes()); + List probes = new ArrayList<>(List.of("front")); + TerrainFollowConfig custom = new TerrainFollowConfig(true, 2, .4, 4, 2, .1, .9, probes); probes.add("back"); + assertEquals(List.of("front"), custom.getGroundProbes()); assertTrue(custom.hasGroundProbes()); + assertThrows(UnsupportedOperationException.class, () -> custom.getGroundProbes().add("side")); + assertTrue(new TerrainFollowConfig(true, 1, 1, 1, 1, 1, 1, null).getGroundProbes().isEmpty()); + } + + @Test void configuredStatesRetainInputsAndBoundsAndFillMissingDefaults() { + Water f = new Water(); YamlConfiguration config = yaml(); + config.set("ground.keybinds.W", "lights"); config.set("ground.animations.default", List.of()); + config.set("ground.climb", 1); config.set("ground.break", true); config.set("ground.terrain-follow", true); + config.set("ground.ground-probes", List.of("left", "right")); + config.set("ground.switch-parameters.velocity.x", "-1,1"); config.set("ground.switch-parameters.velocity.y", "-2,2"); + config.set("ground.switch-parameters.velocity.z", "-3,3"); config.set("ground.switch-parameters.rotations.yaw", "-90,90"); + config.set("ground.switch-parameters.rotations.pitch", "-45,45"); config.set("ground.switch-parameters.rotations.roll", "-30,30"); + StateHandler template = new StateHandler(config); StateHandler handler = new StateHandler(f.vehicle, template); + assertFalse(template.hasState(State.FLOATING)); assertNull(template.getVehicleState(State.FLOATING)); + handler.setState(State.GROUND); VehicleState current = handler.getCurrentState(); + assertEquals(State.GROUND, current.getType()); assertFalse(current.isDefault()); assertTrue(current.isBreakState()); + assertTrue(current.getClimbHandler().canClimb()); assertTrue(current.getTerrainFollow().isEnabled()); + assertEquals(List.of("left", "right"), current.getTerrainFollow().getGroundProbes()); + assertEquals(6, current.getSwitchParameter().getParameters().size()); + Parameter bounds = current.getSwitchParameter().getParameters().get("vX"); + assertEquals(-1, bounds.getMin()); assertEquals(1, bounds.getMax()); assertTrue(bounds.isWithin(-1)); assertFalse(bounds.isWithin(2)); + assertFalse(new Parameter(-1, 1).isWithin(-2)); + Player pilot = mock(Player.class); handler.key(pilot, Keybind.W); verify(f.vehicle).toggleLights(pilot); + assertSame(current.getAnimationHandler(), handler.getAnimationHandler()); assertSame(current.getMoveControls(), handler.getMoveControls()); + assertTrue(handler.getVehicleState(State.FLYING).isDefault()); assertFalse(handler.getVehicleState(State.FLYING).hasSwitchParameter()); + handler.updateModel(mock(ActiveModel.class)); assertTrue(current.getAnimationHandler().hasModel()); + when(f.vehicle.getParameterValue("vY")).thenReturn(3d); handler.setState(State.GROUND); verify(f.vehicle).kill(VehicleDeath.EXPLODE); + when(f.vehicle.isDestroyed()).thenReturn(true); handler.setState(State.FLYING); assertSame(current, handler.getCurrentState()); + when(f.vehicle.isDestroyed()).thenReturn(false); handler.setState(null); assertSame(current, handler.getCurrentState()); + } + + @Test void stateTicksSelectDeepWaterAirAndGroundAndBrakeConfiguredGroundState() { + Water f = new Water(); YamlConfiguration config = yaml(); config.createSection("floating"); config.createSection("flying"); config.set("ground.break", true); + StateHandler handler = new StateHandler(f.vehicle, new StateHandler(config)); + when(f.vehicle.getStateHandler()).thenReturn(handler); + f.grid.fill = p -> p.y <= 66 ? Material.WATER : Material.AIR; + handler.tick(); assertEquals(State.FLOATING, handler.getCurrentState().getType()); + handler.tick(); assertEquals(State.FLOATING, handler.getCurrentState().getType()); + f.grid.fill = p -> Material.AIR; handler.tick(); assertEquals(State.FLYING, handler.getCurrentState().getType()); + f.grid.fill = p -> p.y < 64 ? Material.STONE : Material.AIR; + handler.tick(); assertEquals(State.GROUND, handler.getCurrentState().getType()); verify(f.vehicle).applyBreakBraking(); + f.grid.fill = p -> p.y == 64 ? Material.WATER : p.y < 64 ? Material.STONE : Material.AIR; + handler.tick(); assertEquals(State.GROUND, handler.getCurrentState().getType()); + } + + @Test void shallowWideWaterMayFloatWhenTheSampledWaterHasDepthAwayFromTheCentre() { + Water f = new Water(); YamlConfiguration config = yaml(); config.createSection("floating"); + StateHandler handler = new StateHandler(f.vehicle, new StateHandler(config)); + f.grid.fill = p -> p.y <= 64 && (p.x != 0 || p.z != 0) ? Material.WATER : Material.AIR; + handler.tick(); assertEquals(State.FLOATING, handler.getCurrentState().getType()); + } + + @Test void climbingRequiresMotionSupportAndHeadroomAndMayUseHarnessAnimals() { + Water f = new Water(); YamlConfiguration config = yaml(); config.set("climb", 1); ClimbHandler climb = new ClimbHandler(config); + assertFalse(new ClimbHandler().canClimb()); assertFalse(climb.shouldClimb(f.vehicle)); + when(f.vehicle.getAccessPanel().getSpeed()).thenReturn(.1); assertFalse(new ClimbHandler().shouldClimb(f.vehicle)); + assertFalse(climb.shouldClimb(f.vehicle)); + f.grid.fill = p -> p.y == 63 || p.equals(new Cell(1, 64, 0)) ? Material.STONE : Material.AIR; + assertTrue(climb.shouldClimb(f.vehicle)); + f.grid.fill = p -> p.y == 63 || (p.x == 1 && p.z == 0 && p.y >= 64 && p.y <= 65) ? Material.STONE : Material.AIR; + assertFalse(climb.shouldClimb(f.vehicle)); + LivingEntity horse = mock(LivingEntity.class); when(horse.getWorld()).thenReturn(f.grid.world); + when(horse.getBoundingBox()).thenReturn(new BoundingBox(4, 64, 0, 5, 65, 1)); + Harness harness = mock(Harness.class); when(harness.getMountedEntities()).thenReturn(List.of(horse)); + when(f.vehicle.hasComponent(Component.HARNESS)).thenReturn(true); when(f.vehicle.getComponent(Component.HARNESS)).thenReturn(harness); + f.grid.fill = p -> p.y == 63 || p.equals(new Cell(5, 65, 0)) ? Material.STONE : Material.AIR; + assertTrue(climb.shouldClimb(f.vehicle)); + } + + @Test void scoreboardReportsComponentsWeaponAndOrderedLinesThenRemovesOnlyItsObjective() { + ActiveVehicle vehicle = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); Player viewer = mock(Player.class); + when(vehicle.getName()).thenReturn("River boat"); when(vehicle.getId()).thenReturn("river_boat"); + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); Pump pump = mock(Pump.class, RETURNS_DEEP_STUBS); + GearedEngine geared = mock(GearedEngine.class, RETURNS_DEEP_STUBS); SinkableHull hull = mock(SinkableHull.class, RETURNS_DEEP_STUBS); + List components = List.of(engine, pump, geared, hull); when(vehicle.getComponents()).thenReturn(components); + for (VehicleComponent part : components) { when(part.getAlias()).thenReturn(part.getClass().getSimpleName()); when(part.getHealthData().getHealthPercentageString()).thenReturn("HP 75%"); } + when(engine.isOnFire()).thenReturn(true); when(engine.getFire().getFireString()).thenReturn("Fire 10%"); + when(vehicle.getComponent(Component.ENGINE)).thenReturn(engine); when(vehicle.getComponent(Component.PUMP)).thenReturn(pump); + when(vehicle.getComponent(Component.GEARED_ENGINE)).thenReturn(geared); when(vehicle.getComponent(Component.HULL)).thenReturn(hull); + when(engine.getThrottle().getName()).thenReturn("Throttle"); when(engine.getThrottle().getCurrent()).thenReturn(25); + when(engine.getFuelTank().useFuel()).thenReturn(true); when(engine.getFuelTank().getPercentage()).thenReturn(80); + when(pump.getPower()).thenReturn(3); when(geared.getGear().getName()).thenReturn("First"); + when(geared.getGear().getThrottle().getName()).thenReturn("Power"); when(geared.getGear().getThrottle().getCurrent()).thenReturn(40); + when(geared.getFuelTank().getPercentage()).thenReturn(60); when(vehicle.hasComponent(Component.HULL)).thenReturn(true); + when(hull.hasSinkProgress()).thenReturn(true); when(hull.getSinkProgress()).thenReturn(15); + when(vehicle.isPassenger(viewer, false)).thenReturn(true); when(vehicle.getSeat(viewer).hasWeapon()).thenReturn(true); + ActiveWeapon gun = vehicle.getSeat(viewer).getWeapon(); when(gun.getName()).thenReturn("Cannon"); + when(gun.getAmmunitionHandler().hasAmmo()).thenReturn(true); when(gun.getAmmunitionHandler().getCount()).thenReturn(4); + when(gun.getAmmunitionHandler().getAmmo().getName()).thenReturn("Shell"); + Scoreboard board = mock(Scoreboard.class); Objective objective = mock(Objective.class); ScoreboardManager manager = mock(ScoreboardManager.class); + keep(mockStatic(Bukkit.class)).when(Bukkit::getScoreboardManager).thenReturn(manager); + when(manager.getNewScoreboard()).thenReturn(board); when(board.registerNewObjective("vehicleDummy", Criteria.DUMMY, "River boat")).thenReturn(objective); + Map lines = new LinkedHashMap<>(); + when(objective.getScore(anyString())).thenAnswer(call -> { String line = call.getArgument(0); Score score = mock(Score.class); + doAnswer(set -> { lines.put(line, set.getArgument(0)); return null; }).when(score).setScore(anyInt()); return score; }); + ScoreboardController controller = new ScoreboardController(vehicle); controller.scoreboard(viewer); + assertTrue(lines.containsKey("§fType: §eRiver Boat")); assertEquals(0, lines.get("Shell §fx4")); + assertTrue(lines.containsKey("§f- Fuel: §e80%")); assertTrue(lines.containsKey("§f- Power: §e3")); + assertTrue(lines.containsKey("§f- Gear: §eFirst")); assertTrue(lines.containsKey("§9SINKING!! §b15% §1")); + verify(objective).setDisplaySlot(DisplaySlot.SIDEBAR); verify(viewer).setScoreboard(board); + when(vehicle.isLocomotive()).thenReturn(true); + when(vehicle.getTrainHandler().getOverdrive().cooldownSeconds(anyLong())).thenReturn(9L); lines.clear(); controller.scoreboard(viewer); + assertTrue(lines.containsKey("§f- Overdrive cooldown: §e9s")); + when(vehicle.getTrainHandler().getOverdrive().cooldownSeconds(anyLong())).thenReturn(0L); + when(vehicle.getTrainHandler().getOverdrive().remainingSeconds()).thenReturn(5L); lines.clear(); controller.scoreboard(viewer); + assertTrue(lines.containsKey("§f- Overdrive left: §e5s")); + when(vehicle.getTrainHandler().getOverdrive().remainingSeconds()).thenReturn(0L); lines.clear(); controller.scoreboard(viewer); + assertTrue(lines.containsKey("§f- Overdrive: §aReady (W > 100%)")); + when(viewer.getScoreboard()).thenReturn(board); controller.removeScoreboard(viewer); + when(board.getObjective("vehicleDummy")).thenReturn(objective); controller.removeScoreboard(viewer); verify(objective).unregister(); + } + + private static YamlConfiguration yaml() { return new YamlConfiguration(); } + private record Cell(int x, int y, int z) {} + private static final class Grid { + final World world = mock(World.class); final Map blocks = new HashMap<>(); + Function fill = p -> Material.AIR; + Grid() { + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(c -> block(c.getArgument(0), c.getArgument(1), c.getArgument(2))); + when(world.getBlockAt(any(Location.class))).thenAnswer(c -> { Location p = c.getArgument(0); return block(p.getBlockX(), p.getBlockY(), p.getBlockZ()); }); + } + Block block(int x, int y, int z) { + Cell cell = new Cell(x, y, z); return blocks.computeIfAbsent(cell, p -> { + Block b = mock(Block.class); when(b.getLocation()).thenReturn(new Location(world, x, y, z)); + when(b.getWorld()).thenReturn(world); when(b.getType()).thenAnswer(c -> fill.apply(p)); + when(b.isLiquid()).thenAnswer(c -> fill.apply(p) == Material.WATER || fill.apply(p) == Material.LAVA); + when(b.isPassable()).thenAnswer(c -> !fill.apply(p).isSolid()); + when(b.getRelative(any(BlockFace.class))).thenAnswer(c -> { BlockFace face = c.getArgument(0); return block(x + face.getModX(), y + face.getModY(), z + face.getModZ()); }); + when(b.getRelative(anyInt(), anyInt(), anyInt())).thenAnswer(c -> block(x + (int)c.getArgument(0), y + (int)c.getArgument(1), z + (int)c.getArgument(2))); + return b; + }); + } + } + private static final class Water { + final Grid grid = new Grid(); final ActiveVehicle vehicle = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); + final LivingEntity entity = mock(LivingEntity.class); final Location location = new Location(grid.world, .5, 64, .5); + Water() { when(vehicle.shouldFloat()).thenReturn(true); bind(entity); when(vehicle.getComponents()).thenReturn(List.of()); } + void bind(LivingEntity body) { + when(vehicle.getEntity()).thenReturn(body); when(body.isValid()).thenReturn(true); when(body.getWorld()).thenReturn(grid.world); + when(body.getLocation()).thenAnswer(c -> location.clone()); when(body.getVelocity()).thenReturn(new Vector()); + when(body.getBoundingBox()).thenReturn(new BoundingBox(.2, 64, .2, .8, 65, .8)); + } + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleMovementControllerCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleMovementControllerCoverageTest.java new file mode 100644 index 0000000..7c88c7e --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleMovementControllerCoverageTest.java @@ -0,0 +1,544 @@ +package net.tfminecraft.vehicleframework.vehicles.controller; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.entity.*; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.Vector; +import org.joml.AxisAngle4d; +import org.joml.Quaternionf; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.*; +import net.tfminecraft.vehicleframework.bones.BoneRotator; +import net.tfminecraft.vehicleframework.bones.VectorBone; +import net.tfminecraft.vehicleframework.enums.Animation; +import net.tfminecraft.vehicleframework.enums.Component; +import net.tfminecraft.vehicleframework.enums.Direction; +import net.tfminecraft.vehicleframework.enums.Input; +import net.tfminecraft.vehicleframework.enums.SeatType; +import net.tfminecraft.vehicleframework.enums.State; +import net.tfminecraft.vehicleframework.enums.VehicleDeath; +import net.tfminecraft.vehicleframework.managers.InventoryManager; +import net.tfminecraft.vehicleframework.tracks.TrackJunction; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.component.*; +import net.tfminecraft.vehicleframework.vehicles.handlers.TowHandler; +import net.tfminecraft.vehicleframework.vehicles.handlers.state.TerrainFollowConfig; +import net.tfminecraft.vehicleframework.vehicles.seat.Seat; +import net.tfminecraft.vehicleframework.vehicles.state.VehicleState; + +class VehicleMovementControllerCoverageTest { + private final List scopes = new ArrayList<>(); + private final ActiveVehicle vehicle = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); + private final LivingEntity entity = mock(LivingEntity.class); + private final World world = mock(World.class); + private final Block ground = mock(Block.class); + private final Player player = mock(Player.class); + private final VehicleState state = mock(VehicleState.class); + private final BoneRotator rotator = mock(BoneRotator.class, RETURNS_DEEP_STUBS); + private final VectorBone vector = mock(VectorBone.class); + private final Seat seat = mock(Seat.class); + private MockedConstruction inventories; + private MockedConstruction floats; + private MockedStatic terrain; + private VehicleMovementController controller; + private Vector velocity = new Vector(.4, .3, .2); + private Location location; + + @BeforeEach + void setup() { + inventories = keep(mockConstruction(InventoryManager.class)); + floats = keep(mockConstruction(FloatController.class, (mock, context) -> + when(mock.calculateFloat(any(), any())).thenAnswer(call -> ((Vector) call.getArgument(1)).clone().setY(.1)))); + terrain = keep(mockStatic(TerrainFollowEngine.class)); + location = new Location(world, 8, 64, 8); + when(vehicle.getEntity()).thenReturn(entity); + when(vehicle.getCurrentState()).thenReturn(state); + when(vehicle.getStateHandler().getCurrentState()).thenReturn(state); + when(vehicle.getBehaviourHandler().getRotator()).thenReturn(rotator); + when(vehicle.getBehaviourHandler().getVector()).thenReturn(vector); + when(vehicle.getSeat(player)).thenReturn(seat); + when(seat.getType()).thenReturn(SeatType.CAPTAIN); + when(vehicle.getLocation()).thenAnswer(call -> location.clone()); + when(vehicle.getSpawnTime()).thenReturn(System.currentTimeMillis() - 20_000); + when(vehicle.getAccessPanel().getSpeed()).thenReturn(.4); + when(vehicle.getAccessPanel().getTurnRate()).thenReturn(2d); + when(vehicle.getThrottle().getCurrent()).thenReturn(25); + when(state.getType()).thenReturn(State.GROUND); + when(state.getTerrainFollow()).thenReturn(TerrainFollowConfig.disabled()); + when(entity.isValid()).thenReturn(true); + when(entity.getVelocity()).thenAnswer(call -> velocity.clone()); + doAnswer(call -> { velocity = ((Vector) call.getArgument(0)).clone(); return null; }).when(entity).setVelocity(any(Vector.class)); + when(entity.getLocation()).thenAnswer(call -> location.clone()); + when(entity.getWorld()).thenReturn(world); + when(entity.getBoundingBox()).thenReturn(new BoundingBox(7.5, 64, 7.5, 8.5, 66, 8.5)); + when(entity.teleport(any(Location.class))).thenAnswer(call -> { location = ((Location) call.getArgument(0)).clone(); return true; }); + when(world.getBlockAt(any(Location.class))).thenReturn(ground); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(ground); + when(ground.isPassable()).thenReturn(true); + when(vector.getVector()).thenAnswer(call -> new Vector(1, 0, 0)); + when(rotator.getAnimator().getRotation()).thenReturn(new Quaternionf()); + when(rotator.getAngles()).thenReturn(new AxisAngle4d()); + when(rotator.getDriveYaw()).thenReturn(35f); + controller = new VehicleMovementController(vehicle, state); + clearInvocations(vehicle, entity, rotator, state); + } + + @AfterEach + void teardown() throws Exception { + Collections.reverse(scopes); + for (AutoCloseable scope : scopes) scope.close(); + } + + private T keep(T scope) { scopes.add(scope); return scope; } + private T component(Component type, Class clazz) { + T component = mock(clazz, RETURNS_DEEP_STUBS); + when(vehicle.hasComponent(type)).thenReturn(true); + when(vehicle.getComponent(type)).thenReturn(component); + return component; + } + private TerrainFollowConfig follow() { + TerrainFollowConfig config = new TerrainFollowConfig(true, 1, .25, 3, 1, .08, .98, List.of()); + when(state.getTerrainFollow()).thenReturn(config); + return config; + } + private void terrainResult(TerrainFollowEngine.Result result) { + terrain.when(() -> TerrainFollowEngine.step(eq(vehicle), any(VectorBone.class), any(Direction.class), + any(BaseController.class), any(TerrainFollowConfig.class))).thenReturn(result); + } + + @Test + void exposesControllersAndRefreshesModelVectorsAndRotator() { + assertInstanceOf(BaseController.class, controller.getBaseController()); + assertInstanceOf(LiftController.class, controller.getLiftController()); + assertSame(floats.constructed().getFirst(), controller.getFloatController()); + assertInstanceOf(ThrottleController.class, controller.getThrottleController()); + assertInstanceOf(RotateController.class, controller.getRotateController()); + BoneRotator replacement = mock(BoneRotator.class); + VectorBone replacementVector = mock(VectorBone.class); + when(replacementVector.getVector()).thenReturn(new Vector(0, 0, 1)); + when(vehicle.getBehaviourHandler().getRotator()).thenReturn(replacement); + when(vehicle.getBehaviourHandler().getVector()).thenReturn(replacementVector); + controller.update(vehicle); + Wings wings = component(Component.WINGS, Wings.class); + when(wings.getTurnRate()).thenReturn(3f); + controller.input(player, Input.PITCH_UP); + verify(replacement).rotateSmoothed(-3, 0, 0); + component(Component.ENGINE, Engine.class); + controller.input(player, Input.FORWARD); + assertEquals(new Vector(0, 0, .4), velocity); + } + + @Test + void throttleInputsChangeOrdinaryEngineAndStartItBeforeChangingThrottle() { + Engine engine = component(Component.ENGINE, Engine.class); + controller.input(player, Input.THROTTLE_UP); + controller.input(player, Input.THROTTLE_DOWN); + verify(engine.getThrottle()).change(1); + verify(engine.getThrottle()).change(-1); + when(engine.requiresStart()).thenReturn(true); + controller.input(player, Input.THROTTLE_UP); + verify(engine).start(player); + verify(engine.getThrottle(), times(1)).change(1); + when(engine.isStarted()).thenReturn(true); + controller.input(player, Input.THROTTLE_UP); + verify(engine.getThrottle(), times(2)).change(1); + when(vehicle.usesFuel()).thenReturn(true); + when(vehicle.hasFuel()).thenReturn(false); + controller.input(player, Input.THROTTLE_DOWN); + verify(engine.getThrottle(), times(1)).change(-1); + when(vehicle.hasFuel()).thenReturn(true); + controller.input(player, Input.THROTTLE_DOWN); + verify(engine.getThrottle(), times(2)).change(-1); + } + + @Test + void gearedThrottleStartsEngineThenDelegatesDirection() { + GearedEngine engine = component(Component.GEARED_ENGINE, GearedEngine.class); + when(engine.requiresStart()).thenReturn(true); + controller.input(player, Input.THROTTLE_DOWN); + verify(engine).start(player); + verify(engine, never()).throttle(anyBoolean()); + when(engine.isStarted()).thenReturn(true); + controller.input(player, Input.THROTTLE_UP); + controller.input(player, Input.THROTTLE_DOWN); + verify(engine).throttle(false); + verify(engine).throttle(true); + } + + @Test + void captainTurnsWorldYawAndLocalYawInBothDirections() { + controller.input(player, Input.TURN_LEFT); + controller.input(player, Input.TURN_RIGHT); + controller.input(player, Input.TURN_LEFT_LOCAL); + controller.input(player, Input.TURN_RIGHT_LOCAL); + verify(rotator).setRotation(45, 0, 0, true, false, false); + verify(rotator).setRotation(25, 0, 0, true, false, false); + verify(rotator).rotateSmoothed(0, 2, 0); + verify(rotator).rotateSmoothed(0, -2, 0); + verify(vehicle, times(2)).animate(Animation.LEFT); + verify(vehicle, times(2)).animate(Animation.RIGHT); + when(vehicle.getAccessPanel().getTurnRate()).thenReturn(0d); + controller.input(player, Input.TURN_LEFT); + controller.input(player, Input.TURN_RIGHT_LOCAL); + verify(rotator, times(1)).rotateSmoothed(0, -2, 0); + } + + @Test + void pitchAndRollUseConfiguredWingRateOrZeroWithoutWings() { + controller.input(player, Input.PITCH_UP); + verify(rotator).rotateSmoothed(-0.0, 0, 0); + Wings wings = component(Component.WINGS, Wings.class); + when(wings.getTurnRate()).thenReturn(4f); + controller.input(player, Input.PITCH_UP); + controller.input(player, Input.PITCH_DOWN); + controller.input(player, Input.ROLL_LEFT); + controller.input(player, Input.ROLL_RIGHT); + verify(rotator).rotateSmoothed(-4, 0, 0); + verify(rotator).rotateSmoothed(4, 0, 0); + verify(rotator).rotateSmoothed(0, 0, -4); + verify(rotator).rotateSmoothed(0, 0, 4); + } + + @Test + void junctionControlsRequireLocomotiveCaptainAndPreserveRequestedSide() { + controller.input(null, Input.JUNCTION_LEFT); + controller.input(player, Input.JUNCTION_LEFT); + when(vehicle.isTrain()).thenReturn(true); + controller.input(player, Input.JUNCTION_LEFT); + when(vehicle.getSeatHandler().isCaptain(player)).thenReturn(true); + when(vehicle.hasParent()).thenReturn(true); + controller.input(player, Input.JUNCTION_LEFT); + verify(vehicle.getTrainHandler(), never()).holdJunction(any()); + when(vehicle.hasParent()).thenReturn(false); + controller.input(player, Input.JUNCTION_LEFT); + controller.input(player, Input.JUNCTION_RIGHT); + verify(vehicle.getTrainHandler()).holdJunction(TrackJunction.Side.LEFT); + verify(vehicle.getTrainHandler()).holdJunction(TrackJunction.Side.RIGHT); + when(vehicle.getSeatHandler()).thenReturn(null); + controller.input(player, Input.JUNCTION_RIGHT); + verify(vehicle.getTrainHandler(), times(1)).holdJunction(TrackJunction.Side.RIGHT); + } + + @Test + void seatSelectionOpensInventoryAndReportsProviderErrors() { + InventoryManager inventory = inventories.constructed().getFirst(); + controller.input(player, Input.SEAT_SELECTION); + verify(inventory).seatSelection(null, player, vehicle, true); + IllegalStateException error = new IllegalStateException("inventory unavailable"); + doThrow(error).when(inventory).seatSelection(null, player, vehicle, true); + controller.input(player, Input.SEAT_SELECTION); + verify(player).sendMessage("An error occurred while opening inventory: inventory unavailable"); + } + + @Test + void lightsAreToggledAndOnlyCaptainCanHonk() { + controller.input(player, Input.LIGHTS); + controller.input(player, Input.HORN); + verify(vehicle).toggleLights(player); + verify(vehicle).honk(player); + when(seat.getType()).thenReturn(SeatType.PASSENGER); + controller.input(player, Input.HORN); + verify(vehicle, times(1)).honk(player); + } + + @ParameterizedTest + @EnumSource(value = Input.class, names = {"FORWARD", "BACKWARD", "UP", "DOWN", "THROTTLE_UP", "THROTTLE_DOWN", + "TURN_LEFT", "TURN_RIGHT", "TURN_LEFT_LOCAL", "TURN_RIGHT_LOCAL", "PITCH_UP", "PITCH_DOWN", "ROLL_LEFT", "ROLL_RIGHT"}) + void passengerCannotDriveOrRotate(Input input) { + when(seat.getType()).thenReturn(SeatType.PASSENGER); + component(Component.ENGINE, Engine.class); + component(Component.BALLOON, Balloon.class); + component(Component.WINGS, Wings.class); + controller.input(player, input); + verify(entity, never()).setVelocity(any()); + verify(rotator, never()).rotateSmoothed(anyFloat(), anyFloat(), anyFloat()); + verify(rotator, never()).setRotation(anyFloat(), anyFloat(), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean()); + } + + @ParameterizedTest + @EnumSource(value = Input.class, names = {"NONE", "WEAPON_UP", "WEAPON_DOWN", "WEAPON_LEFT", "WEAPON_RIGHT", "WEAPON_RELOAD", "WEAPON_SHOOT", "WEAPON_RELOAD_AND_SHOOT", "WEAPON_SWITCH"}) + void unrelatedInputsLeaveMovementUntouched(Input input) { + controller.input(player, input); + verifyNoInteractions(entity, rotator); + } + + @ParameterizedTest + @EnumSource(value = Input.class, names = {"FORWARD", "BACKWARD", "MOVE"}) + void breakStateStopsHorizontalMovementWithoutCancellingGravity(Input input) { + when(state.isBreakState()).thenReturn(true); + controller.input(player, input); + assertEquals(new Vector(0, .3, 0), velocity); + terrain.verifyNoInteractions(); + verify(vehicle, never()).animate(any()); + } + + @Test + void stationaryOrParentedVehiclesDoNotApplyDriveMovement() { + when(vehicle.getAccessPanel().getSpeed()).thenReturn(0d); + controller.input(player, Input.FORWARD); + controller.input(player, Input.BACKWARD); + when(vehicle.hasParent()).thenReturn(true); + controller.input(player, Input.MOVE); + verify(entity, never()).setVelocity(any()); + terrain.verifyNoInteractions(); + } + + @Test + void forwardAndBackwardHarnessInputsUseRealBasePhysicsAndAnimations() { + Harness harness = component(Component.HARNESS, Harness.class); + when(harness.hasMounts()).thenReturn(true); + when(harness.getSpeed()).thenReturn(2d); + controller.input(player, Input.FORWARD); + assertEquals(new Vector(2, -.49, 0), velocity); + verify(vehicle).animate(Animation.FORWARD); + controller.input(player, Input.BACKWARD); + assertEquals(new Vector(-.6, -.49, 0), velocity); + verify(vehicle).animate(Animation.BACKWARD); + } + + @Test + void defaultStateAndMissingTerrainConfigUseSafeOrdinaryMovement() { + when(state.isDefault()).thenReturn(true); + controller.input(player, Input.FORWARD); + verify(entity, never()).setVelocity(any()); + when(state.isDefault()).thenReturn(false); + when(state.getTerrainFollow()).thenReturn(null); + controller.input(player, Input.BACKWARD); + assertEquals(new Vector(.4, -.49, .2), velocity); + terrain.verifyNoInteractions(); + } + + @Test + void floatingStateStopsVerticalDriveWhenVehicleCannotFloat() { + when(state.getType()).thenReturn(State.FLOATING); + controller.input(player, Input.FORWARD); + assertEquals(new Vector(.4, 0, .2), velocity); + when(vehicle.shouldFloat()).thenReturn(true); + velocity = new Vector(.4, .3, .2); + controller.input(player, Input.MOVE); + assertEquals(new Vector(.4, .1, .2), velocity); + verify(controller.getFloatController()).calculateFloat(eq(vehicle), any()); + verify(rotator).rotateSmoothed(0, 0, 0); + } + + @Test + void groundPlaneWithLowThrottleCannotPitchThroughSolidGround() { + component(Component.WINGS, Wings.class); + component(Component.ENGINE, Engine.class); + when(vector.getVector()).thenReturn(new Vector(1, 1, 0)); + when(vehicle.getThrottle().getCurrent()).thenReturn(10); + when(ground.isPassable()).thenReturn(false); + controller.input(player, Input.FORWARD); + assertEquals(0, velocity.getY()); + assertTrue(velocity.getX() > 0); + when(ground.isPassable()).thenReturn(true); + controller.input(player, Input.FORWARD); + assertTrue(velocity.getY() > 0); + } + + @Test + void wingsWithoutEngineKeepVerticalMovementAndLowThrottleFlightKeepsGliding() { + component(Component.WINGS, Wings.class); + controller.input(player, Input.FORWARD); + assertEquals(.3, velocity.getY()); + component(Component.ENGINE, Engine.class); + when(state.getType()).thenReturn(State.FLYING); + when(vehicle.getThrottle().getCurrent()).thenReturn(0); + controller.input(player, Input.FORWARD); + assertEquals(.65, velocity.getX(), 1e-9); + assertEquals(0, velocity.getY()); + } + + @Test + void ordinaryMoveAppliesBalloonLiftAndResetsBodyTilt() { + Balloon balloon = component(Component.BALLOON, Balloon.class); + when(balloon.getLift()).thenReturn(.25); + when(balloon.getDelta()).thenReturn(.12); + controller.input(player, Input.MOVE); + assertEquals(.12, velocity.getY()); + verify(rotator).rotateSmoothed(0, 0, 0); + } + + @Test + void matureTrainUsesSplineTickWhileFreshTrainUsesSpawnVelocity() { + when(vehicle.isTrain()).thenReturn(true); + follow(); + controller.input(player, Input.MOVE); + verify(vehicle.getTrainHandler()).splineTick(); + verify(entity, never()).setVelocity(any()); + verify(rotator, never()).rotateSmoothed(anyFloat(), anyFloat(), anyFloat()); + terrain.verifyNoInteractions(); + when(vehicle.getSpawnTime()).thenReturn(System.currentTimeMillis()); + controller.input(player, Input.FORWARD); + verify(entity).setVelocity(any()); + verify(vehicle.getTrainHandler(), times(1)).splineTick(); + verify(vehicle, never()).animate(Animation.FORWARD); + } + + @Test + void terrainFollowingTeleportsSyncsHarnessTiltsAndPropagatesToTow() { + TerrainFollowConfig config = follow(); + Harness harness = component(Component.HARNESS, Harness.class); + when(harness.hasMounts()).thenReturn(true); + TowHandler tow = vehicle.getTowHandler(); + when(vehicle.hasTowHandler()).thenReturn(true); + when(tow.getTowPoint().isOccupied()).thenReturn(true); + Location dest = new Location(world, 9, 64.5, 8); + Vector moved = new Vector(.5, 0, 0); + terrainResult(new TerrainFollowEngine.Result(dest, moved, new TerrainFollowMath.Tilt(6, -8))); + controller.input(player, Input.FORWARD); + terrain.verify(() -> TerrainFollowEngine.step(vehicle, vector, Direction.FORWARD, controller.getBaseController(), config)); + assertEquals(dest, location); + assertEquals(moved, velocity); + verify(harness).syncMountedEntities(); + verify(rotator).rotateToTarget(35, 6, -8, .25f, true, true, true); + verify(tow).animate(Direction.FORWARD); + verify(tow.getTowPoint().getEntity()).setVelocity(moved); + verify(vehicle).animate(Animation.FORWARD); + } + + @Test + void backwardAndAutomaticTerrainMovesPreserveDirectionAndAcceptNoTilt() { + follow(); + terrainResult(new TerrainFollowEngine.Result(location.clone(), new Vector(), null)); + controller.input(player, Input.BACKWARD); + terrain.verify(() -> TerrainFollowEngine.step(eq(vehicle), eq(vector), eq(Direction.BACKWARD), any(), any())); + when(vehicle.getAccessPanel().isReverse()).thenReturn(true); + controller.input(player, Input.MOVE); + terrain.verify(() -> TerrainFollowEngine.step(eq(vehicle), eq(vector), eq(Direction.BACKWARD), any(), any()), times(2)); + verify(rotator, never()).rotateToTarget(anyFloat(), anyFloat(), anyFloat(), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean()); + } + + @Test + void invalidTerrainEntityProducesNoFallbackVelocity() { + follow(); + controller.input(player, Input.FORWARD); + verify(entity, never()).teleport(any(Location.class)); + verify(entity, never()).setVelocity(any()); + verify(vehicle, never()).animate(any()); + } + + @Test + void balloonClimbAndDescentPreserveHorizontalVelocityAndSetDelta() { + Balloon balloon = component(Component.BALLOON, Balloon.class); + when(balloon.getLift()).thenReturn(.4); + when(balloon.getBaseLift()).thenReturn(.2); + controller.input(player, Input.UP); + assertEquals(new Vector(.4, .4, .2), velocity); + verify(balloon).setDelta(.4); + controller.input(player, Input.DOWN); + assertEquals(new Vector(.4, -.2, .2), velocity); + verify(balloon).setDelta(-.2); + velocity.setY(-.3); + controller.input(player, Input.DOWN); + assertEquals(-.3, velocity.getY()); + verify(balloon, times(1)).setDelta(-.2); + } + + @Test + void balloonControlsIgnoreMissingOrInsufficientLift() { + controller.input(player, Input.UP); + controller.input(player, Input.DOWN); + Balloon balloon = component(Component.BALLOON, Balloon.class); + when(balloon.getLift()).thenReturn(-.1); + when(balloon.getBaseLift()).thenReturn(-.1); + controller.input(player, Input.UP); + controller.input(player, Input.DOWN); + verify(entity, never()).setVelocity(any()); + verify(balloon, never()).setDelta(anyDouble()); + } + + @Test + void idleAnimationStopsBothDirectionsAndUpdatesOccupiedTowPoint() { + when(vehicle.hasTowHandler()).thenReturn(true); + TowHandler tow = vehicle.getTowHandler(); + when(tow.getTowPoint().isOccupied()).thenReturn(true); + velocity = new Vector(.01, 0, .01); + controller.setAnimation(); + verify(vehicle).stopAnimation(Animation.FORWARD); + verify(vehicle).stopAnimation(Animation.BACKWARD); + verify(tow).animate(Direction.STILL); + verify(tow.getTowPoint().getEntity()).setVelocity(velocity); + when(tow.getTowPoint().isOccupied()).thenReturn(false); + controller.setAnimation(); + verify(tow, times(1)).animate(Direction.STILL); + } + + @Test + void idleAnimationLeavesAutomaticParentedAndMovingVehiclesAlone() { + when(vehicle.shouldAutoMove()).thenReturn(true); + controller.setAnimation(); + when(vehicle.shouldAutoMove()).thenReturn(false); + when(vehicle.hasParent()).thenReturn(true); + controller.setAnimation(); + when(vehicle.hasParent()).thenReturn(false); + controller.setAnimation(); + verify(vehicle, never()).stopAnimation(any()); + } + + @Test + void explicitAnimationSupportsForwardBackwardStillAndNeutralMovement() { + controller.animateMove(Direction.FORWARD); + controller.animateMove(Direction.BACKWARD); + controller.animateMove(Direction.STILL); + controller.animateMove(Direction.MOVING); + verify(vehicle).animate(Animation.FORWARD); + verify(vehicle).animate(Animation.BACKWARD); + verify(vehicle).stopAnimation(Animation.FORWARD); + verify(vehicle).stopAnimation(Animation.BACKWARD); + } + + @Test + void realLiftPhysicsHandlesHealthyAndDamagedBalloon() { + Balloon balloon = component(Component.BALLOON, Balloon.class); + when(balloon.getLift()).thenReturn(.2); + assertEquals(0, controller.getLiftController().calculateLift(rotator, vehicle, new Vector()).getY()); + when(balloon.getLift()).thenReturn(-.2); + when(balloon.getDelta()).thenReturn(.4); + assertEquals(-.2, controller.getLiftController().calculateLift(rotator, vehicle, new Vector()).getY()); + } + + @Test + void realLiftPhysicsAppliesWingLiftAndThrottleGravityWithCap() { + Wings wings = component(Component.WINGS, Wings.class); + Engine engine = component(Component.ENGINE, Engine.class); + when(wings.getLift()).thenReturn(100d); + when(engine.getThrottle().getCurrent()).thenReturn(30); + Vector lifted = controller.getLiftController().calculateLift(rotator, vehicle, new Vector(1, 0, 0)); + assertEquals(.03, lifted.getY(), 1e-9); + when(state.getType()).thenReturn(State.FLYING); + when(engine.getThrottle().getCurrent()).thenReturn(0); + Vector stalled = controller.getLiftController().calculateLift(rotator, vehicle, new Vector(1, 0, 0)); + assertEquals(-.95, stalled.getY(), 1e-9); + when(engine.getThrottle().getCurrent()).thenReturn(50); + when(wings.getLift()).thenReturn(0d); + assertEquals(-.49, controller.getLiftController().calculateLift(rotator, vehicle, new Vector(1, 0, 0)).getY(), 1e-9); + when(state.getType()).thenReturn(State.GROUND); + when(engine.getThrottle().getCurrent()).thenReturn(0); + assertEquals(new Vector(1, .2, 0), controller.getLiftController().calculateLift(rotator, vehicle, new Vector(1, .2, 0))); + } + + @Test + void flyingChassisCollisionDestroysVehicleOnlyWhenMovingIntoSolidBlock() { + component(Component.WINGS, Wings.class); + when(vehicle.isDestroyed()).thenReturn(true); + controller.getLiftController().checkHitWall(vehicle); + when(vehicle.isDestroyed()).thenReturn(false); + when(vehicle.getAccessPanel().getSpeed()).thenReturn(.1); + controller.getLiftController().checkHitWall(vehicle); + when(vehicle.getAccessPanel().getSpeed()).thenReturn(.4); + controller.getLiftController().checkHitWall(vehicle); + verify(vehicle, never()).kill(any()); + when(ground.isPassable()).thenReturn(false); + controller.getLiftController().checkHitWall(vehicle); + verify(vehicle).kill(VehicleDeath.EXPLODE); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandlerCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandlerCoverageTest.java new file mode 100644 index 0000000..9700f1f --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandlerCoverageTest.java @@ -0,0 +1,654 @@ +package net.tfminecraft.vehicleframework.vehicles.handlers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.*; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.inventory.*; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.Vector; +import org.joml.Vector3f; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.bone.ModelBone; +import com.ticxo.modelengine.api.generator.blueprint.BlueprintBone; +import net.tfminecraft.vehicleframework.*; +import net.tfminecraft.vehicleframework.bones.BoneRotator; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.database.*; +import net.tfminecraft.vehicleframework.enums.Direction; +import net.tfminecraft.vehicleframework.managers.VehicleManager; +import net.tfminecraft.vehicleframework.tracks.*; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.component.fuel.FuelTank; +import net.tfminecraft.vehicleframework.vehicles.component.propulsion.Throttle; +import net.tfminecraft.vehicleframework.vehicles.controller.VehicleMovementController; +import net.tfminecraft.vehicleframework.vehicles.fuel.Fuel; +import net.tfminecraft.vehicleframework.vehicles.handlers.container.Container; +import net.tfminecraft.vehicleframework.vehicles.handlers.container.ContainerHandler; +import net.tfminecraft.vehicleframework.vehicles.handlers.state.AnimationHandler; +import net.tfminecraft.vehicleframework.vehicles.handlers.train.*; +import net.tfminecraft.vehicleframework.vehicles.util.AccessPanel; + +class TrainHandlerCoverageTest { + @TempDir Path directory; + private final List scopes = new ArrayList<>(); + private MockedStatic framework; + private MockedStatic bukkit; + private MockedStatic tapes; + private MockedStatic tools; + private MockedStatic effects; + private TrackRegistry registry; + private TrackStore store; + private final World world = mock(World.class); + private double oldArm, oldFx, oldOffset, oldSnap; + + @BeforeAll static void initializeSounds() { + net.tfminecraft.vehicleframework.test.RegistryFixture.initialize(); + } + + + @BeforeEach void setup() { + oldArm = Cache.trackJunctionArmDistance; Cache.trackJunctionArmDistance = 10; + oldFx = Cache.trackFxSoundInterval; Cache.trackFxSoundInterval = .5; + oldOffset = Cache.trackVehicleYOffset; Cache.trackVehicleYOffset = 0; + oldSnap = Cache.trackSnapDistance; Cache.trackSnapDistance = 2; + registry = new TrackRegistry(directory.toFile()); + store = new TrackStore(directory.toFile()); + framework = keep(mockStatic(VehicleFramework.class)); + framework.when(VehicleFramework::getTrackRegistry).thenReturn(registry); + bukkit = keep(mockStatic(Bukkit.class)); + keep(mockStatic(PersistenceLog.class)); + keep(mockStatic(RecorderLog.class)); + keep(mockStatic(VFLogger.class)); + effects = keep(mockStatic(TrackFx.class)); + tapes = keep(mockStatic(ThrottleTapeItems.class)); + tools = keep(mockStatic(TrackTools.class)); + when(world.getName()).thenReturn("world"); + org.bukkit.block.Block air = mock(org.bukkit.block.Block.class); + when(air.isPassable()).thenReturn(true); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(air); + } + @AfterEach void cleanup() throws Exception { + Collections.reverse(scopes); + for (AutoCloseable scope : scopes) scope.close(); + Cache.trackJunctionArmDistance = oldArm; Cache.trackFxSoundInterval = oldFx; + Cache.trackVehicleYOffset = oldOffset; Cache.trackSnapDistance = oldSnap; + } + + @Test void copiesKeepConfigurationRoutesAndAnIndependentInstalledTape() { + YamlConfiguration config = config(true); + config.set("locomotive", true); + config.set("wheel-diameter", 2); + config.set("bogies", List.of("front", "back")); + config.set("fuel-cars", List.of("coal", "", " ")); + config.set("walkable.x", List.of(-1, 1)); + config.set("walkable.z", List.of(-2, 2)); + config.set("walkable.top", 2); + TrainHandler template = new TrainHandler(config); + UUID track = UUID.randomUUID(), junction = UUID.randomUUID(); + template.applyConsist(new ConsistData(null, null, track.toString(), 3., -1, + junction.toString(), true, -1, Map.of())); + ThrottleTape tape = new ThrottleTape(track.toString(), List.of(new ThrottleTape.Sample(3, -1, -20))); + template.setInstalledTape(tape); + MockedConstruction decks = keep(mockConstruction(DeckBody.class)); + MockedConstruction bogies = keep(mockConstruction(Bogies.class)); + Car car = car(template); + assertTrue(car.train.isLocomotive()); + assertNotNull(car.train.getOverdrive()); + assertTrue(car.train.isAttachable()); + assertTrue(car.train.canHaveAttached()); + assertEquals(-1, car.train.getTravelSign()); + assertEquals(-1, car.train.getOrientation()); + car.train.setSplineId(track); + assertEquals(Map.of(junction.toString(), true), car.train.toConsistData().getJunctions()); + assertNotSame(tape, car.train.getInstalledTape()); + assertEquals(tape.toJson(), car.train.getInstalledTape().toJson()); + assertSame(decks.constructed().get(0), car.train.getDeckBody()); + car.train.updateModel(car.model); + verify(bogies.constructed().get(0)).updateModel(car.model); + car.train.removeDeck(); + verify(decks.constructed().get(0)).remove(); + Car coal = car(false); when(coal.vehicle.getId()).thenReturn("COAL"); + assertTrue(car.train.acceptsFuelCar(coal.vehicle)); + } + + @Test void malformedWalkableSectionDoesNotCreateADeck() { + YamlConfiguration config = config(false); config.set("walkable.top", 2); + Car car = car(new TrainHandler(config)); + assertNull(car.train.getDeckBody()); + car.train.removeDeck(); car.train.updateModel(car.model); + assertFalse(car.train.isLocomotive()); + } + + @Test void attachingAnUnboundCarJoinsConnectorsAndParentLinks() { + Car engine = car(true), trailer = car(true); + Player driver = mock(Player.class); + engine.location[0].setZ(20); + assertTrue(trailer.train.attach(driver, engine.vehicle)); + assertSame(trailer.vehicle, engine.train.getChild()); + assertSame(engine.vehicle, trailer.vehicle.getParent()); + assertEquals(16, trailer.location[0].getZ(), 1e-9); + verify(driver).sendMessage("§aConnected car to train"); + engine.train.clear(); + } + + @Test void attachingToABoundConsistPlacesTheNewCarOnItsTrack() { + TrackSpline track = track(false); + Car engine = car(true), trailer = car(true); + bindAt(engine, track, 50); + assertTrue(trailer.train.attach(mock(Player.class), engine.vehicle)); + assertEquals(track.getId(), trailer.train.getSplineId()); + assertEquals(46, trailer.train.getS(), 1e-9); + assertEquals(46, trailer.location[0].getZ(), 1e-9); + } + + @Test void aLocomotiveCannotBeCoupledBehindItsOwnDescendant() { + Car root = car(true), tail = car(true); + // Observe the mutation boundary without allowing the broken implementation + // to create a cycle and hang its subsequent traversal. + TrainHandler tailHandler = spy(tail.train); + doNothing().when(tailHandler).setChild(any()); + when(tail.vehicle.getTrainHandler()).thenReturn(tailHandler); + when(tail.behaviour.getTrainHandler()).thenReturn(tailHandler); + root.train.setChild(tail.vehicle); tail.vehicle.setParent(root.vehicle); + doNothing().when(root.vehicle).setParent(any()); + boolean attached = root.train.attach(mock(Player.class), tail.vehicle); + assertAll(() -> assertFalse(attached), + () -> verify(tailHandler, never()).setChild(root.vehicle), + () -> verify(root.vehicle, never()).setParent(tail.vehicle)); + } + + @Test void aCarCannotBeItsOwnParent() { + Car car = car(true); + doNothing().when(car.vehicle).setParent(any()); + assertFalse(car.train.attach(mock(Player.class), car.vehicle)); + verify(car.vehicle, never()).setParent(car.vehicle); + assertFalse(car.train.hasChild()); + } + + @Test void aWholeConsistCanStillCoupleBehindAnUnrelatedLocomotive() { + Car leader = car(true), train = car(true), tail = car(true); + train.train.setChild(tail.vehicle); tail.vehicle.setParent(train.vehicle); + bindAt(leader, track(false), 50); + assertTrue(train.train.attach(mock(Player.class), leader.vehicle)); + assertSame(train.vehicle, leader.train.getChild()); + assertSame(tail.vehicle, train.train.getChild()); + assertEquals(46, train.train.getS(), 1e-9); + assertEquals(42, tail.train.getS(), 1e-9); + } + + @ParameterizedTest @ValueSource(ints = {0, 1, 2, 3, 4, 5}) + void invalidCouplingIsRejectedBeforeChangingEitherTrain(int reason) { + Car target = car(reason != 3), trailer = car(reason != 0); + if (reason == 1) when(trailer.vehicle.hasParent()).thenReturn(true); + if (reason == 2) when(target.behaviour.isTrain()).thenReturn(false); + if (reason == 4) target.train.setChild(car(true).vehicle); + if (reason == 5) target.train.setPendingChild("unloaded-car"); + ActiveVehicle previous = target.train.getChild(); + assertFalse(trailer.train.attach(mock(Player.class), target.vehicle)); + assertSame(previous, target.train.getChild()); + verify(trailer.vehicle, never()).setParent(any()); + } + + @Test void pendingLinksNormalizeAndActualLinksTakePrecedenceWhenSaving() { + Car root = car(false), child = car(false), parent = car(false); + root.train.setPendingParent(" "); root.train.setPendingChild(null); + assertNull(root.train.getPendingParent()); assertNull(root.train.getPendingChild()); + root.train.setPendingParent("waiting-parent"); root.train.setPendingChild("waiting-child"); + assertEquals("waiting-parent", root.train.toConsistData().getParent()); + assertEquals("waiting-child", root.train.toConsistData().getChild()); + root.train.setChild(root.vehicle); + assertFalse(root.train.hasChild()); + root.train.setChild(child.vehicle); root.vehicle.setParent(parent.vehicle); + assertNull(root.train.getPendingChild()); + assertEquals(child.vehicle.getUUID(), root.train.toConsistData().getChild()); + assertEquals(parent.vehicle.getUUID(), root.train.toConsistData().getParent()); + root.train.setChild(null); assertFalse(root.train.hasChild()); + } + + @Test void corruptPersistedIdsDoNotPreventOtherConsistDataLoading() { + Car car = car(false); UUID valid = UUID.randomUUID(); + car.train.applyConsist(null); + car.train.applyConsist(new ConsistData("parent", "child", "invalid", null, -1, + "invalid-junction", true, -1, Map.of(valid.toString(), false))); + assertNull(car.train.getSplineId()); assertEquals(0, car.train.getS()); + assertEquals("parent", car.train.getPendingParent()); + assertEquals("child", car.train.getPendingChild()); + car.train.applyConsist(ConsistData.unbound()); + assertTrue(car.train.toConsistData().isUnbound()); + } + + @Test void onlyAnActualCircuitCanStartRecording() { + Car car = car(false); + assertFalse(car.train.canRecordCircuit()); car.train.startRecording(null); + assertFalse(car.train.isRecording()); + bindAt(car, track(false), 20); car.train.startRecording(null); + assertFalse(car.train.canRecordCircuit()); assertFalse(car.train.isRecording()); + bindAt(car, track(true), 20); car.train.startRecording(null); + assertTrue(car.train.canRecordCircuit()); assertTrue(car.train.isRecording()); + assertEquals(1, car.train.recordingSampleCount()); + assertNull(car.train.playbackThrottle(null)); + } + + @Test void manualRecordingCapturesThrottleAndWritesTheRecorderItem() { + Car car = car(false); TrackSpline track = track(true); + bindAt(car, track, 10); car.throttle.setThrottle(20); + car.train.startRecording(null); car.train.maybeRecordSample(20); + assertEquals(1, car.train.recordingSampleCount()); + car.train.setS(15); car.train.maybeRecordSample(50); + assertEquals(2, car.train.recordingSampleCount()); + ItemStack hand = mock(ItemStack.class); + car.train.stopRecording(hand); + tapes.verify(() -> ThrottleTapeItems.write(eq(hand), argThat(tape -> + tape.getSamples().size() == 2 && tape.getSamples().get(0).holdTicks == 2 + && tape.getSamples().get(1).throttle == 50))); + assertFalse(car.train.isRecording()); assertEquals(0, car.train.recordingSampleCount()); + car.train.stopRecording(null); car.train.maybeRecordSample(99); + } + + @Test void completingALapInstallsTheTapeWhenNoRecorderHolderIsOnline() { + Car car = car(false); TrackSpline circuit = track(true); + bindAt(car, circuit, 0); car.train.startRecording(null); + for (int i = 1; i <= 10; i++) { + car.train.setS(circuit.length() * i / 10 % circuit.length()); + car.train.maybeRecordSample(20); + } + assertFalse(car.train.isRecording()); assertTrue(car.train.hasInstalledTape()); + assertTrue(car.train.getInstalledTape().getSamples().size() >= 9); + } + + @ParameterizedTest @ValueSource(booleans = {true, false}) + void leavingCircuitSavesRecordingToHeldRecorderOrInstallsIt(boolean recorderHeld) { + Car car = car(false); bindAt(car, track(true), 5); + Player player = mock(Player.class); PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack hand = mock(ItemStack.class); UUID id = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(id); when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); when(inventory.getItemInMainHand()).thenReturn(hand); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + tools.when(() -> TrackTools.isRecorder(hand)).thenReturn(recorderHeld); + car.train.startRecording(player); car.train.unbind(); car.train.maybeRecordSample(20); + assertFalse(car.train.isRecording()); + verify(player).sendMessage("§eRecording stopped: locomotive left the circuit"); + if (recorderHeld) tapes.verify(() -> ThrottleTapeItems.write(eq(hand), any())); + else assertTrue(car.train.hasInstalledTape()); + } + + @Test void fullRecordingStopsAtItsDocumentedCapacity() { + Car car = car(false); bindAt(car, track(true), 5); car.train.startRecording(null); + for (int i = 1; i <= ThrottleTape.MAX_SAMPLES; i++) car.train.maybeRecordSample(i % 2); + assertFalse(car.train.isRecording()); + assertEquals(ThrottleTape.MAX_SAMPLES, car.train.getInstalledTape().getSamples().size()); + } + + @Test void recordingMayVisitItsCircuitsBranchButNotAnUnrelatedTrack() { + TrackSpline circuit = track(true), branch = line(30, false); + TrackJunction junction = junction(circuit, branch, 10, 1); + Car car = car(false); bindAt(car, circuit, 5); car.train.startRecording(null); + car.train.setSplineId(branch.getId()); car.train.setS(2); car.train.maybeRecordSample(40); + assertTrue(car.train.isRecording()); assertEquals(2, car.train.recordingSampleCount()); + ItemStack hand = mock(ItemStack.class); car.train.stopRecording(hand); + tapes.verify(() -> ThrottleTapeItems.write(eq(hand), argThat(t -> + t.getSamples().get(1).junctionId.equals(junction.id.toString())))); + bindAt(car, circuit, 5); car.train.startRecording(null); + car.train.setSplineId(line(50, false).getId()); car.train.maybeRecordSample(40); + assertFalse(car.train.isRecording()); + } + + @Test void tapePlaybackRequiresTrackFuelAndNoHumanCaptain() { + Car car = car(false); TrackSpline circuit = track(true); + assertNull(car.train.playbackThrottle(null)); + bindAt(car, circuit, 5); assertNull(car.train.playbackThrottle(null)); + ThrottleTape tape = new ThrottleTape(circuit.getId().toString(), List.of(new ThrottleTape.Sample(5, 1, 30))); + car.train.setInstalledTape(tape); assertEquals(30, car.train.playbackThrottle(null)); + FuelTank tank = tank(0); assertNull(car.train.playbackThrottle(tank)); + tank.setFuel(1); assertEquals(30, car.train.playbackThrottle(tank)); + when(car.seats.hasCaptain()).thenReturn(true); assertNull(car.train.playbackThrottle(tank)); + car.train.setInstalledTape(new ThrottleTape(circuit.getId().toString())); + assertFalse(car.train.hasInstalledTape()); car.train.setInstalledTape(null); + assertNull(car.train.getInstalledTape()); + } + + @Test void switchingTapeCancelsRecordingAndResetsPlayback() { + Car car = car(false); TrackSpline circuit = track(true); + bindAt(car, circuit, 5); car.train.startRecording(null); + car.train.setInstalledTape(new ThrottleTape(circuit.getId().toString(), List.of(new ThrottleTape.Sample(5, 1, 10)))); + assertFalse(car.train.isRecording()); assertEquals(10, car.train.playbackThrottle(null)); + } + + @Test void tenderFuelConsumesOnlyTheFirstMatchingItemAndFillsTheLocomotive() { + YamlConfiguration config = config(false); config.set("fuel-cars", List.of("coal")); + Car root = car(new TrainHandler(config)), tender = car(false); + when(tender.vehicle.getId()).thenReturn("coal"); when(tender.vehicle.hasContainers()).thenReturn(true); + Container empty = mock(Container.class), full = mock(Container.class), spare = mock(Container.class); + ContainerHandler containers = mock(ContainerHandler.class); + HashMap ordered = new LinkedHashMap<>(); + ordered.put("empty", empty); ordered.put("full", full); ordered.put("spare", spare); + when(containers.getContainers()).thenReturn(ordered); when(tender.vehicle.getContainerHandler()).thenReturn(containers); + ItemStack coal = mock(ItemStack.class); when(full.takeOneMatching("coal-item")).thenReturn(coal); + root.train.setChild(tender.vehicle); FuelTank tank = tank(97); + root.train.drainFromChild(tank); + assertEquals(100, tank.getCurrent()); verify(empty).takeOneMatching("coal-item"); + verify(full).takeOneMatching("coal-item"); verifyNoInteractions(spare); + root.train.drainFromChild(tank); verify(full, times(1)).takeOneMatching(any()); + tank.setFuel(0); when(full.takeOneMatching("coal-item")).thenReturn(null); + root.train.drainFromChild(tank); + assertEquals(0, tank.getCurrent()); verify(spare).takeOneMatching("coal-item"); + assertFalse(root.train.acceptsFuelCar(null)); + } + + @Test void fuelTransferGuardsRejectIneligibleCarsAndTanks() { + Car car = car(false), child = car(false); FuelTank tank = tank(0); + car.train.drainFromChild(tank); car.train.setChild(child.vehicle); + car.train.drainFromChild(null); car.train.drainFromChild(new FuelTank(0, 100, 1, List.of(), null)); + car.train.drainFromChild(tank); assertEquals(0, tank.getCurrent()); + when(car.vehicle.hasParent()).thenReturn(true); car.train.drainFromChild(tank); + assertFalse(TrainHandler.childIdAllowed(null, "coal")); + assertFalse(TrainHandler.childIdAllowed(Arrays.asList(null, "coal"), " ")); + assertTrue(TrainHandler.childIdAllowed(Arrays.asList(null, "COAL"), "coal")); + assertTrue(TrainHandler.shouldDrain(List.of("coal"), "coal", true, true)); + } + + @Test void bindingInfersReverseFacingAndUnbindingRestoresConsistGravity() { + Car root = car(true), child = car(true); TrackSpline track = track(false); + root.train.setChild(child.vehicle); root.location[0].setZ(50); root.location[0].setYaw(180); + assertTrue(root.train.bind(track)); assertEquals(-1, root.train.getTravelSign()); + assertEquals(track.getId(), child.train.getSplineId()); + root.train.unbind(); assertFalse(root.train.isBound()); assertFalse(child.train.isBound()); + assertEquals(1, root.train.getTravelSign()); assertEquals(0, child.train.getS()); + verify(root.entity).setGravity(true); verify(child.entity).setGravity(true); + assertFalse(root.train.bind(null)); assertFalse(new TrainHandler(config(false)).bind(track)); + } + + @Test void movingUnboundTrainSnapsToNearbyRailAndMakesProgress() { + Car car = car(false); TrackSpline track = track(false); + car.location[0].setZ(20); car.panel.setSpeed(1); + car.train.splineTick(); assertEquals(track.getId(), car.train.getSplineId()); + assertEquals(21, car.train.getS(), 1e-9); + effects.verify(() -> TrackFx.clack(eq(world), any())); + effects.verify(() -> TrackFx.crumbs(eq(world), any()), atLeastOnce()); + car.train.setSplineId(UUID.randomUUID()); car.location[0].setX(50); + car.train.splineTick(); assertFalse(car.train.isBound()); + } + + @Test void parentedCarsAndTrainsWithoutWorldOrRegistryDoNotMoveIndependently() { + Car car = car(false); car.panel.setSpeed(1); + when(car.vehicle.hasParent()).thenReturn(true); car.train.splineTick(); + verify(car.entity, never()).teleport(any(Location.class)); + when(car.vehicle.hasParent()).thenReturn(false); when(car.entity.getWorld()).thenReturn(null); + car.train.splineTick(); assertFalse(car.train.isBound()); + when(car.entity.getWorld()).thenReturn(world); framework.when(VehicleFramework::getTrackRegistry).thenReturn(null); + car.train.splineTick(); assertFalse(car.train.isBound()); + } + + @Test void engineCanArmNearbySwitchAndReportsDistantOrMissingSwitches() { + Car car = car(false); TrackSpline stem = track(false), branch = line(10, false); + TrackJunction switchAt = junction(stem, branch, 50, 1); + bindAt(car, stem, 45); Player captain = mock(Player.class); when(car.seats.captainPlayer()).thenReturn(captain); + car.train.holdJunction(TrackJunction.Side.RIGHT); + assertTrue(registry.getJunction(switchAt.id).orElseThrow().thrown); + verify(captain).sendMessage("§eSwitch: diverge (diverge), 5 ahead"); + car.train.holdJunction(TrackJunction.Side.RIGHT); + verify(captain, times(1)).sendMessage(anyString()); + Car distant = car(false); bindAt(distant, stem, 10); Player driver = mock(Player.class); + when(distant.seats.captainPlayer()).thenReturn(driver); + distant.train.holdJunction(TrackJunction.Side.LEFT); + verify(driver).sendMessage(contains("press A/D within 10")); + distant.train.holdJunction(TrackJunction.Side.LEFT); verify(driver, times(1)).sendMessage(anyString()); + Car behind = car(false); bindAt(behind, stem, 60); when(behind.seats.captainPlayer()).thenReturn(driver); + behind.train.holdJunction(TrackJunction.Side.LEFT); + verify(driver).sendMessage(contains("no facing turnout")); + } + + @Test void occupiedSwitchCannotBeThrownAgainstTheOtherTrain() { + TrackSpline stem = track(false); TrackJunction switchAt = junction(stem, line(10, false), 50, 1); + registry.onJunctionOccupied(id -> true); + Car car = car(false); bindAt(car, stem, 45); Player captain = mock(Player.class); + when(car.seats.captainPlayer()).thenReturn(captain); + car.train.holdJunction(TrackJunction.Side.RIGHT); + assertFalse(registry.getJunction(switchAt.id).orElseThrow().thrown); + verify(captain).sendMessage(contains("points locked")); + car.train.holdJunction(TrackJunction.Side.RIGHT); verify(captain, times(1)).sendMessage(anyString()); + } + + @Test void occupiedTrackQueriesIncludeLoadedCarsAndRejectMalformedSavedRows() { + assertFalse(TrainHandler.anyTrainOn(null)); assertFalse(TrainHandler.anyTrainOn(UUID.randomUUID())); + UUID id = UUID.randomUUID(); Car car = car(false); car.train.setSplineId(id); + VehicleManager vehicles = mock(VehicleManager.class); + when(vehicles.get()).thenReturn(new HashMap<>(Map.of(car.entity, car.vehicle))); + framework.when(VehicleFramework::getVehicleManager).thenReturn(vehicles); + assertTrue(TrainHandler.anyTrainOn(id)); + when(car.vehicle.isTrain()).thenReturn(false); + VehicleRepository repository = mock(VehicleRepository.class); + framework.when(VehicleFramework::getVehicleRepository).thenReturn(repository); + List unreadable = List.of(snapshot(null), snapshot("{}"), + snapshot("broken " + id), snapshot("[\"" + id + "\"]"), snapshot("{\"note\":\"" + id + "\"}")); + when(repository.listAllLive()).thenReturn(unreadable); + assertFalse(TrainHandler.anyTrainOn(id)); + VehicleSnapshot occupied = snapshot("{\"splineId\":\"" + id + "\"}"); + when(repository.listAllLive()).thenReturn(List.of(occupied)); + assertTrue(TrainHandler.anyTrainOn(id)); + } + + @Test void globalRetrackAndJunctionOccupancyUseOnlyTrains() { + TrackSpline old = track(false), replacement = line(0, false); Car car = car(false); + bindAt(car, old, 20); TrainHandler.retrackTrains(old, List.of(replacement)); + assertEquals(old.getId(), car.train.getSplineId()); assertFalse(TrainHandler.junctionOccupied(UUID.randomUUID())); + VehicleManager vehicles = mock(VehicleManager.class); + when(vehicles.get()).thenReturn(new HashMap<>(Map.of(car.entity, car.vehicle))); + framework.when(VehicleFramework::getVehicleManager).thenReturn(vehicles); + TrainHandler.retrackTrains(old, List.of(replacement)); + assertEquals(replacement.getId(), car.train.getSplineId()); + UUID junction = UUID.randomUUID(); + car.train.applyConsist(new ConsistData(null, null, replacement.getId().toString(), 20., 1, + junction.toString(), true)); + assertTrue(TrainHandler.junctionOccupied(junction)); + assertFalse(TrainHandler.junctionOccupied(UUID.randomUUID())); + } + + @Test void bogiesFollowTheirOwnRailsAndADeckFollowsTheBodyPose() { + YamlConfiguration config = config(false); + config.set("bogies", List.of("front", "back")); + config.set("walkable.x", List.of(-1, 1)); config.set("walkable.z", List.of(-2, 2)); + config.set("walkable.top", 2); + MockedConstruction rotators = keep(mockConstruction(BoneRotator.class)); + MockedConstruction decks = keep(mockConstruction(DeckBody.class)); + Car car = car(new TrainHandler(config)); TrackSpline track = track(false); + bindAt(car, track, 50); car.panel.setSpeed(.5); car.train.splineTick(); + assertEquals(50.5, car.train.getS(), 1e-9); + assertEquals(50.5, car.location[0].getZ(), 1e-9); + assertEquals(2, rotators.constructed().size()); + for (BoneRotator rotator : rotators.constructed()) { + verify(rotator, atLeastOnce()).rotateToTarget(anyFloat(), anyFloat(), eq(0f), eq(1f), eq(true), eq(true), eq(false)); + } + verify(decks.constructed().get(0), atLeastOnce()).place(argThat(frame -> frame.z() == 50.5)); + car.train.updateModel(car.model); + for (BoneRotator rotator : rotators.constructed()) verify(rotator).updateModel(car.model); + } + + @ParameterizedTest @ValueSource(ints = {-1, 0, 1, 2}) + void blockedTrainsExplainObstaclesToRidersWithoutSpamming(int blockCount) { + Car car = car(false); bindAt(car, track(false), 50); + Player rider = mock(Player.class); Entity cargo = mock(Entity.class); + when(car.seats.getPassengers()).thenReturn(blockCount < 0 ? List.of(cargo) : List.of(cargo, rider)); + MockedStatic collision = keep(mockStatic(TrainBlockCollision.class)); + MockedStatic highlight = keep(mockStatic(TrainSpaceHighlight.class)); + collision.when(() -> TrainBlockCollision.blocked(any(), any(), anyDouble(), any(), anyDouble(), anyDouble(), anyMap())) + .thenReturn(true); + List blocks = new ArrayList<>(); + for (int i = 0; i < blockCount; i++) blocks.add(new TrainBlockCollision.Obstruction(i, 64, 52, 51)); + collision.when(() -> TrainBlockCollision.blockers(any(), any(), anyDouble(), any(), anyDouble(), anyDouble(), anyMap())) + .thenReturn(blocks); + car.panel.setSpeed(1); car.train.splineTick(); car.train.splineTick(); + assertEquals(50, car.train.getS(), 1e-9); + if (blockCount <= 0) verifyNoInteractions(rider); + else { + String message = "Blocked at 0, 64, 52" + (blockCount == 1 ? "" : " and 1 more"); + verify(rider).sendActionBar(net.kyori.adventure.text.Component.text(message, net.kyori.adventure.text.format.NamedTextColor.RED)); + highlight.verify(() -> TrainSpaceHighlight.show(rider, blocks)); + } + } + + @Test void positionAndOccupancyGuardsHandleUnboundAndUnloadedCars() { + TrainHandler template = new TrainHandler(config(false)); + template.holdJunction(TrackJunction.Side.LEFT); + assertFalse(template.occupies(List.of())); + Car car = car(false); car.train.holdJunction(null); car.train.holdJunction(TrackJunction.Side.RIGHT); + assertFalse(car.train.occupies(null)); + TrackSpline loop = track(true); bindAt(car, loop, 1); + assertFalse(car.train.occupies(List.of(new TrackRegistry.Span(UUID.randomUUID(), 1, 1)))); + assertTrue(car.train.occupies(List.of(new TrackRegistry.Span(loop.getId(), loop.length() - .5, 1)))); + when(car.vehicle.hasParent()).thenReturn(true); car.train.holdJunction(TrackJunction.Side.LEFT); + assertFalse(car.train.occupies(List.of(new TrackRegistry.Span(loop.getId(), 1, 1)))); + framework.when(VehicleFramework::getTrackRegistry).thenReturn(null); + assertFalse(car.train.canRecordCircuit()); + assertFalse(TrainHandler.shouldDrain(List.of("coal"), "coal", false, true)); + } + + @Test void verticallyFacingEntityUsesForwardRailDirectionWhenBinding() { + Car car = car(false); car.location[0].setPitch(90); + assertTrue(car.train.bind(track(false))); assertEquals(1, car.train.getTravelSign()); + } + + @Test void attachingAfterAnExistingCarFindsTheRootLocomotive() { + Car root = car(true), first = car(true), second = car(true); + bindAt(root, track(false), 50); + assertTrue(first.train.attach(mock(Player.class), root.vehicle)); + assertTrue(second.train.attach(mock(Player.class), first.vehicle)); + assertEquals(42, second.train.getS(), 1e-9); + assertSame(second.vehicle, first.train.getChild()); + } + + @ParameterizedTest @ValueSource(ints = {-1, 0, 1}) + void junctionApproachUsesVelocityThenThrottleAndIgnoresUnbuiltOrOpposingSwitches(int movement) { + TrackSpline stem = track(false), branch = line(10, false); + TrackJunction switchAt = junction(stem, branch, movement < 0 ? 40 : 50, movement < 0 ? -1 : 1); + store.saveJunction("world", new TrackJunction(UUID.randomUUID(), stem.getId(), 44, 1, TrackJunction.Side.LEFT, null)); + store.saveJunction("world", new TrackJunction(UUID.randomUUID(), stem.getId(), 46, -switchAt.facingSign, + TrackJunction.Side.LEFT, branch.getId())); registry.loadFromDisk(); + Car car = car(false); bindAt(car, stem, 45); + if (movement == 0) car.throttle.setThrottle(20); else car.panel.setSpeed(movement); + car.train.holdJunction(TrackJunction.Side.RIGHT); + assertTrue(registry.getJunction(switchAt.id).orElseThrow().thrown); + } + + @Test void retrackingToBranchKeepsAnExistingJunctionChoice() { + TrackSpline stem = track(false), branch = line(0, false); + TrackJunction junction = junction(stem, branch, 50, 1); + Car car = car(false); bindAt(car, stem, 50); + car.train.applyConsist(new ConsistData(null, null, stem.getId().toString(), 50., 1, + junction.id.toString(), true)); + car.train.retrack(stem, List.of(branch)); + assertEquals(branch.getId(), car.train.getSplineId()); + assertEquals(junction.id.toString(), car.train.toConsistData().getJunctionId()); + assertTrue(car.train.toConsistData().isDiverge()); + } + + @Test void loadingWithoutABodyRotatorPreservesTheSavedPhysicalLocation() { + Car car = car(false); TrackSpline track = track(false); bindAt(car, track, 40); + car.train.applyConsist(new ConsistData(null, null, track.getId().toString(), 40.)); + car.train.placeLoadedCars(); + assertEquals(track.getId(), car.train.getSplineId()); + assertEquals(40, car.location[0].getZ(), 1e-9); + } + + @Test void retrackingClearsRoutesWhoseJunctionWasRemoved() { + Car car = car(false); TrackSpline old = track(false), rebuilt = line(0, false); + bindAt(car, old, 40); + car.train.applyConsist(new ConsistData(null, null, old.getId().toString(), 40., 1, + UUID.randomUUID().toString(), true)); + car.train.retrack(old, List.of(rebuilt)); + assertEquals(rebuilt.getId(), car.train.getSplineId()); + assertTrue(car.train.toConsistData().getJunctions().isEmpty()); + } + + @Test void placementCanUpdateLinkedCarMetadataBeforeItsEntityIsAvailable() { + Car root = car(false), loading = car(false); + when(loading.vehicle.getEntity()).thenReturn(null); + root.train.setChild(loading.vehicle); + TrackSpline track = track(false); + bindAt(root, track, 40); + assertEquals(track.getId(), loading.train.getSplineId()); + assertEquals(40, loading.train.getS(), 1e-9); + verify(loading.entity, never()).teleport(any(Location.class)); + root.train.unbind(); + assertFalse(loading.train.isBound()); + } + + private Car car(boolean couplers) { return car(new TrainHandler(config(couplers))); } + private Car car(TrainHandler template) { + ActiveVehicle vehicle = mock(ActiveVehicle.class); Entity entity = mock(Entity.class); + ActiveModel model = mock(ActiveModel.class); when(model.getScale()).thenReturn(new Vector3f(1)); + Location[] location = {new Location(world, 0, 64, 0)}; + when(entity.getLocation()).thenAnswer(call -> location[0].clone()); when(entity.getWorld()).thenReturn(world); + when(entity.getBoundingBox()).thenAnswer(call -> new BoundingBox(location[0].getX() - .5, + location[0].getY(), location[0].getZ() - .5, location[0].getX() + .5, + location[0].getY() + 2, location[0].getZ() + .5)); + when(entity.teleport(any(Location.class))).thenAnswer(call -> { location[0] = call.getArgument(0).clone(); return true; }); + when(vehicle.getEntity()).thenReturn(entity); when(vehicle.getModel()).thenReturn(model); + when(vehicle.getUUID()).thenReturn(UUID.randomUUID().toString()); when(vehicle.isTrain()).thenReturn(true); + when(vehicle.getId()).thenReturn("car"); + BehaviourHandler behaviour = mock(BehaviourHandler.class); when(behaviour.isTrain()).thenReturn(true); + when(vehicle.getBehaviourHandler()).thenReturn(behaviour); + SeatHandler seats = mock(SeatHandler.class); when(vehicle.getSeatHandler()).thenReturn(seats); + AccessPanel panel = new AccessPanel(); when(vehicle.getAccessPanel()).thenReturn(panel); + Throttle throttle = new Throttle("Throttle", 100, -100, null); when(vehicle.getThrottle()).thenReturn(throttle); + VehicleMovementController controls = mock(VehicleMovementController.class); + when(vehicle.getMoveControls()).thenReturn(controls); + AnimationHandler animation = mock(AnimationHandler.class); when(vehicle.getAnimationHandler()).thenReturn(animation); + ActiveVehicle[] parent = {null}; when(vehicle.hasParent()).thenAnswer(call -> parent[0] != null); + when(vehicle.getParent()).thenAnswer(call -> parent[0]); + doAnswer(call -> { parent[0] = call.getArgument(0); return null; }).when(vehicle).setParent(any()); + bone(model, location, "front", 2); bone(model, location, "back", -2); + TrainHandler train = new TrainHandler(vehicle, template); + when(vehicle.getTrainHandler()).thenReturn(train); when(behaviour.getTrainHandler()).thenReturn(train); + return new Car(vehicle, entity, model, behaviour, seats, panel, throttle, train, location); + } + private ModelBone bone(ActiveModel model, Location[] location, String name, float z) { + ModelBone bone = mock(ModelBone.class); BlueprintBone blueprint = new BlueprintBone(); + blueprint.setRotatedGlobalPosition(new Vector3f(0, 0, z)); + when(bone.getBoneId()).thenReturn(name); when(bone.getBlueprintBone()).thenReturn(blueprint); + when(bone.getLocation()).thenAnswer(call -> location[0].clone().add(0, 0, z)); + when(model.getBone(name)).thenReturn(Optional.of(bone)); return bone; + } + private YamlConfiguration config(boolean couplers) { + YamlConfiguration config = new YamlConfiguration(); + if (couplers) { config.set("front-connector", "front"); config.set("back-connector", "back"); } + return config; + } + private TrackSpline track(boolean loop) { return line(0, loop); } + private TrackSpline line(double x, boolean loop) { + TrackSpline track = TrackSpline.fromPoints(UUID.randomUUID(), "world", loop, + List.of(new double[]{x, 64, 0}, new double[]{x, 64, 100})); + store.save(track); registry.loadFromDisk(); return track; + } + private TrackJunction junction(TrackSpline stem, TrackSpline branch, double at, int facing) { + TrackJunction junction = new TrackJunction(UUID.randomUUID(), stem.getId(), at, facing, + TrackJunction.Side.RIGHT, branch.getId(), false, 3); + store.saveJunction("world", junction); registry.loadFromDisk(); return junction; + } + private void bindAt(Car car, TrackSpline track, double at) { + TrackPose pose = track.sampleAt(at); + car.location[0] = new Location(world, pose.x, pose.y, pose.z, pose.yaw, pose.pitch); + assertTrue(car.train.bind(track)); + } + private FuelTank tank(double current) { + Fuel fuel = mock(Fuel.class); when(fuel.getItem()).thenReturn("coal-item"); when(fuel.getAmount()).thenReturn(10); + return new FuelTank(current, 100, 1, List.of(), fuel); + } + private VehicleSnapshot snapshot(String payload) { + VehicleSnapshot row = mock(VehicleSnapshot.class); when(row.getPayloadJson()).thenReturn(payload); return row; + } + private T keep(T scope) { scopes.add(scope); return scope; } + private record Car(ActiveVehicle vehicle, Entity entity, ActiveModel model, BehaviourHandler behaviour, + SeatHandler seats, AccessPanel panel, Throttle throttle, TrainHandler train, Location[] location) {} +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleHandlersCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleHandlersCoverageTest.java new file mode 100644 index 0000000..495be26 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleHandlersCoverageTest.java @@ -0,0 +1,550 @@ +package net.tfminecraft.vehicleframework.vehicles.handlers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.inventory.*; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.*; +import org.bukkit.scheduler.*; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import org.joml.Vector3f; +import com.google.gson.*; +import com.ticxo.modelengine.api.ModelEngineAPI; +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.bone.ModelBone; +import com.ticxo.modelengine.api.generator.blueprint.BlueprintBone; +import com.ticxo.modelengine.api.model.bone.manager.MountManager; +import com.ticxo.modelengine.api.model.bone.type.Mount; +import com.ticxo.modelengine.api.mount.controller.MountController; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.vehicleframework.*; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.data.*; +import net.tfminecraft.vehicleframework.database.*; +import net.tfminecraft.vehicleframework.enums.Animation; +import net.tfminecraft.vehicleframework.enums.Component; +import net.tfminecraft.vehicleframework.enums.CustomAction; +import net.tfminecraft.vehicleframework.enums.Direction; +import net.tfminecraft.vehicleframework.enums.SeatType; +import net.tfminecraft.vehicleframework.enums.State; +import net.tfminecraft.vehicleframework.enums.VehicleDeath; +import net.tfminecraft.vehicleframework.loaders.*; +import net.tfminecraft.vehicleframework.projectiles.Fragment; +import net.tfminecraft.vehicleframework.util.ExplosionCreator; +import net.tfminecraft.vehicleframework.util.LightEffect; +import net.tfminecraft.vehicleframework.vehicles.*; +import net.tfminecraft.vehicleframework.vehicles.Vehicle; +import net.tfminecraft.vehicleframework.vehicles.component.*; +import net.tfminecraft.vehicleframework.vehicles.handlers.skins.VehicleSkin; +import net.tfminecraft.vehicleframework.vehicles.handlers.utility.DirectionalLight; +import net.tfminecraft.vehicleframework.vehicles.seat.Seat; +import net.tfminecraft.vehicleframework.weapons.*; + +class VehicleHandlersCoverageTest { + @BeforeAll static void registry() { net.tfminecraft.vehicleframework.test.RegistryFixture.initialize(); } + private final List scopes = new ArrayList<>(); + private final List later = new ArrayList<>(), timers = new ArrayList<>(); + private final BukkitScheduler scheduler = mock(BukkitScheduler.class); + private final World world = mock(World.class); + private MockedStatic modelApi; + private VehicleFramework previousPlugin; + private String previousTicket; + + @BeforeEach void setup() { + previousPlugin = VehicleFramework.plugin; previousTicket = Cache.ticketItem; + VehicleFramework.plugin = mock(VehicleFramework.class); when(VehicleFramework.plugin.getName()).thenReturn("VehicleFramework"); + when(VehicleFramework.plugin.namespace()).thenReturn("vehicleframework"); + keep(mockStatic(VFLogger.class)); keep(mockStatic(PersistenceLog.class)); + var bukkit = keep(mockStatic(Bukkit.class)); bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())).thenAnswer(c -> task(c.getArgument(1), later)); + when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong())).thenAnswer(c -> task(c.getArgument(1), timers)); + modelApi = keep(mockStatic(ModelEngineAPI.class, RETURNS_DEEP_STUBS)); + } + @AfterEach void cleanup() throws Exception { + Collections.reverse(scopes); for (AutoCloseable scope : scopes) scope.close(); + VehicleFramework.plugin = previousPlugin; Cache.ticketItem = previousTicket; + } + + @Test void vehiclesWithoutComponentsSafelyIgnoreRandomFire() { + ComponentHandler handler = new ComponentHandler(yaml()); + assertDoesNotThrow(handler::randomFire); assertTrue(handler.getComponents().isEmpty()); + } + + @Test void unknownSeatTypeUsesTheDocumentedPassengerFallback() { + Seat seat = new Seat("visitor(bench)", "cart"); + assertEquals(SeatType.PASSENGER, seat.getType()); assertEquals("bench", seat.getBone()); + } + + @Test void malformedSeatReportsTheVehicleAndExpectedSyntax() { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> new Seat("captain", "cart")); + assertTrue(failure.getMessage().contains("cart")); assertTrue(failure.getMessage().contains("seat")); + } + + @Test void unknownFloatMaterialIsSkippedAndValidConfiguredMaterialsSurvive() { + YamlConfiguration config = behaviourConfig(); config.set("float", true); config.set("float-in", List.of("water", "not_a_block", "lava")); + BehaviourHandler handler = new BehaviourHandler(config); + assertTrue(handler.shouldFloat()); assertEquals(List.of(Material.WATER, Material.LAVA), handler.getFloatsIn()); + } + + @Test void healthDecayNeverRepairsDamageAlreadyBeyondItsMinimumRemainingHealth() { + assertEquals(99, VehicleHealthDecay.nextDamage(99, 100, .2, .03)); + assertEquals(99, VehicleHealthDecay.nextDamage(99, 100, 0, .03)); + ActiveVehicle vehicle = vehicle(); Hull hull = new Hull(new Hull(componentConfig()), vehicle, model(0), null); + hull.getHealthData().setDamage(99); when(vehicle.getComponents()).thenReturn(List.of(hull)); + VehicleHealthDecay.applyToLive(vehicle, .2, .03); assertEquals(99, hull.getHealthData().getDamage()); + JsonObject stored = JsonParser.parseString("{components:{hull:{damage:99}}}").getAsJsonObject(); + Vehicle template = mock(Vehicle.class); ComponentHandler components = new ComponentHandler(yaml()); components.getComponents().add(hull); + when(template.getComponentHandler()).thenReturn(components); when(template.getWeapons()).thenReturn(List.of()); + assertFalse(VehicleHealthDecay.applyToJson(stored, VehicleHealthDecay.lookupFromTemplate(template), .2, .03)); + assertEquals(99, stored.getAsJsonObject("components").getAsJsonObject("hull").get("damage").getAsDouble()); + } + + @Test void configuredDieBehaviorIsAvailableToTheVehicleDeathDispatcher() { + YamlConfiguration config = vehicleConfig(); config.set("death.die.duration", 9); + Vehicle template = new Vehicle("cart", config); + assertTrue(template.getDeathData().stream().anyMatch(data -> data.getType() == VehicleDeath.DIE && data.getDuration() == 9)); + } + + @Test void occupiedSeatCannotAttachOrReparentASecondVehicle() { + ActiveVehicle parent = vehicle(), first = vehicle(), second = vehicle(); + Seat seat = new Seat(parent, new Seat(SeatType.TOWING, "tow")); seat.mount(first, parent); seat.mount(second, parent); + assertSame(first.getEntity(), seat.getEntity()); assertSame(first, seat.getMountedVehicle()); + verify(second, never()).setParent(any()); seat.dismount(); verify(first).setParent(null); + } + + @Test void templateLoadsOptionalSystemsAndResolvesWeaponsAndEveryDeathType() { + YamlConfiguration config = vehicleConfig(); config.set("name", "Harbour tug"); config.set("fixed", true); config.set("towable", true); + config.set("seats", List.of("captain(driver)", "passenger(bench)")); + config.set("behaviour.rotator", "base"); config.set("behaviour.vector", "base.align"); + config.set("custom-effects.engine_start", List.of()); config.set("weapons.cannon.name", "Cannon"); config.createSection("weapons.cannon.data"); + config.set("weapons.unavailable.template", "nonexistent-template-for-test"); + config.set("towing.bone", "tow"); config.set("containers.cargo.size", 9); config.set("utilities.horn.sound", "custom.horn"); + config.set("entity-seat-whitelist", List.of("horse", "pig")); + for (VehicleDeath death : VehicleDeath.values()) config.set("death." + death.name().toLowerCase() + ".duration", 8); + Vehicle template = new Vehicle("tug", config); + assertEquals("tug", template.getId()); assertEquals("Harbour tug", template.getName()); assertEquals("red", template.getModel()); + assertTrue(template.isFixed()); assertTrue(template.isTowable()); assertNotNull(template.getBehaviourHandler()); + assertEquals(2, template.getSeatHandler().getSeats().size()); assertEquals(1, template.getWeapons().size()); assertEquals("cannon", template.getWeapons().get(0).getId()); + assertNotNull(template.getTowHandler()); assertNotNull(template.getContainerHandler()); assertNotNull(template.getUtilityHandler()); + assertEquals(List.of("horse", "pig"), template.getEntitySeatWhitelist()); assertNotNull(template.getSkinHandler()); + assertNotNull(template.getStateHandler()); assertNotNull(template.getComponentHandler()); assertNotNull(template.getEffectHandler()); + assertEquals(4, template.getDeathData().size()); + YamlConfiguration minimal = vehicleConfig(); minimal.set("model", null); + Vehicle unnamed = new Vehicle("default-name", minimal); assertEquals("default-name", unnamed.getName()); + assertTrue(unnamed.getDeathData().isEmpty()); assertTrue(unnamed.getSeatHandler().getSeats().isEmpty()); + } + + @ParameterizedTest @ValueSource(strings = {"skins", "states", "components"}) + void missingRequiredTemplateSectionsReportTheirVehicleAndSection(String section) { + YamlConfiguration config = vehicleConfig(); config.set(section, null); + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> new Vehicle("broken-cart", config)); + assertTrue(failure.getMessage().contains("broken-cart")); assertTrue(failure.getMessage().contains(section)); + } + + @Test void healthDecayUsesTemplateHealthForLiveAndSerializedComponentsAndWeapons() { + ActiveVehicle live = vehicle(); Hull hull = new Hull(new Hull(componentConfig()), live, model(0), null); + ActiveWeapon gun = mock(ActiveWeapon.class); HealthData gunHealth = new HealthData(50, 0, 20); when(gun.getHealthData()).thenReturn(gunHealth); + VehicleComponent missingHealth = mock(VehicleComponent.class); when(missingHealth.getType()).thenReturn(Component.PUMP); + when(live.getComponents()).thenReturn(Arrays.asList(null, missingHealth, new Hull(componentConfig()), hull)); + when(live.getWeaponHandler().getWeapons()).thenReturn(Arrays.asList(null, mock(ActiveWeapon.class), gun)); + VehicleHealthDecay.applyToLive(live, .2, .03); assertEquals(20, hull.getHealthData().getDamage()); assertEquals(10, gunHealth.getDamage()); + VehicleHealthDecay.applyToLive(null, .2, .03); when(live.getComponents()).thenReturn(null); when(live.getWeaponHandler()).thenReturn(null); + VehicleHealthDecay.applyToLive(live, .2, .03); + Vehicle template = mock(Vehicle.class); ComponentHandler components = new ComponentHandler(yaml()); + components.getComponents().addAll(Arrays.asList(null, mock(VehicleComponent.class), missingHealth, hull)); + when(template.getComponentHandler()).thenReturn(components); + Weapon weapon = mock(Weapon.class); when(weapon.getId()).thenReturn("Cannon"); when(weapon.getHealthData()).thenReturn(new HealthData(50, 0, 20)); + Weapon missingWeaponHealth = mock(Weapon.class); when(missingWeaponHealth.getId()).thenReturn("incomplete"); + when(template.getWeapons()).thenReturn(Arrays.asList(null, mock(Weapon.class), missingWeaponHealth, weapon)); + var lookup = VehicleHealthDecay.lookupFromTemplate(template); + assertEquals(100, lookup.componentMaxHealth("HULL")); assertEquals(0, lookup.componentMaxHealth(null)); assertEquals(0, lookup.componentMaxHealth("absent")); + assertEquals(50, lookup.weaponMaxHealth("Cannon")); assertEquals(50, lookup.weaponMaxHealth("cannon")); + assertEquals(0, lookup.weaponMaxHealth(null)); assertEquals(0, lookup.weaponMaxHealth("absent")); + assertEquals(0, VehicleHealthDecay.lookupFromTemplate(null).componentMaxHealth("hull")); + assertFalse(VehicleHealthDecay.applyToJson(null, lookup, .2, .03)); assertFalse(VehicleHealthDecay.applyToJson(new JsonObject(), null, .2, .03)); + for (String damage : List.of("null", "\"bad\"", "{}", "[]", "0")) { + JsonObject stored = JsonParser.parseString("{components:{hull:{damage:" + damage + ",fire:12},absent:{damage:1},bad:[]},weapons:{cannon:{}}}").getAsJsonObject(); + assertTrue(VehicleHealthDecay.applyToJson(stored, lookup, .2, .03)); + assertEquals(20, stored.getAsJsonObject("components").getAsJsonObject("hull").get("damage").getAsDouble()); + assertEquals(12, stored.getAsJsonObject("components").getAsJsonObject("hull").get("fire").getAsInt()); + assertEquals(10, stored.getAsJsonObject("weapons").getAsJsonObject("cannon").get("damage").getAsDouble()); + } + assertFalse(VehicleHealthDecay.applyToJson(JsonParser.parseString("{components:[],weapons:null}").getAsJsonObject(), lookup, .2, .03)); + assertEquals(4, VehicleHealthDecay.nextDamage(4, 0, .2, .03)); assertEquals(0, VehicleHealthDecay.nextDamage(-50, 100, .2, .03)); + } + + @Test void effectsCloneConfiguredActionsAndPlayTheSelectedActionOnly() { + YamlConfiguration config = yaml(); config.set("engine_start", List.of("delay(1)")); + EffectHandler template = new EffectHandler(config); EffectHandler handler = new EffectHandler(template); ActiveVehicle vehicle = vehicle(); + assertTrue(handler.hasEffect(CustomAction.ENGINE_START)); assertFalse(handler.hasEffect(CustomAction.DIE)); + assertNotSame(template.getEffect(CustomAction.ENGINE_START), handler.getEffect(CustomAction.ENGINE_START)); + assertSame(handler.getEffect(CustomAction.DIE), handler.playEffect(List.of(), vehicle, CustomAction.DIE)); + var effect = handler.playEffect(List.of(), vehicle, CustomAction.ENGINE_START); assertFalse(effect.isFinished()); later.get(0).run(); assertTrue(effect.isFinished()); + assertFalse(new EffectHandler().hasEffect(CustomAction.ENGINE_START)); + } + + @Test void componentConfigurationClonesEverySupportedTypeAndRestoresPersistedDamage() { + YamlConfiguration config = yaml(); + for (Component type : Component.values()) config.createSection(type.name().toLowerCase(), componentConfig().getValues(true)); + config.set("geared_engine.gears.first.name", "First"); config.set("geared_engine.gears.first.max", 100); + config.createSection("geared_engine.gears.first.engine-sound"); config.createSection("geared_engine.gears.first.accelerate-sound"); + ComponentHandler template = new ComponentHandler(config); assertEquals(7, template.getComponents().size()); + ActiveVehicle vehicle = vehicle(); ActiveModel model = model(0); IncompleteVehicle saved = mock(IncompleteVehicle.class); + when(saved.getComponents()).thenReturn(List.of(new IncompleteComponent(Component.HULL, 30, 0, 0))); + ComponentHandler live = new ComponentHandler(vehicle, vehicle.getEntity(), model, saved, template); + assertEquals(7, live.getComponents().size()); assertEquals(30, live.getComponent(Component.HULL).getHealthData().getDamage()); + assertTrue(live.hasComponent(Component.PUMP)); assertFalse(live.hasComponent(Component.STEERING)); + assertNull(live.getComponent(Component.STEERING)); assertEquals(1, live.getComponents(Component.WINGS).size()); + assertTrue(live.getComponents(Component.STEERING).isEmpty()); + live.damage("fall", 2); assertEquals(32, live.getComponent(Component.HULL).getHealthData().getDamage()); + live.updateModel(model(10)); live.slowTick(); + config.set("hull.sinkable", true); ComponentHandler sinkTemplate = new ComponentHandler(config); + ComponentHandler sink = new ComponentHandler(vehicle, vehicle.getEntity(), model, null, sinkTemplate); + assertInstanceOf(SinkableHull.class, sink.getComponent(Component.HULL)); + for (VehicleComponent component : sink.getComponents()) assertEquals(0, component.getHealthData().getDamage()); + } + + @Test void randomFireIgnitesAnAvailableComponentAndHandlesAnAlreadyBurningVehicle() { + YamlConfiguration config = yaml(); config.createSection("hull", componentConfig().getValues(true)); + ActiveVehicle vehicle = vehicle(); ComponentHandler live = new ComponentHandler(vehicle, vehicle.getEntity(), model(0), null, new ComponentHandler(config)); + live.randomFire(); assertTrue(live.getComponent(Component.HULL).isOnFire()); + live.randomFire(); assertTrue(live.getComponent(Component.HULL).isOnFire()); live.slowTick(); + } + + @Test void behaviourCopiesFloatSettingsAndBindsRotatorsAndTargetsToReplacementModels() { + ActiveVehicle vehicle = vehicle(); ActiveModel model = model(0); + YamlConfiguration config = behaviourConfig(); config.set("float", true); config.set("secondary-rotators", List.of("second")); + config.set("rotation-targets.level.pitch", 10); config.set("rotation-targets.level.interval", 2); + config.createSection("train"); + BehaviourHandler template = new BehaviourHandler(config); BehaviourHandler live = new BehaviourHandler(vehicle, vehicle.getEntity(), model, template); + assertTrue(live.shouldFloat()); assertEquals(List.of(Material.WATER), live.getFloatsIn()); assertTrue(live.turnScale()); + assertEquals("base.align", live.getVectorString()); assertEquals("base", live.getRotatorString()); + assertEquals(List.of("second"), template.getSecondaryRotatorStrings()); assertEquals(1, live.getRotationTargets().size()); + assertTrue(live.isTrain()); assertNotNull(live.getTrainHandler()); assertEquals(new Vector(0, 0, 1), live.getVector().getVector()); + live.tick(vehicle); live.updateModel(model(10)); assertEquals(10, live.getRotator().getBone().getLocation().getX()); + assertEquals(10, live.getVector().getBaseLocation().getX()); + assertFalse(new BehaviourHandler().shouldFloat()); assertFalse(new BehaviourHandler(yaml()).shouldFloat()); + config.set("float", false); assertTrue(new BehaviourHandler(config).getFloatsIn().isEmpty()); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void towingMaintainsAttachmentPoseReversalAndVehicleControls(boolean reversed) { + ActiveVehicle vehicle = vehicle(), trailer = vehicle(); ActiveModel model = model(0); when(vehicle.getModel()).thenReturn(model); + when(vehicle.getBehaviourHandler().getVector().getVector()).thenReturn(new Vector(1, 0, 0)); + YamlConfiguration config = yaml(); config.set("bone", "tow"); config.set("reversed", reversed); + TowHandler handler = new TowHandler(vehicle, new TowHandler(config)); assertFalse(handler.isOccupied()); handler.tick(); handler.animate(Direction.FORWARD); + handler.attach(trailer); handler.attach(vehicle); assertTrue(handler.isOccupied()); assertSame(trailer, handler.getTowPoint().getMountedVehicle()); + verify(trailer).setParent(vehicle); verify(trailer.getBehaviourHandler().getRotator()).reset(); handler.tick(); + ArgumentCaptor target = ArgumentCaptor.forClass(Location.class); verify(trailer.getEntity()).teleport(target.capture()); + assertEquals(reversed ? 90 : -90, target.getValue().getYaw()); assertEquals(handler.getTowLocation().toVector(), target.getValue().toVector()); + handler.animate(Direction.FORWARD); verify(trailer.getMoveControls()).animateMove(reversed ? Direction.BACKWARD : Direction.FORWARD); + handler.animate(Direction.BACKWARD); verify(trailer.getMoveControls()).animateMove(reversed ? Direction.FORWARD : Direction.BACKWARD); + handler.animate(Direction.STILL); verify(trailer.getMoveControls()).animateMove(Direction.STILL); + when(trailer.getEntity().isDead()).thenReturn(true); handler.tick(); assertFalse(handler.isOccupied()); verify(trailer).setParent(null); + handler.getTowPoint().mount(mock(Entity.class)); handler.animate(Direction.FORWARD); handler.unattach(); assertFalse(handler.isOccupied()); + } + + @Test void deathEffectsScheduleFragmentsAndDelayedRemovalAndCancelTheirParticleTask() { + ActiveVehicle vehicle = vehicle(); Player viewer = mock(Player.class); when(vehicle.getNearbyPlayers()).thenReturn(List.of(viewer)); + when(vehicle.hasEffect(any())).thenReturn(true); DeathData data = death(VehicleDeath.EXPLODE, 7); + SoundData sound = mock(SoundData.class); data.getSfx().add(sound); + when(vehicle.getDeathData(VehicleDeath.EXPLODE)).thenReturn(data); + var fragments = keep(mockConstruction(Fragment.class)); var explosions = keep(mockStatic(ExplosionCreator.class)); + new DeathHandler(vehicle).explode(true); + assertEquals(2, fragments.constructed().size()); verify(vehicle).playEffect(CustomAction.EXPLODE); verify(vehicle.getAnimationHandler()).animate(Animation.EXPLODE); + explosions.verify(() -> ExplosionCreator.triggerExplosion(any(), eq(1d), eq(3d), eq(5d), eq("ENTITY_EXPLOSION"))); + verify(viewer, times(50)).spawnParticle(eq(Particle.CAMPFIRE_COSY_SMOKE), any(Location.class), eq(0), anyDouble(), anyDouble(), anyDouble(), eq(3d)); + verify(sound).playSound(eq(List.of(viewer)), any(Location.class), eq(1f)); + assertEquals(1, later.size()); later.get(0).run(); verify(vehicle).remove(); + assertEquals(1, timers.size()); timers.get(0).run(); timers.get(0).run(); timers.get(0).run(); + verify(viewer, times(20)).spawnParticle(eq(Particle.EXPLOSION_EMITTER), any(Location.class), eq(0), anyDouble(), anyDouble(), anyDouble(), eq(.2)); + verify(scheduler).cancelTask(1); + } + + @Test void crashIgnoresAirAndLightThenExplodesOnTheFirstSolidCollision() { + ActiveVehicle vehicle = vehicle(); Block block = mock(Block.class); when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block); + when(block.getType()).thenReturn(Material.AIR); when(vehicle.hasEffect(CustomAction.CRASH)).thenReturn(true); + when(vehicle.getDeathData(VehicleDeath.EXPLODE)).thenReturn(death(VehicleDeath.EXPLODE, 0)); + keep(mockStatic(ExplosionCreator.class)); keep(mockConstruction(Fragment.class)); + new DeathHandler(vehicle).crash(); Runnable crash = timers.get(0); crash.run(); when(block.getType()).thenReturn(Material.LIGHT); crash.run(); + verify(vehicle, never()).remove(); when(block.getType()).thenReturn(Material.STONE); crash.run(); + verify(vehicle).playEffect(CustomAction.CRASH); verify(vehicle).remove(); verify(scheduler).cancelTask(1); + assertEquals(0, later.size()); assertEquals(2, timers.size()); + } + + @Test void crashWithoutACollisionEventuallyRemovesTheVehicle() { + ActiveVehicle vehicle = vehicle(); Block block = mock(Block.class); when(block.getType()).thenReturn(Material.AIR); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block); + new DeathHandler(vehicle).crash(); Runnable crash = timers.get(0); + for (int i = 0; i <= 601; i++) crash.run(); verify(vehicle).remove(); verify(scheduler).cancelTask(1); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void sinkingAndDyingUseConfiguredSoundsAndDurationOrRemoveImmediately(boolean sink) { + ActiveVehicle vehicle = vehicle(); VehicleDeath type = sink ? VehicleDeath.SINK : VehicleDeath.DIE; + CustomAction action = sink ? CustomAction.SINK : CustomAction.DIE; when(vehicle.hasEffect(action)).thenReturn(true); + when(vehicle.getDeathData(type)).thenReturn(null); + DeathHandler handler = new DeathHandler(vehicle); + if (sink) handler.sink(); else handler.die(); verify(vehicle).remove(); + DeathData data = death(type, 12); SoundData sound = mock(SoundData.class); data.getSfx().add(sound); + when(vehicle.getDeathData(type)).thenReturn(data); if (sink) handler.sink(); else handler.die(); + verify(vehicle, times(2)).playEffect(action); verify(sound).playSound(anyList(), any(Location.class), eq(1f)); + verify(scheduler).runTaskLater(eq(VehicleFramework.plugin), any(Runnable.class), eq(12L)); later.get(0).run(); verify(vehicle, times(2)).remove(); + } + + @Test void skinChangesRespectPassengersFlyingEnginePowerAndMissingModels() { + ActiveVehicle vehicle = vehicle(); YamlConfiguration config = yaml(); + config.set("red.model", "red_model"); config.set("red.name", "Red"); config.set("blue.model", "blue_model"); config.set("gone.model", "missing_model"); + modelApi.when(() -> ModelEngineAPI.getBlueprint("missing_model")).thenReturn(null); + SkinHandler template = new SkinHandler("red", config), handler = new SkinHandler(vehicle, "red", template); + assertFalse(SkinHandler.isModelAvailable((String)null)); assertFalse(SkinHandler.isModelAvailable(" ")); assertFalse(SkinHandler.isModelAvailable((VehicleSkin)null)); + assertFalse(handler.canChangeSkin("red", false)); assertFalse(handler.canChangeSkin("absent", true)); assertFalse(handler.canChangeSkin("gone", false)); + assertNull(handler.changeSkin("absent")); assertNull(handler.changeSkin("gone")); + assertTrue(handler.canChangeSkin("blue", false)); when(vehicle.getSeatHandler().hasPassengers()).thenReturn(true); + assertFalse(handler.canChangeSkin("blue", false)); assertTrue(handler.canChangeSkin("blue", true)); + when(vehicle.getSeatHandler().hasPassengers()).thenReturn(false); when(vehicle.getStateHandler().getCurrentState().getType()).thenReturn(State.FLYING); + assertFalse(handler.canChangeSkin("blue", false)); when(vehicle.getStateHandler().getCurrentState().getType()).thenReturn(State.GROUND); + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); when(vehicle.hasComponent(Component.ENGINE)).thenReturn(true); when(vehicle.getComponent(Component.ENGINE)).thenReturn(engine); + when(engine.requiresStart()).thenReturn(true); when(engine.isStarted()).thenReturn(true); assertFalse(handler.canChangeSkin("blue", false)); + when(engine.isStarted()).thenReturn(false); when(engine.getThrottle().getCurrent()).thenReturn(-1); assertFalse(handler.canChangeSkin("blue", false)); + when(engine.getThrottle().getCurrent()).thenReturn(0); assertTrue(handler.canChangeSkin("blue", false)); + assertEquals("blue_model", handler.changeSkin("blue")); assertEquals("blue", handler.getCurrentSkin().getId()); + assertEquals("blue", handler.getCurrentSkin().getName()); assertEquals(3, handler.getSkins().size()); + } + + @Test void utilityLightsAdvanceAlongTheirBoneVectorAndHornSharesPlayerCooldown() { + YamlConfiguration config = yaml(); config.set("lights.front.vector", "base.align"); config.set("lights.front.power", 4); config.set("lights.front.falloff", 2); + config.set("horn.sound", "minecraft:entity.goat.screaming.ambient"); + UtilityHandler template = new UtilityHandler(config); UtilityHandler handler = new UtilityHandler(model(0), template); + assertSame(template.getHorn(), handler.getHorn()); assertEquals(1, handler.getLights().size()); + DirectionalLight light = handler.getLights().get(0); assertEquals("base.align", light.getVectorString()); + assertEquals(4, light.getPower()); assertEquals(2, light.getFalloff()); + var lights = keep(mockConstruction(LightEffect.class)); handler.tick(List.of()); assertTrue(lights.constructed().isEmpty()); + Player pilot = mock(Player.class); handler.toggleLights(pilot); handler.toggleLights(pilot); handler.tick(List.of(pilot)); + assertEquals(2, lights.constructed().size()); verify(lights.constructed().get(0)).createTemporaryLight(new Location(world, 0, 64, 1), 15); + verify(lights.constructed().get(1)).createTemporaryLight(new Location(world, 0, 64, 2), 15); + light.updateModel(model(5)); assertEquals(5, light.getVector().getBaseLocation().getX()); + handler.honk(pilot, new Location(world, 0, 64, 0)); verify(world, never()).playSound(any(Location.class), anyString(), anyFloat(), anyFloat()); + Player other = mock(Player.class); handler.honk(other, new Location(world, 0, 64, 0)); handler.honk(other, new Location(world, 0, 64, 0)); + verify(world).playSound(eq(new Location(world, 0, 64, 0)), anyString(), anyFloat(), anyFloat()); + new UtilityHandler(yaml()).honk(pilot, new Location(world, 0, 64, 0)); + YamlConfiguration invalid = yaml(); invalid.set("vector", "base.align"); invalid.set("power", 99); invalid.set("falloff", -3); + DirectionalLight normalized = new DirectionalLight(invalid); assertEquals(8, normalized.getPower()); assertEquals(1, normalized.getFalloff()); + } + + @Test void fireSpreadRollsOnceForEachUnburningComponentAndHonorsTheFireProgress() { + YamlConfiguration config = yaml(); config.createSection("hull", componentConfig().getValues(true)); config.createSection("pump", componentConfig().getValues(true)); + ActiveVehicle vehicle = vehicle(); ComponentHandler live = spy(new ComponentHandler(vehicle, vehicle.getEntity(), model(0), null, new ComponentHandler(config))); + live.getComponent(Component.HULL).startFire(); live.getComponent(Component.HULL).getFire().setProgress(50); + doReturn(99d).when(live).rollFireSpreadChance(); live.slowTick(); assertFalse(live.getComponent(Component.PUMP).isOnFire()); + doReturn(0d).when(live).rollFireSpreadChance(); live.slowTick(); assertTrue(live.getComponent(Component.PUMP).isOnFire()); + ComponentHandler realRoll = new ComponentHandler(yaml()); double rolled = realRoll.rollFireSpreadChance(); + assertTrue(rolled >= 0 && rolled < 100); assertEquals(Math.floor(rolled), rolled); + } + + @Test void seatsConnectOneWeaponAtATimeAndDebounceCyclingBeforeWrapping() throws InterruptedException { + ActiveVehicle vehicle = vehicle(); Seat seat = new Seat(vehicle, new Seat("gunner(bench)", "cart")); + ActiveWeapon first = mock(ActiveWeapon.class), second = mock(ActiveWeapon.class); Player pilot = player("Pilot"); + seat.changeWeapon(); seat.connectWeapon(first); seat.mount(pilot); assertSame(first, seat.getWeapon()); verify(first).setController(pilot); + seat.mount(player("Other")); assertSame(pilot, seat.getEntity()); seat.changeWeapon(); assertSame(first, seat.getWeapon()); + seat.connectWeapon(second); Thread.sleep(320); seat.changeWeapon(); assertSame(second, seat.getWeapon()); + seat.changeWeapon(); assertSame(second, seat.getWeapon()); verify(first).disconnect(); verify(second).setController(pilot); + Thread.sleep(320); seat.changeWeapon(); assertSame(first, seat.getWeapon()); verify(second).disconnect(); + seat.dismount(); assertFalse(seat.isOccupied()); verify(first, times(2)).disconnect(); + seat.mount(mock(Entity.class)); seat.changeWeapon(); assertSame(first, seat.getWeapon()); + assertFalse(seat.mountedIsVehicle()); verify(vehicle.getAccessPanel()).addSeat(seat); + } + + @Test void seatHandlerMaintainsCaptainAndPassengerMembershipAcrossSwitchesAndDismounts() { + Seats f = new Seats(); Player pilot = player("Pilot"); Seat driver = f.handler.getSeat("DRIVER"), bench = f.handler.getSeat("bench"); + assertFalse(f.handler.hasCaptain()); assertFalse(f.handler.isCaptain(null)); assertNull(f.handler.getSeat("missing")); + assertEquals(SeatHandler.MountResult.UNAVAILABLE, f.handler.addPassenger(null, driver)); + assertEquals(SeatHandler.MountResult.UNAVAILABLE, f.handler.changeSeat(pilot, null)); + assertEquals(SeatHandler.MountResult.MOUNTED, f.handler.addPassenger(pilot, driver)); + assertTrue(f.handler.hasCaptain()); assertSame(pilot, f.handler.captainPlayer()); assertTrue(f.handler.isCaptain(pilot)); + assertEquals(List.of(pilot), f.handler.getPassengers()); assertNull(f.handler.getSeat(player("Not aboard"))); + assertFalse(f.handler.isCaptain(player("Other"))); assertTrue(f.handler.isMounted(pilot)); assertSame(driver, f.handler.getSeat(pilot)); + assertEquals(SeatHandler.MountResult.MOUNTED, f.handler.changeSeat(pilot, bench)); assertFalse(f.handler.hasCaptain()); + assertSame(bench, f.handler.getSeat(pilot)); assertTrue(f.handler.hasPassengers()); + f.handler.slowTick(); assertTrue(f.handler.isMounted(pilot)); f.handler.dismountAll(); + assertFalse(f.handler.hasPassengers()); assertNull(f.handler.getSeat(pilot)); verify(pilot).closeInventory(); + verify(f.vehicle.getVehicleManager()).dismount(pilot); verify(f.vehicle).removeBoard(pilot); + Entity horse = mock(Entity.class); when(horse.getUniqueId()).thenReturn(UUID.randomUUID()); + assertEquals(SeatHandler.MountResult.MOUNTED, f.handler.addPassenger(horse, driver)); assertFalse(f.handler.hasCaptain()); f.handler.dismountAll(); + } + + @Test void seatHandlerRecoversStaleMountsAndRejectsRidersWhenTheModelLosesItsManager() { + Seats f = new Seats(); Player rider = player("Rider"); Seat bench = f.handler.getSeat("bench"); f.handler.addPassenger(rider, bench); + f.controllers.clear(); f.handler.slowTick(); assertTrue(f.handler.isMounted(rider)); + f.controllers.clear(); f.accept = false; f.handler.slowTick(); assertFalse(f.handler.isPassenger(rider)); assertFalse(bench.isOccupied()); + verify(f.vehicle.getVehicleManager()).recoverRejectedMount(f.vehicle, rider, "bench", rider); + f.accept = true; f.handler.addPassenger(rider, bench); ActiveModel replacement = model(5); when(replacement.getMountManager()).thenReturn(Optional.empty()); + f.handler.updateModel(replacement); f.handler.slowTick(); assertFalse(f.handler.hasPassengers()); + verify(f.vehicle.getVehicleManager(), times(2)).recoverRejectedMount(f.vehicle, rider, "bench", rider); + SeatHandler noManager = new SeatHandler(f.vehicle, replacement, new SeatHandler(List.of("passenger(bench)"), f.template)); + assertEquals(SeatHandler.MountResult.REJECTED, noManager.addPassenger(rider, noManager.getSeat("bench"))); + } + + @Test void rejectedSeatSwitchRollsBackPassengersAndAcceptedButDetachedMountIsRejected() { + Seats f = new Seats(); Player rider = player("Rider"); f.handler.addPassenger(rider, f.handler.getSeat("bench")); + f.accept = false; assertEquals(SeatHandler.MountResult.REJECTED, f.handler.changeSeat(rider, f.handler.getSeat("driver"))); + assertFalse(f.handler.hasPassengers()); assertFalse(f.handler.getSeat("bench").isOccupied()); + f.accept = true; f.registerController = false; + assertEquals(SeatHandler.MountResult.REJECTED, f.handler.addPassenger(rider, f.handler.getSeat("bench"))); + assertFalse(f.handler.isPassenger(rider)); assertFalse(f.passengers.containsKey(rider)); + } + + @Test void externalSeatResetCleansOrphanedPassengersAndNonPlayerRecovery() { + Seats f = new Seats(); Player rider = player("Rider"); f.handler.addPassenger(rider, f.handler.getSeat("bench")); + f.handler.resetSeat(rider); f.handler.slowTick(); assertFalse(f.handler.isPassenger(rider)); verify(f.vehicle).removeBoard(rider); + Entity pig = mock(Entity.class); when(pig.getUniqueId()).thenReturn(UUID.randomUUID()); f.handler.addPassenger(pig, f.handler.getSeat("bench")); + f.controllers.clear(); f.accept = false; f.handler.slowTick(); + verify(f.vehicle.getVehicleManager()).recoverRejectedMount(f.vehicle, pig, "bench", null); + } + + @Test void ticketMetadataRoundTripsAndSearchIncludesOffhandAndStorageOnly() { + ItemStack ticket = stack(1); assertNull(VehicleTicketItems.readId(null)); assertNull(VehicleTicketItems.readId(mock(ItemStack.class))); + assertNull(VehicleTicketItems.readId(ticket)); VehicleTicketItems.write(ticket, " ", "ignored"); assertNull(VehicleTicketItems.readId(ticket)); + VehicleTicketItems.write(null, "id", "Cart"); VehicleTicketItems.write(mock(ItemStack.class), "id", "Cart"); + VehicleTicketItems.write(ticket, "ABC", null); assertEquals("ABC", VehicleTicketItems.readId(ticket)); + verify(ticket.getItemMeta()).setDisplayName("§eTicket: vehicle"); verify(ticket.getItemMeta()).setLore(List.of("§7Valid for this vehicle")); + ticket.getItemMeta().getPersistentDataContainer().set(VehicleTicketItems.key(), PersistentDataType.STRING, " "); assertNull(VehicleTicketItems.readId(ticket)); + VehicleTicketItems.write(ticket, "ABC", "Cart"); + Player rider = player("Rider"); assertFalse(VehicleTicketItems.inventoryHas(null, "ABC")); assertFalse(VehicleTicketItems.inventoryHas(rider, " ")); + when(rider.getInventory().getStorageContents()).thenReturn(null); assertFalse(VehicleTicketItems.inventoryHas(rider, "ABC")); + when(rider.getInventory().getItemInOffHand()).thenReturn(ticket); assertTrue(VehicleTicketItems.inventoryHas(rider, "abc")); + when(rider.getInventory().getItemInOffHand()).thenReturn(null); + ItemStack unrelated = stack(1); when(rider.getInventory().getStorageContents()).thenReturn(new ItemStack[]{null, unrelated, ticket}); + assertTrue(VehicleTicketItems.inventoryHas(rider, "abc")); assertFalse(VehicleTicketItems.inventoryHas(rider, "other")); + } + + @Test void ticketInteractionRequiresBlankTicketItemAndOwnerOrAdministrator() { + ItemAPI items = ticketApi(); ActiveVehicle vehicle = vehicle(); Player rider = player("Pilot"); OwnerData owner = new OwnerData(); + when(vehicle.ticketSource()).thenReturn(vehicle); when(vehicle.getOwnerData()).thenReturn(owner); + assertFalse(VehicleTicketInteract.handle(null, vehicle)); assertFalse(VehicleTicketInteract.handle(rider, null)); + assertFalse(VehicleTicketInteract.handle(rider, vehicle)); + ItemStack blank = stack(1); when(rider.getInventory().getItemInMainHand()).thenReturn(blank); + Cache.ticketItem = null; assertFalse(VehicleTicketInteract.handle(rider, vehicle)); Cache.ticketItem = " "; assertFalse(VehicleTicketInteract.handle(rider, vehicle)); + Cache.ticketItem = "custom:ticket"; when(blank.getType()).thenReturn(Material.AIR); assertFalse(VehicleTicketInteract.handle(rider, vehicle)); + when(blank.getType()).thenReturn(Material.PAPER); when(items.getChecker().checkItemWithPath(blank, Cache.ticketItem)).thenReturn(false); + assertFalse(VehicleTicketInteract.handle(rider, vehicle)); when(items.getChecker().checkItemWithPath(blank, Cache.ticketItem)).thenReturn(true); + assertFalse(VehicleTicketInteract.handle(rider, vehicle)); owner.setOwner(null); assertFalse(VehicleTicketInteract.handle(rider, vehicle)); + owner.setOwner("PLAYER_pilot"); assertTrue(VehicleTicketInteract.handle(rider, vehicle)); + verify(rider).sendMessage("§cTickets are not enabled on this vehicle"); owner.setTicketsEnabled(true); owner.setTicketId(null); + assertTrue(VehicleTicketInteract.handle(rider, vehicle)); assertNull(VehicleTicketItems.readId(blank)); + owner.setTicketId("ticket-id"); assertTrue(VehicleTicketInteract.handle(rider, vehicle)); assertEquals("ticket-id", VehicleTicketItems.readId(blank)); + assertFalse(VehicleTicketInteract.handle(rider, vehicle)); verify(rider).sendMessage("§aCreated a ticket for §eCart"); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void ticketStacksMintExactlyOneAndReturnOrDropInventoryLeftovers(boolean full) { + ticketApi(); ActiveVehicle vehicle = vehicle(), locomotive = vehicle(); Player admin = player("Admin"); + when(admin.hasPermission("vf.admin")).thenReturn(true); when(vehicle.ticketSource()).thenReturn(locomotive); + OwnerData owner = new OwnerData(); owner.setTicketsEnabled(true); owner.setTicketId("train-pass"); when(locomotive.getOwnerData()).thenReturn(owner); + ItemStack blank = stack(4); when(admin.getInventory().getItemInMainHand()).thenReturn(blank); + when(admin.getInventory().addItem(any(ItemStack.class))).thenAnswer(c -> { + ItemStack minted = c.getArgument(0); assertEquals(1, minted.getAmount()); assertEquals("train-pass", VehicleTicketItems.readId(minted)); + HashMap leftovers = new HashMap<>(); if (full) leftovers.put(0, minted); return leftovers; + }); + assertTrue(VehicleTicketInteract.handle(admin, vehicle)); assertEquals(3, blank.getAmount()); assertNull(VehicleTicketItems.readId(blank)); + verify(admin.getInventory()).addItem(any(ItemStack.class)); + if (full) verify(world).dropItemNaturally(any(Location.class), any(ItemStack.class)); else verify(world, never()).dropItemNaturally(any(), any()); + } + + private final class Seats { + final ActiveVehicle vehicle = vehicle(); final Vehicle template = mock(Vehicle.class); + final MountManager manager = mock(MountManager.class); final Map passengers = new HashMap<>(); + final Map controllers = new HashMap<>(); final Map mounts = new HashMap<>(); + final Map> occupants = new HashMap<>(); final SeatHandler handler; + boolean accept = true, registerController = true; + Seats() { + keep(mockStatic(VehicleFramework.class, RETURNS_DEEP_STUBS)); when(template.getId()).thenReturn("cart"); + ActiveModel model = model(0); doReturn(Optional.of(manager)).when(model).getMountManager(); + handler = new SeatHandler(vehicle, model, new SeatHandler(List.of("captain(driver)", "passenger(bench)"), template)); + when(manager.getPassengerSeatMap()).thenReturn(passengers); + for (String bone : List.of("driver", "bench")) { + Mount mount = mock(Mount.class); Set riders = new HashSet<>(); mounts.put(bone, mount); occupants.put(mount, riders); + when(mount.getPassengers()).thenReturn(riders); doReturn(Optional.of(mount)).when(manager).getSeat(bone); + } + when(ModelEngineAPI.getMountPairManager().getController(any(UUID.class))).thenAnswer(c -> controllers.get(c.getArgument(0))); + when(manager.mountPassenger(anyString(), any(Entity.class), any())).thenAnswer(c -> { + if (!accept) return false; Entity rider = c.getArgument(1); Mount mount = mounts.get(c.getArgument(0)); + passengers.put(rider, mount); occupants.get(mount).add(rider); + if (registerController) { MountController controller = mock(MountController.class); when(controller.getMount()).thenReturn(mount); controllers.put(rider.getUniqueId(), controller); } + return true; + }); + doAnswer(c -> { Entity rider = c.getArgument(0); Mount mount = passengers.remove(rider); if (mount != null) occupants.get(mount).remove(rider); controllers.remove(rider.getUniqueId()); return null; }) + .when(manager).dismountPassenger(any(Entity.class)); + } + } + + private Player player(String name) { + Player player = mock(Player.class); PlayerInventory inventory = mock(PlayerInventory.class); + when(player.getName()).thenReturn(name); when(player.getUniqueId()).thenReturn(UUID.randomUUID()); when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenAnswer(c -> new Location(world, 0, 64, 0)); when(player.getInventory()).thenReturn(inventory); return player; + } + private ItemStack stack(int amount) { + ItemStack item = mock(ItemStack.class); ItemMeta meta = mock(ItemMeta.class); PersistentDataContainer data = mock(PersistentDataContainer.class); + AtomicInteger count = new AtomicInteger(amount); Map values = new HashMap<>(); + when(item.getType()).thenReturn(Material.PAPER); when(item.getAmount()).thenAnswer(c -> count.get()); doAnswer(c -> { count.set(c.getArgument(0)); return null; }).when(item).setAmount(anyInt()); + when(item.hasItemMeta()).thenReturn(true); when(item.getItemMeta()).thenReturn(meta); when(meta.getPersistentDataContainer()).thenReturn(data); + when(data.get(any(NamespacedKey.class), eq(PersistentDataType.STRING))).thenAnswer(c -> values.get(c.getArgument(0))); + doAnswer(c -> { values.put(c.getArgument(0), c.getArgument(2)); return null; }).when(data).set(any(NamespacedKey.class), eq(PersistentDataType.STRING), anyString()); + when(item.clone()).thenAnswer(c -> { ItemStack copy = stack(count.get()); for (var entry : values.entrySet()) copy.getItemMeta().getPersistentDataContainer().set(entry.getKey(), PersistentDataType.STRING, entry.getValue()); return copy; }); + return item; + } + private ItemAPI ticketApi() { + ItemAPI items = mock(ItemAPI.class, RETURNS_DEEP_STUBS); keep(mockStatic(TLibs.class)).when(TLibs::getItemAPI).thenReturn(items); + Cache.ticketItem = "custom:ticket"; when(items.getChecker().checkItemWithPath(any(ItemStack.class), eq(Cache.ticketItem))).thenReturn(true); return items; + } + + private ActiveVehicle vehicle() { + ActiveVehicle vehicle = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); Entity entity = mock(Entity.class); + when(vehicle.getEntity()).thenReturn(entity); when(vehicle.getId()).thenReturn("cart"); when(vehicle.getName()).thenReturn("Cart"); + when(entity.getWorld()).thenReturn(world); when(entity.getUniqueId()).thenReturn(UUID.randomUUID()); + when(entity.getLocation()).thenAnswer(c -> new Location(world, 0, 64, 0)); when(entity.getVelocity()).thenReturn(new Vector()); + when(entity.getBoundingBox()).thenReturn(new BoundingBox(0, 64, 0, 1, 65, 1)); + ActiveModel model = model(0); when(vehicle.getModel()).thenReturn(model); when(vehicle.getStateHandler().getCurrentState().getType()).thenReturn(State.GROUND); + when(vehicle.getComponents()).thenReturn(List.of()); when(vehicle.getNearbyPlayers()).thenReturn(List.of()); + when(vehicle.getWeaponHandler().getWeapons()).thenReturn(List.of()); return vehicle; + } + private ActiveModel model(double x) { + ActiveModel model = mock(ActiveModel.class, RETURNS_DEEP_STUBS); + for (String name : List.of("base", "align", "second", "tow", "driver", "bench")) { + ModelBone bone = mock(ModelBone.class, RETURNS_DEEP_STUBS); BlueprintBone blueprint = new BlueprintBone(); blueprint.setRotatedGlobalPosition(new Vector3f()); + when(bone.getLocalTransform().getSafePosition()).thenReturn(new Vector3f()); + when(bone.getLocalTransform().getSafeLeftQuaternion()).thenReturn(new org.joml.Quaternionf()); + when(bone.getLocalTransform().getSafeScale()).thenReturn(new Vector3f(1)); + when(bone.getBoneId()).thenReturn(name); when(bone.getBlueprintBone()).thenReturn(blueprint); when(bone.getActiveModel()).thenReturn(model); + when(bone.getLocation()).thenAnswer(c -> new Location(world, x, 64, name.equals("align") ? 1 : 0)); + when(model.getBone(name)).thenReturn(Optional.of(bone)); + } + return model; + } + private YamlConfiguration componentConfig() { YamlConfiguration config = yaml(); config.set("health", 100); config.set("damage-chance", 1); return config; } + private YamlConfiguration vehicleConfig() { + YamlConfiguration config = yaml(); config.set("model", "red"); config.set("skins.red.model", "red_model"); + config.createSection("states"); config.createSection("components"); return config; + } + private YamlConfiguration behaviourConfig() { YamlConfiguration config = yaml(); config.set("rotator", "base"); config.set("vector", "base.align"); return config; } + private DeathData death(VehicleDeath type, int duration) { YamlConfiguration config = yaml(); config.set("duration", duration); config.set("fragments", 2); return new DeathData(type, config); } + private static YamlConfiguration yaml() { return new YamlConfiguration(); } + private BukkitTask task(Runnable runnable, List target) { + target.add(runnable); BukkitTask task = mock(BukkitTask.class); when(task.getTaskId()).thenReturn(later.size() + timers.size()); return task; + } + private T keep(T scope) { scopes.add(scope); return scope; } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/TrainRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/TrainRuntimeCoverageTest.java new file mode 100644 index 0000000..b7c4efe --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/TrainRuntimeCoverageTest.java @@ -0,0 +1,301 @@ +package net.tfminecraft.vehicleframework.vehicles.handlers.train; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import org.bukkit.*; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.player.PlayerInteractAtEntityEvent; +import org.bukkit.event.world.EntitiesLoadEvent; +import org.bukkit.persistence.*; +import org.bukkit.util.Vector; +import org.joml.Vector3f; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import io.papermc.paper.entity.TeleportFlag; +import io.papermc.paper.event.player.PrePlayerAttackEntityEvent; +import net.tfminecraft.vehicleframework.*; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.data.VehicleRemovePayload; +import net.tfminecraft.vehicleframework.database.PersistenceLog; +import net.tfminecraft.vehicleframework.enums.VehicleRemoveReason; +import net.tfminecraft.vehicleframework.managers.VehicleManager; +import net.tfminecraft.vehicleframework.test.RegistryFixture; +import net.tfminecraft.vehicleframework.util.RelativeMove; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.handlers.TrainHandler; +import net.tfminecraft.vehicleframework.vehicles.util.AccessPanel; +import net.tfminecraft.vehicleframework.bones.BoneRotator; +import net.tfminecraft.vehicleframework.tracks.TrackPose; +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.bone.ModelBone; +import com.ticxo.modelengine.api.generator.blueprint.BlueprintBone; + +class TrainRuntimeCoverageTest { + final List scopes = new ArrayList<>(); + final List spawned = new ArrayList<>(); + final List bodies = new ArrayList<>(); + final Map fleet = new HashMap<>(); + final World world = mock(World.class); + final VehicleManager manager = mock(VehicleManager.class); + final AtomicInteger tick = new AtomicInteger(); + VehicleFramework previousPlugin; + MockedStatic framework; + MockedStatic bukkit; + MockedStatic logger; + double previousCarrySpeed; + boolean refuseShulker, cancelSpawn, refusePassenger, supplyScale = true; + + @BeforeAll static void registries() { RegistryFixture.initialize(); } + @BeforeEach void setup() { + previousPlugin = VehicleFramework.plugin; VehicleFramework.plugin = mock(VehicleFramework.class); + when(VehicleFramework.plugin.getName()).thenReturn("VehicleFramework"); + when(VehicleFramework.plugin.namespace()).thenReturn("vehicleframework"); + previousCarrySpeed = Cache.trainDeckCarryMaxSpeed; Cache.trainDeckCarryMaxSpeed = 2; + framework = keep(mockStatic(VehicleFramework.class)); framework.when(VehicleFramework::getVehicleManager).thenReturn(manager); + keep(mockStatic(PersistenceLog.class)); logger = keep(mockStatic(VFLogger.class)); + bukkit = keep(mockStatic(Bukkit.class)); bukkit.when(Bukkit::getCurrentTick).thenAnswer(call -> tick.get()); + when(manager.getByUUID(anyString())).thenAnswer(call -> fleet.get(call.getArgument(0))); + when(world.getName()).thenReturn("world"); when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + when(world.spawn(any(Location.class), any(Class.class), any(Consumer.class))).thenAnswer(call -> { + Class type = call.getArgument(1); + if (type == Shulker.class && refuseShulker) throw new IllegalStateException("Protection plugin refused the shulker"); + Part part = new Part(type, call.getArgument(0)); spawned.add(part); + ((Consumer) call.getArgument(2)).accept(part.entity); part.valid = !cancelSpawn; + return part.entity; + }); + } + @AfterEach void cleanup() throws Exception { + for (DeckBody body : bodies) body.remove(); + for (Part part : spawned) part.entity.remove(); + for (int i = 0; i < 42; i++) DeckRiders.tick(List.of()); + Collections.reverse(scopes); for (AutoCloseable scope : scopes) scope.close(); + VehicleFramework.plugin = previousPlugin; Cache.trainDeckCarryMaxSpeed = previousCarrySpeed; + } + T keep(T scope) { scopes.add(scope); return scope; } + final class Part { + final Entity entity; final Map tags = new HashMap<>(); + Location location; Entity riding; boolean valid = true; + Part(Class type, Location at) { + entity = mock(type); location = at.clone(); + when(entity.getUniqueId()).thenReturn(UUID.randomUUID()); when(entity.getWorld()).thenAnswer(call -> location.getWorld()); + when(entity.getLocation()).thenAnswer(call -> location.clone()); when(entity.isValid()).thenAnswer(call -> valid); + doAnswer(call -> { valid = false; return null; }).when(entity).remove(); + when(entity.getVehicle()).thenAnswer(call -> riding); + when(entity.teleport(any(Location.class), any(TeleportFlag[].class))).thenAnswer(call -> { location = call.getArgument(0); return true; }); + PersistentDataContainer pdc = mock(PersistentDataContainer.class); when(entity.getPersistentDataContainer()).thenReturn(pdc); + doAnswer(call -> { tags.put(call.getArgument(0), call.getArgument(2)); return null; }).when(pdc).set(any(), eq(PersistentDataType.STRING), anyString()); + when(pdc.has(any(), eq(PersistentDataType.STRING))).thenAnswer(call -> tags.containsKey(call.getArgument(0))); + when(entity.addPassenger(any(Entity.class))).thenAnswer(call -> { + if (refusePassenger) return false; + Entity passenger = call.getArgument(0); spawned.stream().filter(p -> p.entity == passenger).findFirst().orElseThrow().riding = entity; return true; + }); + if (entity instanceof Shulker shulker && supplyScale) when(shulker.getAttribute(any())).thenReturn(mock(AttributeInstance.class)); + } + } + final class Car { + final ActiveVehicle vehicle = mock(ActiveVehicle.class); + final TrainHandler train; + final Entity entity = mock(Entity.class); + ActiveVehicle parent; + Car() { + String id = UUID.randomUUID().toString(); when(vehicle.getUUID()).thenReturn(id); when(vehicle.getName()).thenReturn("Coach"); + when(vehicle.isTrain()).thenReturn(true); when(vehicle.getEntity()).thenReturn(entity); + when(entity.getWorld()).thenReturn(world); when(entity.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(entity.isValid()).thenReturn(true); + when(vehicle.getParent()).thenAnswer(call -> parent); when(vehicle.hasParent()).thenAnswer(call -> parent != null); + doAnswer(call -> { parent = call.getArgument(0); return null; }).when(vehicle).setParent(any()); + train = spy(new TrainHandler(vehicle, new TrainHandler(new YamlConfiguration()))); when(vehicle.getTrainHandler()).thenReturn(train); + doNothing().when(train).placeLoadedCars(); fleet.put(id, vehicle); + } + } + DeckBody body(Car car) { + DeckBody body = new DeckBody(car.vehicle, new Deck(-1, 1, -1, 1, 2, 2)); bodies.add(body); + doReturn(body).when(car.train).getDeckBody(); return body; + } + Deck.Frame frame(double x) { return new Deck.Frame(x, 64, 0, 0, 0); } + + @Test void deckFramesPreserveCoordinatesAndComparePositionRotationAndDistance() { + assertNull(Deck.fromConfig(null)); + Deck.Frame from = frame(0); assertFalse(from.same(null)); assertTrue(from.same(frame(0))); + assertFalse(from.same(frame(1))); assertFalse(from.same(new Deck.Frame(0, 64, 0, 1, 0))); assertFalse(from.same(new Deck.Frame(0, 64, 0, 0, 1))); + assertEquals(5, from.distance(new Deck.Frame(3, 68, 0, 0, 0))); + YamlConfiguration config = new YamlConfiguration(); config.set("x", List.of(0, .1)); config.set("z", List.of(0, 2)); config.set("top", 1); assertNull(Deck.fromConfig(config)); + } + + @Test void decksBuildTagMoveRebuildAndRemoveTheirBoxes() { + Car car = new Car(); DeckBody body = body(car); assertSame(car.vehicle, body.getVehicle()); assertNotNull(body.getDeck()); + assertNull(body.frame()); assertNull(body.carried()); assertNull(DeckBody.owner(null)); assertFalse(DeckBody.isPart(null)); assertFalse(DeckBody.isStray(null)); + body.place(null); assertTrue(spawned.isEmpty()); body.place(frame(0)); assertEquals(2, spawned.size()); + Part carrier = spawned.get(0), box = spawned.get(1); assertSame(car.vehicle, DeckBody.owner(box.entity)); assertTrue(DeckBody.isPart(carrier.entity)); assertFalse(DeckBody.isStray(box.entity)); + assertTrue(carrier.tags.containsValue(car.vehicle.getUUID())); assertSame(carrier.entity, box.riding); + assertEquals(frame(0), body.frame()); assertEquals(frame(0), body.carried()); + body.place(frame(0)); verify(carrier.entity, never()).teleport(any(Location.class), any(TeleportFlag[].class)); + body.place(frame(1)); assertEquals(1, carrier.location.getX()); assertEquals(frame(0), body.carried()); body.settle(); assertEquals(frame(1), body.carried()); + box.valid = false; body.place(frame(2)); assertEquals(4, spawned.size()); assertFalse(carrier.valid); + spawned.get(2).location.setWorld(mock(World.class)); body.place(frame(3)); assertEquals(6, spawned.size()); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); body.place(frame(16)); assertNull(body.frame()); assertNull(body.carried()); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); supplyScale = false; body.place(frame(16)); assertNotNull(body.frame()); + body.remove(); assertNull(body.frame()); assertNull(DeckBody.owner(box.entity)); assertTrue(DeckBody.isStray(box.entity)); assertTrue(spawned.stream().noneMatch(p -> p.valid)); + when(car.vehicle.getEntity()).thenReturn(null); body.place(frame(0)); when(car.vehicle.getEntity()).thenReturn(car.entity); when(car.entity.getWorld()).thenReturn(null); body.place(frame(0)); assertNull(body.frame()); + } + + @Test void cancelledDeckSpawnBacksOffAndLogsOnlyOnce() { + Car car = new Car(); DeckBody body = body(car); cancelSpawn = true; + body.place(frame(0)); assertNull(body.frame()); assertTrue(spawned.stream().noneMatch(p -> p.valid)); + tick.set(99); body.place(frame(0)); assertEquals(2, spawned.size()); tick.set(100); body.place(frame(0)); assertEquals(4, spawned.size()); + logger.verify(() -> VFLogger.log(contains("Could not make the walkable deck")), times(1)); + tick.set(200); cancelSpawn = false; body.place(frame(0)); assertNotNull(body.frame()); + } + + @Test void failedShulkerSpawnDoesNotLeakItsAlreadySpawnedCarrier() { + Car car = new Car(); DeckBody body = body(car); refuseShulker = true; + body.place(frame(0)); assertEquals(1, spawned.size()); assertNull(body.frame()); + verify(spawned.getFirst().entity).remove(); assertFalse(spawned.getFirst().valid, "A failed deck must remove its already created display"); + } + + @Test void rejectedPassengerMountDoesNotPublishAnUnsupportedDeck() { + Car car = new Car(); DeckBody body = body(car); refusePassenger = true; + body.place(frame(0)); assertNull(body.frame(), "Players must not be carried by an unattached deck box"); + assertTrue(spawned.stream().noneMatch(p -> p.valid)); + } + + @Test void deckEventsProtectPartsForwardAttacksAndRemoveUnownedTaggedEntities() { + Car car = new Car(); DeckBody body = body(car); body.place(frame(0)); DeckListener listener = new DeckListener(); Player player = mock(Player.class); + Entity ordinary = new Part(Entity.class, new Location(world, 0, 64, 0)).entity, box = spawned.get(1).entity; + PrePlayerAttackEntityEvent normal = new PrePlayerAttackEntityEvent(player, ordinary, true); listener.attack(normal); assertFalse(normal.isCancelled()); + PrePlayerAttackEntityEvent attack = new PrePlayerAttackEntityEvent(player, box, true); listener.attack(attack); assertTrue(attack.isCancelled()); verify(player).attack(car.entity); + when(car.entity.isValid()).thenReturn(false); listener.attack(attack); verify(player, times(1)).attack(car.entity); + EntityDamageEvent damage = new EntityDamageEvent(box, EntityDamageEvent.DamageCause.FALL, mock(org.bukkit.damage.DamageSource.class), 3); listener.damage(damage); assertTrue(damage.isCancelled()); + EntityDamageEvent unrelated = new EntityDamageEvent(ordinary, EntityDamageEvent.DamageCause.FALL, mock(org.bukkit.damage.DamageSource.class), 3); listener.damage(unrelated); assertFalse(unrelated.isCancelled()); + PlayerInteractAtEntityEvent click = new PlayerInteractAtEntityEvent(player, box, new Vector()); listener.interactAt(click); assertTrue(click.isCancelled()); + listener.interactAt(new PlayerInteractAtEntityEvent(player, ordinary, new Vector())); + listener.entitiesLoad(new EntitiesLoadEvent(mock(Chunk.class), List.of(box, ordinary))); verify(box, never()).remove(); + body.remove(); listener.attack(new PrePlayerAttackEntityEvent(player, box, true)); listener.entitiesLoad(new EntitiesLoadEvent(mock(Chunk.class), List.of(box, ordinary))); verify(box, times(2)).remove(); + } + + @Test void pendingConsistLinksReconnectInEitherOrderAndPlaceTheBoundLocomotive() { + Car root = new Car(), middle = new Car(), last = new Car(); root.train.setSplineId(UUID.randomUUID()); + middle.train.setPendingParent(root.vehicle.getUUID()); root.train.setPendingChild(middle.vehicle.getUUID()); + middle.train.setPendingChild(last.vehicle.getUUID()); last.train.setPendingParent(middle.vehicle.getUUID()); + ConsistRelinker.tryLink(middle.vehicle); assertSame(middle.vehicle, root.train.getChild()); assertSame(root.vehicle, middle.parent); + assertSame(last.vehicle, middle.train.getChild()); assertSame(middle.vehicle, last.parent); assertNull(middle.train.getPendingChild()); assertNull(middle.train.getPendingParent()); + assertNull(root.train.getPendingChild()); assertNull(last.train.getPendingParent()); verify(root.train).placeLoadedCars(); + ConsistRelinker.tryLink(null); ActiveVehicle unrelated = mock(ActiveVehicle.class); ConsistRelinker.tryLink(unrelated); + root.train.setPendingParent("not-loaded"); last.train.setPendingChild("not-loaded"); ConsistRelinker.tryLink(root.vehicle); ConsistRelinker.tryLink(last.vehicle); assertEquals("not-loaded", last.train.getPendingChild()); + framework.when(VehicleFramework::getVehicleManager).thenReturn(null); ConsistRelinker.tryLink(root.vehicle); + } + + @ParameterizedTest @ValueSource(strings = {"child", "parent"}) + void restoringPersistedLinksCannotCreateAConsistCycle(String direction) { + Car root = new Car(), tail = new Car(); root.train.setChild(tail.vehicle); tail.vehicle.setParent(root.vehicle); + // Observe the mutation boundary without permitting the old code to create a cycle and hang traversal. + doNothing().when(tail.train).setChild(any()); doNothing().when(root.vehicle).setParent(any()); + if (direction.equals("child")) tail.train.setPendingChild(root.vehicle.getUUID()); else root.train.setPendingParent(tail.vehicle.getUUID()); + ConsistRelinker.tryLink(direction.equals("child") ? tail.vehicle : root.vehicle); + verify(tail.train, never()).setChild(root.vehicle); verify(root.vehicle, never()).setParent(tail.vehicle); + assertSame(tail.vehicle, root.train.getChild()); assertSame(root.vehicle, tail.parent); + } + + @Test void preexistingCorruptParentCyclesDoNotHangRelinkingOrReceiveMoreCars() { + Car first = new Car(), second = new Car(), incoming = new Car(); first.vehicle.setParent(second.vehicle); second.vehicle.setParent(first.vehicle); + first.train.setPendingChild(incoming.vehicle.getUUID()); + assertTimeout(java.time.Duration.ofSeconds(2), () -> ConsistRelinker.tryLink(first.vehicle)); + verify(first.train, never()).setChild(incoming.vehicle); assertNull(incoming.parent); + } + + @Test void unloadingSplitsLiveLinksWhilePermanentRemovalAlsoClearsPendingReferences() { + Car root = new Car(), middle = new Car(), last = new Car(); root.train.setChild(middle.vehicle); middle.vehicle.setParent(root.vehicle); middle.train.setChild(last.vehicle); last.vehicle.setParent(middle.vehicle); + ConsistRelinker.onRemove(middle.vehicle, VehicleRemovePayload.remove(VehicleRemoveReason.UNLOAD)); + assertNull(root.train.getChild()); assertNull(middle.parent); assertNull(middle.train.getChild()); assertNull(last.parent); + assertEquals(middle.vehicle.getUUID(), root.train.getPendingChild()); assertEquals(middle.vehicle.getUUID(), last.train.getPendingParent()); + middle.train.setPendingParent(root.vehicle.getUUID()); middle.train.setPendingChild(last.vehicle.getUUID()); middle.train.setSplineId(UUID.randomUUID()); + root.train.setChild(middle.vehicle); last.vehicle.setParent(middle.vehicle); + ConsistRelinker.onRemove(middle.vehicle, null); assertNull(root.train.getChild()); assertNull(last.parent); assertNull(root.train.getPendingChild()); assertNull(last.train.getPendingParent()); + assertNull(middle.train.getPendingParent()); assertNull(middle.train.getPendingChild()); assertNull(middle.train.getSplineId()); + root.train.setChild(middle.vehicle); middle.vehicle.setParent(root.vehicle); middle.train.setChild(last.vehicle); last.vehicle.setParent(middle.vehicle); + ConsistRelinker.onRemove(middle.vehicle, VehicleRemovePayload.remove(null)); assertNull(root.train.getChild()); assertNull(last.parent); + ConsistRelinker.onRemove(null, null); ConsistRelinker.onRemove(mock(ActiveVehicle.class), null); + middle.train.setPendingChild("offline"); framework.when(VehicleFramework::getVehicleManager).thenReturn(null); ConsistRelinker.onRemove(middle.vehicle, null); assertNull(middle.train.getPendingChild()); + } + + @Test void permanentRemovalClearsThePendingLinksOfOtherLoadedCars() { + Car root = new Car(), middle = new Car(), child = new Car(); + root.train.setPendingChild(middle.vehicle.getUUID()); middle.train.setPendingParent(root.vehicle.getUUID()); middle.train.setPendingChild(child.vehicle.getUUID()); child.train.setPendingParent(middle.vehicle.getUUID()); + ConsistRelinker.onRemove(middle.vehicle, null); assertNull(root.train.getPendingChild()); assertNull(child.train.getPendingParent()); assertNull(middle.train.getPendingParent()); assertNull(middle.train.getPendingChild()); + } + + Player rider(double y) { + Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(UUID.randomUUID()); when(player.isOnline()).thenReturn(true); when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(player.getLocation()).thenReturn(new Location(world, 0, y, 0)); when(world.getNearbyPlayers(any(Location.class), anyDouble())).thenReturn(List.of(player)); return player; + } + @Test void ridersAreCarriedOnceLiftedOntoSupportAndRetainUnconfirmedClientMovement() { + Car car = new Car(), neighbour = new Car(); DeckBody body = body(car), second = body(neighbour); Player player = rider(65.8); when(player.getPing()).thenReturn(150); + List moves = new ArrayList<>(); + try (var relative = mockStatic(RelativeMove.class)) { + relative.when(() -> RelativeMove.send(eq(player), anyDouble(), anyDouble(), anyDouble(), anyFloat())).thenAnswer(call -> { moves.add(new double[]{call.getArgument(1), call.getArgument(2), call.getArgument(3)}); return true; }); + body.place(frame(0)); second.place(frame(0)); DeckRiders.tick(List.of(car.vehicle)); assertTrue(moves.isEmpty()); + body.place(frame(.5)); second.place(frame(.5)); DeckRiders.tick(List.of(car.vehicle, neighbour.vehicle)); assertEquals(1, moves.size()); assertArrayEquals(new double[]{.5, .2, 0}, moves.getFirst(), 1e-9); + body.place(frame(1)); DeckRiders.tick(List.of(car.vehicle)); assertEquals(2, moves.size()); assertArrayEquals(new double[]{.5, 0, 0}, moves.getLast(), 1e-9); + for (int i = 0; i < 42; i++) DeckRiders.tick(List.of()); + when(player.getLocation()).thenReturn(new Location(world, 1, 66, 0)); body.place(frame(1.5)); DeckRiders.tick(List.of(car.vehicle)); assertEquals(3, moves.size()); assertArrayEquals(new double[]{.5, 0, 0}, moves.getLast(), 1e-9); + relative.when(() -> RelativeMove.send(eq(player), anyDouble(), anyDouble(), anyDouble(), anyFloat())).thenReturn(false); + body.place(frame(1)); DeckRiders.tick(List.of(car.vehicle)); assertEquals(frame(1), body.carried()); + } + } + + @ParameterizedTest @ValueSource(strings = {"offline", "dead", "mounted", "flying", "gliding", "sleeping", "spectator", "outside", "disabled", "too-fast"}) + void ridingRequiresAnEligiblePlayerOnASlowMovingDeck(String reason) { + Car car = new Car(); DeckBody body = body(car); Player player = rider(66); + switch (reason) { + case "offline" -> when(player.isOnline()).thenReturn(false); + case "dead" -> when(player.isDead()).thenReturn(true); + case "mounted" -> when(player.isInsideVehicle()).thenReturn(true); + case "flying" -> when(player.isFlying()).thenReturn(true); + case "gliding" -> when(player.isGliding()).thenReturn(true); + case "sleeping" -> when(player.isSleeping()).thenReturn(true); + case "spectator" -> when(player.getGameMode()).thenReturn(GameMode.SPECTATOR); + case "outside" -> when(player.getLocation()).thenReturn(new Location(world, 5, 66, 5)); + case "disabled" -> Cache.trainDeckCarryMaxSpeed = 0; + case "too-fast" -> Cache.trainDeckCarryMaxSpeed = .1; + } + try (var relative = mockStatic(RelativeMove.class)) { + body.place(frame(0)); body.place(frame(.5)); DeckRiders.tick(List.of(car.vehicle)); relative.verifyNoInteractions(); + } + } + + @Test void missingDecksAndUnloadedVehicleEntitiesDoNotCarryPlayers() { + Car car = new Car(); when(car.vehicle.isTrain()).thenReturn(false); DeckRiders.tick(List.of(car.vehicle)); + when(car.vehicle.isTrain()).thenReturn(true); when(car.vehicle.getEntity()).thenReturn(null); DeckRiders.tick(List.of(car.vehicle)); + when(car.vehicle.getEntity()).thenReturn(car.entity); when(car.entity.getWorld()).thenReturn(null); DeckRiders.tick(List.of(car.vehicle)); + when(car.entity.getWorld()).thenReturn(world); DeckRiders.tick(List.of(car.vehicle)); DeckBody body = body(car); DeckRiders.tick(List.of(car.vehicle)); assertNull(body.frame()); + } + + ActiveModel model() { + ActiveModel model = mock(ActiveModel.class); when(model.getScale()).thenReturn(new Vector3f(1)); + for (String id : List.of("front", "back")) { + BlueprintBone blueprint = mock(BlueprintBone.class); when(blueprint.getRotatedGlobalPosition()).thenReturn(new Vector3f(0, 1, id.equals("front") ? 2 : -2)); + ModelBone bone = mock(ModelBone.class); when(bone.getBlueprintBone()).thenReturn(blueprint); when(bone.getBoneId()).thenReturn(id); when(model.getBone(id)).thenReturn(Optional.of(bone)); + } + return model; + } + @Test void bogiesFallBackWhileModelsLoadAndReleaseRotatorsAfterFailedSkinUpdates() { + Car car = new Car(); AccessPanel panel = new AccessPanel(); when(car.vehicle.getAccessPanel()).thenReturn(panel); + Bogies bogies = new Bogies(car.vehicle, List.of("front", "back"), (vehicle, bone) -> { BoneRotator rotator = mock(BoneRotator.class); panel.addRotator(rotator); return rotator; }); + assertFalse(new Bogies(List.of()).isReady()); assertFalse(bogies.isReady()); when(car.vehicle.getModel()).thenThrow(new IllegalStateException("Model loading")); assertFalse(bogies.isReady()); + ActiveModel pending = mock(ActiveModel.class); when(pending.getBone(anyString())).thenThrow(new IllegalStateException("Bones loading")); doReturn(pending).when(car.vehicle).getModel(); assertFalse(bogies.isReady()); + ActiveModel ready = model(); when(car.vehicle.getModel()).thenReturn(ready); assertTrue(bogies.isReady()); assertTrue(bogies.isReady()); assertEquals(2, panel.getRotators().size()); + bogies.updateModel(pending); assertTrue(panel.getRotators().isEmpty()); assertTrue(bogies.isReady()); bogies.updateModel(null); assertTrue(panel.getRotators().isEmpty()); + TrackPose single = new TrackPose(0, 64, 0, 10, 2); assertSame(single, Bogies.bodyPose(single, single, 2, -2)); + when(car.vehicle.getModel()).thenReturn(ready); ModelBone front = ready.getBone("front").orElseThrow(); when(front.getLocation()).thenReturn(new Location(world, 1, 66, 2)); + Connector connector = new Connector(car.vehicle, new Connector("front")); assertSame(front, connector.getBone()); assertEquals(new Vector(1, 2, 2), connector.getOffset()); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java new file mode 100644 index 0000000..91be8d6 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java @@ -0,0 +1,256 @@ +package net.tfminecraft.vehicleframework.weapons; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.potion.PotionEffectType; +import org.bukkit.util.RayTraceResult; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.*; +import org.mockito.*; +import com.ticxo.modelengine.api.ModelEngineAPI; +import com.ticxo.modelengine.api.animation.BlueprintAnimation; +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.ModeledEntity; +import com.ticxo.modelengine.api.model.bone.ModelBone; +import io.lumine.mythic.api.mobs.MythicMob; +import io.lumine.mythic.bukkit.MythicBukkit; +import io.lumine.mythic.core.mobs.ActiveMob; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.vehicleframework.bones.*; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.data.HealthData; +import net.tfminecraft.vehicleframework.enums.*; +import net.tfminecraft.vehicleframework.enums.Input; +import net.tfminecraft.vehicleframework.loaders.AmmunitionLoader; +import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig; +import net.tfminecraft.vehicleframework.projectiles.BulletRaycast; +import net.tfminecraft.vehicleframework.util.ExplosionCreator; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.util.AccessPanel; +import net.tfminecraft.vehicleframework.weapons.ammunition.*; +import net.tfminecraft.vehicleframework.weapons.ammunition.data.AmmunitionData; +import net.tfminecraft.vehicleframework.weapons.ammunition.data.projectile.*; +import net.tfminecraft.vehicleframework.weapons.controller.WeaponMovementController; +import net.tfminecraft.vehicleframework.weapons.data.WeaponData; +import net.tfminecraft.vehicleframework.weapons.handlers.AmmunitionHandler; +import net.tfminecraft.vehicleframework.weapons.shooter.*; + +class WeaponsCoverageTest { + Rig r;ActiveModel model;Map bones;ActiveVehicle vehicle;Player player;AccessPanel panel;boolean oldDebug;double oldMultiplier;HashMap oldAmmo;MockedConstruction stacks; + @BeforeEach void setup(){r=new Rig();model=mock(ActiveModel.class,RETURNS_DEEP_STUBS);bones=new HashMap<>();when(model.getBone(anyString())).thenAnswer(c->Optional.ofNullable(bones.get(c.getArgument(0))));when(model.getBlueprint().getAnimations()).thenReturn(new HashMap<>());bone("muzzle",new Location(r.world,0,64,0));bone("aim",new Location(r.world,0,64,1));vehicle=r.vehicle();panel=new AccessPanel();when(vehicle.getAccessPanel()).thenReturn(panel);when(vehicle.getCurrentState().getType()).thenReturn(State.GROUND);player=r.entity(Player.class,new Location(r.world,0,64,0));ItemStack air=item(Material.AIR,1);when(player.getInventory().getItemInMainHand()).thenReturn(air);oldDebug=Cache.weaponAimDebug;oldMultiplier=Cache.weaponDegradedReloadMultiplier;oldAmmo=AmmunitionLoader.map;AmmunitionLoader.map=new HashMap<>();Cache.weaponAimDebug=false;Cache.weaponDegradedReloadMultiplier=2; + // ItemStack construction belongs to the unavailable CraftBukkit item factory. + stacks=mockConstruction(ItemStack.class,(stack,context)->{ItemMeta meta=mock(ItemMeta.class,RETURNS_DEEP_STUBS);when(stack.getItemMeta()).thenReturn(meta);when(stack.setItemMeta(any())).thenReturn(true);if(!context.arguments().isEmpty()&&context.arguments().getFirst() instanceof Material material)when(stack.getType()).thenReturn(material);}); + } + @AfterEach void cleanup(){stacks.close();Cache.weaponAimDebug=oldDebug;Cache.weaponDegradedReloadMultiplier=oldMultiplier;AmmunitionLoader.map=oldAmmo;r.close();} + + @Test void configuredDelayDefersTheLastRoundAndPreservesItsAmmunition()throws Exception { + ActiveWeapon active=active("fixed: true\ndelay: 3\nbones: [muzzle.aim]\n");Bullet bullet=r.bullet(false);active.getAmmunitionHandler().setAmmo(bullet,1); + active.getAmmunitionHandler().shoot(player,List.of());assertEquals(0,active.getAmmunitionHandler().getCount());assertFalse(active.getAmmunitionHandler().hasAmmo()); + assertEquals(Material.AIR,r.block(0,64,0).getType(),"Muzzle flash and projectile must wait for the configured delay"); + r.ticks(2);assertEquals(Material.AIR,r.block(0,64,0).getType());assertDoesNotThrow(r::tick);assertEquals(Material.LIGHT,r.block(0,64,0).getType()); + } + + @Test void fixedWeaponsIgnoreManualTurnInputs()throws Exception { + Weapon stored=new Weapon("fixed",Rig.yaml("fixed: true\n"));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null);WeaponMovementController controls=new WeaponMovementController(vehicle,model,active,stored,stored.getLimits()); + for(Input input:List.of(Input.WEAPON_UP,Input.WEAPON_DOWN,Input.WEAPON_LEFT,Input.WEAPON_RIGHT))assertDoesNotThrow(()->controls.input(List.of(),input,player)); + } + + @Test void trailingDotInExitBoneIsRejectedWithoutCrashingWeaponCreation()throws Exception { + assertDoesNotThrow(()->active("fixed: true\nbones: [muzzle.]\n")); + } + + @Test void unconfiguredMuzzlesCannotConsumeAmmunitionOrScheduleReload()throws Exception { + ActiveWeapon active=active("fixed: true\nbones: []\n");assertDoesNotThrow(()->active.getAmmunitionHandler().reloadStart(player,r.bullet(false)));assertFalse(active.getAmmunitionHandler().hasAmmo());assertEquals(0,r.pending()); + } + + @Test void configurationAndActiveCopiesPreserveAllWeaponFields()throws Exception { + Weapon stored=new Weapon("cannon",Rig.yaml(""" + name: Deck Cannon + health: 250 + repair-time: 2 + body-bone: body + head-bone: head + head-axis: z + seat: captain + turn-rate: 3 + aim-mode: cursor + cursor-range: 45 + aim-vector: muzzle.aim + aim-offset: {body-yaw: 1, head-pitch: 2, head-yaw: 3, head-roll: 4} + rotation-limits: {min-yaw: -90, max-yaw: 90} + projectile-damage: 20 + projectile-damage-type: FIRE + projectile-speed: 5 + projectile-yield: 3 + projectile-radius: 8 + projectile-explosive: true + projectile-cluster-amount: 7 + damage: ['FIRE(0.5)'] + animations: {shoot: [left, right], reload: [load]} + keybinds: {W: WEAPON_UP} + bones: [muzzle.aim] + data: + velocity: 4 + reload-animation: reload + shoot-animation: shoot + reload-sounds: {done: {sound: test.done}} + reload-start-sounds: {begin: {sound: test.begin}} + shoot-sounds: {bang: {sound: test.bang}} + particles: {smoke: {particle: FLAME, amount: 1}} + """));bone("body",new Location(r.world,0,64,0));bone("head",new Location(r.world,0,64,0));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null); + assertEquals("cannon",stored.getId());assertEquals("Deck Cannon",stored.getName());assertEquals(250,stored.getHealthData().getHealth());assertFalse(stored.isFixed());assertEquals("body",stored.getBodyBone());assertEquals("head",stored.getHeadBone());assertEquals("z",stored.getAxis());assertEquals("captain",stored.getSeat());assertEquals(3,stored.getTurnRate());assertEquals(WeaponAimMode.CURSOR,stored.getAimMode());assertEquals(45,stored.getCursorRange());assertEquals("muzzle.aim",stored.getAimVector());assertEquals(1,stored.getAimOffset().getBodyYaw());assertEquals(2,stored.getAimOffset().getHeadPitch());assertEquals(3,stored.getAimOffset().getHeadYaw());assertEquals(4,stored.getAimOffset().getHeadRoll());assertEquals(90,stored.getLimits().getMaxYaw());assertEquals(20,stored.getProjectileDamage());assertEquals("FIRE",stored.getProjectileDamageType());assertEquals(5,stored.getProjectileSpeed());assertEquals(3,stored.getProjectileYield());assertEquals(8,stored.getProjectileRadius());assertTrue(stored.getProjectileExplosive());assertEquals(7,stored.getProjectileClusterAmount());assertEquals(List.of("muzzle.aim"),stored.getBones()); + assertEquals("cannon",active.getId());assertEquals("Deck Cannon",active.getName());assertEquals("captain",active.getSeat());assertSame(stored.getWeaponData(),active.getWeaponData());assertSame(stored.getDamageData(),active.getDamageData());assertNotSame(stored.getHealthData(),active.getHealthData());assertEquals(Input.WEAPON_UP,active.getInputHandler().getInput(Keybind.W));assertTrue(active.getAnimationHandler().hasModel());assertEquals(4,active.getWeaponData().getVelocity());assertEquals("reload",active.getWeaponData().getReloadAnimation());assertEquals("shoot",active.getWeaponData().getShootAnimation());assertEquals(1,active.getWeaponData().getParticles().size()); + assertEquals(20,Weapon.effectiveDamage(active,r.bullet(false).getData()));assertEquals("FIRE",Weapon.effectiveDamageType(active,null));assertEquals(5,Weapon.effectiveProjectileSpeed(active,r.bullet(false)));assertEquals(5,Weapon.effectiveProjectileVelocity(active));assertEquals(3,Weapon.effectiveYield(active,null));assertEquals(8,Weapon.effectiveRadius(active,null));assertTrue(Weapon.effectiveExplosive(active,null));assertEquals(7,Weapon.effectiveClusterAmount(active,null)); + active.damage("FIRE",20);active.damage("OTHER",5);assertEquals(15,active.getHealthData().getDamage());assertEquals(0,stored.getHealthData().getDamage());active.getHealthData().startRepair();active.tick();active.tick();assertFalse(active.getHealthData().isUnderRepair());active.input(List.of(),Keybind.W);active.setController(player);assertTrue(active.isControlled());assertSame(player,active.getController());active.input(List.of(),Keybind.W);active.tick();active.updateModel(model);active.disconnect();assertFalse(active.isControlled()); + } + + @Test void defaultsAndFallbacksSupportEveryProjectileType()throws Exception { + Weapon stored=new Weapon("default",new YamlConfiguration());assertEquals("default",stored.getName());assertEquals(WeaponAimMode.MANUAL,stored.getAimMode());assertEquals(80,stored.getCursorRange());assertNotNull(stored.getLimits());WeaponData none=new WeaponData(null),empty=new WeaponData(new YamlConfiguration());for(WeaponData data:List.of(none,empty)){assertEquals(3,data.getVelocity());assertEquals("none",data.getReloadAnimation());assertEquals("none",data.getShootAnimation());for(SoundArg sound:List.of(SoundArg.SHOOT,SoundArg.RELOAD,SoundArg.RELOAD_START))assertTrue(data.getSounds(sound).isEmpty());assertTrue(data.getParticles().isEmpty());} + for(net.tfminecraft.vehicleframework.enums.Projectile type:net.tfminecraft.vehicleframework.enums.Projectile.values()){YamlConfiguration config=Rig.yaml("type: "+type+"\ncluster: {}\n");Ammunition ammo=Ammunition.create(type.name().toLowerCase(Locale.ROOT),config);assertEquals(type,ammo.getType());assertEquals(type.name().toLowerCase(Locale.ROOT),ammo.getId());assertEquals(ammo.getId(),ammo.getName());if(ammo instanceof Bullet b){assertEquals(80,b.getRange());assertEquals(7,b.getSpeed());assertEquals(-.05,b.getGravity());}if(ammo instanceof FusedExplosive fused)assertEquals(60,fused.getFuse());if(ammo instanceof ClusterBomb cluster){assertEquals(40,cluster.getFuse());assertEquals(4,cluster.getAmount());assertEquals(2,cluster.getSpread());assertNotNull(cluster.getClusterData());}} + var ammo=r.bullet(false).getData();assertEquals(8,Weapon.effectiveDamage((ActiveWeapon)null,ammo));assertEquals(0,Weapon.effectiveDamage((Integer)null,null));assertEquals("PROJECTILE",Weapon.effectiveDamageType((ActiveWeapon)null,null));assertEquals("PROJECTILE",Weapon.effectiveDamageType(" ",ammo));assertEquals(7,Weapon.effectiveProjectileSpeed((ActiveWeapon)null,null));assertEquals(3,Weapon.effectiveProjectileVelocity(null));assertEquals(0,Weapon.effectiveYield((ActiveWeapon)null,null));assertEquals(1,Weapon.effectiveYield((Float)null,ammo));assertEquals(0,Weapon.effectiveRadius((ActiveWeapon)null,null));assertEquals(5,Weapon.effectiveRadius((Integer)null,ammo));assertFalse(Weapon.effectiveExplosive((ActiveWeapon)null,null));assertFalse(Weapon.effectiveExplosive((Boolean)null,ammo));assertEquals(0,Weapon.effectiveClusterAmount((ActiveWeapon)null,null));assertEquals(6,Weapon.effectiveClusterAmount(6,null)); + ActiveWeapon active=active("fixed: true\nprojectile-velocity: 9\n");assertEquals(9,active.effectiveProjectileVelocity());active.setController(player);active.tick();assertEquals(WeaponAimMode.MANUAL,WeaponAimMode.fromConfig("unknown")); + } + + @Test void damageIdentifiersAreLocaleIndependent()throws Exception { + Locale old=Locale.getDefault();try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertEquals("FIRE",new Weapon("gun",Rig.yaml("projectile-damage-type: fire\n")).getProjectileDamageType());assertEquals("FIRE",new AmmunitionData(Rig.yaml("damage-type: fire\n")).getDamageType());}finally{Locale.setDefault(old);} + } + + @Test void ammunitionEffectsGettersAndTrailsPreserveConfiguredTiming()throws Exception { + AmmunitionData data=new AmmunitionData(Rig.yaml(""" + type: BULLET + input: test:round + yield: 2.5 + radius: 7 + damage: 17 + rounds: 4 + damage-type: fire + explosive: true + fire: true + model: {type: item, material: STONE, model-data: 4} + sounds: {flight: {sound: test.flight, delay: 2}} + particles: {trail: {particle: FLAME, amount: 1, spread: 0, y: 1}} + hit-sfx: {impact: {sound: test.impact}} + hit-vfx: {impact: {particle: FLAME, amount: 1, spread: 0, y: 1}} + """));assertEquals("test:round",data.getInput());assertEquals(2.5f,data.getYield());assertEquals(7,data.getRadius());assertEquals(17,data.getDamage());assertEquals(4,data.getRounds());assertEquals("FIRE",data.getDamageType());assertTrue(data.isExplosive());assertTrue(data.isFire());assertEquals(1.5,data.getOffset());assertInstanceOf(ItemModel.class,data.getModel());assertInstanceOf(ArmorStand.class,data.spawn(new Location(r.world,0,64,0))); + Location from=new Location(r.world,0,64,0),to=new Location(r.world,0,64,2);data.fx(List.of(),from,1,0);verify(r.world,never()).playSound(any(Location.class),eq("test.flight"),anyFloat(),anyFloat());data.fx(List.of(),from,1,2);data.trailSegment(List.of(),from,to,1,3);verify(r.world,times(2)).playSound(any(Location.class),eq("test.flight"),eq(1f),eq(1f));data.hitFX(List.of(),to,1);verify(r.world).playSound(eq(to),eq("test.impact"),eq(1f),eq(1f));assertEquals(new Location(r.world,0,64,0),from);assertEquals(new Location(r.world,0,64,2),to); + AmmunitionData empty=new AmmunitionData(new YamlConfiguration());empty.trailSegment(List.of(),null,to,1,0);empty.trailSegment(List.of(),from,null,1,0);empty.trailSegment(List.of(),new Location(null,0,0,0),to,1,0);empty.trailSegment(List.of(),from,new Location(mock(World.class),0,0,0),1,0);empty.trailSegment(List.of(),from,from.clone().add(0,0,.1),1,0);empty.trailSegment(List.of(),from,to,1,0);assertFalse(empty.isFire());assertTrue(empty.isExplosive()); + } + + @Test void lingeringCloudConfigurationParsesEffectsColorsDurationsAndMalformedFallbacks()throws Exception { + var yaml=new YamlConfiguration();yaml.set("potion-effects",List.of("","potion(speed,2,8) color(300,10,20) 12","potion(unknown,1,2)","cloud","potion(poison,999999999999999,5)"));var data=new AmmunitionData(yaml);var clouds=data.getLingeringClouds();assertEquals(4,clouds.size());var first=clouds.getFirst();assertEquals(PotionEffectType.SPEED,first.getEffect().getType());assertEquals(2,first.getEffect().getAmplifier());assertEquals(160,first.getEffect().getDuration());assertEquals(Color.fromRGB(255,10,20),first.getColor());assertEquals(240,first.getCloudDurationTicks());assertEquals(PotionEffectType.POISON,clouds.get(1).getEffect().getType());assertEquals(600,clouds.get(2).getCloudDurationTicks());assertEquals(100,clouds.get(3).getEffect().getDuration());assertEquals(Color.fromRGB(0,255,0),clouds.get(3).getColor()); + try(var explosions=mockStatic(ExplosionCreator.class)){new ProjectileShooter().triggerExplosion(List.of(),new Location(r.world,0,64,0),data,null);assertEquals(4,r.spawned.stream().filter(AreaEffectCloud.class::isInstance).count());AreaEffectCloud cloud=(AreaEffectCloud)r.spawned.getFirst();verify(cloud).setDuration(240);verify(cloud).setRadiusPerTick(-2.5f/240);verify(cloud).addCustomEffect(first.getEffect(),true);} + } + + @Test void itemModelsSpawnAtTheirOffsetsWithConfiguredEquipment()throws Exception { + for(boolean small:List.of(false,true)){ItemModel itemModel=new ItemModel(Rig.yaml("material: stone\nsmall: "+small+"\nmodel-data: 123\n"));Location origin=new Location(r.world,2,70,4);ArmorStand stand=(ArmorStand)itemModel.spawn(origin);assertEquals(Material.STONE,itemModel.getMaterial());assertEquals(123,itemModel.getModel());assertEquals(small,itemModel.isSmall());assertEquals(small?1:1.5,itemModel.getOffset());assertEquals(70-itemModel.getOffset(),stand.getLocation().getY());assertEquals(70,origin.getY());verify(stand).setInvisible(true);verify(stand).setSmall(small);verify(stand).setMarker(false);verify(stand).setGravity(true);var stack=stacks.constructed().getLast();verify(stand.getEquipment()).setHelmet(stack);verify(stack.getItemMeta().getCustomModelDataComponent()).setFloats(List.of(123f));} + var disabled=new ItemModel(Rig.yaml("material: STONE\nmodel-data: -1\n"));disabled.spawn(new Location(r.world,0,64,0));verify(stacks.constructed().getLast(),never()).getItemMeta(); + } + + @Test void modelEngineProjectilesHandleMissingMobModelAnimationAndInvalidAngles()throws Exception { + MythicBukkit mythic=mock(MythicBukkit.class,RETURNS_DEEP_STUBS);MythicMob mob=mock(MythicMob.class);ActiveMob spawned=mock(ActiveMob.class,RETURNS_DEEP_STUBS);Entity entity=r.entity(Entity.class,new Location(r.world,0,64,0));when(spawned.getEntity().getBukkitEntity()).thenReturn(entity);when(mob.spawn(any(),anyDouble())).thenReturn(spawned);ModeledEntity modeled=mock(ModeledEntity.class); + try(var mythicScope=mockStatic(MythicBukkit.class);var engine=mockStatic(ModelEngineAPI.class)){mythicScope.when(MythicBukkit::inst).thenReturn(mythic);when(mythic.getMobManager().getMythicMob("VehicleFrameworkDummy")).thenReturn(Optional.empty());var empty=new MEGModel(new YamlConfiguration());assertEquals("none",empty.getModel());assertEquals(0,empty.getOffset());assertNull(empty.spawn(new Location(r.world,0,64,0)));when(mythic.getMobManager().getMythicMob("VehicleFrameworkDummy")).thenReturn(Optional.of(mob));var engineConfig=mock(com.ticxo.modelengine.api.utils.config.ConfigCache.class);engine.when(ModelEngineAPI::getConfigCache).thenReturn(engineConfig);engine.when(()->ModelEngineAPI.createModeledEntity(entity)).thenReturn(modeled);engine.when(()->ModelEngineAPI.createActiveModel(anyString())).thenReturn(model); + var simple=new MEGModel(Rig.yaml("model: shell\n"));Location invalid=new Location(r.world,0,64,0,Float.NaN,Float.POSITIVE_INFINITY);assertSame(entity,simple.spawn(invalid));verify(entity).setRotation(0,0);verify(modeled).addModel(model,true);var animated=new MEGModel(Rig.yaml("model: shell\nanimation: fly\n"));when(model.getBlueprint().getAnimations()).thenReturn(new HashMap<>());assertSame(entity,animated.spawn(new Location(r.world,0,64,0,30,15)));var animation=new BlueprintAnimation(model.getBlueprint(),"fly");animation.setLength(1);animation.setLoopMode(BlueprintAnimation.LoopMode.LOOP);when(model.getBlueprint().getAnimations()).thenReturn(Map.of("fly",animation));assertSame(entity,animated.spawn(new Location(r.world,0,64,0)));verify(model.getAnimationHandler()).playAnimation(any(),eq(true));assertInstanceOf(MEGModel.class,new AmmunitionData(Rig.yaml("model: {type: meg, model: shell}\n")).getModel());} + } + + @Test void reloadConsumesOneRoundPerMuzzleAndCompletesAfterConfiguredTime()throws Exception { + ActiveWeapon active=active("fixed: true\nbones: [muzzle.aim, muzzle.aim]\naccepted-ammunition: [bullet]\nreload-time: 1\ndata:\n reload-start-sounds: {a: {sound: test.reload.begin}}\n reload-sounds: {a: {sound: test.reload.done}}\n");Bullet ammo=new Bullet("bullet",Rig.yaml("type: BULLET\ninput: test:round\nrounds: 3\n"));AmmunitionLoader.map.put(ammo.getId(),ammo);ItemStack held=item(Material.TNT,5);ItemAPI api=mock(ItemAPI.class,RETURNS_DEEP_STUBS);when(api.getChecker().getAsStringPath(any())).thenReturn("test:round"); + try(var libs=mockStatic(TLibs.class)){libs.when(TLibs::getItemAPI).thenReturn(api);AmmunitionHandler handler=active.getAmmunitionHandler();assertEquals(2,handler.getAmmoAmount());assertEquals(1,handler.getBaseReloadTime());assertEquals(-1,handler.getReloadTime());handler.load(player,held,true);assertEquals(3,held.getAmount());assertEquals(1,handler.getReloadTime());handler.load(player,held,true);assertEquals(3,held.getAmount());verify(player).sendMessage("§eAlready reloading");r.tick();assertEquals(0,handler.getReloadTime());r.ticks(20);assertEquals(6,handler.getCount());assertSame(ammo,handler.getAmmo());assertEquals(-1,handler.getReloadTime());handler.load(player,held,true);verify(player).sendMessage("§eWeapon already loaded");handler.updateModel(model);} + } + + @Test void reloadRejectsAirUnknownWrongStateUnsupportedAndInsufficientRounds()throws Exception { + ActiveWeapon active=active("fixed: true\nbones: [muzzle.aim, invalid, absent.aim, muzzle.absent]\naccepted-ammunition: [bullet]\nreload-states: [ground, invalid]\n");var handler=active.getAmmunitionHandler();assertEquals(1,handler.getAmmoAmount());assertEquals(List.of(State.GROUND),handler.getReloadStates());assertEquals(List.of("bullet"),handler.getAcceptedAmmunition());assertEquals(10,handler.getCooldown());assertEquals(0,handler.getActiveCooldown());ItemAPI api=mock(ItemAPI.class,RETURNS_DEEP_STUBS);when(api.getChecker().getAsStringPath(any())).thenReturn("test:round");ItemStack held=item(Material.TNT,1); + try(var libs=mockStatic(TLibs.class)){libs.when(TLibs::getItemAPI).thenReturn(api);handler.load(player,item(Material.AIR,1),true);handler.load(player,held,true);Bullet ammo=new Bullet("other",Rig.yaml("type: BULLET\ninput: test:round\n"));AmmunitionLoader.map.put("other",ammo);when(vehicle.getCurrentState().getType()).thenReturn(State.FLYING);handler.load(player,held,true);verify(player).sendMessage("§cCannot reload in the flying state");handler.load(player,held,false);when(vehicle.getCurrentState().getType()).thenReturn(State.GROUND);handler.load(player,held,true);verify(player).sendMessage("§eCannot use other for this weapon");AmmunitionLoader.map.clear();AmmunitionLoader.map.put("bullet",new Bullet("bullet",Rig.yaml("type: BULLET\ninput: test:round\n")));held.setAmount(0);handler.load(player,held,true);verify(player).sendMessage("§eYou need 1 of that ammunition to reload this weapon");assertFalse(handler.hasAmmo());assertEquals(0,r.pending());} + } + + @Test void firingAlternatesMuzzlesConsumesRoundsAndRespectsCooldown()throws Exception { + ActiveWeapon active=active("fixed: true\nbones: [muzzle.aim, muzzle.aim]\ncooldown: 0\nanimations: {shoot: [left,right]}\ndata:\n shoot-sounds: {shot: {sound: test.shot}}\n particles: {smoke: {particle: FLAME, amount: 1}}\n");var handler=active.getAmmunitionHandler();handler.shoot(player,List.of());verify(player).sendMessage("§cNo ammo");handler.setAmmo(r.bullet(false),3);for(int i=0;i<3;i++)handler.shoot(player,List.of());assertFalse(handler.hasAmmo());assertEquals(0,handler.getCount());verify(vehicle,times(3)).updateBoard();verify(r.world,times(3)).playSound(any(Location.class),eq("test.shot"),eq(1f),eq(1f)); + ActiveWeapon cooling=active("fixed: true\nbones: [muzzle.aim]\ncooldown: 100\n");cooling.getAmmunitionHandler().setAmmo(r.bullet(false),2);cooling.getAmmunitionHandler().shoot(player,List.of());long until=cooling.getAmmunitionHandler().getActiveCooldown();cooling.getAmmunitionHandler().shoot(player,List.of());assertEquals(1,cooling.getAmmunitionHandler().getCount());assertTrue(until>System.currentTimeMillis()); + } + + @Test void manualControllersRotateAllAxesAndBrokenWeaponsNotifyOnlyOnce()throws Exception { + bone("body",new Location(r.world,0,64,0));bone("head",new Location(r.world,0,64,0)); + for(String axis:List.of("x","y","z")){Weapon stored=new Weapon("turret",Rig.yaml("body-bone: body\nhead-bone: head\nhead-axis: "+axis+"\nbones: [muzzle.aim]\nturn-rate: 2\n"));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null);WeaponMovementController controls=new WeaponMovementController(vehicle,model,active,stored,stored.getLimits());assertSame(bones.get("body"),controls.getBodyBone());assertSame(bones.get("head"),controls.getHeadBone());for(Input input:List.of(Input.WEAPON_UP,Input.WEAPON_DOWN,Input.WEAPON_LEFT,Input.WEAPON_RIGHT,Input.NONE)){var bodyBefore=new org.joml.Quaternionf(panel.getRotator("body").getAnimator().getRotation());var headBefore=new org.joml.Quaternionf(panel.getRotator("head").getAnimator().getRotation());controls.input(List.of(),input,player);if(input==Input.WEAPON_UP||input==Input.WEAPON_DOWN)assertNotEquals(headBefore,panel.getRotator("head").getAnimator().getRotation());else if(input==Input.WEAPON_LEFT||input==Input.WEAPON_RIGHT)assertNotEquals(bodyBefore,panel.getRotator("body").getAnimator().getRotation());else{assertEquals(bodyBefore,panel.getRotator("body").getAnimator().getRotation());assertEquals(headBefore,panel.getRotator("head").getAnimator().getRotation());}}controls.move();controls.normalize();controls.updateModel(model);controls.trackCursor(player);assertTrue(Float.isFinite(panel.getRotator("body").getDriveYaw()));active.getHealthData().setDamage(100);controls.input(List.of(),Input.WEAPON_UP,player);controls.input(List.of(),Input.WEAPON_UP,player);controls.input(List.of(),Input.WEAPON_UP,null);} + verify(player,times(3)).sendMessage(contains("is broken")); + } + + @Test void cursorControllersTrackTargetsOffsetsAndDebugWithoutManualTurning()throws Exception { + bone("body",new Location(r.world,0,64,0));bone("head",new Location(r.world,0,64,0));Cache.weaponAimDebug=true; + for(String axis:List.of("x","z","y")){Weapon stored=new Weapon("cursor",Rig.yaml("body-bone: body\nhead-bone: head\nhead-axis: "+axis+"\naim-mode: cursor\naim-vector: muzzle.aim\nturn-rate: 2\n"));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null);WeaponMovementController controls=new WeaponMovementController(vehicle,model,active,stored,stored.getLimits());controls.trackCursor(null);controls.trackCursor(player);controls.trackCursor(player);player.teleport(new Location(r.world,0,64,0,90,-30));var beforeTarget=new org.joml.Quaternionf(panel.getRotator("head").getAnimator().getRotation());controls.trackCursor(player);assertNotEquals(beforeTarget,panel.getRotator("head").getAnimator().getRotation());controls.move();for(Input input:List.of(Input.WEAPON_UP,Input.WEAPON_DOWN,Input.WEAPON_LEFT,Input.WEAPON_RIGHT))controls.input(List.of(),input,player);active.getHealthData().setDamage(100);controls.trackCursor(player);controls.updateModel(model);assertNotEquals(0,panel.getRotator("body").getDriveYaw());player.teleport(new Location(r.world,0,64,0));} + verify(player,atLeastOnce()).spawnParticle(eq(Particle.END_ROD),any(Location.class),eq(1),eq(0d),eq(0d),eq(0d),eq(0d)); + } + + @Test void aimingIgnoresMissingModelBonesAndInvalidVectorDefinitions()throws Exception { + for(String spec:List.of("absent","absent.aim","muzzle.absent","muzzle.aim")){Weapon stored=new Weapon("bad",Rig.yaml("aim-mode: cursor\naim-vector: "+spec+"\n"));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null);active.setController(player);active.tick();active.updateModel(model);} + bone("weapon_body",new Location(r.world,0,64,0));active("aim-mode: cursor\nbones: []\n").tick();bone("cannon_controller",new Location(r.world,0,64,0));active("aim-mode: cursor\nbones: []\n").tick();active("aim-mode: cursor\nbones: [muzzle.aim]\n").tick();active("fixed: true\naim-mode: cursor\n").tick(); + } + + @Test void controllerCommandsRouteReloadShootCombinedAndWeaponSwitch()throws Exception { + Weapon stored=new Weapon("fixed",Rig.yaml("fixed: true\nbones: [muzzle.aim]\n"));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null);WeaponMovementController controls=new WeaponMovementController(vehicle,model,active,stored,stored.getLimits());for(Input input:List.of(Input.WEAPON_RELOAD,Input.WEAPON_SHOOT,Input.WEAPON_RELOAD_AND_SHOOT,Input.WEAPON_SWITCH,Input.NONE))controls.input(List.of(),input,player);verify(vehicle.getSeat(player)).changeWeapon();verify(vehicle).updateBoard();verify(player,times(2)).sendMessage("§cNo ammo"); + } + + @Test void targetResolverSelectsNearestSolidOrLivingHitAndFiltersPassengers() { + assertNull(WeaponTargetResolver.resolveTarget(null,vehicle,80));Location eye=player.getEyeLocation();Entity root=vehicle.getEntity();LivingEntity passenger=r.entity(LivingEntity.class,eye.clone().add(0,0,1));when(vehicle.getSeatHandler().getPassengers()).thenReturn(List.of(passenger));assertEquals(Set.of(player,root,passenger),WeaponEntityFilters.buildShooterIgnoreSet(player,root,vehicle));assertTrue(WeaponEntityFilters.buildShooterIgnoreSet(null,null,null).isEmpty()); + LivingEntity target=r.entity(LivingEntity.class,eye.clone().add(0,0,5));r.rayTargets.addAll(List.of(player,root,passenger,target));assertEquals(target.getLocation(),WeaponTargetResolver.resolveTarget(player,vehicle,80));when(r.world.rayTraceBlocks(any(),any(),anyDouble(),any(),anyBoolean())).thenReturn(new RayTraceResult(eye.clone().add(0,0,2).toVector()));assertEquals(eye.clone().add(0,0,2),WeaponTargetResolver.resolveTarget(player,vehicle,80));r.rayTargets.clear();assertEquals(eye.clone().add(0,0,2),WeaponTargetResolver.resolveTarget(player,null,80));when(r.world.rayTraceBlocks(any(),any(),anyDouble(),any(),anyBoolean())).thenReturn(null);assertEquals(eye.clone().add(0,0,80),WeaponTargetResolver.resolveTarget(player,null,-1));when(player.getEyeLocation()).thenReturn(new Location(null,1,2,3));assertEquals(new Location(null,1,2,13),WeaponTargetResolver.resolveTarget(player,null,10)); + } + + @Test void aimMathHandlesMissingTargetsOffsetsHysteresisAndHealthScaling() { + var offset=new WeaponAimOffset(10,20,30,40);assertEquals(10,offset.applyToAngles(null,"x").getYaw());assertEquals(25,offset.applyToAngles(new ConvertedAngle(1,5,0),null).getPitch());assertEquals(45,offset.applyToAngles(new ConvertedAngle(1,5,0),"z").getPitch());assertEquals(41,offset.applyToAngles(new ConvertedAngle(1,5,0),"y").getYaw());assertEquals(0,WeaponAimAligner.yawError(null,new Vector(0,0,1)));assertEquals(0,WeaponAimAligner.yawError(new Vector(0,0,1),(ConvertedAngle)null));assertEquals(0,WeaponAimAligner.elevationError(null,new Vector(0,0,1),"x"));assertEquals(0,WeaponAimAligner.elevationError(new Vector(0,0,1),(ConvertedAngle)null,"x"));assertEquals(0,WeaponAimAligner.followStep(2,0,false));assertEquals(0,WeaponAimAligner.followStep(2,1,true));assertEquals(0,WeaponAimAligner.followStep(.5f,1,false));assertTrue(WeaponAimAligner.followStep(-20,2,false)<0);assertTrue(WeaponAimAligner.updateSettled(.5f,false));assertFalse(WeaponAimAligner.updateSettled(20,false));assertTrue(WeaponAimAligner.updateSettled(2,true));assertFalse(WeaponAimAligner.updateSettled(8,true));assertEquals(-2,WeaponAimAligner.toBoneYawStep(2));assertEquals(-2,WeaponAimAligner.toBoneElevationStep(2));assertEquals(3,WeaponPerformance.effectiveTurnRate(3,null));assertEquals(1.5,WeaponPerformance.effectiveTurnRate(3,new HealthData(100,50,1)));assertEquals(1,WeaponPerformance.effectiveReloadSeconds(0,null,2));assertEquals(2,WeaponPerformance.effectiveReloadSeconds(2,null,2));assertEquals(4,WeaponPerformance.effectiveReloadSeconds(2,new HealthData(100,100,1),2));var cooldown=new NoticeCooldown(10);UUID id=UUID.randomUUID();assertTrue(cooldown.tryAcquire(id,0));assertFalse(cooldown.tryAcquire(id,1));assertTrue(cooldown.tryAcquire(id,10)); + } + + @Test void muzzleFlashDoesNotDeleteAReplacementBlock() { + ProjectileShooter shooter=new ProjectileShooter();Location location=new Location(r.world,0,64,0);shooter.lightEffect(location);r.block(0,64,0,Material.STONE);r.ticks(4);assertEquals(Material.STONE,location.getBlock().getType(),"The temporary muzzle light must not erase a subsequently placed block"); + } + + @Test void cursorTrackingHandlesAnUnavailableMuzzleCoincidentTargetAndBrokenElevation()throws Exception { + bone("body",new Location(r.world,0,64,0));bone("head",new Location(r.world,0,64,0));Weapon stored=new Weapon("cursor",Rig.yaml("body-bone: body\nhead-bone: head\naim-mode: cursor\naim-vector: muzzle.aim\n"));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null);WeaponMovementController controls=new WeaponMovementController(vehicle,model,active,stored,stored.getLimits()); + when(bones.get("muzzle").getLocation()).thenReturn(null);assertDoesNotThrow(()->controls.trackCursor(player));when(bones.get("muzzle").getLocation()).thenAnswer(c->new Location(r.world,0,64,0));LivingEntity close=r.entity(LivingEntity.class,new Location(r.world,0,64,0));r.rayTargets.add(close);controls.trackCursor(player);assertEquals(0,panel.getRotator("body").getDriveYaw());r.rayTargets.clear();active.getHealthData().setDamage(100);player.teleport(new Location(r.world,0,64,0,0,-45));controls.trackCursor(player);verify(player).sendMessage(contains("is broken")); + Entity decoration=r.entity(Entity.class,new Location(r.world,0,64,1));LivingEntity dead=r.entity(LivingEntity.class,new Location(r.world,0,64,2));when(dead.isDead()).thenReturn(true);r.rayTargets.addAll(List.of(decoration,dead));assertNotNull(WeaponTargetResolver.resolveTarget(player,vehicle,80)); + } + + @Test void muzzleLightAnimatesThenRemovesOnlyItsTemporaryLight() { + ProjectileShooter shooter=new ProjectileShooter();Location location=new Location(r.world,0,64,0);r.block(0,64,0,Material.STONE);shooter.lightEffect(location);assertEquals(0,r.pending());r.block(0,64,0,Material.AIR);shooter.lightEffect(location);var level=(org.bukkit.block.data.Levelled)location.getBlock().getBlockData();verify(level).setLevel(10);r.tick();r.tick();verify(level).setLevel(15);r.tick();verify(level).setLevel(11);r.tick();assertEquals(Material.AIR,location.getBlock().getType());assertEquals(0,r.pending()); + } + + @Test void cannonballMovesWithGravityAndIsRemovedOnImpact()throws Exception { + Ammunition ammo=ammo("CANNONBALL","explosive: false\n");ProjectileShooter shooter=new ProjectileShooter();shooter.shoot(List.of(),vehicle.getEntity(),new Location(r.world,0,64,0),new Vector(0,0,1),ammo,null);Entity shot=r.spawned.getLast();assertTrue(Cache.projectiles.contains(shot));r.tick();assertEquals(3,shot.getLocation().getZ(),1e-9);assertEquals(63.951,shot.getLocation().getY(),1e-9);when(shot.isOnGround()).thenReturn(true);r.ticks(5);verify(shot).remove();assertFalse(Cache.projectiles.contains(shot));assertEquals(0,r.pending()); + } + + @Test void airborneCannonAndBombSoundsNeverDropBelowTheirPitchFloor()throws Exception { + for(String type:List.of("CANNONBALL","BOMB")){Ammunition ammo=ammo(type,"explosive: false\nfuse: 80\nsounds: {flight: {sound: test.flight, pitched: true}}\n");new ProjectileShooter().shoot(List.of(),vehicle.getEntity(),new Location(r.world,0,1000,0),new Vector(0,0,1),ammo,null);Entity shot=r.spawned.getLast();r.ticks(85);ArgumentCaptor pitches=ArgumentCaptor.forClass(Float.class);verify(r.world,atLeast(85)).playSound(any(Location.class),eq("test.flight"),eq(1f),pitches.capture());assertTrue(pitches.getAllValues().stream().allMatch(p->p>=0.3f));assertTrue(pitches.getAllValues().contains(0.3f));when(shot.isDead()).thenReturn(true);r.tick();} + } + + @Test void bombsInheritVehicleVelocityAndWaitForFuseBeforeGroundImpact()throws Exception { + vehicle.getEntity().setVelocity(new Vector(2,.2,0));Ammunition ammo=ammo("BOMB","fuse: 1\nexplosive: false\n");new ProjectileShooter().shoot(List.of(),vehicle.getEntity(),new Location(r.world,0,64,0),new Vector(0,0,1),ammo,null);Entity shot=r.spawned.getLast();when(shot.isOnGround()).thenReturn(true);r.tick();assertEquals(2,shot.getLocation().getX(),1e-9);assertEquals(64.151,shot.getLocation().getY(),1e-9);verify(shot,never()).remove();r.tick();verify(shot).remove();assertFalse(Cache.projectiles.contains(shot)); + } + + @Test void clusterFuseCreatesConfiguredChildrenAndCleansThemOnImpact()throws Exception { + ClusterBomb ammo=(ClusterBomb)ammo("CLUSTER","fuse: 2\namount: 2\nspread: 0\nexplosive: false\ncluster:\n explosive: false\n model: {type: item, material: STONE}\n");new ProjectileShooter().shoot(List.of(player),vehicle.getEntity(),new Location(r.world,0,64,0),new Vector(0,0,1),ammo,null);Entity shell=r.spawned.getFirst();r.ticks(2);verify(shell).remove();assertEquals(3,r.spawned.size());assertEquals(2,Cache.projectiles.size());for(Entity child:r.spawned.subList(1,3)){assertEquals(new Vector(0,-3.5,0),child.getVelocity());when(child.isOnGround()).thenReturn(true);}r.ticks(7);assertTrue(Cache.projectiles.isEmpty());for(Entity child:r.spawned.subList(1,3))verify(child).remove();verify(player).spawnParticle(eq(Particle.EXPLOSION_EMITTER),any(Location.class),eq(15),eq(0d),eq(0d),eq(0d),eq(0d)); + } + + @Test void clusterImpactAtFuseBoundaryDetonatesOnlyOnce()throws Exception { + ClusterBomb ammo=(ClusterBomb)ammo("CLUSTER","fuse: 6\namount: 0\ncluster: {model: {type: item, material: STONE}}\n");try(var explosions=mockStatic(ExplosionCreator.class)){new ProjectileShooter().shoot(List.of(),vehicle.getEntity(),new Location(r.world,0,64,0),new Vector(0,0,1),ammo,null);Entity shell=r.spawned.getFirst();when(shell.isOnGround()).thenReturn(true);r.ticks(6);explosions.verify(()->ExplosionCreator.triggerExplosion(any(Location.class),eq(1d),eq(5d),eq(8d),eq("PROJECTILE"),eq(false)),times(1));verify(shell,times(1)).remove();} + } + + @Test void torpedoesLevelAccelerateInWaterAndExpireAfterTheirFuse()throws Exception { + for(boolean waterlogged:List.of(false,true)){Location launch=new Location(r.world,waterlogged?10:0,64,0);var block=r.block(launch.getBlockX(),63,0,waterlogged?Material.OAK_SLAB:Material.WATER);if(waterlogged){var data=mock(org.bukkit.block.data.Waterlogged.class);when(data.isWaterlogged()).thenReturn(true);when(block.getBlockData()).thenReturn(data);}Ammunition ammo=ammo("TORPEDO","fuse: 10\nexplosive: false\nmodel: {type: item, material: STONE, small: true}\n");new ProjectileShooter().shoot(List.of(),vehicle.getEntity(),launch,new Vector(0,.1,1),ammo,null);Entity torpedo=r.spawned.getLast();r.tick();assertEquals(0,torpedo.getVelocity().getY(),1e-9);assertEquals(3.4,torpedo.getVelocity().getZ(),.003);r.ticks(10);verify(torpedo).remove();assertFalse(Cache.projectiles.contains(torpedo));} + } + + @Test void bulletShooterStopsAtRangeOrCollisionAndProducesInterpolatedTrails()throws Exception { + Bullet shortRange=new Bullet("short",Rig.yaml("type: BULLET\nrange: 1\nspeed: 2\ngravity: 0\n"));new ProjectileShooter().shoot(List.of(),vehicle.getEntity(),new Location(r.world,0,64,0),new Vector(0,0,1),shortRange,null);r.tick();r.ticks(3);assertEquals(0,r.pending());r.block(0,64,2,Material.STONE);new ProjectileShooter().shoot(List.of(),vehicle.getEntity(),new Location(r.world,0,64,0),new Vector(0,0,1),shortRange,null);r.tick();r.ticks(3);assertEquals(0,r.pending());verify(r.world).playSound(any(Location.class),eq(Sound.BLOCK_STONE_BREAK),eq(1f),eq(2f)); + } + + private ActiveWeapon active(String yaml)throws Exception{return new ActiveWeapon(model,vehicle,new Weapon("test",Rig.yaml(yaml)),null);} + private Ammunition ammo(String type,String yaml)throws Exception{YamlConfiguration config=Rig.yaml("type: "+type+"\n"+yaml);if(!config.contains("model")){config.set("model.type","item");config.set("model.material","STONE");}return Ammunition.create("round",config);} + private ItemStack item(Material material,int amount){ItemStack stack=mock(ItemStack.class);AtomicInteger count=new AtomicInteger(amount);when(stack.getType()).thenReturn(material);when(stack.getAmount()).thenAnswer(c->count.get());doAnswer(c->{count.set(c.getArgument(0));return null;}).when(stack).setAmount(anyInt());return stack;} + private ModelBone bone(String id,Location location){ModelBone b=mock(ModelBone.class,RETURNS_DEEP_STUBS);when(b.getLocalTransform().getSafeLeftQuaternion()).thenReturn(new org.joml.Quaternionf());when(b.getLocalTransform().getSafePosition()).thenReturn(new org.joml.Vector3f());when(b.getLocalTransform().getSafeScale()).thenReturn(new org.joml.Vector3f(1));when(b.getBoneId()).thenReturn(id);when(b.getLocation()).thenAnswer(c->location.clone());when(b.getActiveModel()).thenReturn(model);bones.put(id,b);return b;} +} diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker index fdbd0b1..1f0955d 100644 --- a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -1 +1 @@ -mock-maker-subclass +mock-maker-inline From 4aba80ecfac16c6c25ec81da87bb81d1694304c1 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:22:05 +0000 Subject: [PATCH 2/5] fix: preserve vehicle configuration on startup and reload failures --- .../vehicleframework/VehicleFramework.java | 17 +- .../vehicleframework/cache/Cache.java | 41 +- .../loaders/AmmunitionLoader.java | 103 +- .../vehicleframework/loaders/FuelLoader.java | 110 +- .../loaders/VehicleLoader.java | 93 +- .../managers/RepairManager.java | 2 +- .../MetricsIntegrationTest.java | 4 +- .../VehicleFrameworkCoverageTest.java | 30 + .../bones/BoneRuntimeCoverageTest.java | 365 +++- .../data/RuntimeValuesCoverageTest.java | 531 +++++- .../database/VehicleBackupCoverageTest.java | 4 + .../effects/EffectsCoverageTest.java | 214 ++- .../ConfigurationLoadersCoverageTest.java | 1033 +++++++++--- .../managers/SpawnAndRepairCoverageTest.java | 15 + .../projectiles/ProjectilesCoverageTest.java | 658 ++++++-- .../tracks/TrackPersistenceCoverageTest.java | 12 +- .../util/RuntimeUtilitiesCoverageTest.java | 1499 ++++++++++++++--- .../handlers/TrainReversePlacementTest.java | 10 + .../weapons/WeaponsCoverageTest.java | 1089 ++++++++++-- 19 files changed, 4792 insertions(+), 1038 deletions(-) diff --git a/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java b/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java index be45ee5..ae6c8f3 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java +++ b/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java @@ -187,8 +187,7 @@ public void loadConfigs() { configLoader.load(new File(getDataFolder(), "config.yml")); trainsLoader.load(new File(getDataFolder(), "trains.yml")); VFLogger.info("Loading fuel..."); - FuelLoader.get().clear(); - fuelLoader.load(new File(getDataFolder(), "fuel.yml")); + fuelLoader.reload(new File(getDataFolder(), "fuel.yml")); weaponTemplateLoader.clear(); VFLogger.info("Loading weapon templates..."); weaponTemplateLoader.loadFolder(new File(getDataFolder(), "templates/weapons")); @@ -202,21 +201,11 @@ public void loadConfigs() { deathTemplateLoader.loadFolder(new File(getDataFolder(), "templates/death")); File folder = new File(getDataFolder(), "vehicles"); VFLogger.info("Loading vehicles..."); - VehicleLoader.get().clear(); - for (final File file : folder.listFiles()) { - if(!file.isDirectory()) { - vehicleLoader.load(file); - } - } + vehicleLoader.reload(folder); folder = new File(getDataFolder(), "ammunition"); VFLogger.info("Loading ammunition..."); - AmmunitionLoader.get().clear(); - for (final File file : folder.listFiles()) { - if(!file.isDirectory()) { - ammunitionLoader.load(file); - } - } + ammunitionLoader.reload(folder); GroundEngineLog.configure(Cache.groundEngineLogging, Cache.wipeLog, getDataFolder()); TrackLog.configure(Cache.debugLogging, Cache.debugLogging, getDataFolder()); PersistenceLog.configure(Cache.persistenceLogging, getDataFolder()); diff --git a/src/main/java/net/tfminecraft/vehicleframework/cache/Cache.java b/src/main/java/net/tfminecraft/vehicleframework/cache/Cache.java index aceefa9..94ff1b4 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/cache/Cache.java +++ b/src/main/java/net/tfminecraft/vehicleframework/cache/Cache.java @@ -20,30 +20,31 @@ public class Cache { public static HashMap convertExplode = new HashMap<>(); - public static int despawnDistance; + // Keep the same defaults as an empty config if the first YAML load fails. + public static int despawnDistance = 64 * 64; - public static boolean blockDamage; + public static boolean blockDamage = true; public static Set lightLocations = new HashSet<>(); - public static String skinItem; - public static String repairItem; - public static String destroyItem; - public static String ticketItem; - public static String trackItemSmall; - public static String trackItemMedium; - public static String trackItemLarge; + public static String skinItem = "v.bucket"; + public static String repairItem = "v.iron_shovel"; + public static String destroyItem = "v.stone_axe"; + public static String ticketItem = "v.paper"; + public static String trackItemSmall = "ia.tfmc:track_small"; + public static String trackItemMedium = "ia.tfmc:track_medium"; + public static String trackItemLarge = "ia.tfmc:track_large"; public static String appliedTrackItemSmall; public static String appliedTrackItemMedium; public static String appliedTrackItemLarge; public static double appliedTrackDisplayYOffset = 0.5; public static int trackResyncChunksPerTick = 2; - public static String trackLayerItem; - public static String trackRemoverItem; - public static String trackRecorderItem; - public static String trackJunctionItem; - public static String trackSwitchItem; - public static String trackItem; + public static String trackLayerItem = "v.iron_shovel"; + public static String trackRemoverItem = "v.iron_pickaxe"; + public static String trackRecorderItem = "v.clock"; + public static String trackJunctionItem = "v.diamond_shovel"; + public static String trackSwitchItem = "ia.tfmc:railroad_switch"; + public static String trackItem = "ia.tfmc:train_track"; public static double trackSwitchOffsetAlong = -1.2; public static double trackSwitchOffsetOut = 2.4; public static double trackSwitchOffsetY = 0; @@ -69,8 +70,8 @@ public class Cache { public static double trackJunctionArmDistance = 16; public static double trackDesiredGradeDegrees = 6; public static double trackMaxGradeDegrees = 10; - public static Particle trackFxParticle; - public static Material trackFxBlock; + public static Particle trackFxParticle = Particle.BLOCK; + public static Material trackFxBlock = Material.GRAVEL; public static int trackFxCount = 3; public static int trackFxWidth = 3; public static double trackFxExtra = 0.06; @@ -87,15 +88,15 @@ public class Cache { public static String trackBuildSound2 = "minecraft:block.iron.place"; public static float trackBuildSound2Volume = 1.0f; public static float trackBuildSound2Pitch = 2.0f; - public static Particle trackBuildParticle; - public static Material trackBuildBlock; + public static Particle trackBuildParticle = Particle.BLOCK; + public static Material trackBuildBlock = Material.GRAVEL; public static int trackBuildCount = 6; public static int trackBuildWidth = 1; public static double trackBuildExtra = 0.08; public static double trackBuildYOffset = 0.08; public static boolean enableLogging; - public static String mythicMob; + public static String mythicMob = "none"; public static boolean allowWhitelist; public static boolean whitelistedByDefault; diff --git a/src/main/java/net/tfminecraft/vehicleframework/loaders/AmmunitionLoader.java b/src/main/java/net/tfminecraft/vehicleframework/loaders/AmmunitionLoader.java index 74011f6..d6810ed 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/loaders/AmmunitionLoader.java +++ b/src/main/java/net/tfminecraft/vehicleframework/loaders/AmmunitionLoader.java @@ -2,55 +2,72 @@ import java.io.File; import java.io.IOException; -import java.util.ArrayList; import java.util.HashMap; -import java.util.List; import java.util.Map; -import java.util.Set; - +import net.tfminecraft.tlibs.interfaces.LoaderInterface; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.weapons.ammunition.Ammunition; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; -import net.tfminecraft.tlibs.interfaces.LoaderInterface; -import net.tfminecraft.vehicleframework.weapons.ammunition.Ammunition; +public class AmmunitionLoader implements LoaderInterface { + + public static HashMap map = new HashMap<>(); + + public static HashMap get() { + return map; + } + + @Override + public void load(File configFile) { + try { + map.putAll(read(configFile)); + } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { + VFLogger.log("Failed to load ammunition from " + configFile + ": " + e.getMessage()); + } + } + + public void reload(File folder) { + try { + File[] files = folder.listFiles(File::isFile); + if (files == null) throw new IOException("Cannot list " + folder); + Map replacement = new HashMap<>(); + for (File file : files) replacement.putAll(read(file)); + map.clear(); + map.putAll(replacement); + } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { + VFLogger.log( + "Failed to reload ammunition from " + + folder + + "; keeping existing definitions: " + + e.getMessage()); + } + } + + private Map read(File configFile) + throws IOException, InvalidConfigurationException { + FileConfiguration config = new YamlConfiguration(); + config.load(configFile); + Map loaded = new HashMap<>(); + for (String key : config.getKeys(false)) { + if (!config.isConfigurationSection(key)) { + throw new IllegalArgumentException("Ammunition '" + key + "' must be a section"); + } + loaded.put(key, Ammunition.create(key, config.getConfigurationSection(key))); + } + return loaded; + } -public class AmmunitionLoader implements LoaderInterface{ - - public static HashMap map = new HashMap<>(); - - public static HashMap get(){ - return map; - } - - @Override - public void load(File configFile) { - FileConfiguration config = new YamlConfiguration(); - try { - config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); - } - Set set = config.getKeys(false); - - List list = new ArrayList(set); - - for(String key : list) { - Ammunition o = Ammunition.create(key, config.getConfigurationSection(key)); - map.put(key, o); - } - } - - public static Ammunition getByString(String id) { - if(map.containsKey(id)) return map.get(id); - return null; - } - - public static Ammunition getByInput(String s) { - for(Map.Entry entry : map.entrySet()) { - if(entry.getValue().getData().getInput().equalsIgnoreCase(s)) return entry.getValue(); - } - return null; - } + public static Ammunition getByString(String id) { + if (map.containsKey(id)) return map.get(id); + return null; + } + public static Ammunition getByInput(String s) { + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue().getData().getInput().equalsIgnoreCase(s)) return entry.getValue(); + } + return null; + } } diff --git a/src/main/java/net/tfminecraft/vehicleframework/loaders/FuelLoader.java b/src/main/java/net/tfminecraft/vehicleframework/loaders/FuelLoader.java index 624326d..f439541 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/loaders/FuelLoader.java +++ b/src/main/java/net/tfminecraft/vehicleframework/loaders/FuelLoader.java @@ -2,61 +2,75 @@ import java.io.File; import java.io.IOException; -import java.util.ArrayList; import java.util.HashMap; -import java.util.List; import java.util.Map; -import java.util.Set; - +import net.tfminecraft.tlibs.interfaces.LoaderInterface; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.vehicles.fuel.Fuel; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; -import net.tfminecraft.tlibs.interfaces.LoaderInterface; -import net.tfminecraft.vehicleframework.vehicles.fuel.Fuel; +public class FuelLoader implements LoaderInterface { + public static HashMap map = new HashMap<>(); + + public static HashMap get() { + return map; + } + + @Override + public void load(File configFile) { + try { + map.putAll(read(configFile)); + } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { + VFLogger.log("Failed to load fuel from " + configFile + ": " + e.getMessage()); + } + } + + public void reload(File configFile) { + try { + Map replacement = read(configFile); + map.clear(); + map.putAll(replacement); + } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { + VFLogger.log( + "Failed to reload fuel from " + + configFile + + "; keeping existing definitions: " + + e.getMessage()); + } + } -public class FuelLoader implements LoaderInterface{ - public static HashMap map = new HashMap<>(); - - public static HashMap get(){ - return map; - } - - @Override - public void load(File configFile) { - - FileConfiguration config = new YamlConfiguration(); - try { - config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); - } - Set set = config.getKeys(false); - - List list = new ArrayList(set); - - for(String key : list) { - Fuel o = new Fuel(key, config.getConfigurationSection(key)); - map.put(key, o); - } - } - - public static Fuel getByString(String id) { - if(map.containsKey(id)) return map.get(id); - return null; - } - - public static Fuel getByInput(String item) { - for(Map.Entry entry : map.entrySet()) { - if(entry.getValue().getItem().equalsIgnoreCase(item)) return entry.getValue(); - } - return null; + private Map read(File configFile) + throws IOException, InvalidConfigurationException { + FileConfiguration config = new YamlConfiguration(); + config.load(configFile); + Map loaded = new HashMap<>(); + for (String key : config.getKeys(false)) { + if (!config.isConfigurationSection(key)) { + throw new IllegalArgumentException("Fuel '" + key + "' must be a section"); + } + loaded.put(key, new Fuel(key, config.getConfigurationSection(key))); } + return loaded; + } + + public static Fuel getByString(String id) { + if (map.containsKey(id)) return map.get(id); + return null; + } - public static boolean itemIsFuel(String item) { - for(Map.Entry entry : map.entrySet()) { - if(entry.getValue().getItem().equalsIgnoreCase(item)) return true; - } - return false; - } + public static Fuel getByInput(String item) { + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue().getItem().equalsIgnoreCase(item)) return entry.getValue(); + } + return null; + } + + public static boolean itemIsFuel(String item) { + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue().getItem().equalsIgnoreCase(item)) return true; + } + return false; + } } diff --git a/src/main/java/net/tfminecraft/vehicleframework/loaders/VehicleLoader.java b/src/main/java/net/tfminecraft/vehicleframework/loaders/VehicleLoader.java index b08632b..a5a00fe 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/loaders/VehicleLoader.java +++ b/src/main/java/net/tfminecraft/vehicleframework/loaders/VehicleLoader.java @@ -2,46 +2,71 @@ import java.io.File; import java.io.IOException; -import java.util.ArrayList; import java.util.HashMap; -import java.util.List; -import java.util.Set; - +import java.util.Map; +import net.tfminecraft.tlibs.interfaces.LoaderInterface; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.vehicles.Vehicle; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; -import net.tfminecraft.tlibs.interfaces.LoaderInterface; -import net.tfminecraft.vehicleframework.vehicles.Vehicle; +public class VehicleLoader implements LoaderInterface { + public static HashMap map = new HashMap<>(); -public class VehicleLoader implements LoaderInterface{ - public static HashMap map = new HashMap<>(); - - public static HashMap get(){ - return map; - } - - @Override - public void load(File configFile) { - - FileConfiguration config = new YamlConfiguration(); - try { - config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); - } - Set set = config.getKeys(false); + public static HashMap get() { + return map; + } + + @Override + public void load(File configFile) { + try { + map.putAll(read(configFile)); + } catch (IOException | InvalidConfigurationException e) { + VFLogger.log("Failed to load vehicles from " + configFile + ": " + e.getMessage()); + } + } - List list = new ArrayList(set); - - for(String key : list) { - Vehicle o = new Vehicle(key, config.getConfigurationSection(key)); - map.put(key, o); - } - } + public void reload(File folder) { + try { + File[] files = folder.listFiles(File::isFile); + if (files == null) throw new IOException("Cannot list " + folder); + Map replacement = new HashMap<>(); + for (File file : files) replacement.putAll(read(file)); + map.clear(); + map.putAll(replacement); + } catch (IOException | InvalidConfigurationException e) { + VFLogger.log( + "Failed to reload vehicles from " + + folder + + "; keeping existing definitions: " + + e.getMessage()); + } + } + + private Map read(File configFile) + throws IOException, InvalidConfigurationException { + FileConfiguration config = new YamlConfiguration(); + config.load(configFile); + Map loaded = new HashMap<>(); + for (String key : config.getKeys(false)) { + try { + if (!config.isConfigurationSection(key)) { + throw new IllegalArgumentException("Vehicle must be a section"); + } + loaded.put(key, new Vehicle(key, config.getConfigurationSection(key))); + } catch (RuntimeException e) { + VFLogger.log( + "Skipping vehicle " + key + " in " + configFile.getName() + ": " + e.getMessage()); + // A bad edit must not discard a definition already used by saved vehicles. + if (map.containsKey(key)) loaded.put(key, map.get(key)); + } + } + return loaded; + } - public static Vehicle getByString(String id) { - if(map.containsKey(id)) return map.get(id); - return null; - } + public static Vehicle getByString(String id) { + if (map.containsKey(id)) return map.get(id); + return null; + } } diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/RepairManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/RepairManager.java index 0a7e01f..782aeb4 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/RepairManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/RepairManager.java @@ -167,7 +167,7 @@ private static boolean startAllowed(Player p, ActiveVehicle v) { } public void repairComponent(Player p, ItemMeta m, NamespacedKey key, ActiveVehicle v ) { - Component type = Component.valueOf(m.getPersistentDataContainer().get(key, PersistentDataType.STRING).toUpperCase()); + Component type = Component.valueOf(m.getPersistentDataContainer().get(key, PersistentDataType.STRING).toUpperCase(java.util.Locale.ROOT)); VehicleComponent c = v.getComponent(type); if(c == null) return; if(isBeingRepaired(c)) { diff --git a/src/test/java/net/tfminecraft/vehicleframework/MetricsIntegrationTest.java b/src/test/java/net/tfminecraft/vehicleframework/MetricsIntegrationTest.java index 0b0b732..f23048d 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/MetricsIntegrationTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/MetricsIntegrationTest.java @@ -19,13 +19,13 @@ class MetricsIntegrationTest { Path config=directory.resolve("bStats/config.yml"); Files.createDirectories(config.getParent()); String original="enabled: false\nserverUuid: retained-server-id\nlogFailedRequests: true\n"; Files.writeString(config,original); - Metrics metrics=new Metrics(plugin(),23136); + Metrics metrics=new Metrics(plugin(),26823); try { assertEquals(original,Files.readString(config)); } finally { metrics.shutdown(); } } @Test void dependencyCreatesACompatibleConfigurationOnFirstStartup() { - Metrics metrics=new Metrics(plugin(),23136); + Metrics metrics=new Metrics(plugin(),26823); try { YamlConfiguration config=YamlConfiguration.loadConfiguration(directory.resolve("bStats/config.yml").toFile()); assertTrue(config.getBoolean("enabled")); assertNotNull(config.getString("serverUuid")); diff --git a/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java index 6ae8cfc..65cba3f 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java @@ -8,6 +8,7 @@ import java.nio.file.*; import java.util.*; import java.util.logging.Logger; +import org.bstats.bukkit.Metrics; import org.bukkit.*; import org.bukkit.command.PluginCommand; import org.bukkit.event.Listener; @@ -92,7 +93,12 @@ class VehicleFrameworkCoverageTest { } @Test void startupCreatesDefaultsRegistersSystemsAndClosesItsDatabase() throws Exception { + MockedConstruction metrics = keep(mockConstruction(Metrics.class, (mock, context) -> { + assertSame(plugin, context.arguments().get(0)); + assertEquals(Integer.valueOf(26823), context.arguments().get(1)); + })); plugin.onEnable(); + assertEquals(1, metrics.constructed().size()); assertSame(plugin, VehicleFramework.getInstance()); assertNotNull(VehicleFramework.getVehicleRepository()); assertNotNull(VehicleFramework.getTrackRegistry()); assertNotNull(VehicleFramework.getTrackDisplayManager()); assertNotNull(VehicleFramework.getPacketListener()); assertNotNull(VehicleFramework.getLog()); @@ -130,6 +136,30 @@ class VehicleFrameworkCoverageTest { assertTrue(VehicleLoader.get().isEmpty()); assertTrue(AmmunitionLoader.get().isEmpty()); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"vehicles/old.yml", "ammunition/old.yml", "fuel.yml"}) + void malformedReloadRetainsTheLastWorkingDefinitions(String failedFile) throws Exception { + plugin.createFolders(); + plugin.createConfigs(); + Files.writeString(temp.resolve("plugin/vehicles/old.yml"), + "old_cart: {model: default, skins: {}, states: {}, components: {}, seats: []}\n"); + Files.writeString(temp.resolve("plugin/ammunition/old.yml"), "old_shell: {}\n"); + Files.writeString(temp.resolve("plugin/fuel.yml"), "old_fuel: {}\n"); + plugin.onEnable(); + Object previous = failedFile.startsWith("vehicles") ? VehicleLoader.getByString("old_cart") + : failedFile.startsWith("ammunition") ? AmmunitionLoader.getByString("old_shell") + : FuelLoader.getByString("old_fuel"); + assertNotNull(previous); + + Files.writeString(temp.resolve("plugin/" + failedFile), "broken: [unterminated\n"); + assertDoesNotThrow(plugin::reload); + + Object retained = failedFile.startsWith("vehicles") ? VehicleLoader.getByString("old_cart") + : failedFile.startsWith("ammunition") ? AmmunitionLoader.getByString("old_shell") + : FuelLoader.getByString("old_fuel"); + assertSame(previous, retained, "A failed load must not discard the last usable registry"); + } + @Test void databaseOpenFailureDisablesThePluginBeforeRegisteringListeners() throws Exception { Files.createDirectory(temp.resolve("plugin")); Files.writeString(temp.resolve("plugin/data"), "not a directory"); plugin.onEnable(); assertNull(VehicleFramework.getVehicleRepository()); diff --git a/src/test/java/net/tfminecraft/vehicleframework/bones/BoneRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/bones/BoneRuntimeCoverageTest.java index f5ee995..f1d1831 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/bones/BoneRuntimeCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/bones/BoneRuntimeCoverageTest.java @@ -4,74 +4,345 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -import java.util.*; -import org.bukkit.*; -import org.bukkit.entity.Player; -import org.bukkit.util.Vector; -import org.joml.Quaternionf; -import org.junit.jupiter.api.*; -import org.mockito.ArgumentCaptor; -import com.ticxo.modelengine.api.model.ActiveModel; -import com.ticxo.modelengine.api.model.bone.ModelBone; import com.comphenix.protocol.ProtocolLibrary; import com.comphenix.protocol.ProtocolManager; import com.comphenix.protocol.events.*; import com.comphenix.protocol.reflect.StructureModifier; +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.bone.ModelBone; +import java.util.*; import net.tfminecraft.vehicleframework.enums.Keybind; import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig; import net.tfminecraft.vehicleframework.protocol.PacketConverter; import net.tfminecraft.vehicleframework.protocol.VehiclePacketListener; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; import net.tfminecraft.vehicleframework.vehicles.util.AccessPanel; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; +import org.joml.Quaternionf; +import org.junit.jupiter.api.*; +import org.mockito.ArgumentCaptor; class BoneRuntimeCoverageTest { - Rig r;ActiveVehicle vehicle;ActiveModel model;AccessPanel panel;Map bones; - @BeforeEach void setup(){r=new Rig();vehicle=r.vehicle();panel=new AccessPanel();when(vehicle.getAccessPanel()).thenReturn(panel);model=mock(ActiveModel.class,RETURNS_DEEP_STUBS);bones=new HashMap<>();when(model.getBone(anyString())).thenAnswer(c->Optional.ofNullable(bones.get(c.getArgument(0))));} - @AfterEach void cleanup(){r.close();} + Rig r; + ActiveVehicle vehicle; + ActiveModel model; + AccessPanel panel; + Map bones; - @Test void normalizationPreservesUnselectedAxes() { - BoneRotator rotator=rotator("body",new RotationLimits());rotator.setRotation(30,0,0,true,true,true);rotator.normalize(true,false,false);assertEquals(30,rotator.getConvertedAngles().getYaw(),.001,"Normalizing X must preserve Y rotation");rotator.normalize(false,true,false);assertEquals(0,rotator.getConvertedAngles().getYaw(),.001); - } + @BeforeEach + void setup() { + r = new Rig(); + vehicle = r.vehicle(); + panel = new AccessPanel(); + when(vehicle.getAccessPanel()).thenReturn(panel); + model = mock(ActiveModel.class, RETURNS_DEEP_STUBS); + bones = new HashMap<>(); + when(model.getBone(anyString())) + .thenAnswer(c -> Optional.ofNullable(bones.get(c.getArgument(0)))); + } - @Test void normalizationSnapsToNeutralWithoutOvershooting() { - BoneRotator rotator=rotator("body",new RotationLimits());rotator.setRotation(40,0,0,true,true,true);rotator.normalize(true,true,true);assertEquals(0,rotator.getConvertedAngles().getYaw(),.001,"Interpolation past 1 mirrors the angle instead of resetting it");assertEquals(0,rotator.getDriveYaw(),.001); - } + @AfterEach + void cleanup() { + r.close(); + } - @Test void directionAnglesWrapAndKeepInputVectorsIntact() { - ConvertedAngle explicit=new ConvertedAngle(10,20,30);assertEquals(10,explicit.getYaw());assertEquals(20,explicit.getPitch());assertEquals(30,explicit.getRoll());assertEquals(90,new ConvertedAngle(new Quaternionf().rotateY((float)Math.PI/2)).getYaw(),.001);assertEquals(30,new ConvertedAngle(new Quaternionf().rotateX((float)Math.PI/6)).getPitch(),.001);assertEquals(45,new ConvertedAngle(new Quaternionf().rotateZ((float)Math.PI/4)).getRoll(),.001);assertEquals(0,ConvertedAngle.fromDirection(null).getYaw());assertEquals(0,ConvertedAngle.fromDirection(new Vector()).getPitch());Vector direction=new Vector(2,0,0);assertEquals(-90,ConvertedAngle.fromDirection(direction).getYaw(),.001);assertEquals(new Vector(2,0,0),direction);assertEquals(-90,ConvertedAngle.fromDirection(new Vector(0,1,0)).getPitch(),.001);assertEquals(-90,ConvertedAngle.wrapDegrees(270));assertEquals(90,ConvertedAngle.wrapDegrees(-270));assertEquals(180,ConvertedAngle.wrapDegrees(-180));assertEquals(20,ConvertedAngle.shortestDelta(170,-170)); - } + @Test + void normalizationPreservesUnselectedAxes() { + BoneRotator rotator = rotator("body", new RotationLimits()); + rotator.setRotation(30, 0, 0, true, true, true); + rotator.normalize(true, false, false); + assertEquals( + 30, rotator.getConvertedAngles().getYaw(), .001, "Normalizing X must preserve Y rotation"); + rotator.normalize(false, true, false); + assertEquals(0, rotator.getConvertedAngles().getYaw(), .001); + } - @Test void rotationLimitsApplyOptionalAxesDefaultsWrappingAndClamps()throws Exception { - RotationLimits none=new RotationLimits();assertTrue(none.withinAll(300,100,200));assertEquals(300,none.clampYaw(300));assertEquals(100,none.clampPitch(100));assertEquals(200,none.clampRoll(200));RotationLimits configured=new RotationLimits(Rig.yaml("min-yaw: -30\nmax-yaw: 30\nmin-pitch: -45\nmax-pitch: 45\nmin-roll: -20\nmax-roll: 20\n"));assertEquals(-30,configured.getMinYaw());assertEquals(30,configured.getMaxYaw());assertEquals(-45,configured.getMinPitch());assertEquals(45,configured.getMaxPitch());assertEquals(-20,configured.getMinRoll());assertEquals(20,configured.getMaxRoll());assertTrue(configured.withinAll(30,45,20));assertTrue(configured.withinYaw(350));assertTrue(configured.withinYaw(-350));assertFalse(configured.withinYaw(40));assertFalse(configured.withinYaw(-40));assertFalse(configured.withinAll(0,46,0));assertFalse(configured.withinAll(0,0,21));assertEquals(30,configured.clampYaw(90));assertEquals(-45,configured.clampPitch(-90));assertEquals(20,configured.clampRoll(50));assertEquals(10,configured.clampYaw(10)); - RotationLimits partial=new RotationLimits(Rig.yaml("max-yaw: 30\nmin-pitch: -40\nmax-roll: 10\n"));assertEquals(-180,partial.getMinYaw());assertEquals(90,partial.getMaxPitch());assertEquals(-180,partial.getMinRoll());assertTrue(new RotationLimits(Rig.yaml("")).withinAll(400,200,300));assertFalse(RotationLimits.yawOnly(-1,1).withinYaw(2));assertTrue(RotationLimits.yawOnly(-1,1).withinPitch(200));assertFalse(RotationLimits.pitchOnly(-1,1).withinPitch(2));assertTrue(RotationLimits.pitchOnly(-1,1).withinRoll(200));assertFalse(RotationLimits.rollOnly(-1,1).withinRoll(2));assertTrue(RotationLimits.rollOnly(-1,1).withinYaw(200)); - } + @Test + void normalizationSnapsToNeutralWithoutOvershooting() { + BoneRotator rotator = rotator("body", new RotationLimits()); + rotator.setRotation(40, 0, 0, true, true, true); + rotator.normalize(true, true, true); + assertEquals( + 0, + rotator.getConvertedAngles().getYaw(), + .001, + "Interpolation past 1 mirrors the angle instead of resetting it"); + assertEquals(0, rotator.getDriveYaw(), .001); + } - @Test void boneRotationsMaintainDriveHeadingLimitsSmoothingAndEntityFacing()throws Exception { - BoneRotator rotator=rotator("body",RotationLimits.yawOnly(-5,5));assertEquals("body",rotator.getId());assertSame(bones.get("body"),rotator.getBone());assertSame(rotator,panel.getRotator("body"));rotator.rotate(0,2,0);assertEquals(0,rotator.getDriveYaw(),.001);rotator.rotate(0,.1,0);assertEquals(Math.toDegrees(.01),rotator.getDriveYaw(),.001);rotator.rotate(0,0,0);rotator.reset();assertEquals(0,rotator.getDriveYaw(),.001);rotator.rotateSmoothed(0,10,0);assertEquals(Math.toDegrees(.05),rotator.getDriveYaw(),.001);rotator.resetSmoothing();rotator.reset();rotator.rotateSmoothed(0,10,0);assertEquals(Math.toDegrees(.05),rotator.getDriveYaw(),.001);rotator.rotateEntity(25,-10);assertEquals(25,vehicle.getEntity().getLocation().getYaw());assertEquals(-10,vehicle.getEntity().getLocation().getPitch());assertEquals(64,vehicle.getEntity().getLocation().getY());assertTrue(Double.isFinite(rotator.getAngles().angle));Quaternionf heading=new Quaternionf().rotateY((float)Math.toRadians(20));rotator.rawSet(heading.x,heading.y,heading.z,heading.w);assertEquals(20,rotator.getDriveYaw(),.001);ModelBone replacement=bone("body",new Location(r.world,0,64,0));rotator.updateModel(model);assertSame(replacement,rotator.getBone());verify(replacement).setManualAnimator(rotator.getAnimator());assertEquals(20,rotator.getConvertedAngles().getYaw(),.001);assertTrue(rotator.rotateToTarget(20,0,0,1,true,true,true));assertFalse(rotator.rotateToTarget(0,10,0,.5f,false,true,false));assertTrue(rotator.getConvertedAngles().getPitch()>0); - } + @Test + void directionAnglesWrapAndKeepInputVectorsIntact() { + ConvertedAngle explicit = new ConvertedAngle(10, 20, 30); + assertEquals(10, explicit.getYaw()); + assertEquals(20, explicit.getPitch()); + assertEquals(30, explicit.getRoll()); + assertEquals( + 90, new ConvertedAngle(new Quaternionf().rotateY((float) Math.PI / 2)).getYaw(), .001); + assertEquals( + 30, new ConvertedAngle(new Quaternionf().rotateX((float) Math.PI / 6)).getPitch(), .001); + assertEquals( + 45, new ConvertedAngle(new Quaternionf().rotateZ((float) Math.PI / 4)).getRoll(), .001); + assertEquals(0, ConvertedAngle.fromDirection(null).getYaw()); + assertEquals(0, ConvertedAngle.fromDirection(new Vector()).getPitch()); + Vector direction = new Vector(2, 0, 0); + assertEquals(-90, ConvertedAngle.fromDirection(direction).getYaw(), .001); + assertEquals(new Vector(2, 0, 0), direction); + assertEquals(-90, ConvertedAngle.fromDirection(new Vector(0, 1, 0)).getPitch(), .001); + assertEquals(-90, ConvertedAngle.wrapDegrees(270)); + assertEquals(90, ConvertedAngle.wrapDegrees(-270)); + assertEquals(180, ConvertedAngle.wrapDegrees(-180)); + assertEquals(20, ConvertedAngle.shortestDelta(170, -170)); + } - @Test void vectorBonesTrackReplacementModelsAndCoincidentFallbacks() { - ModelBone base=bone("base",new Location(r.world,1,64,1)),align=bone("aim",new Location(r.world,1,64,3));VectorBone vector=new VectorBone(base,align);assertEquals("base",vector.getId());assertSame(base,vector.getBase());assertSame(align,vector.getAlign());assertSame(model,vector.getModel());assertEquals(new Location(r.world,1,64,1),vector.getBaseLocation());assertEquals(new Location(r.world,1,64,3),vector.getAlignLocation());assertEquals(new Vector(0,0,1),vector.getVector());ModelBone replacement=bone("base",new Location(r.world,1,64,3));vector.updateModel(model);assertSame(replacement,vector.getBase());assertEquals(new Vector(1,0,0),vector.getVector()); - } + @Test + void rotationLimitsApplyOptionalAxesDefaultsWrappingAndClamps() throws Exception { + RotationLimits none = new RotationLimits(); + assertTrue(none.withinAll(300, 100, 200)); + assertEquals(300, none.clampYaw(300)); + assertEquals(100, none.clampPitch(100)); + assertEquals(200, none.clampRoll(200)); + RotationLimits configured = + new RotationLimits( + Rig.yaml( + "min-yaw: -30\n" + + "max-yaw: 30\n" + + "min-pitch: -45\n" + + "max-pitch: 45\n" + + "min-roll: -20\n" + + "max-roll: 20\n")); + assertEquals(-30, configured.getMinYaw()); + assertEquals(30, configured.getMaxYaw()); + assertEquals(-45, configured.getMinPitch()); + assertEquals(45, configured.getMaxPitch()); + assertEquals(-20, configured.getMinRoll()); + assertEquals(20, configured.getMaxRoll()); + assertTrue(configured.withinAll(30, 45, 20)); + assertTrue(configured.withinYaw(350)); + assertTrue(configured.withinYaw(-350)); + assertFalse(configured.withinYaw(40)); + assertFalse(configured.withinYaw(-40)); + assertFalse(configured.withinAll(0, 46, 0)); + assertFalse(configured.withinAll(0, 0, 21)); + assertEquals(30, configured.clampYaw(90)); + assertEquals(-45, configured.clampPitch(-90)); + assertEquals(20, configured.clampRoll(50)); + assertEquals(10, configured.clampYaw(10)); + RotationLimits partial = + new RotationLimits(Rig.yaml("max-yaw: 30\nmin-pitch: -40\nmax-roll: 10\n")); + assertEquals(-180, partial.getMinYaw()); + assertEquals(90, partial.getMaxPitch()); + assertEquals(-180, partial.getMinRoll()); + assertTrue(new RotationLimits(Rig.yaml("")).withinAll(400, 200, 300)); + assertFalse(RotationLimits.yawOnly(-1, 1).withinYaw(2)); + assertTrue(RotationLimits.yawOnly(-1, 1).withinPitch(200)); + assertFalse(RotationLimits.pitchOnly(-1, 1).withinPitch(2)); + assertTrue(RotationLimits.pitchOnly(-1, 1).withinRoll(200)); + assertFalse(RotationLimits.rollOnly(-1, 1).withinRoll(2)); + assertTrue(RotationLimits.rollOnly(-1, 1).withinYaw(200)); + } - @Test void rotationTargetsBecomeActiveAtTheTargetAndReactivateAfterConditionsChange()throws Exception { - BoneRotator base=rotator("base",new RotationLimits());RotationTarget stored=new RotationTarget(Rig.yaml("yaw: 30\npitch: 0\nroll: 0\ninterval: 1\nconditions: [has_fuel(true)]\n"));assertEquals("none",stored.getRotatorOverride());assertEquals(List.of("has_fuel(true)"),stored.getConditions());assertEquals(30,stored.getYaw());assertEquals(0,stored.getPitch());assertEquals(0,stored.getRoll());assertTrue(stored.shouldYaw());assertTrue(stored.shouldPitch());assertTrue(stored.shouldRoll());assertEquals(1,stored.getInterval());RotationTarget active=new RotationTarget(vehicle,stored,base);assertSame(base,active.getRotator());assertFalse(active.isActive());active.run(vehicle);assertEquals(0,base.getDriveYaw());when(vehicle.hasFuel()).thenReturn(true);active.run(vehicle);assertFalse(active.isActive());active.run(vehicle);assertTrue(active.isActive());active.run(vehicle);assertEquals(30,base.getDriveYaw(),.001);active.updateModel();when(vehicle.hasFuel()).thenReturn(false);active.run(vehicle);assertFalse(active.isActive());when(vehicle.hasFuel()).thenReturn(true);active.run(vehicle);assertTrue(active.isActive()); - BoneRotator override=rotator("override",new RotationLimits());RotationTarget selected=new RotationTarget(vehicle,new RotationTarget(Rig.yaml("rotator: override\n")),base);assertSame(override,selected.getRotator());assertFalse(selected.shouldYaw());assertFalse(selected.shouldPitch());assertFalse(selected.shouldRoll());RotationTarget missing=new RotationTarget(vehicle,new RotationTarget(Rig.yaml("rotator: missing\n")),base);assertSame(base,missing.getRotator());selected.setRotator(base);assertSame(base,selected.getRotator()); - } + @Test + void boneRotationsMaintainDriveHeadingLimitsSmoothingAndEntityFacing() throws Exception { + BoneRotator rotator = rotator("body", RotationLimits.yawOnly(-5, 5)); + assertEquals("body", rotator.getId()); + assertSame(bones.get("body"), rotator.getBone()); + assertSame(rotator, panel.getRotator("body")); + rotator.rotate(0, 2, 0); + assertEquals(0, rotator.getDriveYaw(), .001); + rotator.rotate(0, .1, 0); + assertEquals(Math.toDegrees(.01), rotator.getDriveYaw(), .001); + rotator.rotate(0, 0, 0); + rotator.reset(); + assertEquals(0, rotator.getDriveYaw(), .001); + rotator.rotateSmoothed(0, 10, 0); + assertEquals(Math.toDegrees(.05), rotator.getDriveYaw(), .001); + rotator.resetSmoothing(); + rotator.reset(); + rotator.rotateSmoothed(0, 10, 0); + assertEquals(Math.toDegrees(.05), rotator.getDriveYaw(), .001); + rotator.rotateEntity(25, -10); + assertEquals(25, vehicle.getEntity().getLocation().getYaw()); + assertEquals(-10, vehicle.getEntity().getLocation().getPitch()); + assertEquals(64, vehicle.getEntity().getLocation().getY()); + assertTrue(Double.isFinite(rotator.getAngles().angle)); + Quaternionf heading = new Quaternionf().rotateY((float) Math.toRadians(20)); + rotator.rawSet(heading.x, heading.y, heading.z, heading.w); + assertEquals(20, rotator.getDriveYaw(), .001); + ModelBone replacement = bone("body", new Location(r.world, 0, 64, 0)); + rotator.updateModel(model); + assertSame(replacement, rotator.getBone()); + verify(replacement).setManualAnimator(rotator.getAnimator()); + assertEquals(20, rotator.getConvertedAngles().getYaw(), .001); + assertTrue(rotator.rotateToTarget(20, 0, 0, 1, true, true, true)); + assertFalse(rotator.rotateToTarget(0, 10, 0, .5f, false, true, false)); + assertTrue(rotator.getConvertedAngles().getPitch() > 0); + } - @Test void packetConversionDistinguishesHeldJumpSneakAndOppositeAxes() { - PacketConverter converter=new PacketConverter();assertEquals(List.of(Keybind.A,Keybind.W),converter.convert(1,1,false,false));assertEquals(List.of(Keybind.D,Keybind.S),converter.convert(-1,-1,false,false));assertEquals(List.of(),converter.convert(0,0,false,false));assertEquals(List.of(Keybind.SPACE_A,Keybind.SPACE_W,Keybind.SPACE),converter.convert(1,1,true,false));assertEquals(List.of(Keybind.SPACE_D,Keybind.SPACE_S,Keybind.SPACE),converter.convert(-1,-1,true,false));assertEquals(List.of(Keybind.SHIFT_A,Keybind.SHIFT_W,Keybind.SHIFT,Keybind.SPACE),converter.convert(1,1,true,true));assertEquals(List.of(Keybind.SHIFT_D,Keybind.SHIFT_S,Keybind.SHIFT),converter.convert(-1,-1,false,true));assertEquals(List.of(Keybind.SHIFT),converter.convert(0,0,false,true)); - } + @Test + void vectorBonesTrackReplacementModelsAndCoincidentFallbacks() { + ModelBone base = bone("base", new Location(r.world, 1, 64, 1)), + align = bone("aim", new Location(r.world, 1, 64, 3)); + VectorBone vector = new VectorBone(base, align); + assertEquals("base", vector.getId()); + assertSame(base, vector.getBase()); + assertSame(align, vector.getAlign()); + assertSame(model, vector.getModel()); + assertEquals(new Location(r.world, 1, 64, 1), vector.getBaseLocation()); + assertEquals(new Location(r.world, 1, 64, 3), vector.getAlignLocation()); + assertEquals(new Vector(0, 0, 1), vector.getVector()); + ModelBone replacement = bone("base", new Location(r.world, 1, 64, 3)); + vector.updateModel(model); + assertSame(replacement, vector.getBase()); + assertEquals(new Vector(1, 0, 0), vector.getVector()); + } - @Test void packetListenerReplaysHeldInputsUntilQuitAndCancelsOppositeDirections() { - Server server=mock(Server.class);when(server.getVersion()).thenReturn("Paper (MC: 1.21.10)");when(server.getBukkitVersion()).thenReturn("1.21.10-R0.1-SNAPSHOT");r.bukkit.when(Bukkit::getServer).thenReturn(server);r.bukkit.when(Bukkit::getVersion).thenReturn("Paper (MC: 1.21.10)");r.bukkit.when(Bukkit::getBukkitVersion).thenReturn("1.21.10-R0.1-SNAPSHOT");r.bukkit.when(Bukkit::getLogger).thenReturn(java.util.logging.Logger.getAnonymousLogger());ProtocolManager protocol=mock(ProtocolManager.class); - try(var library=mockStatic(ProtocolLibrary.class);var reflection=mockStatic(com.comphenix.protocol.utility.MinecraftReflection.class);var nativeStacks=mockConstruction(org.bukkit.inventory.ItemStack.class)){ - reflection.when(com.comphenix.protocol.utility.MinecraftReflection::getItemStackClass).thenReturn(org.bukkit.inventory.ItemStack.class); - reflection.when(com.comphenix.protocol.utility.MinecraftReflection::getBlockClass).thenReturn(org.bukkit.block.Block.class); - library.when(ProtocolLibrary::getProtocolManager).thenReturn(protocol);VehiclePacketListener listener=new VehiclePacketListener(r.manager);listener.register();ArgumentCaptor capture=ArgumentCaptor.forClass(PacketListener.class);verify(protocol).addPacketListener(capture.capture());PacketListener adapter=capture.getValue();Player driver=r.entity(Player.class,new Location(r.world,0,64,0)),other=r.entity(Player.class,new Location(r.world,1,64,0));r.bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(driver,other));PacketEvent event=mock(PacketEvent.class,RETURNS_DEEP_STUBS);when(event.getPlayer()).thenReturn(driver);StructureModifier structures=mock(StructureModifier.class);when(event.getPacket().getStructures()).thenReturn(structures);when(structures.size()).thenReturn(0);adapter.onPacketReceiving(event);r.tick();verify(r.manager,never()).inputPacket(any(),anyFloat(),anyFloat(),anyBoolean(),anyBoolean());InternalStructure input=mock(InternalStructure.class,RETURNS_DEEP_STUBS);when(structures.size()).thenReturn(1);when(structures.read(0)).thenReturn(input);boolean[] flags={true,false,true,false,true,false};when(input.getBooleans().read(anyInt())).thenAnswer(c->flags[c.getArgument(0)]);adapter.onPacketReceiving(event);r.ticks(2);verify(r.manager,times(2)).inputPacket(driver,1,1,true,false);Arrays.fill(flags,true);adapter.onPacketReceiving(event);r.tick();verify(r.manager).inputPacket(driver,0,0,true,true);Arrays.fill(flags,false);flags[1]=true;flags[3]=true;adapter.onPacketReceiving(event);r.tick();verify(r.manager).inputPacket(driver,-1,-1,false,false);listener.unregisterPlayer(driver);clearInvocations(r.manager);r.tick();verifyNoInteractions(r.manager); - } + @Test + void rotationTargetsBecomeActiveAtTheTargetAndReactivateAfterConditionsChange() throws Exception { + BoneRotator base = rotator("base", new RotationLimits()); + RotationTarget stored = + new RotationTarget( + Rig.yaml("yaw: 30\npitch: 0\nroll: 0\ninterval: 1\nconditions: [has_fuel(true)]\n")); + assertEquals("none", stored.getRotatorOverride()); + assertEquals(List.of("has_fuel(true)"), stored.getConditions()); + assertEquals(30, stored.getYaw()); + assertEquals(0, stored.getPitch()); + assertEquals(0, stored.getRoll()); + assertTrue(stored.shouldYaw()); + assertTrue(stored.shouldPitch()); + assertTrue(stored.shouldRoll()); + assertEquals(1, stored.getInterval()); + RotationTarget active = new RotationTarget(vehicle, stored, base); + assertSame(base, active.getRotator()); + assertFalse(active.isActive()); + active.run(vehicle); + assertEquals(0, base.getDriveYaw()); + when(vehicle.hasFuel()).thenReturn(true); + active.run(vehicle); + assertFalse(active.isActive()); + active.run(vehicle); + assertTrue(active.isActive()); + active.run(vehicle); + assertEquals(30, base.getDriveYaw(), .001); + active.updateModel(); + when(vehicle.hasFuel()).thenReturn(false); + active.run(vehicle); + assertFalse(active.isActive()); + when(vehicle.hasFuel()).thenReturn(true); + active.run(vehicle); + assertTrue(active.isActive()); + BoneRotator override = rotator("override", new RotationLimits()); + RotationTarget selected = + new RotationTarget(vehicle, new RotationTarget(Rig.yaml("rotator: override\n")), base); + assertSame(override, selected.getRotator()); + assertFalse(selected.shouldYaw()); + assertFalse(selected.shouldPitch()); + assertFalse(selected.shouldRoll()); + RotationTarget missing = + new RotationTarget(vehicle, new RotationTarget(Rig.yaml("rotator: missing\n")), base); + assertSame(base, missing.getRotator()); + selected.setRotator(base); + assertSame(base, selected.getRotator()); + } + + @Test + void packetConversionDistinguishesHeldJumpSneakAndOppositeAxes() { + PacketConverter converter = new PacketConverter(); + assertEquals(List.of(Keybind.A, Keybind.W), converter.convert(1, 1, false, false)); + assertEquals(List.of(Keybind.D, Keybind.S), converter.convert(-1, -1, false, false)); + assertEquals(List.of(), converter.convert(0, 0, false, false)); + assertEquals( + List.of(Keybind.SPACE_A, Keybind.SPACE_W, Keybind.SPACE), + converter.convert(1, 1, true, false)); + assertEquals( + List.of(Keybind.SPACE_D, Keybind.SPACE_S, Keybind.SPACE), + converter.convert(-1, -1, true, false)); + assertEquals( + List.of(Keybind.SHIFT_A, Keybind.SHIFT_W, Keybind.SHIFT, Keybind.SPACE), + converter.convert(1, 1, true, true)); + assertEquals( + List.of(Keybind.SHIFT_D, Keybind.SHIFT_S, Keybind.SHIFT), + converter.convert(-1, -1, false, true)); + assertEquals(List.of(Keybind.SHIFT), converter.convert(0, 0, false, true)); + } + + @Test + void packetListenerReplaysHeldInputsUntilQuitAndCancelsOppositeDirections() { + Server server = mock(Server.class); + when(server.getVersion()).thenReturn("Paper (MC: 1.21.10)"); + when(server.getBukkitVersion()).thenReturn("1.21.10-R0.1-SNAPSHOT"); + r.bukkit.when(Bukkit::getServer).thenReturn(server); + r.bukkit.when(Bukkit::getVersion).thenReturn("Paper (MC: 1.21.10)"); + r.bukkit.when(Bukkit::getBukkitVersion).thenReturn("1.21.10-R0.1-SNAPSHOT"); + r.bukkit.when(Bukkit::getLogger).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + ProtocolManager protocol = mock(ProtocolManager.class); + try (var library = mockStatic(ProtocolLibrary.class); + var reflection = mockStatic(com.comphenix.protocol.utility.MinecraftReflection.class); + var nativeStacks = mockConstruction(org.bukkit.inventory.ItemStack.class)) { + reflection + .when(com.comphenix.protocol.utility.MinecraftReflection::getItemStackClass) + .thenReturn(org.bukkit.inventory.ItemStack.class); + reflection + .when(com.comphenix.protocol.utility.MinecraftReflection::getBlockClass) + .thenReturn(org.bukkit.block.Block.class); + library.when(ProtocolLibrary::getProtocolManager).thenReturn(protocol); + VehiclePacketListener listener = new VehiclePacketListener(r.manager); + listener.register(); + ArgumentCaptor capture = ArgumentCaptor.forClass(PacketListener.class); + verify(protocol).addPacketListener(capture.capture()); + PacketListener adapter = capture.getValue(); + Player driver = r.entity(Player.class, new Location(r.world, 0, 64, 0)), + other = r.entity(Player.class, new Location(r.world, 1, 64, 0)); + r.bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(driver, other)); + PacketEvent event = mock(PacketEvent.class, RETURNS_DEEP_STUBS); + when(event.getPlayer()).thenReturn(driver); + StructureModifier structures = mock(StructureModifier.class); + when(event.getPacket().getStructures()).thenReturn(structures); + when(structures.size()).thenReturn(0); + adapter.onPacketReceiving(event); + r.tick(); + verify(r.manager, never()) + .inputPacket(any(), anyFloat(), anyFloat(), anyBoolean(), anyBoolean()); + InternalStructure input = mock(InternalStructure.class, RETURNS_DEEP_STUBS); + when(structures.size()).thenReturn(1); + when(structures.read(0)).thenReturn(input); + boolean[] flags = {true, false, true, false, true, false}; + when(input.getBooleans().read(anyInt())).thenAnswer(c -> flags[c.getArgument(0)]); + adapter.onPacketReceiving(event); + r.ticks(2); + verify(r.manager, times(2)).inputPacket(driver, 1, 1, true, false); + Arrays.fill(flags, true); + adapter.onPacketReceiving(event); + r.tick(); + verify(r.manager).inputPacket(driver, 0, 0, true, true); + Arrays.fill(flags, false); + flags[1] = true; + flags[3] = true; + adapter.onPacketReceiving(event); + r.tick(); + verify(r.manager).inputPacket(driver, -1, -1, false, false); + listener.unregisterPlayer(driver); + clearInvocations(r.manager); + r.tick(); + verifyNoInteractions(r.manager); } + } + + private BoneRotator rotator(String id, RotationLimits limits) { + return new BoneRotator( + vehicle, vehicle.getEntity(), bone(id, new Location(r.world, 0, 64, 0)), limits); + } - private BoneRotator rotator(String id,RotationLimits limits){return new BoneRotator(vehicle,vehicle.getEntity(),bone(id,new Location(r.world,0,64,0)),limits);} - private ModelBone bone(String id,Location location){ModelBone bone=mock(ModelBone.class,RETURNS_DEEP_STUBS);when(bone.getBoneId()).thenReturn(id);when(bone.getActiveModel()).thenReturn(model);when(bone.getLocation()).thenAnswer(c->location.clone());when(bone.getLocalTransform().getSafeLeftQuaternion()).thenReturn(new Quaternionf());when(bone.getLocalTransform().getSafePosition()).thenReturn(new org.joml.Vector3f());when(bone.getLocalTransform().getSafeScale()).thenReturn(new org.joml.Vector3f(1));bones.put(id,bone);return bone;} + private ModelBone bone(String id, Location location) { + ModelBone bone = mock(ModelBone.class, RETURNS_DEEP_STUBS); + when(bone.getBoneId()).thenReturn(id); + when(bone.getActiveModel()).thenReturn(model); + when(bone.getLocation()).thenAnswer(c -> location.clone()); + when(bone.getLocalTransform().getSafeLeftQuaternion()).thenReturn(new Quaternionf()); + when(bone.getLocalTransform().getSafePosition()).thenReturn(new org.joml.Vector3f()); + when(bone.getLocalTransform().getSafeScale()).thenReturn(new org.joml.Vector3f(1)); + bones.put(id, bone); + return bone; + } } diff --git a/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java index 838d6a7..282ade8 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java @@ -6,97 +6,508 @@ import java.util.*; import java.util.logging.Logger; -import org.bukkit.*; -import org.bukkit.entity.*; -import org.bukkit.plugin.java.JavaPlugin; -import org.bukkit.util.Vector; -import org.junit.jupiter.api.*; import net.tfminecraft.vehicleframework.VFLogger; import net.tfminecraft.vehicleframework.cache.Cache; import net.tfminecraft.vehicleframework.enums.*; import net.tfminecraft.vehicleframework.events.*; import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.*; class RuntimeValuesCoverageTest { - Rig r; - @BeforeEach void setup(){r=new Rig();} - @AfterEach void cleanup(){if(r!=null)r.close();} - - @Test void colocatedSoundSourceKeepsItsConfiguredPitch()throws Exception { - Location source=new Location(r.world,0,64,0);Player player=r.entity(Player.class,source); - SoundData sound=new SoundData(Rig.yaml("sound: engine\ndoppler: true\npitch: 1.2\n"));sound.playSound(List.of(player),source,new Vector(),1); - verify(player).playSound(source,"engine",1f,1.2f); - } + Rig r; - @Test void damageCauseIdsUseRootLocaleForBothConfigurationForms() { - Locale previous=Locale.getDefault();try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertTrue(new DamageData(List.of("fire(0.5)")).hasModifier("FIRE"));assertTrue(new DamageData(Map.of("fire",.5)).hasModifier("FIRE"));}finally{Locale.setDefault(previous);} - } + @BeforeEach + void setup() { + r = new Rig(); + } - @Test void particleIdsUseRootLocaleRatherThanSilentlyChangingTheEffect()throws Exception { - Locale previous=Locale.getDefault();try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertEquals(Particle.CRIT,new ParticleData(Rig.yaml("particle: crit\n")).getParticle());}finally{Locale.setDefault(previous);} - } + @AfterEach + void cleanup() { + if (r != null) r.close(); + } - @Test void deathOverrideIdsUseRootLocale()throws Exception { - Locale previous=Locale.getDefault();try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertEquals(VehicleDeath.SINK,new DeathOverride(Rig.yaml("type: sink\n")).getDeath());}finally{Locale.setDefault(previous);} - } + @Test + void colocatedSoundSourceKeepsItsConfiguredPitch() throws Exception { + Location source = new Location(r.world, 0, 64, 0); + Player player = r.entity(Player.class, source); + SoundData sound = new SoundData(Rig.yaml("sound: engine\ndoppler: true\npitch: 1.2\n")); + sound.playSound(List.of(player), source, new Vector(), 1); + verify(player).playSound(source, "engine", 1f, 1.2f); + } - @Test void damageModifiersReadNumbersStringsNullMapsAndNullEntries() { - DamageData list=new DamageData(List.of("EXPLOSION(0.5)","FIRE(2)"));assertEquals(.5,list.getModifier("EXPLOSION"));assertTrue(list.hasModifier("FIRE"));assertFalse(list.hasModifier("OTHER"));assertEquals(2,list.getModifiers().size());assertTrue(new DamageData((List)null).getModifiers().isEmpty());assertTrue(new DamageData((Map)null).getModifiers().isEmpty());Map values=new LinkedHashMap<>();values.put(null,1);values.put("skip",null);values.put("projectile",2);values.put("fire","0.25");DamageData mapped=new DamageData(values);assertEquals(Map.of("PROJECTILE",2d,"FIRE",.25d),mapped.getModifiers());assertThrows(NumberFormatException.class,()->new DamageData(Map.of("fire","invalid"))); + @Test + void damageCauseIdsUseRootLocaleForBothConfigurationForms() { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertTrue(new DamageData(List.of("fire(0.5)")).hasModifier("FIRE")); + assertTrue(new DamageData(Map.of("fire", .5)).hasModifier("FIRE")); + } finally { + Locale.setDefault(previous); } + } - @Test void repairCountsDownClampsDamageAndReportsHealthBands() { - HealthData health=new HealthData(100,5,2);assertEquals(100,health.getHealth());assertEquals(5,health.getDamage());assertEquals(2,health.getBaseRepairTime());assertEquals(-1,health.getRepairTime());assertFalse(health.isUnderRepair());health.tick();health.startRepair();assertTrue(health.isUnderRepair());assertEquals("0.1s",health.getRepairString());health.tick();assertEquals(1,health.getRepairTime());health.tick();assertFalse(health.isUnderRepair());assertEquals(0,health.getDamage(),"Every configured repair restores at least seven health");health.startRepair();health.stopRepair();assertFalse(health.isUnderRepair());health.setDamage(120);assertEquals(100,health.getDamage());health.setDamage(95);health.damage(20);assertEquals(100,health.getDamage()); - int[] percentages={90,70,50,30,10};String[] colors={"§2","§a","§e","§c","§4"};for(int i=0;i(List.of("alex")));owner.addToWhiteList("sam");owner.removeFromWhiteList("alex");assertEquals(List.of("sam"),owner.getWhiteList());owner.setWhiteListed(false);assertFalse(owner.isWhiteListed());assertFalse(owner.isTicketsEnabled());assertNull(owner.getTicketId());owner.setTicketId(" ");assertNull(owner.getTicketId());owner.setTicketsEnabled(true);String generated=owner.getTicketId();assertNotNull(UUID.fromString(generated));owner.toggleTickets();assertFalse(owner.isTicketsEnabled());owner.toggleTickets();assertEquals(generated,owner.getTicketId());owner.setTicketId("ticket-1");assertEquals("ticket-1",owner.getTicketId());owner.setTicketId(null);assertNull(owner.getTicketId());Cache.allowWhitelist=false;assertFalse(new OwnerData().isWhiteListed());Cache.allowWhitelist=true;Cache.whitelistedByDefault=false;assertFalse(new OwnerData().isWhiteListed());}finally{Cache.allowWhitelist=allow;Cache.whitelistedByDefault=defaults;} + @Test + void deathOverrideIdsUseRootLocale() throws Exception { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertEquals(VehicleDeath.SINK, new DeathOverride(Rig.yaml("type: sink\n")).getDeath()); + } finally { + Locale.setDefault(previous); } + } - @Test void namingTimeoutAndStoredSummariesRetainTheirPublicValues() { - ActiveVehicle vehicle=r.vehicle();NamingData naming=new NamingData(vehicle);assertSame(vehicle,naming.getVehicle());for(int i=0;i<30;i++)assertFalse(naming.tick());assertTrue(naming.tick());assertTrue(naming.tick());StoredVehicleMeta stored=new StoredVehicleMeta("uuid","Wagon","cart","player_alex");assertEquals("uuid",stored.getUuid());assertEquals("Wagon",stored.getName());assertEquals("cart",stored.getTypeId());assertEquals("player_alex",stored.getOwner());for(String owner:Arrays.asList(null,""," ","none","player_","player_none","company_acme"))assertFalse(StoredVehicleMeta.isPlayerOwner(owner));assertTrue(StoredVehicleMeta.isPlayerOwner("PLAYER_alex"));Location location=new Location(r.world,1,64,2);OwnedVehicleSummary summary=new OwnedVehicleSummary("uuid","Wagon","cart",Optional.of(location),true,"player_alex");assertEquals("uuid",summary.getUuid());assertEquals("Wagon",summary.getName());assertEquals("cart",summary.getTypeId());assertEquals(Optional.of(location),summary.getLocation());assertTrue(summary.isSpawned());assertEquals("player_alex",summary.getOwner());OwnedVehicleSummary unloaded=new OwnedVehicleSummary("id","Stored","cart",null,false);assertFalse(unloaded.isSpawned());assertTrue(unloaded.getLocation().isEmpty());assertNull(unloaded.getOwner()); - } + @Test + void damageModifiersReadNumbersStringsNullMapsAndNullEntries() { + DamageData list = new DamageData(List.of("EXPLOSION(0.5)", "FIRE(2)")); + assertEquals(.5, list.getModifier("EXPLOSION")); + assertTrue(list.hasModifier("FIRE")); + assertFalse(list.hasModifier("OTHER")); + assertEquals(2, list.getModifiers().size()); + assertTrue(new DamageData((List) null).getModifiers().isEmpty()); + assertTrue(new DamageData((Map) null).getModifiers().isEmpty()); + Map values = new LinkedHashMap<>(); + values.put(null, 1); + values.put("skip", null); + values.put("projectile", 2); + values.put("fire", "0.25"); + DamageData mapped = new DamageData(values); + assertEquals(Map.of("PROJECTILE", 2d, "FIRE", .25d), mapped.getModifiers()); + assertThrows(NumberFormatException.class, () -> new DamageData(Map.of("fire", "invalid"))); + } - @Test void deathConfigurationExposesSoundsFragmentsAndConditionalOverrides()throws Exception { - DeathData empty=new DeathData(VehicleDeath.DIE,Rig.yaml(""));assertEquals(VehicleDeath.DIE,empty.getType());assertEquals(0,empty.getDuration());assertEquals(0,empty.getFragments());assertTrue(empty.getSfx().isEmpty());assertFalse(empty.hasOverrides());DeathData configured=new DeathData(VehicleDeath.EXPLODE,Rig.yaml("duration: 20\nfragments: 3\nsounds: {bang: {sound: custom.bang}}\noverrides:\n water:\n type: sink\n conditions: [state(floating)]\n"));assertEquals(20,configured.getDuration());assertEquals(3,configured.getFragments());assertEquals("custom.bang",configured.getSfx().getFirst().getSound());assertTrue(configured.hasOverrides());assertEquals(VehicleDeath.SINK,configured.getOverrides().getFirst().getDeath());assertEquals(List.of("state(floating)"),configured.getOverrides().getFirst().getConditions()); + @Test + void repairCountsDownClampsDamageAndReportsHealthBands() { + HealthData health = new HealthData(100, 5, 2); + assertEquals(100, health.getHealth()); + assertEquals(5, health.getDamage()); + assertEquals(2, health.getBaseRepairTime()); + assertEquals(-1, health.getRepairTime()); + assertFalse(health.isUnderRepair()); + health.tick(); + health.startRepair(); + assertTrue(health.isUnderRepair()); + assertEquals("0.1s", health.getRepairString()); + health.tick(); + assertEquals(1, health.getRepairTime()); + health.tick(); + assertFalse(health.isUnderRepair()); + assertEquals(0, health.getDamage(), "Every configured repair restores at least seven health"); + health.startRepair(); + health.stopRepair(); + assertFalse(health.isUnderRepair()); + health.setDamage(120); + assertEquals(100, health.getDamage()); + health.setDamage(95); + health.damage(20); + assertEquals(100, health.getDamage()); + int[] percentages = {90, 70, 50, 30, 10}; + String[] colors = {"§2", "§a", "§e", "§c", "§4"}; + for (int i = 0; i < percentages.length; i++) { + health.setDamage(100 - percentages[i]); + assertEquals(percentages[i], health.getHealthPercentage()); + assertEquals( + "§fHealth: " + colors[i] + percentages[i] + "%", health.getHealthPercentageString()); } + } - @Test void removalPayloadsDistinguishDeathFromUnloadAndRequireDeathCause() { - assertThrows(IllegalArgumentException.class,()->VehicleRemovePayload.death(null));VehicleRemovePayload death=VehicleRemovePayload.death(VehicleDeath.CRASH);assertEquals(VehicleRemoveType.DEATH,death.getType());assertTrue(death.isDeath());assertFalse(death.isRemove());assertEquals(Optional.of(VehicleDeath.CRASH),death.getDeathCause());assertTrue(death.getRemoveReason().isEmpty());VehicleRemovePayload remove=VehicleRemovePayload.remove(VehicleRemoveReason.UNLOAD);assertEquals(VehicleRemoveType.REMOVE,remove.getType());assertTrue(remove.isRemove());assertFalse(remove.isDeath());assertEquals(Optional.of(VehicleRemoveReason.UNLOAD),remove.getRemoveReason());assertTrue(remove.getDeathCause().isEmpty());assertEquals(Optional.of(VehicleRemoveReason.GENERIC),VehicleRemovePayload.remove(null).getRemoveReason()); + @Test + void ownerSettingsSupportWhitelistAndStableTicketIdentity() { + boolean allow = Cache.allowWhitelist, defaults = Cache.whitelistedByDefault; + try { + Cache.allowWhitelist = true; + Cache.whitelistedByDefault = true; + OwnerData owner = new OwnerData(); + assertEquals("none", owner.getOwner()); + assertTrue(owner.isWhiteListed()); + owner.setOwner("player_alex"); + assertEquals("player_alex", owner.getOwner()); + owner.setWhiteList(new ArrayList<>(List.of("alex"))); + owner.addToWhiteList("sam"); + owner.removeFromWhiteList("alex"); + assertEquals(List.of("sam"), owner.getWhiteList()); + owner.setWhiteListed(false); + assertFalse(owner.isWhiteListed()); + assertFalse(owner.isTicketsEnabled()); + assertNull(owner.getTicketId()); + owner.setTicketId(" "); + assertNull(owner.getTicketId()); + owner.setTicketsEnabled(true); + String generated = owner.getTicketId(); + assertNotNull(UUID.fromString(generated)); + owner.toggleTickets(); + assertFalse(owner.isTicketsEnabled()); + owner.toggleTickets(); + assertEquals(generated, owner.getTicketId()); + owner.setTicketId("ticket-1"); + assertEquals("ticket-1", owner.getTicketId()); + owner.setTicketId(null); + assertNull(owner.getTicketId()); + Cache.allowWhitelist = false; + assertFalse(new OwnerData().isWhiteListed()); + Cache.allowWhitelist = true; + Cache.whitelistedByDefault = false; + assertFalse(new OwnerData().isWhiteListed()); + } finally { + Cache.allowWhitelist = allow; + Cache.whitelistedByDefault = defaults; } + } - @Test void soundConfigurationControlsVolumePitchClampsAndDoppler()throws Exception { - Location source=new Location(r.world,0,64,0);Player player=r.entity(Player.class,new Location(r.world,10,64,0));SoundData regular=new SoundData(Rig.yaml("sound: custom.motor\nvolume: 2\npitch: 1.2\ndelay: 3\n"));assertEquals("custom.motor",regular.getSound());assertEquals(2,regular.getVolume());assertEquals(1.2f,regular.getPitch());assertEquals(3,regular.getDelay());assertFalse(regular.isPitched());assertFalse(regular.isDoppler());regular.playSound(source);regular.playSound(List.of(player),source,2f);regular.playSound(List.of(player),source,new Vector(),2f);verify(r.world,times(2)).playSound(source,"custom.motor",2f,1.2f);verify(player).playSound(source,"custom.motor",2f,1.2f); - SoundData pitched=new SoundData(Rig.yaml("sound: custom.pitched\npitched: true\nmin-pitch: 0.8\nmax-pitch: 1.5\n"));assertTrue(pitched.isPitched());pitched.playSound(List.of(player),source,new Vector(),.1f);pitched.playSound(List.of(player),source,new Vector(),3f);verify(player).playSound(source,"custom.pitched",1f,.8f);verify(player).playSound(source,"custom.pitched",1f,1.5f);pitched.playSound(List.of(player),source,.1f);pitched.playSound(List.of(player),source,3f);verify(r.world).playSound(source,"custom.pitched",1f,.8f);verify(r.world).playSound(source,"custom.pitched",1f,1.5f); - SoundData doppler=new SoundData(Rig.yaml("sound: custom.doppler\npitched: true\ndoppler: true\n"));assertTrue(doppler.isDoppler());doppler.playSound(List.of(player),source,new Vector(-1000,0,0),1);doppler.playSound(List.of(player),source,new Vector(300,0,0),1);verify(player).playSound(source,"custom.doppler",1f,.5f);verify(player).playSound(source,"custom.doppler",1f,2f); - } + @Test + void namingTimeoutAndStoredSummariesRetainTheirPublicValues() { + ActiveVehicle vehicle = r.vehicle(); + NamingData naming = new NamingData(vehicle); + assertSame(vehicle, naming.getVehicle()); + for (int i = 0; i < 30; i++) assertFalse(naming.tick()); + assertTrue(naming.tick()); + assertTrue(naming.tick()); + StoredVehicleMeta stored = new StoredVehicleMeta("uuid", "Wagon", "cart", "player_alex"); + assertEquals("uuid", stored.getUuid()); + assertEquals("Wagon", stored.getName()); + assertEquals("cart", stored.getTypeId()); + assertEquals("player_alex", stored.getOwner()); + for (String owner : + Arrays.asList(null, "", " ", "none", "player_", "player_none", "company_acme")) + assertFalse(StoredVehicleMeta.isPlayerOwner(owner)); + assertTrue(StoredVehicleMeta.isPlayerOwner("PLAYER_alex")); + Location location = new Location(r.world, 1, 64, 2); + OwnedVehicleSummary summary = + new OwnedVehicleSummary( + "uuid", "Wagon", "cart", Optional.of(location), true, "player_alex"); + assertEquals("uuid", summary.getUuid()); + assertEquals("Wagon", summary.getName()); + assertEquals("cart", summary.getTypeId()); + assertEquals(Optional.of(location), summary.getLocation()); + assertTrue(summary.isSpawned()); + assertEquals("player_alex", summary.getOwner()); + OwnedVehicleSummary unloaded = new OwnedVehicleSummary("id", "Stored", "cart", null, false); + assertFalse(unloaded.isSpawned()); + assertTrue(unloaded.getLocation().isEmpty()); + assertNull(unloaded.getOwner()); + } - @Test void particleConfigurationUsesOffsetsAndLimitsVisibleDelivery()throws Exception { - ParticleData fallback=new ParticleData(Rig.yaml("particle: missing\n"));assertEquals(Particle.FLAME,fallback.getParticle());ParticleData data=new ParticleData(Rig.yaml("particle: crit\namount: 2\nspread: 0\nspeed: 0.25\nx: 1\ny: 1\nz: 1\n"));assertEquals(Particle.CRIT,data.getParticle());assertEquals(2,data.getAmount());assertEquals(0,data.getSpread());assertEquals(.25,data.getSpeed());Location source=new Location(r.world,0,64,0);data.spawnParticle(null,new Vector(0,0,1));data.spawnParticle(new Location(null,0,0,0),new Vector(0,0,1));data.spawnParticle(source,new Vector(0,0,1));Vector expected=new Vector(1,1,2).normalize();verify(r.world,times(2)).spawnParticle(Particle.CRIT,source,0,expected.getX(),expected.getY(),expected.getZ(),.25,null,true);Player viewer=r.entity(Player.class,source);when(r.world.getPlayers()).thenReturn(List.of(viewer));data.spawnParticleVisible(source,new Vector(0,0,1));verify(viewer,times(2)).spawnParticle(Particle.CRIT,source,0,expected.getX(),expected.getY(),expected.getZ(),.25); - ParticleData negative=new ParticleData(Rig.yaml("amount: 1\nspread: 0\nx: -1\ny: -1\nz: -1\n"));negative.spawnParticle(source,new Vector());Vector backwards=new Vector(-1,-1,-1).normalize();verify(r.world).spawnParticle(Particle.FLAME,source,0,backwards.getX(),backwards.getY(),backwards.getZ(),.4,null,true); - } + @Test + void deathConfigurationExposesSoundsFragmentsAndConditionalOverrides() throws Exception { + DeathData empty = new DeathData(VehicleDeath.DIE, Rig.yaml("")); + assertEquals(VehicleDeath.DIE, empty.getType()); + assertEquals(0, empty.getDuration()); + assertEquals(0, empty.getFragments()); + assertTrue(empty.getSfx().isEmpty()); + assertFalse(empty.hasOverrides()); + DeathData configured = + new DeathData( + VehicleDeath.EXPLODE, + Rig.yaml( + "duration: 20\n" + + "fragments: 3\n" + + "sounds: {bang: {sound: custom.bang}}\n" + + "overrides:\n" + + " water:\n" + + " type: sink\n" + + " conditions: [state(floating)]\n")); + assertEquals(20, configured.getDuration()); + assertEquals(3, configured.getFragments()); + assertEquals("custom.bang", configured.getSfx().getFirst().getSound()); + assertTrue(configured.hasOverrides()); + assertEquals(VehicleDeath.SINK, configured.getOverrides().getFirst().getDeath()); + assertEquals(List.of("state(floating)"), configured.getOverrides().getFirst().getConditions()); + } - @Test void timedSoundsPreventOverlapAndCopiesStartIdle()throws Exception { - TimedSound sound=new TimedSound(Rig.yaml("sound: timer\nduration: 3\n"));assertFalse(sound.isPlaying());assertEquals(3,sound.getDuration());Location location=new Location(r.world,0,64,0);sound.playSound(location);sound.playSound(location);assertTrue(sound.isPlaying());verify(r.world).playSound(location,"timer",1f,1f);TimedSound copy=new TimedSound(sound);assertSame(sound.getSound(),copy.getSound());assertEquals(3,copy.getDuration());assertFalse(copy.isPlaying());r.ticks(2);assertTrue(sound.isPlaying());r.tick();assertFalse(sound.isPlaying());sound.playSound(location);verify(r.world,times(2)).playSound(location,"timer",1f,1f); - } + @Test + void removalPayloadsDistinguishDeathFromUnloadAndRequireDeathCause() { + assertThrows(IllegalArgumentException.class, () -> VehicleRemovePayload.death(null)); + VehicleRemovePayload death = VehicleRemovePayload.death(VehicleDeath.CRASH); + assertEquals(VehicleRemoveType.DEATH, death.getType()); + assertTrue(death.isDeath()); + assertFalse(death.isRemove()); + assertEquals(Optional.of(VehicleDeath.CRASH), death.getDeathCause()); + assertTrue(death.getRemoveReason().isEmpty()); + VehicleRemovePayload remove = VehicleRemovePayload.remove(VehicleRemoveReason.UNLOAD); + assertEquals(VehicleRemoveType.REMOVE, remove.getType()); + assertTrue(remove.isRemove()); + assertFalse(remove.isDeath()); + assertEquals(Optional.of(VehicleRemoveReason.UNLOAD), remove.getRemoveReason()); + assertTrue(remove.getDeathCause().isEmpty()); + assertEquals( + Optional.of(VehicleRemoveReason.GENERIC), + VehicleRemovePayload.remove(null).getRemoveReason()); + } + + @Test + void soundConfigurationControlsVolumePitchClampsAndDoppler() throws Exception { + Location source = new Location(r.world, 0, 64, 0); + Player player = r.entity(Player.class, new Location(r.world, 10, 64, 0)); + SoundData regular = + new SoundData(Rig.yaml("sound: custom.motor\nvolume: 2\npitch: 1.2\ndelay: 3\n")); + assertEquals("custom.motor", regular.getSound()); + assertEquals(2, regular.getVolume()); + assertEquals(1.2f, regular.getPitch()); + assertEquals(3, regular.getDelay()); + assertFalse(regular.isPitched()); + assertFalse(regular.isDoppler()); + regular.playSound(source); + regular.playSound(List.of(player), source, 2f); + regular.playSound(List.of(player), source, new Vector(), 2f); + verify(r.world, times(2)).playSound(source, "custom.motor", 2f, 1.2f); + verify(player).playSound(source, "custom.motor", 2f, 1.2f); + SoundData pitched = + new SoundData( + Rig.yaml("sound: custom.pitched\npitched: true\nmin-pitch: 0.8\nmax-pitch: 1.5\n")); + assertTrue(pitched.isPitched()); + pitched.playSound(List.of(player), source, new Vector(), .1f); + pitched.playSound(List.of(player), source, new Vector(), 3f); + verify(player).playSound(source, "custom.pitched", 1f, .8f); + verify(player).playSound(source, "custom.pitched", 1f, 1.5f); + pitched.playSound(List.of(player), source, .1f); + pitched.playSound(List.of(player), source, 3f); + verify(r.world).playSound(source, "custom.pitched", 1f, .8f); + verify(r.world).playSound(source, "custom.pitched", 1f, 1.5f); + SoundData doppler = + new SoundData(Rig.yaml("sound: custom.doppler\npitched: true\ndoppler: true\n")); + assertTrue(doppler.isDoppler()); + doppler.playSound(List.of(player), source, new Vector(-1000, 0, 0), 1); + doppler.playSound(List.of(player), source, new Vector(300, 0, 0), 1); + verify(player).playSound(source, "custom.doppler", 1f, .5f); + verify(player).playSound(source, "custom.doppler", 1f, 2f); + } + + @Test + void particleConfigurationUsesOffsetsAndLimitsVisibleDelivery() throws Exception { + ParticleData fallback = new ParticleData(Rig.yaml("particle: missing\n")); + assertEquals(Particle.FLAME, fallback.getParticle()); + ParticleData data = + new ParticleData( + Rig.yaml("particle: crit\namount: 2\nspread: 0\nspeed: 0.25\nx: 1\ny: 1\nz: 1\n")); + assertEquals(Particle.CRIT, data.getParticle()); + assertEquals(2, data.getAmount()); + assertEquals(0, data.getSpread()); + assertEquals(.25, data.getSpeed()); + Location source = new Location(r.world, 0, 64, 0); + data.spawnParticle(null, new Vector(0, 0, 1)); + data.spawnParticle(new Location(null, 0, 0, 0), new Vector(0, 0, 1)); + data.spawnParticle(source, new Vector(0, 0, 1)); + Vector expected = new Vector(1, 1, 2).normalize(); + verify(r.world, times(2)) + .spawnParticle( + Particle.CRIT, + source, + 0, + expected.getX(), + expected.getY(), + expected.getZ(), + .25, + null, + true); + Player viewer = r.entity(Player.class, source); + when(r.world.getPlayers()).thenReturn(List.of(viewer)); + data.spawnParticleVisible(source, new Vector(0, 0, 1)); + verify(viewer, times(2)) + .spawnParticle( + Particle.CRIT, source, 0, expected.getX(), expected.getY(), expected.getZ(), .25); + ParticleData negative = + new ParticleData(Rig.yaml("amount: 1\nspread: 0\nx: -1\ny: -1\nz: -1\n")); + negative.spawnParticle(source, new Vector()); + Vector backwards = new Vector(-1, -1, -1).normalize(); + verify(r.world) + .spawnParticle( + Particle.FLAME, + source, + 0, + backwards.getX(), + backwards.getY(), + backwards.getZ(), + .4, + null, + true); + } + + @Test + void timedSoundsPreventOverlapAndCopiesStartIdle() throws Exception { + TimedSound sound = new TimedSound(Rig.yaml("sound: timer\nduration: 3\n")); + assertFalse(sound.isPlaying()); + assertEquals(3, sound.getDuration()); + Location location = new Location(r.world, 0, 64, 0); + sound.playSound(location); + sound.playSound(location); + assertTrue(sound.isPlaying()); + verify(r.world).playSound(location, "timer", 1f, 1f); + TimedSound copy = new TimedSound(sound); + assertSame(sound.getSound(), copy.getSound()); + assertEquals(3, copy.getDuration()); + assertFalse(copy.isPlaying()); + r.ticks(2); + assertTrue(sound.isPlaying()); + r.tick(); + assertFalse(sound.isPlaying()); + sound.playSound(location); + verify(r.world, times(2)).playSound(location, "timer", 1f, 1f); + } - @Test void eventsPreservePayloadsCancellationAndStableHandlerLists() { - ActiveVehicle vehicle=r.vehicle();Player player=r.entity(Player.class,new Location(r.world,0,64,0));VehicleSpawnEvent spawn=new VehicleSpawnEvent(vehicle);assertSame(vehicle,spawn.getVehicle());assertSame(VehicleSpawnEvent.getHandlerList(),spawn.getHandlers());VehicleRemovePayload payload=VehicleRemovePayload.death(VehicleDeath.DIE);VehicleRemoveEvent remove=new VehicleRemoveEvent(vehicle,payload);assertSame(vehicle,remove.getVehicle());assertSame(payload,remove.getPayload());assertSame(VehicleRemoveEvent.getHandlerList(),remove.getHandlers());assertTrue(new VehicleRemoveEvent(vehicle,null).getPayload().isRemove());VehicleOwnerClaimedEvent owner=new VehicleOwnerClaimedEvent(player,vehicle,"none","player_alex");assertSame(player,owner.getPlayer());assertSame(vehicle,owner.getVehicle());assertEquals("none",owner.getPreviousOwner());assertEquals("player_alex",owner.getNewOwner());assertFalse(owner.isCancelled());owner.setCancelled(true);assertTrue(owner.isCancelled());assertSame(VehicleOwnerClaimedEvent.getHandlerList(),owner.getHandlers());VehiclePreInteractEvent interact=new VehiclePreInteractEvent(player,vehicle);assertSame(vehicle,interact.getVehicle());assertFalse(interact.isCancelled());interact.setCancelled(true);assertTrue(interact.isCancelled());assertSame(VehiclePreInteractEvent.getHandlerList(),interact.getHandlers());VehicleRepairStartEvent repair=new VehicleRepairStartEvent(player,vehicle);assertSame(vehicle,repair.getVehicle());assertFalse(repair.isCancelled());repair.setCancelled(true);assertTrue(repair.isCancelled());assertSame(VehicleRepairStartEvent.getHandlerList(),repair.getHandlers());VFEntityDamageEvent damage=new VFEntityDamageEvent(vehicle.getEntity(),player,"FIRE",10);assertSame(vehicle.getEntity(),damage.getEntity());assertSame(player,damage.getDamager());assertEquals("FIRE",damage.getCause());assertEquals(10,damage.getDamage());damage.setDamage(5);assertEquals(5,damage.getDamage());assertFalse(damage.isCancelled());damage.setCancelled(true);assertTrue(damage.isCancelled());assertSame(VFEntityDamageEvent.getHandlerList(),damage.getHandlers());boolean before=Cache.blockDamage;try{Cache.blockDamage=true;Location center=player.getLocation();VFExplosionEvent explosion=new VFExplosionEvent(center);assertSame(center,explosion.getLocation());assertTrue(explosion.doesBlockDamage());explosion.setBlockDamage(false);assertFalse(explosion.doesBlockDamage());assertFalse(explosion.isCancelled());explosion.setCancelled(true);assertTrue(explosion.isCancelled());assertSame(VFExplosionEvent.getHandlerList(),explosion.getHandlers());}finally{Cache.blockDamage=before;} + @Test + void eventsPreservePayloadsCancellationAndStableHandlerLists() { + ActiveVehicle vehicle = r.vehicle(); + Player player = r.entity(Player.class, new Location(r.world, 0, 64, 0)); + VehicleSpawnEvent spawn = new VehicleSpawnEvent(vehicle); + assertSame(vehicle, spawn.getVehicle()); + assertSame(VehicleSpawnEvent.getHandlerList(), spawn.getHandlers()); + VehicleRemovePayload payload = VehicleRemovePayload.death(VehicleDeath.DIE); + VehicleRemoveEvent remove = new VehicleRemoveEvent(vehicle, payload); + assertSame(vehicle, remove.getVehicle()); + assertSame(payload, remove.getPayload()); + assertSame(VehicleRemoveEvent.getHandlerList(), remove.getHandlers()); + assertTrue(new VehicleRemoveEvent(vehicle, null).getPayload().isRemove()); + VehicleOwnerClaimedEvent owner = + new VehicleOwnerClaimedEvent(player, vehicle, "none", "player_alex"); + assertSame(player, owner.getPlayer()); + assertSame(vehicle, owner.getVehicle()); + assertEquals("none", owner.getPreviousOwner()); + assertEquals("player_alex", owner.getNewOwner()); + assertFalse(owner.isCancelled()); + owner.setCancelled(true); + assertTrue(owner.isCancelled()); + assertSame(VehicleOwnerClaimedEvent.getHandlerList(), owner.getHandlers()); + VehiclePreInteractEvent interact = new VehiclePreInteractEvent(player, vehicle); + assertSame(vehicle, interact.getVehicle()); + assertFalse(interact.isCancelled()); + interact.setCancelled(true); + assertTrue(interact.isCancelled()); + assertSame(VehiclePreInteractEvent.getHandlerList(), interact.getHandlers()); + VehicleRepairStartEvent repair = new VehicleRepairStartEvent(player, vehicle); + assertSame(vehicle, repair.getVehicle()); + assertFalse(repair.isCancelled()); + repair.setCancelled(true); + assertTrue(repair.isCancelled()); + assertSame(VehicleRepairStartEvent.getHandlerList(), repair.getHandlers()); + VFEntityDamageEvent damage = new VFEntityDamageEvent(vehicle.getEntity(), player, "FIRE", 10); + assertSame(vehicle.getEntity(), damage.getEntity()); + assertSame(player, damage.getDamager()); + assertEquals("FIRE", damage.getCause()); + assertEquals(10, damage.getDamage()); + damage.setDamage(5); + assertEquals(5, damage.getDamage()); + assertFalse(damage.isCancelled()); + damage.setCancelled(true); + assertTrue(damage.isCancelled()); + assertSame(VFEntityDamageEvent.getHandlerList(), damage.getHandlers()); + boolean before = Cache.blockDamage; + try { + Cache.blockDamage = true; + Location center = player.getLocation(); + VFExplosionEvent explosion = new VFExplosionEvent(center); + assertSame(center, explosion.getLocation()); + assertTrue(explosion.doesBlockDamage()); + explosion.setBlockDamage(false); + assertFalse(explosion.doesBlockDamage()); + assertFalse(explosion.isCancelled()); + explosion.setCancelled(true); + assertTrue(explosion.isCancelled()); + assertSame(VFExplosionEvent.getHandlerList(), explosion.getHandlers()); + } finally { + Cache.blockDamage = before; } + } - @Test void cacheCleanupRemovesProjectilesAndOnlyTemporaryLights() { - Set old=new HashSet<>(Cache.lightLocations);try{new Cache();Entity projectile=r.entity(Entity.class,new Location(r.world,0,64,0));Cache.projectiles.add(null);Cache.projectiles.add(projectile);Cache.removeProjectiles();verify(projectile).remove();Location light=new Location(r.world,0,64,0),stone=new Location(r.world,1,64,0);r.block(0,64,0,Material.LIGHT);r.block(1,64,0,Material.STONE);Cache.lightLocations.clear();Cache.lightLocations.addAll(List.of(light,stone));Cache.removeLights();assertEquals(Material.AIR,light.getBlock().getType());assertEquals(Material.STONE,stone.getBlock().getType());}finally{Cache.lightLocations.clear();Cache.lightLocations.addAll(old);} + @Test + void cacheCleanupRemovesProjectilesAndOnlyTemporaryLights() { + Set old = new HashSet<>(Cache.lightLocations); + try { + new Cache(); + Entity projectile = r.entity(Entity.class, new Location(r.world, 0, 64, 0)); + Cache.projectiles.add(null); + Cache.projectiles.add(projectile); + Cache.removeProjectiles(); + verify(projectile).remove(); + Location light = new Location(r.world, 0, 64, 0), stone = new Location(r.world, 1, 64, 0); + r.block(0, 64, 0, Material.LIGHT); + r.block(1, 64, 0, Material.STONE); + Cache.lightLocations.clear(); + Cache.lightLocations.addAll(List.of(light, stone)); + Cache.removeLights(); + assertEquals(Material.AIR, light.getBlock().getType()); + assertEquals(Material.STONE, stone.getBlock().getType()); + } finally { + Cache.lightLocations.clear(); + Cache.lightLocations.addAll(old); } + } - @Test void applyingTrackStylePublishesTheConfiguredModelsAndOffset() { - String small=Cache.trackItemSmall,medium=Cache.trackItemMedium,large=Cache.trackItemLarge,appliedSmall=Cache.appliedTrackItemSmall,appliedMedium=Cache.appliedTrackItemMedium,appliedLarge=Cache.appliedTrackItemLarge;double offset=Cache.trackDisplayYOffset,appliedOffset=Cache.appliedTrackDisplayYOffset; - try{Cache.trackItemSmall="small";Cache.trackItemMedium="medium";Cache.trackItemLarge="large";Cache.trackDisplayYOffset=.75;Cache.applyTrackDisplayStyle();assertEquals("small",Cache.appliedTrackItemSmall);assertEquals("medium",Cache.appliedTrackItemMedium);assertEquals("large",Cache.appliedTrackItemLarge);assertEquals(.75,Cache.appliedTrackDisplayYOffset);}finally{Cache.trackItemSmall=small;Cache.trackItemMedium=medium;Cache.trackItemLarge=large;Cache.appliedTrackItemSmall=appliedSmall;Cache.appliedTrackItemMedium=appliedMedium;Cache.appliedTrackItemLarge=appliedLarge;Cache.trackDisplayYOffset=offset;Cache.appliedTrackDisplayYOffset=appliedOffset;} + @Test + void applyingTrackStylePublishesTheConfiguredModelsAndOffset() { + String small = Cache.trackItemSmall, + medium = Cache.trackItemMedium, + large = Cache.trackItemLarge, + appliedSmall = Cache.appliedTrackItemSmall, + appliedMedium = Cache.appliedTrackItemMedium, + appliedLarge = Cache.appliedTrackItemLarge; + double offset = Cache.trackDisplayYOffset, appliedOffset = Cache.appliedTrackDisplayYOffset; + try { + Cache.trackItemSmall = "small"; + Cache.trackItemMedium = "medium"; + Cache.trackItemLarge = "large"; + Cache.trackDisplayYOffset = .75; + Cache.applyTrackDisplayStyle(); + assertEquals("small", Cache.appliedTrackItemSmall); + assertEquals("medium", Cache.appliedTrackItemMedium); + assertEquals("large", Cache.appliedTrackItemLarge); + assertEquals(.75, Cache.appliedTrackDisplayYOffset); + } finally { + Cache.trackItemSmall = small; + Cache.trackItemMedium = medium; + Cache.trackItemLarge = large; + Cache.appliedTrackItemSmall = appliedSmall; + Cache.appliedTrackItemMedium = appliedMedium; + Cache.appliedTrackItemLarge = appliedLarge; + Cache.trackDisplayYOffset = offset; + Cache.appliedTrackDisplayYOffset = appliedOffset; } + } - @Test void loggerRoutesSeverityPluginNamesAndOnlineCreatorMessages() { - r.close();r=null;try(var bukkit=mockStatic(Bukkit.class)){ - Logger logger=mock(Logger.class);bukkit.when(Bukkit::getLogger).thenReturn(logger);JavaPlugin plugin=mock(JavaPlugin.class);when(plugin.getName()).thenReturn("TestPlugin");Player player=mock(Player.class);new VFLogger();VFLogger.log("broken");VFLogger.info("ready");VFLogger.log(plugin,"bad config");VFLogger.info(plugin,"loaded");verify(logger).warning("[VehicleFramework] Error! broken");verify(logger).info("[VehicleFramework] ready");verify(logger).warning("[TestPlugin] Error! bad config");verify(logger).info("[TestPlugin] loaded");VFLogger.message(player,"hello");verify(player).sendMessage("§a[VehicleFramework] §ehello");VFLogger.creatorLog("first");bukkit.when(()->Bukkit.getPlayerExact("drefvelin")).thenReturn(player);VFLogger.creatorLog("offline");verify(player,never()).sendMessage(contains("offline"));when(player.isOnline()).thenReturn(true);VFLogger.creatorLog("trace");verify(player).sendMessage("§a[VFLogger]§e: §btrace"); - } + @Test + void loggerRoutesSeverityPluginNamesAndOnlineCreatorMessages() { + r.close(); + r = null; + try (var bukkit = mockStatic(Bukkit.class)) { + Logger logger = mock(Logger.class); + bukkit.when(Bukkit::getLogger).thenReturn(logger); + JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.getName()).thenReturn("TestPlugin"); + Player player = mock(Player.class); + new VFLogger(); + VFLogger.log("broken"); + VFLogger.info("ready"); + VFLogger.log(plugin, "bad config"); + VFLogger.info(plugin, "loaded"); + verify(logger).warning("[VehicleFramework] Error! broken"); + verify(logger).info("[VehicleFramework] ready"); + verify(logger).warning("[TestPlugin] Error! bad config"); + verify(logger).info("[TestPlugin] loaded"); + VFLogger.message(player, "hello"); + verify(player).sendMessage("§a[VehicleFramework] §ehello"); + VFLogger.creatorLog("first"); + bukkit.when(() -> Bukkit.getPlayerExact("drefvelin")).thenReturn(player); + VFLogger.creatorLog("offline"); + verify(player, never()).sendMessage(contains("offline")); + when(player.isOnline()).thenReturn(true); + VFLogger.creatorLog("trace"); + verify(player).sendMessage("§a[VFLogger]§e: §btrace"); } + } } diff --git a/src/test/java/net/tfminecraft/vehicleframework/database/VehicleBackupCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/database/VehicleBackupCoverageTest.java index 0926472..a39c661 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/database/VehicleBackupCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/database/VehicleBackupCoverageTest.java @@ -274,9 +274,13 @@ class VehicleBackupCoverageTest { @Test void restoreWriteFailureKeepsTheOriginalFailureAndDatabaseBytes() throws Exception { Path backups = Files.createDirectory(folder.resolve("backups")); database(backups.resolve("vehicles-20260101-000000.db")); Path restricted = Files.createDirectory(folder.resolve("read-only")); Path live = Files.writeString(restricted.resolve("vehicles.db"), "corrupt but preserved"); + org.junit.jupiter.api.Assumptions.assumeTrue(Files.getFileStore(restricted).supportsFileAttributeView("posix"), + "Requires POSIX directory permissions"); Set original = Files.getPosixFilePermissions(restricted); Files.setPosixFilePermissions(restricted, java.nio.file.attribute.PosixFilePermissions.fromString("r-x------")); try { + org.junit.jupiter.api.Assumptions.assumeFalse(Files.isWritable(restricted), + "Requires enforced write permissions (not a privileged process)"); RuntimeException failure = assertThrows(RuntimeException.class, () -> VehicleRepository.openWithRestore(live.toFile(), backups.toFile())); assertTrue(failure.getSuppressed().length > 0); assertEquals("corrupt but preserved", Files.readString(live)); } finally { Files.setPosixFilePermissions(restricted, original); } diff --git a/src/test/java/net/tfminecraft/vehicleframework/effects/EffectsCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/effects/EffectsCoverageTest.java index 123343d..e79b8bd 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/effects/EffectsCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/effects/EffectsCoverageTest.java @@ -4,12 +4,6 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -import java.util.*; -import java.util.concurrent.atomic.AtomicReference; -import org.bukkit.*; -import org.bukkit.entity.Player; -import org.junit.jupiter.api.*; -import org.mockito.MockedStatic; import com.ticxo.modelengine.api.ModelEngineAPI; import com.ticxo.modelengine.api.animation.BlueprintAnimation; import com.ticxo.modelengine.api.animation.property.SimpleProperty; @@ -17,63 +11,187 @@ import com.ticxo.modelengine.api.model.bone.ModelBone; import com.ticxo.modelengine.api.model.bone.SimpleManualAnimator; import com.ticxo.modelengine.api.utils.config.ConfigCache; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; import net.tfminecraft.vehicleframework.enums.Component; import net.tfminecraft.vehicleframework.enums.VehicleDeath; import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; class EffectsCoverageTest { - Rig r; ActiveVehicle vehicle; ActiveModel model; Player viewer; - final Map bones=new HashMap<>(); - final Map animations=new HashMap<>(); - MockedStatic api; + Rig r; + ActiveVehicle vehicle; + ActiveModel model; + Player viewer; + final Map bones = new HashMap<>(); + final Map animations = new HashMap<>(); + MockedStatic api; - @BeforeEach void setup(){ - r=new Rig();vehicle=r.vehicle();model=mock(ActiveModel.class,RETURNS_DEEP_STUBS);viewer=r.entity(Player.class,new Location(r.world,0,64,0));when(vehicle.getModel()).thenReturn(model); - when(model.getBone(anyString())).thenAnswer(c->Optional.ofNullable(bones.get(c.getArgument(0))));when(model.getBlueprint().getAnimations()).thenReturn(animations); - api=mockStatic(ModelEngineAPI.class);ConfigCache config=mock(ConfigCache.class);api.when(ModelEngineAPI::getConfigCache).thenReturn(config);bone("base",new Location(r.world,0,64,0));bone("aim",new Location(r.world,0,64,1)); - } - @AfterEach void cleanup(){api.close();r.close();} + @BeforeEach + void setup() { + r = new Rig(); + vehicle = r.vehicle(); + model = mock(ActiveModel.class, RETURNS_DEEP_STUBS); + viewer = r.entity(Player.class, new Location(r.world, 0, 64, 0)); + when(vehicle.getModel()).thenReturn(model); + when(model.getBone(anyString())) + .thenAnswer(c -> Optional.ofNullable(bones.get(c.getArgument(0)))); + when(model.getBlueprint().getAnimations()).thenReturn(animations); + api = mockStatic(ModelEngineAPI.class); + ConfigCache config = mock(ConfigCache.class); + api.when(ModelEngineAPI::getConfigCache).thenReturn(config); + bone("base", new Location(r.world, 0, 64, 0)); + bone("aim", new Location(r.world, 0, 64, 1)); + } - @Test void effectIdentifiersDoNotDependOnTheServerLocale(){ - when(vehicle.hasComponent(Component.WINGS)).thenReturn(true);Locale saved=Locale.getDefault(); - try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));CustomEffect effect=new CustomEffect(List.of("particle(crit;base.aim;1;0;0.2)","start_fire(wings)","death(sink)"));assertDoesNotThrow(()->effect.play(List.of(viewer),vehicle));verify(vehicle).kill(VehicleDeath.SINK);verify(vehicle.getComponent(Component.WINGS)).startFire();}finally{Locale.setDefault(saved);} - } + @AfterEach + void cleanup() { + api.close(); + r.close(); + } - @Test void emptyEffectsFinishImmediatelyAndExposeConfiguredCommands(){ - CustomEffect empty=new CustomEffect(List.of());assertTrue(empty.isEmpty());assertTrue(empty.isFinished());empty.play(List.of(),vehicle);assertTrue(empty.isFinished());assertEquals(List.of(),empty.getCommands()); - CustomEffect effect=new CustomEffect(List.of("delay(2)","sound(base;test.horn;2;0.5)"));assertFalse(effect.isEmpty());assertEquals(2,effect.getCommands().size());effect.play(List.of(viewer),vehicle);assertFalse(effect.isFinished());r.tick();verify(r.world,never()).playSound(any(Location.class),anyString(),anyFloat(),anyFloat());r.tick();assertTrue(effect.isFinished());verify(r.world).playSound(new Location(r.world,0,64,0),"test.horn",2f,.5f); + @Test + void effectIdentifiersDoNotDependOnTheServerLocale() { + when(vehicle.hasComponent(Component.WINGS)).thenReturn(true); + Locale saved = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + CustomEffect effect = + new CustomEffect( + List.of("particle(crit;base.aim;1;0;0.2)", "start_fire(wings)", "death(sink)")); + assertDoesNotThrow(() -> effect.play(List.of(viewer), vehicle)); + verify(vehicle).kill(VehicleDeath.SINK); + verify(vehicle.getComponent(Component.WINGS)).startFire(); + } finally { + Locale.setDefault(saved); } + } - @Test void particleEffectsAimAlongBonesAndEmitForEachViewer(){ - CustomEffect effect=new CustomEffect(List.of("particle(flame;base.aim;3;0;0.4)"));effect.play(List.of(viewer),vehicle);verify(viewer,times(3)).spawnParticle(Particle.FLAME,new Location(r.world,0,64,0),0,0,0,1,.4);assertTrue(effect.isFinished()); - } + @Test + void emptyEffectsFinishImmediatelyAndExposeConfiguredCommands() { + CustomEffect empty = new CustomEffect(List.of()); + assertTrue(empty.isEmpty()); + assertTrue(empty.isFinished()); + empty.play(List.of(), vehicle); + assertTrue(empty.isFinished()); + assertEquals(List.of(), empty.getCommands()); + CustomEffect effect = new CustomEffect(List.of("delay(2)", "sound(base;test.horn;2;0.5)")); + assertFalse(effect.isEmpty()); + assertEquals(2, effect.getCommands().size()); + effect.play(List.of(viewer), vehicle); + assertFalse(effect.isFinished()); + r.tick(); + verify(r.world, never()).playSound(any(Location.class), anyString(), anyFloat(), anyFloat()); + r.tick(); + assertTrue(effect.isFinished()); + verify(r.world).playSound(new Location(r.world, 0, 64, 0), "test.horn", 2f, .5f); + } - @Test void conditionsStopFollowingCommandsAndPreserveCompositeValues(){ - for(String condition:List.of("condition()","condition( ;true)","condition(has_fuel)","condition(has_fuel;true)","condition(AND;has_fuel=true;passengers=true)")){ - CustomEffect effect=new CustomEffect(List.of(condition,"death(explode)"));effect.play(List.of(),vehicle);assertTrue(effect.isFinished());verify(vehicle,never()).kill(any()); - } - when(vehicle.hasFuel()).thenReturn(true);when(vehicle.getSeatHandler().hasPassengers()).thenReturn(true);CustomEffect passes=new CustomEffect(List.of("condition(AND;has_fuel=true;passengers=true)","death(explode)"));passes.play(List.of(),vehicle);verify(vehicle).kill(VehicleDeath.EXPLODE); - } + @Test + void particleEffectsAimAlongBonesAndEmitForEachViewer() { + CustomEffect effect = new CustomEffect(List.of("particle(flame;base.aim;3;0;0.4)")); + effect.play(List.of(viewer), vehicle); + verify(viewer, times(3)) + .spawnParticle(Particle.FLAME, new Location(r.world, 0, 64, 0), 0, 0, 0, 1, .4); + assertTrue(effect.isFinished()); + } - @Test void deathAndFireEffectsRespectExistingDestructionAndBurning(){ - when(vehicle.isDestroyed()).thenReturn(true);new CustomEffect(List.of("death(crash)")).play(List.of(),vehicle);verify(vehicle,never()).kill(any()); - new CustomEffect(List.of("start_fire(hull)")).play(List.of(),vehicle);verify(vehicle.getComponent(Component.HULL),never()).startFire(); - when(vehicle.hasComponent(Component.HULL)).thenReturn(true);when(vehicle.getComponent(Component.HULL).isOnFire()).thenReturn(true);new CustomEffect(List.of("start_fire(hull)")).play(List.of(),vehicle);verify(vehicle.getComponent(Component.HULL),never()).startFire(); - when(vehicle.getComponent(Component.HULL).isOnFire()).thenReturn(false);new CustomEffect(List.of("start_fire(hull)")).play(List.of(),vehicle);verify(vehicle.getComponent(Component.HULL)).startFire(); + @Test + void conditionsStopFollowingCommandsAndPreserveCompositeValues() { + for (String condition : + List.of( + "condition()", + "condition( ;true)", + "condition(has_fuel)", + "condition(has_fuel;true)", + "condition(AND;has_fuel=true;passengers=true)")) { + CustomEffect effect = new CustomEffect(List.of(condition, "death(explode)")); + effect.play(List.of(), vehicle); + assertTrue(effect.isFinished()); + verify(vehicle, never()).kill(any()); } + when(vehicle.hasFuel()).thenReturn(true); + when(vehicle.getSeatHandler().hasPassengers()).thenReturn(true); + CustomEffect passes = + new CustomEffect(List.of("condition(AND;has_fuel=true;passengers=true)", "death(explode)")); + passes.play(List.of(), vehicle); + verify(vehicle).kill(VehicleDeath.EXPLODE); + } - @Test void animationsIgnoreMissingAndAlreadyPlayingEntries(){ - new CustomEffect(List.of("animation(missing)")).play(List.of(),vehicle);verify(model.getAnimationHandler(),never()).playAnimation(any(SimpleProperty.class),anyBoolean()); - BlueprintAnimation animation=new BlueprintAnimation(model.getBlueprint(),"fly");animation.setLength(1);animation.setLoopMode(BlueprintAnimation.LoopMode.LOOP);animations.put("fly",animation);when(model.getAnimationHandler().isPlayingAnimation("fly")).thenReturn(true);new CustomEffect(List.of("animation(fly)")).play(List.of(),vehicle);verify(model.getAnimationHandler(),never()).playAnimation(any(SimpleProperty.class),anyBoolean());when(model.getAnimationHandler().isPlayingAnimation("fly")).thenReturn(false);new CustomEffect(List.of("animation(fly)")).play(List.of(),vehicle);verify(model.getAnimationHandler()).playAnimation(any(SimpleProperty.class),eq(true)); - } + @Test + void deathAndFireEffectsRespectExistingDestructionAndBurning() { + when(vehicle.isDestroyed()).thenReturn(true); + new CustomEffect(List.of("death(crash)")).play(List.of(), vehicle); + verify(vehicle, never()).kill(any()); + new CustomEffect(List.of("start_fire(hull)")).play(List.of(), vehicle); + verify(vehicle.getComponent(Component.HULL), never()).startFire(); + when(vehicle.hasComponent(Component.HULL)).thenReturn(true); + when(vehicle.getComponent(Component.HULL).isOnFire()).thenReturn(true); + new CustomEffect(List.of("start_fire(hull)")).play(List.of(), vehicle); + verify(vehicle.getComponent(Component.HULL), never()).startFire(); + when(vehicle.getComponent(Component.HULL).isOnFire()).thenReturn(false); + new CustomEffect(List.of("start_fire(hull)")).play(List.of(), vehicle); + verify(vehicle.getComponent(Component.HULL)).startFire(); + } - @Test void boneFreezingCreatesAndReleasesTheAnimatorIdempotently(){ - ModelBone bone=bones.get("base");new CustomEffect(List.of("freeze_bone(missing;true)","freeze_bone(base;false)","freeze_bone(base;true)","freeze_bone(base;true)")).play(List.of(),vehicle);assertNotNull(bone.getManualAnimator());verify(bone,times(1)).setManualAnimator(any(SimpleManualAnimator.class));new CustomEffect(List.of("freeze_bone(base;false)")).play(List.of(),vehicle);assertNull(bone.getManualAnimator());verify(bone).setManualAnimator(null); - } + @Test + void animationsIgnoreMissingAndAlreadyPlayingEntries() { + new CustomEffect(List.of("animation(missing)")).play(List.of(), vehicle); + verify(model.getAnimationHandler(), never()) + .playAnimation(any(SimpleProperty.class), anyBoolean()); + BlueprintAnimation animation = new BlueprintAnimation(model.getBlueprint(), "fly"); + animation.setLength(1); + animation.setLoopMode(BlueprintAnimation.LoopMode.LOOP); + animations.put("fly", animation); + when(model.getAnimationHandler().isPlayingAnimation("fly")).thenReturn(true); + new CustomEffect(List.of("animation(fly)")).play(List.of(), vehicle); + verify(model.getAnimationHandler(), never()) + .playAnimation(any(SimpleProperty.class), anyBoolean()); + when(model.getAnimationHandler().isPlayingAnimation("fly")).thenReturn(false); + new CustomEffect(List.of("animation(fly)")).play(List.of(), vehicle); + verify(model.getAnimationHandler()).playAnimation(any(SimpleProperty.class), eq(true)); + } - private ModelBone bone(String id,Location location){ - ModelBone bone=mock(ModelBone.class,RETURNS_DEEP_STUBS);when(bone.getBoneId()).thenReturn(id);when(bone.getActiveModel()).thenReturn(model);when(bone.getLocation()).thenAnswer(c->location.clone());when(bone.getLocalTransform().getSafeLeftQuaternion()).thenReturn(new org.joml.Quaternionf());when(bone.getLocalTransform().getSafePosition()).thenReturn(new org.joml.Vector3f());when(bone.getLocalTransform().getSafeScale()).thenReturn(new org.joml.Vector3f(1)); - AtomicReference animator=new AtomicReference<>();when(bone.getManualAnimator()).thenAnswer(c->animator.get());doAnswer(c->{animator.set(c.getArgument(0));return null;}).when(bone).setManualAnimator(any());bones.put(id,bone);return bone; - } + @Test + void boneFreezingCreatesAndReleasesTheAnimatorIdempotently() { + ModelBone bone = bones.get("base"); + new CustomEffect( + List.of( + "freeze_bone(missing;true)", + "freeze_bone(base;false)", + "freeze_bone(base;true)", + "freeze_bone(base;true)")) + .play(List.of(), vehicle); + assertNotNull(bone.getManualAnimator()); + verify(bone, times(1)).setManualAnimator(any(SimpleManualAnimator.class)); + new CustomEffect(List.of("freeze_bone(base;false)")).play(List.of(), vehicle); + assertNull(bone.getManualAnimator()); + verify(bone).setManualAnimator(null); + } + + private ModelBone bone(String id, Location location) { + ModelBone bone = mock(ModelBone.class, RETURNS_DEEP_STUBS); + when(bone.getBoneId()).thenReturn(id); + when(bone.getActiveModel()).thenReturn(model); + when(bone.getLocation()).thenAnswer(c -> location.clone()); + when(bone.getLocalTransform().getSafeLeftQuaternion()).thenReturn(new org.joml.Quaternionf()); + when(bone.getLocalTransform().getSafePosition()).thenReturn(new org.joml.Vector3f()); + when(bone.getLocalTransform().getSafeScale()).thenReturn(new org.joml.Vector3f(1)); + AtomicReference animator = + new AtomicReference<>(); + when(bone.getManualAnimator()).thenAnswer(c -> animator.get()); + doAnswer( + c -> { + animator.set(c.getArgument(0)); + return null; + }) + .when(bone) + .setManualAnimator(any()); + bones.put(id, bone); + return bone; + } } diff --git a/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java index c72c1d4..e862b1d 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java @@ -4,73 +4,123 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import com.ticxo.modelengine.api.ModelEngineAPI; import java.io.File; import java.nio.file.*; import java.util.*; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.data.DamageData; +import net.tfminecraft.vehicleframework.util.ConfigMerger; +import net.tfminecraft.vehicleframework.vehicles.Vehicle; +import net.tfminecraft.vehicleframework.vehicles.fuel.Fuel; +import net.tfminecraft.vehicleframework.weapons.ammunition.*; import org.bukkit.*; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.MockedStatic; -import com.ticxo.modelengine.api.ModelEngineAPI; -import net.tfminecraft.vehicleframework.VFLogger; -import net.tfminecraft.vehicleframework.cache.Cache; -import net.tfminecraft.vehicleframework.data.DamageData; -import net.tfminecraft.vehicleframework.util.ConfigMerger; -import net.tfminecraft.vehicleframework.vehicles.Vehicle; -import net.tfminecraft.vehicleframework.vehicles.fuel.Fuel; -import net.tfminecraft.vehicleframework.weapons.ammunition.*; class ConfigurationLoadersCoverageTest { - @TempDir Path temp; - private MockedStatic logger; - private MockedStatic models; - private final Map fuels = new HashMap<>(FuelLoader.get()); - private final Map ammunition = new HashMap<>(AmmunitionLoader.get()); - private final Map vehicles = new HashMap<>(VehicleLoader.get()); - private final List ignoredExplosions = new ArrayList<>(Cache.ignoreExplode); - private final List ignoredLandings = new ArrayList<>(Cache.ignoreLands); - private final List ignoredGround = new ArrayList<>(Cache.ignoreGround); - private final Map conversions = new HashMap<>(Cache.convertExplode); - - @BeforeEach void setup() { - logger = mockStatic(VFLogger.class); models = mockStatic(ModelEngineAPI.class); - FuelLoader.get().clear(); AmmunitionLoader.get().clear(); VehicleLoader.get().clear(); - new WeaponTemplateLoader().clear(); new DeathTemplateLoader().clear(); new ArmorTemplateLoader().clear(); - Cache.ignoreExplode.clear(); Cache.ignoreLands.clear(); Cache.ignoreGround.clear(); Cache.convertExplode.clear(); - } - @AfterEach void cleanup() throws Exception { - File blank = yaml("reset.yml", "{}"); new ConfigLoader().load(blank); new TrainsLoader().load(blank); - FuelLoader.get().clear(); FuelLoader.get().putAll(fuels); - AmmunitionLoader.get().clear(); AmmunitionLoader.get().putAll(ammunition); - VehicleLoader.get().clear(); VehicleLoader.get().putAll(vehicles); - Cache.ignoreExplode.clear(); Cache.ignoreExplode.addAll(ignoredExplosions); - Cache.ignoreLands.clear(); Cache.ignoreLands.addAll(ignoredLandings); - Cache.ignoreGround.clear(); Cache.ignoreGround.addAll(ignoredGround); - Cache.convertExplode.clear(); Cache.convertExplode.putAll(conversions); - new WeaponTemplateLoader().clear(); new DeathTemplateLoader().clear(); new ArmorTemplateLoader().clear(); - models.close(); logger.close(); - } + @TempDir Path temp; + private MockedStatic logger; + private MockedStatic models; + private final Map fuels = new HashMap<>(FuelLoader.get()); + private final Map ammunition = new HashMap<>(AmmunitionLoader.get()); + private final Map vehicles = new HashMap<>(VehicleLoader.get()); + private final List ignoredExplosions = new ArrayList<>(Cache.ignoreExplode); + private final List ignoredLandings = new ArrayList<>(Cache.ignoreLands); + private final List ignoredGround = new ArrayList<>(Cache.ignoreGround); + private final Map conversions = new HashMap<>(Cache.convertExplode); - @Test void configReloadReplacesRemovedMaterialRules() throws Exception { - ConfigLoader loader = new ConfigLoader(); - loader.load(yaml("first.yml", "ignore-explosion: [stone]\nignore-landing: [dirt]\nignore-ground: [gravel]\nconvert-explosion: [stone.dirt]\n")); - loader.load(yaml("second.yml", "ignore-explosion: [sand]\nignore-landing: []\nignore-ground: []\nconvert-explosion: []\n")); - assertEquals(List.of(Material.SAND), Cache.ignoreExplode); - assertTrue(Cache.ignoreLands.isEmpty()); assertTrue(Cache.ignoreGround.isEmpty()); assertTrue(Cache.convertExplode.isEmpty()); - } + @BeforeEach + void setup() { + logger = mockStatic(VFLogger.class); + models = mockStatic(ModelEngineAPI.class); + FuelLoader.get().clear(); + AmmunitionLoader.get().clear(); + VehicleLoader.get().clear(); + new WeaponTemplateLoader().clear(); + new DeathTemplateLoader().clear(); + new ArmorTemplateLoader().clear(); + Cache.ignoreExplode.clear(); + Cache.ignoreLands.clear(); + Cache.ignoreGround.clear(); + Cache.convertExplode.clear(); + } - @Test void malformedExplosionConversionDoesNotPreventOtherConfigLoading() throws Exception { - File file = yaml("conversion.yml", "convert-explosion: [STONE, 'stone.', invalid.dirt, stone.dirt]\nenable-logging: true\n"); - assertDoesNotThrow(() -> new ConfigLoader().load(file)); - assertEquals(Map.of(Material.STONE, Material.DIRT), Cache.convertExplode); assertTrue(Cache.enableLogging); - } + @AfterEach + void cleanup() throws Exception { + File blank = yaml("reset.yml", "{}"); + new ConfigLoader().load(blank); + new TrainsLoader().load(blank); + FuelLoader.get().clear(); + FuelLoader.get().putAll(fuels); + AmmunitionLoader.get().clear(); + AmmunitionLoader.get().putAll(ammunition); + VehicleLoader.get().clear(); + VehicleLoader.get().putAll(vehicles); + Cache.ignoreExplode.clear(); + Cache.ignoreExplode.addAll(ignoredExplosions); + Cache.ignoreLands.clear(); + Cache.ignoreLands.addAll(ignoredLandings); + Cache.ignoreGround.clear(); + Cache.ignoreGround.addAll(ignoredGround); + Cache.convertExplode.clear(); + Cache.convertExplode.putAll(conversions); + new WeaponTemplateLoader().clear(); + new DeathTemplateLoader().clear(); + new ArmorTemplateLoader().clear(); + models.close(); + logger.close(); + } + + @Test + void configReloadReplacesRemovedMaterialRules() throws Exception { + ConfigLoader loader = new ConfigLoader(); + loader.load( + yaml( + "first.yml", + "ignore-explosion: [stone]\n" + + "ignore-landing: [dirt]\n" + + "ignore-ground: [gravel]\n" + + "convert-explosion: [stone.dirt]\n")); + loader.load( + yaml( + "second.yml", + "ignore-explosion: [sand]\n" + + "ignore-landing: []\n" + + "ignore-ground: []\n" + + "convert-explosion: []\n")); + assertEquals(List.of(Material.SAND), Cache.ignoreExplode); + assertTrue(Cache.ignoreLands.isEmpty()); + assertTrue(Cache.ignoreGround.isEmpty()); + assertTrue(Cache.convertExplode.isEmpty()); + } - @Test void configLoadsValidMaterialsAndAllRuntimeOptions() throws Exception { - new ConfigLoader().load(yaml("config.yml", """ + @Test + void malformedExplosionConversionDoesNotPreventOtherConfigLoading() throws Exception { + File file = + yaml( + "conversion.yml", + "convert-explosion: [STONE, 'stone.', invalid.dirt, stone.dirt]\n" + + "enable-logging: true\n"); + assertDoesNotThrow(() -> new ConfigLoader().load(file)); + assertEquals(Map.of(Material.STONE, Material.DIRT), Cache.convertExplode); + assertTrue(Cache.enableLogging); + } + + @Test + void configLoadsValidMaterialsAndAllRuntimeOptions() throws Exception { + new ConfigLoader() + .load( + yaml( + "config.yml", + """ ignore-explosion: [stone, invalid] ignore-landing: [sand, invalid] ignore-ground: [gravel, invalid] @@ -92,134 +142,342 @@ class ConfigurationLoadersCoverageTest { persistence-logging: true wipe-log: true """)); - assertEquals(List.of(Material.STONE), Cache.ignoreExplode); assertEquals(List.of(Material.SAND), Cache.ignoreLands); - assertEquals(List.of(Material.GRAVEL), Cache.ignoreGround); assertEquals(Material.DIRT, Cache.convertExplode.get(Material.STONE)); - assertTrue(Cache.enableLogging); assertFalse(Cache.blockDamage); assertEquals(289, Cache.despawnDistance); - assertEquals("custom.skin", Cache.skinItem); assertEquals("custom.repair", Cache.repairItem); - assertEquals("custom.destroy", Cache.destroyItem); assertEquals("custom.ticket", Cache.ticketItem); assertEquals("testmob", Cache.mythicMob); - assertTrue(Cache.allowWhitelist); assertTrue(Cache.whitelistedByDefault); assertEquals(1, Cache.weaponDegradedReloadMultiplier); - assertTrue(Cache.weaponAimDebug); assertTrue(Cache.terrainFollowDebug); assertTrue(Cache.groundEngineLogging); - assertTrue(Cache.persistenceLogging); assertTrue(Cache.wipeLog); - logger.verify(() -> VFLogger.log("invalid is not a material"), times(3)); - } + assertEquals(List.of(Material.STONE), Cache.ignoreExplode); + assertEquals(List.of(Material.SAND), Cache.ignoreLands); + assertEquals(List.of(Material.GRAVEL), Cache.ignoreGround); + assertEquals(Material.DIRT, Cache.convertExplode.get(Material.STONE)); + assertTrue(Cache.enableLogging); + assertFalse(Cache.blockDamage); + assertEquals(289, Cache.despawnDistance); + assertEquals("custom.skin", Cache.skinItem); + assertEquals("custom.repair", Cache.repairItem); + assertEquals("custom.destroy", Cache.destroyItem); + assertEquals("custom.ticket", Cache.ticketItem); + assertEquals("testmob", Cache.mythicMob); + assertTrue(Cache.allowWhitelist); + assertTrue(Cache.whitelistedByDefault); + assertEquals(1, Cache.weaponDegradedReloadMultiplier); + assertTrue(Cache.weaponAimDebug); + assertTrue(Cache.terrainFollowDebug); + assertTrue(Cache.groundEngineLogging); + assertTrue(Cache.persistenceLogging); + assertTrue(Cache.wipeLog); + logger.verify(() -> VFLogger.log("invalid is not a material"), times(3)); + } - @Test void trainsUseDefaultsWhenFxAndBuildSectionsAreAbsent() throws Exception { - new TrainsLoader().load(yaml("trains.yml", "{}")); - assertEquals("ia.tfmc:track_small", Cache.trackItemSmall); assertEquals("ia.tfmc:track_medium", Cache.trackItemMedium); - assertEquals("ia.tfmc:track_large", Cache.trackItemLarge); assertEquals("v.iron_shovel", Cache.trackLayerItem); - assertEquals(3, Cache.trackSnapDistance); assertEquals(2.5, Cache.trainClearanceHeight); - assertEquals(Particle.BLOCK, Cache.trackFxParticle); assertEquals(Material.GRAVEL, Cache.trackFxBlock); - assertEquals(3, Cache.trackFxCount); assertEquals(3, Cache.trackFxWidth); assertEquals(2.5, Cache.trackFxSoundInterval); - assertEquals(4, Cache.trackBuildIntervalTicks); assertTrue(Cache.trackBuildSwing); assertEquals(6, Cache.trackBuildCount); - assertEquals(1, Cache.trackBuildWidth); assertEquals(Particle.BLOCK, Cache.trackBuildParticle); assertEquals(Material.GRAVEL, Cache.trackBuildBlock); - } + @Test + void trainsUseDefaultsWhenFxAndBuildSectionsAreAbsent() throws Exception { + new TrainsLoader().load(yaml("trains.yml", "{}")); + assertEquals("ia.tfmc:track_small", Cache.trackItemSmall); + assertEquals("ia.tfmc:track_medium", Cache.trackItemMedium); + assertEquals("ia.tfmc:track_large", Cache.trackItemLarge); + assertEquals("v.iron_shovel", Cache.trackLayerItem); + assertEquals(3, Cache.trackSnapDistance); + assertEquals(2.5, Cache.trainClearanceHeight); + assertEquals(Particle.BLOCK, Cache.trackFxParticle); + assertEquals(Material.GRAVEL, Cache.trackFxBlock); + assertEquals(3, Cache.trackFxCount); + assertEquals(3, Cache.trackFxWidth); + assertEquals(2.5, Cache.trackFxSoundInterval); + assertEquals(4, Cache.trackBuildIntervalTicks); + assertTrue(Cache.trackBuildSwing); + assertEquals(6, Cache.trackBuildCount); + assertEquals(1, Cache.trackBuildWidth); + assertEquals(Particle.BLOCK, Cache.trackBuildParticle); + assertEquals(Material.GRAVEL, Cache.trackBuildBlock); + } - @ParameterizedTest @ValueSource(strings = {"BLOCK_CRACK", "BLOCK_DUST", "invalid", "none", " "}) - void trainsParseLegacyParticlesValidateBlocksAndClampUnsafeRanges(String particle) throws Exception { - YamlConfiguration config = new YamlConfiguration(); - config.set("fx.particle", particle); config.set("build.particle", particle); - config.set("fx.particle-block", "not_a_block"); config.set("build.particle-block", "not_a_block"); - config.set("fx.width", 0); config.set("fx.count", -1); config.set("fx.sound-interval-blocks", 0); - config.set("build.interval-ticks", -1); config.set("build.count", -1); config.set("build.width", 0); - config.set("clearance.width", 0); config.set("clearance.height", 0); config.set("vehicle-y-offset", 2); - config.set("snap-distance", 0); config.set("max-grade-degrees", 3); config.set("desired-grade-degrees", 8); - config.set("min-lay-distance", 10); config.set("max-junction-length", 4); - config.set("lay-retry-seconds", -1); config.set("remove-cooldown-seconds", -1); - config.set("resync-chunks-per-tick", 0); config.set("switch.throw-degrees-per-second", 0); - File file = yaml("trains.yml", config.saveToString()); new TrainsLoader().load(file); - Particle expected = particle.isBlank() || particle.equals("none") ? null : Particle.BLOCK; - assertEquals(expected, Cache.trackFxParticle); assertEquals(expected, Cache.trackBuildParticle); - assertEquals(Material.GRAVEL, Cache.trackFxBlock); assertEquals(Material.GRAVEL, Cache.trackBuildBlock); - assertEquals(1, Cache.trackFxWidth); assertEquals(0, Cache.trackFxCount); assertEquals(.25, Cache.trackFxSoundInterval); - assertEquals(0, Cache.trackBuildIntervalTicks); assertEquals(0, Cache.trackBuildCount); assertEquals(1, Cache.trackBuildWidth); - assertEquals(.5, Cache.trainClearanceWidth); assertEquals(2.5, Cache.trainClearanceHeight); assertEquals(.5, Cache.trackSnapDistance); - assertEquals(3, Cache.trackDesiredGradeDegrees); assertEquals(10, Cache.trackMaxJunctionLength); - assertEquals(0, Cache.trackLayRetryMs); assertEquals(0, Cache.trackRemoveCooldownMs); - assertEquals(1, Cache.trackResyncChunksPerTick); assertEquals(1, Cache.trackSwitchThrowDegreesPerSecond); - } + @ParameterizedTest + @ValueSource(strings = {"BLOCK_CRACK", "BLOCK_DUST", "invalid", "none", " "}) + void trainsParseLegacyParticlesValidateBlocksAndClampUnsafeRanges(String particle) + throws Exception { + YamlConfiguration config = new YamlConfiguration(); + config.set("fx.particle", particle); + config.set("build.particle", particle); + config.set("fx.particle-block", "not_a_block"); + config.set("build.particle-block", "not_a_block"); + config.set("fx.width", 0); + config.set("fx.count", -1); + config.set("fx.sound-interval-blocks", 0); + config.set("build.interval-ticks", -1); + config.set("build.count", -1); + config.set("build.width", 0); + config.set("clearance.width", 0); + config.set("clearance.height", 0); + config.set("vehicle-y-offset", 2); + config.set("snap-distance", 0); + config.set("max-grade-degrees", 3); + config.set("desired-grade-degrees", 8); + config.set("min-lay-distance", 10); + config.set("max-junction-length", 4); + config.set("lay-retry-seconds", -1); + config.set("remove-cooldown-seconds", -1); + config.set("resync-chunks-per-tick", 0); + config.set("switch.throw-degrees-per-second", 0); + File file = yaml("trains.yml", config.saveToString()); + new TrainsLoader().load(file); + Particle expected = particle.isBlank() || particle.equals("none") ? null : Particle.BLOCK; + assertEquals(expected, Cache.trackFxParticle); + assertEquals(expected, Cache.trackBuildParticle); + assertEquals(Material.GRAVEL, Cache.trackFxBlock); + assertEquals(Material.GRAVEL, Cache.trackBuildBlock); + assertEquals(1, Cache.trackFxWidth); + assertEquals(0, Cache.trackFxCount); + assertEquals(.25, Cache.trackFxSoundInterval); + assertEquals(0, Cache.trackBuildIntervalTicks); + assertEquals(0, Cache.trackBuildCount); + assertEquals(1, Cache.trackBuildWidth); + assertEquals(.5, Cache.trainClearanceWidth); + assertEquals(2.5, Cache.trainClearanceHeight); + assertEquals(.5, Cache.trackSnapDistance); + assertEquals(3, Cache.trackDesiredGradeDegrees); + assertEquals(10, Cache.trackMaxJunctionLength); + assertEquals(0, Cache.trackLayRetryMs); + assertEquals(0, Cache.trackRemoveCooldownMs); + assertEquals(1, Cache.trackResyncChunksPerTick); + assertEquals(1, Cache.trackSwitchThrowDegreesPerSecond); + } - @Test void unreadableOrMalformedConfigDoesNotReplaceTheLastWorkingSettings() throws Exception { - File valid = yaml("valid.yml", "skin-item: custom.skin\nitem-small: custom.track\n"); - new ConfigLoader().load(valid); new TrainsLoader().load(valid); - File missing = temp.resolve("missing.yml").toFile(); File malformed = yaml("malformed.yml", "broken: [unterminated"); - new ConfigLoader().load(missing); new ConfigLoader().load(malformed); - new TrainsLoader().load(missing); new TrainsLoader().load(malformed); - assertEquals("custom.skin", Cache.skinItem); assertEquals("custom.track", Cache.trackItemSmall); - } + @Test + void unreadableOrMalformedConfigDoesNotReplaceTheLastWorkingSettings() throws Exception { + File valid = yaml("valid.yml", "skin-item: custom.skin\nitem-small: custom.track\n"); + new ConfigLoader().load(valid); + new TrainsLoader().load(valid); + File missing = temp.resolve("missing.yml").toFile(); + File malformed = yaml("malformed.yml", "broken: [unterminated"); + new ConfigLoader().load(missing); + new ConfigLoader().load(malformed); + new TrainsLoader().load(missing); + new TrainsLoader().load(malformed); + assertEquals("custom.skin", Cache.skinItem); + assertEquals("custom.track", Cache.trackItemSmall); + } - @ParameterizedTest @ValueSource(strings = {"weapon", "death", "armor", "role"}) - void templateFoldersFilterFilesPreserveFirstDefinitionAndSkipNonSections(String kind) throws Exception { - java.util.function.Consumer load = templateLoader(kind, false); - java.util.function.Consumer loadFolder = templateLoader(kind, true); - Map> target = templates(kind); - File first = yaml(kind + "/first.YML", "base:\n damage: 5\n nested:\n radius: 2\n values: [one, two]\ninvalid: scalar\n"); - load.accept(first); assertEquals(5, target.get("base").get("damage")); assertFalse(target.containsKey("invalid")); - load.accept(yaml(kind + "/duplicate.yaml", "base:\n damage: 99\nsecond:\n damage: 7\n")); - assertEquals(5, target.get("base").get("damage")); assertEquals(7, target.get("second").get("damage")); - yaml(kind + "/ignored.txt", "ignored: {damage: 8}"); yaml(kind + "/nested/ignored.yml", "nested: {damage: 9}"); - loadFolder.accept(temp.resolve(kind).toFile()); assertEquals(Set.of("base", "second"), target.keySet()); - loadFolder.accept(null); loadFolder.accept(first); loadFolder.accept(temp.resolve("missing").toFile()); - Path inaccessible = Files.createDirectory(temp.resolve("unreadable")); - var permissions = Files.getPosixFilePermissions(inaccessible); - try { - Files.setPosixFilePermissions(inaccessible, Set.of()); - assertNull(inaccessible.toFile().listFiles(), "Fixture must model an unreadable directory"); - loadFolder.accept(inaccessible.toFile()); - } finally { Files.setPosixFilePermissions(inaccessible, permissions); } - load.accept(temp.resolve("missing.yml").toFile()); load.accept(yaml("bad.yml", "key: [unterminated")); - assertEquals(Set.of("base", "second"), target.keySet()); + @ParameterizedTest + @ValueSource(strings = {"missing", "malformed"}) + void firstInvalidConfigAndTrainLoadUsesTheSameDefaultsAsEmptyYaml(String kind) throws Exception { + File empty = yaml("cold-empty.yml", "{}\n"); + File config = temp.resolve("cold-config.yml").toFile(); + File trains = temp.resolve("cold-trains.yml").toFile(); + if (kind.equals("malformed")) { + config = yaml("cold-config.yml", "broken: [unterminated\n"); + trains = yaml("cold-trains.yml", "broken: [unterminated\n"); } - @ParameterizedTest @ValueSource(strings = {"weapon", "death"}) - void templatesResolveDeepInstanceOverridesWithoutMutatingTheTemplate(String kind) throws Exception { - templateLoader(kind, false).accept(yaml("template.yml", "base:\n damage: 5\n nested:\n radius: 2\n count: 3\n values: [one, two]\n")); - java.util.function.BiFunction resolve = kind.equals("weapon") - ? WeaponTemplateLoader::resolve : DeathTemplateLoader::resolve; - java.util.function.Function> find = kind.equals("weapon") - ? WeaponTemplateLoader::getByString : DeathTemplateLoader::getByString; - assertNull(find.apply(null)); assertNull(find.apply("missing")); assertNotNull(find.apply("base")); - assertNull(resolve.apply("test", null)); - YamlConfiguration standalone = configuration("damage: 8\n"); assertSame(standalone, resolve.apply("test", standalone)); - standalone.set("template", " "); assertSame(standalone, resolve.apply("test", standalone)); - standalone.set("template", "missing"); assertNull(resolve.apply("test", standalone)); - ConfigurationSection merged = resolve.apply("test", configuration("template: base\nnested:\n radius: 9\nvalues: [three]\n")); - assertEquals(5, merged.getInt("damage")); assertEquals(9, merged.getInt("nested.radius")); assertEquals(3, merged.getInt("nested.count")); - assertEquals(List.of("three"), merged.getStringList("values")); assertFalse(merged.contains("template")); - assertEquals(2, ((Map) find.apply("base").get("nested")).get("radius")); - assertEquals(List.of("one", "two"), find.apply("base").get("values")); + Map expected; + try (ColdConfiguration baseline = new ColdConfiguration()) { + baseline.load(empty, empty); + expected = baseline.settings(); } + Map requiredDefaults = + Map.ofEntries( + Map.entry("despawnDistance", 4096), + Map.entry("blockDamage", true), + Map.entry("skinItem", "v.bucket"), + Map.entry("repairItem", "v.iron_shovel"), + Map.entry("destroyItem", "v.stone_axe"), + Map.entry("ticketItem", "v.paper"), + Map.entry("mythicMob", "none"), + Map.entry("trackItemSmall", "ia.tfmc:track_small"), + Map.entry("trackItemMedium", "ia.tfmc:track_medium"), + Map.entry("trackItemLarge", "ia.tfmc:track_large"), + Map.entry("trackLayerItem", "v.iron_shovel"), + Map.entry("trackRemoverItem", "v.iron_pickaxe"), + Map.entry("trackRecorderItem", "v.clock"), + Map.entry("trackJunctionItem", "v.diamond_shovel"), + Map.entry("trackSwitchItem", "ia.tfmc:railroad_switch"), + Map.entry("trackItem", "ia.tfmc:train_track"), + Map.entry("appliedTrackItemSmall", "ia.tfmc:track_small"), + Map.entry("appliedTrackItemMedium", "ia.tfmc:track_medium"), + Map.entry("appliedTrackItemLarge", "ia.tfmc:track_large"), + Map.entry("trackFxParticle", Particle.BLOCK), + Map.entry("trackBuildParticle", Particle.BLOCK), + Map.entry("trackFxBlock", Material.GRAVEL), + Map.entry("trackBuildBlock", Material.GRAVEL)); + assertAll( + "Empty YAML supplies usable defaults", + requiredDefaults.entrySet().stream() + .map( + entry -> + (org.junit.jupiter.api.function.Executable) + () -> + assertEquals( + entry.getValue(), expected.get(entry.getKey()), entry.getKey()))); - @Test void armorRoleAndInlineDamageLayersResolveInOrderWithoutMutatingTemplates() throws Exception { - ArmorTemplateLoader loader = new ArmorTemplateLoader(); - loader.load(yaml("armor.yml", "plated: {PROJECTILE: 0.5, FIRE: 0.25}\n"), ArmorTemplateLoader.armor, "armor"); - loader.load(yaml("roles.yml", "carrier: {PROJECTILE: 0.75, FALL: 0.5}\n"), ArmorTemplateLoader.roles, "role"); - DamageData damage = ArmorTemplateLoader.resolve("hull", configuration("armor: plated\nrole: carrier\ndamage:\n PROJECTILE: 0.9\n")); - assertEquals(.9, damage.getModifier("PROJECTILE")); assertEquals(.25, damage.getModifier("FIRE")); assertEquals(.5, damage.getModifier("FALL")); - assertEquals(.5, ArmorTemplateLoader.armor.get("plated").get("PROJECTILE")); - damage = ArmorTemplateLoader.resolve("hull", configuration("armor: missing\nrole: carrier\ndamage: [FALL(0.8)]\n")); - assertEquals(.8, damage.getModifier("FALL")); assertEquals(.75, damage.getModifier("PROJECTILE")); - damage = ArmorTemplateLoader.resolve("hull", configuration("role: missing\n")); assertTrue(damage.getModifiers().isEmpty()); - assertTrue(ArmorTemplateLoader.resolve("hull", null).getModifiers().isEmpty()); - assertEquals(.7, ArmorTemplateLoader.resolve("hull", configuration("damage: {fall: 0.7}")).getModifier("FALL")); - assertEquals(.4, ArmorTemplateLoader.resolve("hull", configuration("damage: [fall(0.4)]")).getModifier("FALL")); - assertNull(ArmorTemplateLoader.damageOverlay(null)); assertNull(ArmorTemplateLoader.damageOverlay(new YamlConfiguration())); - assertEquals(Map.of(), ArmorTemplateLoader.listToMap(null)); - assertEquals(Map.of(), ArmorTemplateLoader.mergeLayers(null, null, null)); - assertEquals(Map.of("FALL", .5), ArmorTemplateLoader.mergeLayers(null, Map.of("FALL", .5), null)); + try (ColdConfiguration startup = new ColdConfiguration()) { + assertNotSame(Cache.class, startup.loadClass(Cache.class.getName())); + assertSame(VFLogger.class, startup.loadClass(VFLogger.class.getName())); + assertSame(YamlConfiguration.class, startup.loadClass(YamlConfiguration.class.getName())); + startup.load(config, trains); + Map actual = startup.settings(); + assertEquals(expected.keySet(), actual.keySet()); + assertAll( + "Cold startup with " + kind + " YAML", + expected.entrySet().stream() + .map( + entry -> + (org.junit.jupiter.api.function.Executable) + () -> + assertEquals( + entry.getValue(), actual.get(entry.getKey()), entry.getKey()))); } + } - @Test void absentTemplateLayersAndNestedValuesAreCopiedWithoutAliasing() { - assertEquals(Map.of(), ConfigMerger.fromSection(null)); assertTrue(ConfigMerger.toConfiguration(null).getKeys(false).isEmpty()); - List nested = new ArrayList<>(List.of(new LinkedHashMap<>(Map.of("amount", 3)))); - Map base = new LinkedHashMap<>(Map.of("entries", nested)); - Map copy = ConfigMerger.overlay(base, null); - assertEquals(base, copy); assertNotSame(base, copy); assertNotSame(nested, copy.get("entries")); - nested.clear(); assertEquals(1, ((List) copy.get("entries")).size()); - assertEquals(Map.of("name", "test"), ConfigMerger.overlay(null, Map.of("name", "test"))); + @ParameterizedTest + @ValueSource(strings = {"weapon", "death", "armor", "role"}) + void templateFoldersFilterFilesPreserveFirstDefinitionAndSkipNonSections(String kind) + throws Exception { + java.util.function.Consumer load = templateLoader(kind, false); + java.util.function.Consumer loadFolder = templateLoader(kind, true); + Map> target = templates(kind); + File first = + yaml( + kind + "/first.YML", + "base:\n" + + " damage: 5\n" + + " nested:\n" + + " radius: 2\n" + + " values: [one, two]\n" + + "invalid: scalar\n"); + load.accept(first); + assertEquals(5, target.get("base").get("damage")); + assertFalse(target.containsKey("invalid")); + load.accept(yaml(kind + "/duplicate.yaml", "base:\n damage: 99\nsecond:\n damage: 7\n")); + assertEquals(5, target.get("base").get("damage")); + assertEquals(7, target.get("second").get("damage")); + yaml(kind + "/ignored.txt", "ignored: {damage: 8}"); + yaml(kind + "/nested/ignored.yml", "nested: {damage: 9}"); + loadFolder.accept(temp.resolve(kind).toFile()); + assertEquals(Set.of("base", "second"), target.keySet()); + loadFolder.accept(null); + loadFolder.accept(first); + loadFolder.accept(temp.resolve("missing").toFile()); + Path inaccessible = Files.createDirectory(temp.resolve("unreadable")); + Assumptions.assumeTrue( + Files.getFileStore(inaccessible).supportsFileAttributeView("posix"), + "An unreadable-directory fixture requires POSIX file permissions"); + var permissions = Files.getPosixFilePermissions(inaccessible); + try { + Files.setPosixFilePermissions(inaccessible, Set.of()); + Assumptions.assumeTrue( + inaccessible.toFile().listFiles() == null, + "This filesystem or user cannot enforce unreadable-directory permissions"); + loadFolder.accept(inaccessible.toFile()); + } finally { + Files.setPosixFilePermissions(inaccessible, permissions); } + load.accept(temp.resolve("missing.yml").toFile()); + load.accept(yaml("bad.yml", "key: [unterminated")); + assertEquals(Set.of("base", "second"), target.keySet()); + } + + @ParameterizedTest + @ValueSource(strings = {"weapon", "death"}) + void templatesResolveDeepInstanceOverridesWithoutMutatingTheTemplate(String kind) + throws Exception { + templateLoader(kind, false) + .accept( + yaml( + "template.yml", + "base:\n" + + " damage: 5\n" + + " nested:\n" + + " radius: 2\n" + + " count: 3\n" + + " values: [one, two]\n")); + java.util.function.BiFunction resolve = + kind.equals("weapon") ? WeaponTemplateLoader::resolve : DeathTemplateLoader::resolve; + java.util.function.Function> find = + kind.equals("weapon") + ? WeaponTemplateLoader::getByString + : DeathTemplateLoader::getByString; + assertNull(find.apply(null)); + assertNull(find.apply("missing")); + assertNotNull(find.apply("base")); + assertNull(resolve.apply("test", null)); + YamlConfiguration standalone = configuration("damage: 8\n"); + assertSame(standalone, resolve.apply("test", standalone)); + standalone.set("template", " "); + assertSame(standalone, resolve.apply("test", standalone)); + standalone.set("template", "missing"); + assertNull(resolve.apply("test", standalone)); + ConfigurationSection merged = + resolve.apply( + "test", configuration("template: base\nnested:\n radius: 9\nvalues: [three]\n")); + assertEquals(5, merged.getInt("damage")); + assertEquals(9, merged.getInt("nested.radius")); + assertEquals(3, merged.getInt("nested.count")); + assertEquals(List.of("three"), merged.getStringList("values")); + assertFalse(merged.contains("template")); + assertEquals(2, ((Map) find.apply("base").get("nested")).get("radius")); + assertEquals(List.of("one", "two"), find.apply("base").get("values")); + } + + @Test + void armorRoleAndInlineDamageLayersResolveInOrderWithoutMutatingTemplates() throws Exception { + ArmorTemplateLoader loader = new ArmorTemplateLoader(); + loader.load( + yaml("armor.yml", "plated: {PROJECTILE: 0.5, FIRE: 0.25}\n"), + ArmorTemplateLoader.armor, + "armor"); + loader.load( + yaml("roles.yml", "carrier: {PROJECTILE: 0.75, FALL: 0.5}\n"), + ArmorTemplateLoader.roles, + "role"); + DamageData damage = + ArmorTemplateLoader.resolve( + "hull", configuration("armor: plated\nrole: carrier\ndamage:\n PROJECTILE: 0.9\n")); + assertEquals(.9, damage.getModifier("PROJECTILE")); + assertEquals(.25, damage.getModifier("FIRE")); + assertEquals(.5, damage.getModifier("FALL")); + assertEquals(.5, ArmorTemplateLoader.armor.get("plated").get("PROJECTILE")); + damage = + ArmorTemplateLoader.resolve( + "hull", configuration("armor: missing\nrole: carrier\ndamage: [FALL(0.8)]\n")); + assertEquals(.8, damage.getModifier("FALL")); + assertEquals(.75, damage.getModifier("PROJECTILE")); + damage = ArmorTemplateLoader.resolve("hull", configuration("role: missing\n")); + assertTrue(damage.getModifiers().isEmpty()); + assertTrue(ArmorTemplateLoader.resolve("hull", null).getModifiers().isEmpty()); + assertEquals( + .7, + ArmorTemplateLoader.resolve("hull", configuration("damage: {fall: 0.7}")) + .getModifier("FALL")); + assertEquals( + .4, + ArmorTemplateLoader.resolve("hull", configuration("damage: [fall(0.4)]")) + .getModifier("FALL")); + assertNull(ArmorTemplateLoader.damageOverlay(null)); + assertNull(ArmorTemplateLoader.damageOverlay(new YamlConfiguration())); + assertEquals(Map.of(), ArmorTemplateLoader.listToMap(null)); + assertEquals(Map.of(), ArmorTemplateLoader.mergeLayers(null, null, null)); + assertEquals( + Map.of("FALL", .5), ArmorTemplateLoader.mergeLayers(null, Map.of("FALL", .5), null)); + } + + @Test + void absentTemplateLayersAndNestedValuesAreCopiedWithoutAliasing() { + assertEquals(Map.of(), ConfigMerger.fromSection(null)); + assertTrue(ConfigMerger.toConfiguration(null).getKeys(false).isEmpty()); + List nested = new ArrayList<>(List.of(new LinkedHashMap<>(Map.of("amount", 3)))); + Map base = new LinkedHashMap<>(Map.of("entries", nested)); + Map copy = ConfigMerger.overlay(base, null); + assertEquals(base, copy); + assertNotSame(base, copy); + assertNotSame(nested, copy.get("entries")); + nested.clear(); + assertEquals(1, ((List) copy.get("entries")).size()); + assertEquals(Map.of("name", "test"), ConfigMerger.overlay(null, Map.of("name", "test"))); + } - @Test void fuelEntriesExposeConfiguredNamesAmountsSoundsAndCaseInsensitiveItemLookup() throws Exception { - new FuelLoader().load(yaml("fuel.yml", """ + @Test + void fuelEntriesExposeConfiguredNamesAmountsSoundsAndCaseInsensitiveItemLookup() + throws Exception { + new FuelLoader() + .load( + yaml( + "fuel.yml", + """ coal: name: Coal item: v.coal @@ -230,58 +488,415 @@ void templatesResolveDeepInstanceOverridesWithoutMutatingTheTemplate(String kind sound-pitch: 1.2 fallback: {} """)); - Fuel fuel = FuelLoader.getByString("coal"); assertNotNull(fuel); assertSame(fuel, FuelLoader.getByInput("V.COAL")); - assertTrue(FuelLoader.itemIsFuel("V.COAL")); assertFalse(FuelLoader.itemIsFuel("missing")); assertNull(FuelLoader.getByInput("missing")); - assertNull(FuelLoader.getByString("missing")); assertEquals(2, FuelLoader.get().size()); - assertEquals("coal", fuel.getId()); assertEquals("Coal", fuel.getName()); assertEquals(45, fuel.getAmount()); assertTrue(fuel.refuelWhileRunning()); - assertEquals("minecraft:block.lava.extinguish", fuel.getSound()); assertEquals(.4f, fuel.getSoundVolume()); assertEquals(1.2f, fuel.getSoundPitch()); - assertEquals("v.bedrock", FuelLoader.getByString("fallback").getItem()); assertEquals(100, FuelLoader.getByString("fallback").getAmount()); + Fuel fuel = FuelLoader.getByString("coal"); + assertNotNull(fuel); + assertSame(fuel, FuelLoader.getByInput("V.COAL")); + assertTrue(FuelLoader.itemIsFuel("V.COAL")); + assertFalse(FuelLoader.itemIsFuel("missing")); + assertNull(FuelLoader.getByInput("missing")); + assertNull(FuelLoader.getByString("missing")); + assertEquals(2, FuelLoader.get().size()); + assertEquals("coal", fuel.getId()); + assertEquals("Coal", fuel.getName()); + assertEquals(45, fuel.getAmount()); + assertTrue(fuel.refuelWhileRunning()); + assertEquals("minecraft:block.lava.extinguish", fuel.getSound()); + assertEquals(.4f, fuel.getSoundVolume()); + assertEquals(1.2f, fuel.getSoundPitch()); + assertEquals("v.bedrock", FuelLoader.getByString("fallback").getItem()); + assertEquals(100, FuelLoader.getByString("fallback").getAmount()); + } + + @Test + void ammunitionAndVehicleFilesRegisterRealConfiguredTemplates() throws Exception { + new AmmunitionLoader() + .load( + yaml( + "ammo.yml", + "round: {type: BULLET, input: custom.round, damage: 12}\n" + + "shell: {input: custom.shell}\n")); + assertInstanceOf(Bullet.class, AmmunitionLoader.getByString("round")); + assertNotNull(AmmunitionLoader.getByString("shell")); + assertSame(AmmunitionLoader.getByString("round"), AmmunitionLoader.getByInput("CUSTOM.ROUND")); + assertNull(AmmunitionLoader.getByString("missing")); + assertNull(AmmunitionLoader.getByInput("missing")); + assertEquals(2, AmmunitionLoader.get().size()); + new VehicleLoader() + .load( + yaml( + "vehicles.yml", + "cart:\n" + + " name: Cargo Cart\n" + + " model: default\n" + + " skins: {default: {model: cart_model}}\n" + + " states: {}\n" + + " components: {hull: {health: 100}}\n" + + " seats: []\n")); + Vehicle cart = VehicleLoader.getByString("cart"); + assertNotNull(cart); + assertEquals("Cargo Cart", cart.getName()); + assertEquals("cart", cart.getId()); + assertEquals("default", cart.getModel()); + assertEquals("cart_model", cart.getSkinHandler().getCurrentSkin().getModel()); + assertEquals(1, cart.getComponentHandler().getComponents().size()); + assertNull(VehicleLoader.getByString("missing")); + } + + @ParameterizedTest + @CsvSource({ + "scalar, false", + "empty-gears, false", + "bad-seat, false", + "scalar-engine, false", + "scalar-hull, false", + "scalar, true", + "empty-gears, true", + "bad-seat, true", + "scalar-engine, true", + "scalar-hull, true" + }) + void invalidVehicleDefinitionsRetainExistingEntriesAndDoNotHideLaterValidVehicles( + String kind, boolean reload) throws Exception { + VehicleLoader loader = new VehicleLoader(); + YamlConfiguration initial = new YamlConfiguration(); + initial.set("existing", vehicleDefinition("Original vehicle")); + loader.load(yaml("initial-vehicles.yml", initial.saveToString())); + Vehicle previous = VehicleLoader.getByString("existing"); + assertNotNull(previous); + + Map invalidSection = vehicleDefinition("Invalid replacement"); + Object invalid; + if (kind.equals("scalar")) { + invalid = "not a vehicle section"; + } else { + if (kind.equals("empty-gears")) { + invalidSection.put("components", Map.of("geared_engine", Map.of("gears", Map.of()))); + } else if (kind.startsWith("scalar-")) { + invalidSection.put("components", Map.of(kind.substring("scalar-".length()), "not a section")); + } else { + invalidSection.put("seats", List.of("captain_without_a_bone")); + } + invalid = invalidSection; } + YamlConfiguration replacement = new YamlConfiguration(); + replacement.set("existing", invalid); + replacement.set("new_invalid", invalid); + replacement.set("later_valid", vehicleDefinition("Later valid vehicle")); + File file = yaml("mixed-vehicles/definitions.yml", replacement.saveToString()); + logger.clearInvocations(); + + assertDoesNotThrow( + () -> { + if (reload) loader.reload(file.getParentFile()); + else loader.load(file); + }); - @Test void ammunitionAndVehicleFilesRegisterRealConfiguredTemplates() throws Exception { - new AmmunitionLoader().load(yaml("ammo.yml", "round: {type: BULLET, input: custom.round, damage: 12}\nshell: {input: custom.shell}\n")); - assertInstanceOf(Bullet.class, AmmunitionLoader.getByString("round")); assertNotNull(AmmunitionLoader.getByString("shell")); - assertSame(AmmunitionLoader.getByString("round"), AmmunitionLoader.getByInput("CUSTOM.ROUND")); - assertNull(AmmunitionLoader.getByString("missing")); assertNull(AmmunitionLoader.getByInput("missing")); assertEquals(2, AmmunitionLoader.get().size()); - new VehicleLoader().load(yaml("vehicles.yml", "cart:\n name: Cargo Cart\n model: default\n skins: {default: {model: cart_model}}\n states: {}\n components: {hull: {health: 100}}\n seats: []\n")); - Vehicle cart = VehicleLoader.getByString("cart"); assertNotNull(cart); assertEquals("Cargo Cart", cart.getName()); - assertEquals("cart", cart.getId()); assertEquals("default", cart.getModel()); assertEquals("cart_model", cart.getSkinHandler().getCurrentSkin().getModel()); - assertEquals(1, cart.getComponentHandler().getComponents().size()); assertNull(VehicleLoader.getByString("missing")); + assertSame(previous, VehicleLoader.getByString("existing")); + assertEquals("Original vehicle", previous.getName()); + assertNull(VehicleLoader.getByString("new_invalid")); + assertNotNull(VehicleLoader.getByString("later_valid")); + assertEquals("Later valid vehicle", VehicleLoader.getByString("later_valid").getName()); + assertEquals(Set.of("existing", "later_valid"), VehicleLoader.get().keySet()); + logger.verify(() -> VFLogger.log(contains("existing")), atLeastOnce()); + logger.verify(() -> VFLogger.log(contains("new_invalid")), atLeastOnce()); + } + + @ParameterizedTest + @ValueSource(strings = {"ammunition", "vehicles"}) + void folderReloadPublishesOnlyAfterAllFilesParseAndEmptySuccessRemovesOldEntries(String kind) + throws Exception { + registryLoader(kind).accept(registryFile(kind, "initial.yml", "old")); + Object previous = registry(kind).get("old"); + assertNotNull(previous); + File good = registryFile(kind, "replacement/first.yml", "new"); + File malformed = yaml("replacement/second.yml", "broken: [unterminated\n"); + File folder = spy(good.getParentFile()); + // The directory listing is the only mocked boundary; both YAML files are read normally. + doReturn(new File[] {good, malformed}).when(folder).listFiles(any(java.io.FileFilter.class)); + + registryReloader(kind).accept(folder); + + assertEquals(Set.of("old"), registry(kind).keySet()); + assertSame(previous, registry(kind).get("old")); + Files.writeString(malformed.toPath(), "{}\n"); + registryReloader(kind).accept(folder); + assertEquals(Set.of("new"), registry(kind).keySet()); + assertNotSame(previous, registry(kind).get("new")); + if (kind.equals("vehicles")) { + assertEquals("Vehicle new", VehicleLoader.getByString("new").getName()); + } else { + assertEquals("custom.new", AmmunitionLoader.getByString("new").getData().getInput()); } - @Test void basicLoadersIgnoreUnreadableAndMalformedFilesWithoutDiscardingExistingEntries() throws Exception { - new FuelLoader().load(yaml("fuel.yml", "coal: {}")); - new AmmunitionLoader().load(yaml("ammunition.yml", "shell: {}")); - File absent = temp.resolve("absent.yml").toFile(), broken = yaml("broken.yml", "bad: [unterminated"); - for (File file : List.of(absent, broken)) { - new FuelLoader().load(file); new AmmunitionLoader().load(file); new VehicleLoader().load(file); - } - assertNotNull(FuelLoader.getByString("coal")); assertNotNull(AmmunitionLoader.getByString("shell")); assertTrue(VehicleLoader.get().isEmpty()); + Path empty = Files.createDirectory(temp.resolve("empty")); + Files.createDirectory(empty.resolve("ignored-subdirectory")); + registryReloader(kind).accept(empty.toFile()); + assertTrue(registry(kind).isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"ammunition", "vehicles"}) + void missingOrUnlistableReloadFolderKeepsTheLastUsableRegistry(String kind) throws Exception { + registryLoader(kind).accept(registryFile(kind, "initial.yml", "old")); + Object previous = registry(kind).get("old"); + File missing = temp.resolve("missing-folder").toFile(); + File ordinaryFile = yaml("ordinary-file.yml", "{}\n"); + for (File folder : List.of(missing, ordinaryFile)) { + assertDoesNotThrow(() -> registryReloader(kind).accept(folder)); + assertEquals(Set.of("old"), registry(kind).keySet()); + assertSame(previous, registry(kind).get("old")); } + } - private java.util.function.Consumer templateLoader(String kind, boolean folder) { - return switch (kind) { - case "weapon" -> folder ? new WeaponTemplateLoader()::loadFolder : new WeaponTemplateLoader()::load; - case "death" -> folder ? new DeathTemplateLoader()::loadFolder : new DeathTemplateLoader()::load; - case "armor" -> folder ? new ArmorTemplateLoader()::loadArmorFolder : file -> new ArmorTemplateLoader().load(file, ArmorTemplateLoader.armor, "armor"); - case "role" -> folder ? new ArmorTemplateLoader()::loadRoleFolder : file -> new ArmorTemplateLoader().load(file, ArmorTemplateLoader.roles, "role"); - default -> throw new IllegalArgumentException(kind); - }; + @ParameterizedTest + @ValueSource(strings = {"ammunition", "vehicles"}) + void unreadableReloadFolderKeepsTheLastUsableRegistry(String kind) throws Exception { + registryLoader(kind).accept(registryFile(kind, "initial.yml", "old")); + Object previous = registry(kind).get("old"); + Path folder = Files.createDirectory(temp.resolve("unreadable-registry")); + Assumptions.assumeTrue( + Files.getFileStore(folder).supportsFileAttributeView("posix"), + "An unreadable-directory fixture requires POSIX file permissions"); + var permissions = Files.getPosixFilePermissions(folder); + try { + Files.setPosixFilePermissions(folder, Set.of()); + Assumptions.assumeTrue( + folder.toFile().listFiles() == null, + "This filesystem or user cannot enforce unreadable-directory permissions"); + assertDoesNotThrow(() -> registryReloader(kind).accept(folder.toFile())); + assertEquals(Set.of("old"), registry(kind).keySet()); + assertSame(previous, registry(kind).get("old")); + } finally { + Files.setPosixFilePermissions(folder, permissions); + } + } + + @Test + void fuelReloadPreservesItsRegistryOnFileErrorsAndRemovesObsoleteEntriesOnSuccess() + throws Exception { + FuelLoader loader = new FuelLoader(); + loader.load(registryFile("fuel", "initial-fuel.yml", "old")); + Fuel previous = FuelLoader.getByString("old"); + File malformed = yaml("malformed-fuel.yml", "broken: [unterminated\n"); + File scalar = yaml("scalar-fuel.yml", "new: {amount: 70}\nbad: not_a_section\n"); + File missing = temp.resolve("missing-fuel.yml").toFile(); + File directory = Files.createDirectory(temp.resolve("fuel-directory")).toFile(); + for (File bad : List.of(malformed, scalar, missing, directory)) { + assertDoesNotThrow(() -> loader.reload(bad)); + assertEquals(Set.of("old"), FuelLoader.get().keySet()); + assertSame(previous, FuelLoader.getByString("old")); } - private Map> templates(String kind) { - return switch (kind) { - case "weapon" -> WeaponTemplateLoader.map; - case "death" -> DeathTemplateLoader.map; - case "armor" -> ArmorTemplateLoader.armor; - case "role" -> ArmorTemplateLoader.roles; - default -> throw new IllegalArgumentException(kind); + loader.load(scalar); + assertEquals(Set.of("old"), FuelLoader.get().keySet()); + assertSame(previous, FuelLoader.getByString("old")); + loader.reload(registryFile("fuel", "replacement-fuel.yml", "new")); + assertEquals(Set.of("new"), FuelLoader.get().keySet()); + assertEquals(42, FuelLoader.getByString("new").getAmount()); + loader.reload(yaml("empty-fuel.yml", "{}\n")); + assertTrue(FuelLoader.get().isEmpty()); + } + + @Test + void unreadableFuelFileKeepsItsPreviousDefinitions() throws Exception { + FuelLoader loader = new FuelLoader(); + loader.load(registryFile("fuel", "initial-fuel.yml", "old")); + Fuel previous = FuelLoader.getByString("old"); + Path unreadable = registryFile("fuel", "unreadable-fuel.yml", "new").toPath(); + Assumptions.assumeTrue( + Files.getFileStore(unreadable).supportsFileAttributeView("posix"), + "An unreadable-file fixture requires POSIX file permissions"); + var permissions = Files.getPosixFilePermissions(unreadable); + try { + Files.setPosixFilePermissions(unreadable, Set.of()); + Assumptions.assumeFalse( + Files.isReadable(unreadable), + "This filesystem or user cannot enforce unreadable-file permissions"); + assertDoesNotThrow(() -> loader.reload(unreadable.toFile())); + assertEquals(Set.of("old"), FuelLoader.get().keySet()); + assertSame(previous, FuelLoader.getByString("old")); + } finally { + Files.setPosixFilePermissions(unreadable, permissions); + } + } + + @ParameterizedTest + @ValueSource(strings = {"scalar", "unknown-type"}) + void invalidAmmunitionEntriesCannotPartiallyReplaceOrAddDefinitions(String kind) + throws Exception { + AmmunitionLoader loader = new AmmunitionLoader(); + loader.load(registryFile("ammunition", "initial-ammunition.yml", "old")); + Ammunition previous = AmmunitionLoader.getByString("old"); + String bad = kind.equals("scalar") ? "not_a_section" : "{type: unknown_ammunition}"; + File mixed = + yaml("invalid-ammunition/definitions.yml", "new: {input: custom.new}\nbad: " + bad + "\n"); + assertDoesNotThrow(() -> loader.load(mixed)); + assertEquals(Set.of("old"), AmmunitionLoader.get().keySet()); + assertSame(previous, AmmunitionLoader.getByString("old")); + assertDoesNotThrow(() -> loader.reload(mixed.getParentFile())); + assertEquals(Set.of("old"), AmmunitionLoader.get().keySet()); + assertSame(previous, AmmunitionLoader.getByString("old")); + } + + private File registryFile(String kind, String filename, String id) throws Exception { + YamlConfiguration config = new YamlConfiguration(); + Object definition = + switch (kind) { + case "vehicles" -> vehicleDefinition("Vehicle " + id); + case "ammunition" -> Map.of("input", "custom." + id); + case "fuel" -> Map.of("amount", 42); + default -> throw new IllegalArgumentException(kind); }; + config.set(id, definition); + return yaml(filename, config.saveToString()); + } + + private static Map registry(String kind) { + return switch (kind) { + case "vehicles" -> VehicleLoader.get(); + case "ammunition" -> AmmunitionLoader.get(); + case "fuel" -> FuelLoader.get(); + default -> throw new IllegalArgumentException(kind); + }; + } + + private static java.util.function.Consumer registryLoader(String kind) { + return switch (kind) { + case "vehicles" -> new VehicleLoader()::load; + case "ammunition" -> new AmmunitionLoader()::load; + case "fuel" -> new FuelLoader()::load; + default -> throw new IllegalArgumentException(kind); + }; + } + + private static java.util.function.Consumer registryReloader(String kind) { + return switch (kind) { + case "vehicles" -> new VehicleLoader()::reload; + case "ammunition" -> new AmmunitionLoader()::reload; + case "fuel" -> new FuelLoader()::reload; + default -> throw new IllegalArgumentException(kind); + }; + } + + private static Map vehicleDefinition(String name) { + Map definition = new LinkedHashMap<>(); + definition.put("name", name); + definition.put("model", "default"); + definition.put("skins", Map.of("default", Map.of("model", "cart_model"))); + definition.put("states", Map.of()); + definition.put("components", Map.of("hull", Map.of("health", 100))); + definition.put("seats", List.of()); + return definition; + } + + @Test + void basicLoadersIgnoreUnreadableAndMalformedFilesWithoutDiscardingExistingEntries() + throws Exception { + new FuelLoader().load(yaml("fuel.yml", "coal: {}")); + new AmmunitionLoader().load(yaml("ammunition.yml", "shell: {}")); + File absent = temp.resolve("absent.yml").toFile(), + broken = yaml("broken.yml", "bad: [unterminated"); + for (File file : List.of(absent, broken)) { + new FuelLoader().load(file); + new AmmunitionLoader().load(file); + new VehicleLoader().load(file); + } + assertNotNull(FuelLoader.getByString("coal")); + assertNotNull(AmmunitionLoader.getByString("shell")); + assertTrue(VehicleLoader.get().isEmpty()); + } + + /** Loads the original configuration classes with fresh static initialization. */ + private static final class ColdConfiguration extends java.net.URLClassLoader { + private static final Set ISOLATED_CLASSES = + Set.of(Cache.class.getName(), ConfigLoader.class.getName(), TrainsLoader.class.getName()); + + ColdConfiguration() { + super( + new java.net.URL[] { + ConfigLoader.class.getProtectionDomain().getCodeSource().getLocation() + }, + ConfigLoader.class.getClassLoader()); + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!ISOLATED_CLASSES.contains(name)) { + return super.loadClass(name, resolve); + } + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) { + loaded = findClass(name); + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } } - private YamlConfiguration configuration(String content) throws Exception { - YamlConfiguration config = new YamlConfiguration(); config.loadFromString(content); return config; + + void load(File config, File trains) throws ReflectiveOperationException { + invokeLoader(ConfigLoader.class.getName(), config); + invokeLoader(TrainsLoader.class.getName(), trains); + // Startup applies the loaded item choices to the display style before creating tracks. + loadClass(Cache.class.getName()).getMethod("applyTrackDisplayStyle").invoke(null); + } + + private void invokeLoader(String className, File file) throws ReflectiveOperationException { + Class loaderClass = loadClass(className); + Object loader = loaderClass.getConstructor().newInstance(); + loaderClass.getMethod("load", File.class).invoke(loader, file); } - private File yaml(String name, String content) throws Exception { - Path path = temp.resolve(name); Files.createDirectories(path.getParent()); Files.writeString(path, content); return path.toFile(); + Map settings() throws ReflectiveOperationException { + Map values = new TreeMap<>(); + for (java.lang.reflect.Field field : loadClass(Cache.class.getName()).getFields()) { + if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) { + values.put(field.getName(), field.get(null)); + } + } + return values; } + } + + private java.util.function.Consumer templateLoader(String kind, boolean folder) { + return switch (kind) { + case "weapon" -> + folder ? new WeaponTemplateLoader()::loadFolder : new WeaponTemplateLoader()::load; + case "death" -> + folder ? new DeathTemplateLoader()::loadFolder : new DeathTemplateLoader()::load; + case "armor" -> + folder + ? new ArmorTemplateLoader()::loadArmorFolder + : file -> new ArmorTemplateLoader().load(file, ArmorTemplateLoader.armor, "armor"); + case "role" -> + folder + ? new ArmorTemplateLoader()::loadRoleFolder + : file -> new ArmorTemplateLoader().load(file, ArmorTemplateLoader.roles, "role"); + default -> throw new IllegalArgumentException(kind); + }; + } + + private Map> templates(String kind) { + return switch (kind) { + case "weapon" -> WeaponTemplateLoader.map; + case "death" -> DeathTemplateLoader.map; + case "armor" -> ArmorTemplateLoader.armor; + case "role" -> ArmorTemplateLoader.roles; + default -> throw new IllegalArgumentException(kind); + }; + } + + private YamlConfiguration configuration(String content) throws Exception { + YamlConfiguration config = new YamlConfiguration(); + config.loadFromString(content); + return config; + } + + private File yaml(String name, String content) throws Exception { + Path path = temp.resolve(name); + Files.createDirectories(path.getParent()); + Files.writeString(path, content); + return path.toFile(); + } } diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/SpawnAndRepairCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/SpawnAndRepairCoverageTest.java index 44fa091..d732f64 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/SpawnAndRepairCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/SpawnAndRepairCoverageTest.java @@ -101,6 +101,21 @@ class SpawnAndRepairCoverageTest { assertNull(repairs.getRepairTarget(player)); } + @ParameterizedTest @ValueSource(strings = {"engine", "wings"}) + void componentRepairTagsAreIndependentOfTheServerLocale(String tag) { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + VehicleComponent target = component(Component.valueOf(tag.toUpperCase(Locale.ROOT))); + repairs.repair(player, vehicle); + assertDoesNotThrow(() -> repairs.repairEvent(repairClick(tagged("vf_component_type", tag)))); + assertTrue(repairs.isBeingRepaired(target)); + assertSame(vehicle, repairs.getRepairTarget(player)); + } finally { + Locale.setDefault(previous); + } + } + @Test void onePlayerCannotStartTwoComponentRepairsAtOnce() { VehicleComponent hull = component(Component.HULL), pump = component(Component.PUMP); startComponent(player, hull); repairs.repairComponent(player, tagged("vf_component_type", "pump"), key("vf_component_type"), vehicle); diff --git a/src/test/java/net/tfminecraft/vehicleframework/projectiles/ProjectilesCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/projectiles/ProjectilesCoverageTest.java index 32e26bb..a0f47ec 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/projectiles/ProjectilesCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/projectiles/ProjectilesCoverageTest.java @@ -8,6 +8,15 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Predicate; +import net.tfminecraft.vehicleframework.*; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.managers.VehicleManager; +import net.tfminecraft.vehicleframework.test.RegistryFixture; +import net.tfminecraft.vehicleframework.util.ExplosionCreator; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.handlers.train.DeckBody; +import net.tfminecraft.vehicleframework.weapons.*; +import net.tfminecraft.vehicleframework.weapons.ammunition.Bullet; import org.bukkit.*; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; @@ -22,133 +31,578 @@ import org.bukkit.util.VoxelShape; import org.junit.jupiter.api.*; import org.mockito.*; -import net.tfminecraft.vehicleframework.*; -import net.tfminecraft.vehicleframework.cache.Cache; -import net.tfminecraft.vehicleframework.managers.VehicleManager; -import net.tfminecraft.vehicleframework.test.RegistryFixture; -import net.tfminecraft.vehicleframework.util.ExplosionCreator; -import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; -import net.tfminecraft.vehicleframework.vehicles.handlers.train.DeckBody; -import net.tfminecraft.vehicleframework.weapons.*; -import net.tfminecraft.vehicleframework.weapons.ammunition.Bullet; public class ProjectilesCoverageTest { - @BeforeAll static void registries() { RegistryFixture.initialize(); assertTrue(Material.AIR.isAir()); assertTrue(Material.STONE.isSolid()); } - Rig r; - @BeforeEach void setup() { r = new Rig(); } - @AfterEach void cleanup() throws Exception { r.close(); } - - @Test void solidWallStopsBulletBeforeLivingTarget() { - r.block(0,64,2,Material.STONE); - LivingEntity target=r.entity(LivingEntity.class,new Location(r.world,0.5,64.5,4.5)); - r.rayTargets.add(target); - try(var explosions=mockStatic(ExplosionCreator.class)) { - assertTrue(segment(r.bullet(false),null,5)); - explosions.verifyNoInteractions(); - verify(r.world).playSound(any(Location.class),eq(Sound.BLOCK_STONE_BREAK),eq(1f),eq(2f)); - } + @BeforeAll + static void registries() { + RegistryFixture.initialize(); + assertTrue(Material.AIR.isAir()); + assertTrue(Material.STONE.isSolid()); + } + + Rig r; + + @BeforeEach + void setup() { + r = new Rig(); + } + + @AfterEach + void cleanup() throws Exception { + r.close(); + } + + @Test + void solidWallStopsBulletBeforeLivingTarget() { + r.block(0, 64, 2, Material.STONE); + LivingEntity target = r.entity(LivingEntity.class, new Location(r.world, 0.5, 64.5, 4.5)); + r.rayTargets.add(target); + try (var explosions = mockStatic(ExplosionCreator.class)) { + assertTrue(segment(r.bullet(false), null, 5)); + explosions.verifyNoInteractions(); + verify(r.world).playSound(any(Location.class), eq(Sound.BLOCK_STONE_BREAK), eq(1f), eq(2f)); } + } - @Test void weaponCanOverrideBulletExplosionPolicyAndBlastSize() throws Exception { - Bullet bullet=r.bullet(false); - YamlConfiguration config=Rig.yaml("fixed: true\nprojectile-explosive: true\nprojectile-yield: 4\nprojectile-radius: 9\nprojectile-damage: 25\nprojectile-damage-type: FIRE\n"); - ActiveWeapon weapon=new ActiveWeapon(mock(com.ticxo.modelengine.api.model.ActiveModel.class),r.vehicle(),new Weapon("gun",config),null); - try(var explosions=mockStatic(ExplosionCreator.class)) { - BulletRaycast.triggerExplosion(new Location(r.world,0,64,0),bullet,weapon); - explosions.verify(()->ExplosionCreator.triggerExplosion(any(Location.class),eq(4d),eq(9d),eq(25d),eq("FIRE"))); - } + @Test + void weaponCanOverrideBulletExplosionPolicyAndBlastSize() throws Exception { + Bullet bullet = r.bullet(false); + YamlConfiguration config = + Rig.yaml( + "fixed: true\n" + + "projectile-explosive: true\n" + + "projectile-yield: 4\n" + + "projectile-radius: 9\n" + + "projectile-damage: 25\n" + + "projectile-damage-type: FIRE\n"); + ActiveWeapon weapon = + new ActiveWeapon( + mock(com.ticxo.modelengine.api.model.ActiveModel.class), + r.vehicle(), + new Weapon("gun", config), + null); + try (var explosions = mockStatic(ExplosionCreator.class)) { + BulletRaycast.triggerExplosion(new Location(r.world, 0, 64, 0), bullet, weapon); + explosions.verify( + () -> + ExplosionCreator.triggerExplosion( + any(Location.class), eq(4d), eq(9d), eq(25d), eq("FIRE"))); } + } - @Test void hitCheckerChecksEachNeighbourAndIgnoresLightAndWater() { - HitChecker checker=new HitChecker(); Entity projectile=r.entity(Entity.class,new Location(r.world,0,64,0)); - int[][] offsets={{0,0,0},{0,-1,0},{0,1,0},{0,0,-1},{0,0,1},{-1,0,0},{1,0,0}}; - for(int[] d:offsets) { - Block block=r.block(d[0],64+d[1],d[2],Material.STONE); - assertTrue(checker.hasHitLocation(projectile.getLocation())); assertTrue(checker.hasHit(projectile,List.of())); assertTrue(checker.hasHitIgnoreWater(projectile,List.of())); - block.setType(Material.LIGHT); assertFalse(checker.hasHitLocation(projectile.getLocation())); assertFalse(checker.hasHit(projectile,List.of())); assertFalse(checker.hasHitIgnoreWater(projectile,List.of())); block.setType(Material.AIR); + @Test + void hitCheckerChecksEachNeighbourAndIgnoresLightAndWater() { + HitChecker checker = new HitChecker(); + Entity projectile = r.entity(Entity.class, new Location(r.world, 0, 64, 0)); + int[][] offsets = { + {0, 0, 0}, {0, -1, 0}, {0, 1, 0}, {0, 0, -1}, {0, 0, 1}, {-1, 0, 0}, {1, 0, 0} + }; + for (int[] d : offsets) { + Block block = r.block(d[0], 64 + d[1], d[2], Material.STONE); + assertTrue(checker.hasHitLocation(projectile.getLocation())); + assertTrue(checker.hasHit(projectile, List.of())); + assertTrue(checker.hasHitIgnoreWater(projectile, List.of())); + block.setType(Material.LIGHT); + assertFalse(checker.hasHitLocation(projectile.getLocation())); + assertFalse(checker.hasHit(projectile, List.of())); + assertFalse(checker.hasHitIgnoreWater(projectile, List.of())); + block.setType(Material.AIR); + } + for (Material water : + List.of( + Material.WATER, + Material.KELP, + Material.KELP_PLANT, + Material.SEAGRASS, + Material.TALL_SEAGRASS)) { + r.block(0, 64, 0, water); + assertFalse(checker.hasHitIgnoreWater(projectile, List.of())); + } + Block logged = r.block(0, 64, 0, Material.OAK_SLAB); + Waterlogged data = mock(Waterlogged.class); + when(data.isWaterlogged()).thenReturn(true); + when(logged.getBlockData()).thenReturn(data); + assertFalse(checker.hasHitIgnoreWater(projectile, List.of())); + when(data.isWaterlogged()).thenReturn(false); + assertTrue(checker.hasHitIgnoreWater(projectile, List.of())); + } + + @Test + void hitCheckerUsesBoundsAndIgnoresSelfPassengersAndMarkers() { + HitChecker checker = new HitChecker(); + Entity projectile = r.entity(Entity.class, new Location(r.world, 0, 64, 0)); + Entity ignored = r.entity(Entity.class, new Location(r.world, 0, 64, 0)); + ArmorStand marker = r.entity(ArmorStand.class, new Location(r.world, 0, 64, 0)); + when(marker.isMarker()).thenReturn(true); + Entity miss = r.entity(Entity.class, new Location(r.world, .45, 64, 0)); + Entity far = r.entity(Entity.class, new Location(r.world, 6, 64, 0)); + when(far.getBoundingBox()).thenReturn(new BoundingBox(-.1, 63.9, -.1, 6.2, 64.2, .2)); + Entity hit = r.entity(Entity.class, new Location(r.world, 0.1, 64, 0)); + r.nearby.addAll(List.of(projectile, ignored, marker, miss, far)); + assertFalse(checker.hasHit(projectile, List.of(ignored))); + r.nearby.remove(marker); + assertFalse(checker.hasHitIgnoreWater(projectile, List.of(ignored))); + assertTrue(checker.getHitEntities(projectile, List.of(ignored), 0.1).isEmpty()); + r.nearby.add(hit); + assertTrue(checker.hasHit(projectile, List.of(ignored))); + assertTrue(checker.hasHitIgnoreWater(projectile, List.of(ignored))); + assertEquals(List.of(hit), checker.getHitEntities(projectile, List.of(ignored), 0.1)); + } + + @Test + void collisionShapesRespectPassabilityLocalCoordinatesAndEmptyShapes() { + Block b = r.block(4, 64, 7, Material.STONE); + assertTrue(BulletRaycast.intersectsCollision(b, new Location(r.world, 4.5, 64.5, 7.5))); + assertFalse(BulletRaycast.intersectsCollision(b, new Location(r.world, 3.5, 64.5, 7.5))); + when(b.isPassable()).thenReturn(true); + assertFalse(BulletRaycast.intersectsCollision(b, new Location(r.world, 4.5, 64.5, 7.5))); + when(b.isPassable()).thenReturn(false); + VoxelShape empty = mock(VoxelShape.class); + when(empty.getBoundingBoxes()).thenReturn(List.of()); + when(b.getCollisionShape()).thenReturn(empty); + assertFalse(BulletRaycast.intersectsCollision(b, new Location(r.world, 4.5, 64.5, 7.5))); + } + + @Test + void bulletsFilterShooterDeckDeadAndNonVehicleEntities() { + Bullet bullet = r.bullet(false); + Entity ignored = r.entity(Entity.class, new Location(r.world, 0.5, 64.5, 1)); + Entity deck = r.entity(Entity.class, new Location(r.world, 0.5, 64.5, 2)); + LivingEntity dead = r.entity(LivingEntity.class, new Location(r.world, 0.5, 64.5, 3)); + when(dead.isDead()).thenReturn(true); + Entity decoration = r.entity(Entity.class, new Location(r.world, 0.5, 64.5, 4)); + r.rayTargets.addAll(List.of(ignored, deck, dead, decoration)); + r.decks.when(() -> DeckBody.isPart(deck)).thenReturn(true); + assertFalse( + BulletRaycast.handleSegment( + new Location(r.world, 0.5, 64.5, 0), + new Location(r.world, 0.5, 64.5, 5), + Set.of(ignored), + null, + bullet.getData(), + bullet, + null, + List.of())); + assertFalse( + BulletRaycast.handleSegment( + new Location(r.world, 0, 0, 0), + new Location(r.world, 0, 0, 0), + Set.of(), + null, + bullet.getData(), + bullet, + null, + List.of())); + } + + @Test + void fragmentsBurnVehiclesTrailAndStopOnImpactOrDeath() { + ActiveVehicle source = r.vehicle(); + Player observer = r.entity(Player.class, new Location(r.world, 0, 64, 0)); + when(source.getNearbyPlayers()).thenReturn(List.of(observer)); + List projectiles = new ArrayList<>(); + new Fragment(source, r.world, new Location(r.world, 0, 64, 0), projectiles); + ArmorStand fragment = (ArmorStand) projectiles.getFirst(); + Vector initial = fragment.getVelocity(); + assertTrue(initial.getY() >= 1.5 && initial.getY() < 2); + assertTrue(Math.abs(initial.getX()) <= 3 && Math.abs(initial.getZ()) <= 3); + verify(fragment).setInvisible(true); + verify(fragment).setSmall(true); + Entity target = r.entity(Entity.class, new Location(r.world, 0.1, 64, 0)); + ActiveVehicle victim = r.vehicle(); + when(source.getVehicleManager().get(target)).thenReturn(victim); + r.nearby.add(target); + r.tick(); + verify(victim).randomFire(); + verify(observer) + .spawnParticle( + eq(Particle.FLAME), any(Location.class), eq(0), eq(0d), eq(0d), eq(0d), eq(0.2d)); + r.nearby.clear(); + r.ticks(10); + r.block(0, 64, 0, Material.STONE); + r.tick(); + verify(fragment).remove(); + assertEquals(0, r.pending()); + new Fragment(source, r.world, new Location(r.world, 5, 64, 0), new ArrayList<>()); + ArmorStand another = (ArmorStand) r.spawned.getLast(); + when(another.isDead()).thenReturn(true); + r.tick(); + verify(another).remove(); + } + + @Test + void livingTargetsReceiveDirectDamageOrConfiguredExplosions() { + LivingEntity target = r.entity(LivingEntity.class, new Location(r.world, .5, 64.5, 2)); + r.rayTargets.add(target); + try (var explosions = mockStatic(ExplosionCreator.class)) { + assertTrue(segment(r.bullet(false), null, 5)); + explosions.verify(() -> ExplosionCreator.applyDamage(target, 8d, "PROJECTILE")); + assertTrue(segment(r.bullet(true), null, 5)); + explosions.verify( + () -> + ExplosionCreator.triggerExplosion( + any(Location.class), eq(1d), eq(5d), eq(8d), eq("PROJECTILE"))); + } + } + + @Test + void vehicleTargetsReceiveDamageExceptForTheShooterItself() { + Entity target = r.entity(Entity.class, new Location(r.world, .5, 64.5, 2)); + ActiveVehicle victim = r.vehicle(), shooter = r.vehicle(); + when(r.manager.get(target)).thenReturn(victim); + r.rayTargets.add(target); + try (var explosions = mockStatic(ExplosionCreator.class)) { + assertTrue(segment(r.bullet(false), null, 5)); + verify(victim).damage("PROJECTILE", 8); + Bullet explosive = r.bullet(true); + assertTrue( + BulletRaycast.handleSegment( + new Location(r.world, .5, 64.5, 0), + new Location(r.world, .5, 64.5, 5), + Set.of(), + shooter, + explosive.getData(), + explosive, + null, + List.of())); + explosions.verify( + () -> + ExplosionCreator.triggerExplosion( + any(Location.class), eq(1d), eq(5d), eq(8d), eq("PROJECTILE"))); + Bullet regular = r.bullet(false); + String victimId = victim.getUUID().toUpperCase(Locale.ROOT); + when(shooter.getUUID()).thenReturn(victimId); + assertFalse( + BulletRaycast.handleSegment( + new Location(r.world, .5, 64.5, 0), + new Location(r.world, .5, 64.5, 5), + Set.of(), + shooter, + regular.getData(), + regular, + null, + List.of())); + verify(victim, times(2)).damage("PROJECTILE", 8); + } + } + + @Test + void leavesAndGlassAllowBulletsThroughWhileSolidBlocksStopThem() { + r.block(0, 64, 1, Material.OAK_LEAVES); + r.block(0, 64, 2, Material.GLASS); + Block hollow = r.block(0, 64, 3, Material.STONE); + when(hollow.isPassable()).thenReturn(true); + assertFalse(segment(r.bullet(false), null, 5)); + verify(r.world, atLeastOnce()) + .playSound(any(Location.class), eq(Sound.BLOCK_GLASS_BREAK), eq(.8f), eq(1.2f)); + r.block(0, 64, 4, Material.STONE); + try (var explosions = mockStatic(ExplosionCreator.class)) { + assertTrue(segment(r.bullet(true), null, 5)); + explosions.verify( + () -> + ExplosionCreator.triggerExplosion( + any(Location.class), eq(1d), eq(5d), eq(8d), eq("PROJECTILE"))); + } + assertTrue(BulletRaycast.isBulletPassable(Material.GLASS_PANE)); + assertFalse(BulletRaycast.isBulletPassable(Material.STONE)); + } + + @Test + void blockSamplingStopsAtTheActualSegmentEndpoint() { + r.block(0, 64, 1, Material.STONE); + Bullet bullet = r.bullet(false); + assertFalse( + BulletRaycast.handleSegment( + new Location(r.world, .5, 64.5, .6), + new Location(r.world, .5, 64.5, .95), + Set.of(), + null, + bullet.getData(), + bullet, + null, + List.of())); + } + + private boolean segment(Bullet bullet, ActiveWeapon weapon, double length) { + return BulletRaycast.handleSegment( + new Location(r.world, 0.5, 64.5, 0), + new Location(r.world, 0.5, 64.5, length), + Set.of(), + null, + bullet.getData(), + bullet, + weapon, + List.of()); + } + + /** + * Stateful external server boundary; production movement, collisions and timers run unchanged. + */ + public static final class Rig implements AutoCloseable { + public final World world = mock(World.class); + public final BukkitScheduler scheduler = mock(BukkitScheduler.class); + public final VehicleManager manager = mock(VehicleManager.class); + public final List nearby = new ArrayList<>(), + rayTargets = new ArrayList<>(), + spawned = new ArrayList<>(); + public final MockedStatic bukkit; + public final MockedStatic framework; + public final MockedStatic decks; + private final MockedStatic logger; + private final VehicleFramework oldPlugin; + private final List oldProjectiles; + private final Map blocks = new HashMap<>(); + private final List scheduled = new ArrayList<>(); + private long tick; + private int nextId = 1; + + public Rig() { + RegistryFixture.initialize(); + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + logger = mockStatic(VFLogger.class); + oldPlugin = VehicleFramework.plugin; + VehicleFramework.plugin = mock(VehicleFramework.class); + framework = mockStatic(VehicleFramework.class); + framework.when(VehicleFramework::getVehicleManager).thenReturn(manager); + decks = mockStatic(DeckBody.class); + oldProjectiles = new ArrayList<>(Cache.projectiles); + Cache.projectiles.clear(); + when(world.getName()).thenReturn("weapons"); + when(world.getUID()).thenReturn(UUID.randomUUID()); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())) + .thenAnswer(c -> block(c.getArgument(0), c.getArgument(1), c.getArgument(2))); + when(world.getBlockAt(any(Location.class))) + .thenAnswer( + c -> { + Location l = c.getArgument(0); + return block(l.getBlockX(), l.getBlockY(), l.getBlockZ()); + }); + when(world.getNearbyEntities(any(Location.class), anyDouble(), anyDouble(), anyDouble())) + .thenAnswer( + c -> { + Location center = c.getArgument(0); + double x = c.getArgument(1), y = c.getArgument(2), z = c.getArgument(3); + BoundingBox query = + new BoundingBox( + center.getX() - x, + center.getY() - y, + center.getZ() - z, + center.getX() + x, + center.getY() + y, + center.getZ() + z); + return nearby.stream().filter(e -> e.getBoundingBox().overlaps(query)).toList(); + }); + when(world.rayTraceEntities( + any(Location.class), any(Vector.class), anyDouble(), anyDouble(), any())) + .thenAnswer( + c -> { + Location from = c.getArgument(0); + double range = c.getArgument(2); + Predicate filter = c.getArgument(4); + return rayTargets.stream() + .filter(filter) + .filter(e -> e.getLocation().distance(from) <= range) + .min(Comparator.comparingDouble(e -> e.getLocation().distanceSquared(from))) + .map(e -> new RayTraceResult(e.getLocation().toVector(), e)) + .orElse(null); + }); + when(world.spawn(any(Location.class), any(Class.class))) + .thenAnswer( + c -> { + Entity e = entity(c.getArgument(1), c.getArgument(0)); + spawned.add(e); + return e; + }); + when(world.spawn(any(Location.class), any(Class.class), any(Consumer.class))) + .thenAnswer( + c -> { + Entity e = entity(c.getArgument(1), c.getArgument(0)); + ((Consumer) c.getArgument(2)).accept(e); + spawned.add(e); + return e; + }); + when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong())) + .thenAnswer(c -> schedule(c.getArgument(1), c.getArgument(2), c.getArgument(3))); + when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())) + .thenAnswer(c -> schedule(c.getArgument(1), c.getArgument(2), 0)); + doAnswer( + c -> { + int id = c.getArgument(0); + scheduled.stream().filter(s -> s.id == id).forEach(s -> s.cancelled = true); + return null; + }) + .when(scheduler) + .cancelTask(anyInt()); + } + + private BukkitTask schedule(Runnable run, long delay, long period) { + Scheduled s = new Scheduled(nextId++, run, tick + Math.max(1, delay), period); + scheduled.add(s); + BukkitTask task = mock(BukkitTask.class); + when(task.getTaskId()).thenReturn(s.id); + return task; + } + + public void tick() { + tick++; + for (Scheduled s : new ArrayList<>(scheduled)) + if (!s.cancelled && s.due <= tick) { + s.run.run(); + if (s.period == 0) s.cancelled = true; + else s.due = tick + s.period; } - for(Material water:List.of(Material.WATER,Material.KELP,Material.KELP_PLANT,Material.SEAGRASS,Material.TALL_SEAGRASS)) { r.block(0,64,0,water); assertFalse(checker.hasHitIgnoreWater(projectile,List.of())); } - Block logged=r.block(0,64,0,Material.OAK_SLAB); Waterlogged data=mock(Waterlogged.class); when(data.isWaterlogged()).thenReturn(true);when(logged.getBlockData()).thenReturn(data);assertFalse(checker.hasHitIgnoreWater(projectile,List.of()));when(data.isWaterlogged()).thenReturn(false);assertTrue(checker.hasHitIgnoreWater(projectile,List.of())); } - @Test void hitCheckerUsesBoundsAndIgnoresSelfPassengersAndMarkers() { - HitChecker checker=new HitChecker();Entity projectile=r.entity(Entity.class,new Location(r.world,0,64,0));Entity ignored=r.entity(Entity.class,new Location(r.world,0,64,0));ArmorStand marker=r.entity(ArmorStand.class,new Location(r.world,0,64,0));when(marker.isMarker()).thenReturn(true); - Entity miss=r.entity(Entity.class,new Location(r.world,.45,64,0));Entity far=r.entity(Entity.class,new Location(r.world,6,64,0));when(far.getBoundingBox()).thenReturn(new BoundingBox(-.1,63.9,-.1,6.2,64.2,.2));Entity hit=r.entity(Entity.class,new Location(r.world,0.1,64,0)); - r.nearby.addAll(List.of(projectile,ignored,marker,miss,far));assertFalse(checker.hasHit(projectile,List.of(ignored)));r.nearby.remove(marker);assertFalse(checker.hasHitIgnoreWater(projectile,List.of(ignored)));assertTrue(checker.getHitEntities(projectile,List.of(ignored),0.1).isEmpty()); - r.nearby.add(hit);assertTrue(checker.hasHit(projectile,List.of(ignored)));assertTrue(checker.hasHitIgnoreWater(projectile,List.of(ignored)));assertEquals(List.of(hit),checker.getHitEntities(projectile,List.of(ignored),0.1)); + public void ticks(int count) { + for (int i = 0; i < count; i++) tick(); } - @Test void collisionShapesRespectPassabilityLocalCoordinatesAndEmptyShapes() { - Block b=r.block(4,64,7,Material.STONE);assertTrue(BulletRaycast.intersectsCollision(b,new Location(r.world,4.5,64.5,7.5)));assertFalse(BulletRaycast.intersectsCollision(b,new Location(r.world,3.5,64.5,7.5))); - when(b.isPassable()).thenReturn(true);assertFalse(BulletRaycast.intersectsCollision(b,new Location(r.world,4.5,64.5,7.5)));when(b.isPassable()).thenReturn(false);VoxelShape empty=mock(VoxelShape.class);when(empty.getBoundingBoxes()).thenReturn(List.of());when(b.getCollisionShape()).thenReturn(empty);assertFalse(BulletRaycast.intersectsCollision(b,new Location(r.world,4.5,64.5,7.5))); + public int pending() { + return (int) scheduled.stream().filter(s -> !s.cancelled).count(); } - @Test void bulletsFilterShooterDeckDeadAndNonVehicleEntities() { - Bullet bullet=r.bullet(false);Entity ignored=r.entity(Entity.class,new Location(r.world,0.5,64.5,1));Entity deck=r.entity(Entity.class,new Location(r.world,0.5,64.5,2));LivingEntity dead=r.entity(LivingEntity.class,new Location(r.world,0.5,64.5,3));when(dead.isDead()).thenReturn(true);Entity decoration=r.entity(Entity.class,new Location(r.world,0.5,64.5,4));r.rayTargets.addAll(List.of(ignored,deck,dead,decoration));r.decks.when(()->DeckBody.isPart(deck)).thenReturn(true); - assertFalse(BulletRaycast.handleSegment(new Location(r.world,0.5,64.5,0),new Location(r.world,0.5,64.5,5),Set.of(ignored),null,bullet.getData(),bullet,null,List.of())); - assertFalse(BulletRaycast.handleSegment(new Location(r.world,0,0,0),new Location(r.world,0,0,0),Set.of(),null,bullet.getData(),bullet,null,List.of())); + public Block block(int x, int y, int z, Material type) { + Block b = block(x, y, z); + b.setType(type); + return b; } - @Test void fragmentsBurnVehiclesTrailAndStopOnImpactOrDeath() { - ActiveVehicle source=r.vehicle();Player observer=r.entity(Player.class,new Location(r.world,0,64,0));when(source.getNearbyPlayers()).thenReturn(List.of(observer));List projectiles=new ArrayList<>();new Fragment(source,r.world,new Location(r.world,0,64,0),projectiles);ArmorStand fragment=(ArmorStand)projectiles.getFirst(); - Vector initial=fragment.getVelocity();assertTrue(initial.getY()>=1.5&&initial.getY()<2);assertTrue(Math.abs(initial.getX())<=3&&Math.abs(initial.getZ())<=3);verify(fragment).setInvisible(true);verify(fragment).setSmall(true); - Entity target=r.entity(Entity.class,new Location(r.world,0.1,64,0));ActiveVehicle victim=r.vehicle();when(source.getVehicleManager().get(target)).thenReturn(victim);r.nearby.add(target);r.tick();verify(victim).randomFire();verify(observer).spawnParticle(eq(Particle.FLAME),any(Location.class),eq(0),eq(0d),eq(0d),eq(0d),eq(0.2d)); - r.nearby.clear();r.ticks(10);r.block(0,64,0,Material.STONE);r.tick();verify(fragment).remove();assertEquals(0,r.pending()); - new Fragment(source,r.world,new Location(r.world,5,64,0),new ArrayList<>());ArmorStand another=(ArmorStand)r.spawned.getLast();when(another.isDead()).thenReturn(true);r.tick();verify(another).remove(); + public Block block(int x, int y, int z) { + return blocks.computeIfAbsent( + x + ":" + y + ":" + z, + key -> { + Block b = mock(Block.class); + AtomicReference type = new AtomicReference<>(Material.AIR); + AtomicReference data = new AtomicReference<>(mock(BlockData.class)); + when(b.getType()).thenAnswer(c -> type.get()); + doAnswer( + c -> { + type.set(c.getArgument(0)); + if (type.get() == Material.LIGHT) data.set(mock(Levelled.class)); + return null; + }) + .when(b) + .setType(any()); + when(b.getBlockData()).thenAnswer(c -> data.get()); + doAnswer( + c -> { + data.set(c.getArgument(0)); + return null; + }) + .when(b) + .setBlockData(any(), anyBoolean()); + when(b.getLocation()).thenAnswer(c -> new Location(world, x, y, z)); + when(b.getWorld()).thenReturn(world); + when(b.isPassable()) + .thenAnswer( + c -> + Set.of( + Material.AIR, + Material.LIGHT, + Material.WATER, + Material.KELP, + Material.KELP_PLANT, + Material.SEAGRASS, + Material.TALL_SEAGRASS) + .contains(type.get())); + VoxelShape shape = mock(VoxelShape.class); + when(shape.getBoundingBoxes()).thenReturn(List.of(new BoundingBox(0, 0, 0, 1, 1, 1))); + when(b.getCollisionShape()).thenReturn(shape); + return b; + }); } - @Test void livingTargetsReceiveDirectDamageOrConfiguredExplosions() { - LivingEntity target=r.entity(LivingEntity.class,new Location(r.world,.5,64.5,2));r.rayTargets.add(target); - try(var explosions=mockStatic(ExplosionCreator.class)){assertTrue(segment(r.bullet(false),null,5));explosions.verify(()->ExplosionCreator.applyDamage(target,8d,"PROJECTILE"));assertTrue(segment(r.bullet(true),null,5));explosions.verify(()->ExplosionCreator.triggerExplosion(any(Location.class),eq(1d),eq(5d),eq(8d),eq("PROJECTILE")));} + public T entity(Class type, Location start) { + T e = mock(type, RETURNS_DEEP_STUBS); + AtomicReference location = new AtomicReference<>(start.clone()); + AtomicReference velocity = new AtomicReference<>(new Vector()); + when(e.getLocation()).thenAnswer(c -> location.get().clone()); + when(e.getWorld()).thenAnswer(c -> location.get().getWorld()); + when(e.getUniqueId()).thenReturn(UUID.randomUUID()); + when(e.getBoundingBox()) + .thenAnswer( + c -> { + Location l = location.get(); + return new BoundingBox( + l.getX() - .2, + l.getY() - .2, + l.getZ() - .2, + l.getX() + .2, + l.getY() + .2, + l.getZ() + .2); + }); + when(e.getVelocity()).thenAnswer(c -> velocity.get().clone()); + doAnswer( + c -> { + velocity.set(((Vector) c.getArgument(0)).clone()); + return null; + }) + .when(e) + .setVelocity(any()); + when(e.teleport(any(Location.class))) + .thenAnswer( + c -> { + location.set(((Location) c.getArgument(0)).clone()); + return true; + }); + when(e.isDead()).thenReturn(false); + when(e.isOnGround()).thenReturn(false); + when(e.isValid()).thenReturn(true); + if (e instanceof Player p) { + when(p.getEyeLocation()).thenAnswer(c -> location.get().clone()); + when(p.isOnline()).thenReturn(true); + } + return e; } - @Test void vehicleTargetsReceiveDamageExceptForTheShooterItself() { - Entity target=r.entity(Entity.class,new Location(r.world,.5,64.5,2));ActiveVehicle victim=r.vehicle(),shooter=r.vehicle();when(r.manager.get(target)).thenReturn(victim);r.rayTargets.add(target); - try(var explosions=mockStatic(ExplosionCreator.class)){assertTrue(segment(r.bullet(false),null,5));verify(victim).damage("PROJECTILE",8);Bullet explosive=r.bullet(true);assertTrue(BulletRaycast.handleSegment(new Location(r.world,.5,64.5,0),new Location(r.world,.5,64.5,5),Set.of(),shooter,explosive.getData(),explosive,null,List.of()));explosions.verify(()->ExplosionCreator.triggerExplosion(any(Location.class),eq(1d),eq(5d),eq(8d),eq("PROJECTILE")));Bullet regular=r.bullet(false);String victimId=victim.getUUID().toUpperCase(Locale.ROOT);when(shooter.getUUID()).thenReturn(victimId);assertFalse(BulletRaycast.handleSegment(new Location(r.world,.5,64.5,0),new Location(r.world,.5,64.5,5),Set.of(),shooter,regular.getData(),regular,null,List.of()));verify(victim,times(2)).damage("PROJECTILE",8);} + public ActiveVehicle vehicle() { + ActiveVehicle v = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); + Entity root = entity(Entity.class, new Location(world, 0, 64, 0)); + when(v.getUUID()).thenReturn(UUID.randomUUID().toString()); + when(v.getEntity()).thenReturn(root); + when(v.getSeatHandler().getPassengers()).thenReturn(List.of()); + when(v.getAccessPanel().getRotator(anyString())).thenReturn(null); + return v; } - @Test void leavesAndGlassAllowBulletsThroughWhileSolidBlocksStopThem() { - r.block(0,64,1,Material.OAK_LEAVES);r.block(0,64,2,Material.GLASS);Block hollow=r.block(0,64,3,Material.STONE);when(hollow.isPassable()).thenReturn(true);assertFalse(segment(r.bullet(false),null,5));verify(r.world,atLeastOnce()).playSound(any(Location.class),eq(Sound.BLOCK_GLASS_BREAK),eq(.8f),eq(1.2f)); - r.block(0,64,4,Material.STONE);try(var explosions=mockStatic(ExplosionCreator.class)){assertTrue(segment(r.bullet(true),null,5));explosions.verify(()->ExplosionCreator.triggerExplosion(any(Location.class),eq(1d),eq(5d),eq(8d),eq("PROJECTILE")));} - assertTrue(BulletRaycast.isBulletPassable(Material.GLASS_PANE));assertFalse(BulletRaycast.isBulletPassable(Material.STONE)); + public Bullet bullet(boolean explosive) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("type", "BULLET"); + yaml.set("explosive", explosive); + return new Bullet("bullet", yaml); } - @Test void blockSamplingStopsAtTheActualSegmentEndpoint() { - r.block(0,64,1,Material.STONE);Bullet bullet=r.bullet(false);assertFalse(BulletRaycast.handleSegment(new Location(r.world,.5,64.5,.6),new Location(r.world,.5,64.5,.95),Set.of(),null,bullet.getData(),bullet,null,List.of())); + public static YamlConfiguration yaml(String text) throws Exception { + YamlConfiguration y = new YamlConfiguration(); + y.loadFromString(text); + return y; } - private boolean segment(Bullet bullet,ActiveWeapon weapon,double length) {return BulletRaycast.handleSegment(new Location(r.world,0.5,64.5,0),new Location(r.world,0.5,64.5,length),Set.of(),null,bullet.getData(),bullet,weapon,List.of());} + @Override + public void close() { + Cache.projectiles.clear(); + Cache.projectiles.addAll(oldProjectiles); + decks.close(); + framework.close(); + VehicleFramework.plugin = oldPlugin; + logger.close(); + bukkit.close(); + } - /** Stateful external server boundary; production movement, collisions and timers run unchanged. */ - public static final class Rig implements AutoCloseable { - public final World world=mock(World.class); public final BukkitScheduler scheduler=mock(BukkitScheduler.class); - public final VehicleManager manager=mock(VehicleManager.class); public final List nearby=new ArrayList<>(),rayTargets=new ArrayList<>(),spawned=new ArrayList<>(); - public final MockedStatic bukkit; public final MockedStatic framework; public final MockedStatic decks; - private final MockedStatic logger; private final VehicleFramework oldPlugin; private final List oldProjectiles; - private final Map blocks=new HashMap<>(); private final List scheduled=new ArrayList<>();private long tick;private int nextId=1; - public Rig() { - RegistryFixture.initialize();bukkit=mockStatic(Bukkit.class);bukkit.when(Bukkit::getScheduler).thenReturn(scheduler);logger=mockStatic(VFLogger.class);oldPlugin=VehicleFramework.plugin;VehicleFramework.plugin=mock(VehicleFramework.class);framework=mockStatic(VehicleFramework.class);framework.when(VehicleFramework::getVehicleManager).thenReturn(manager);decks=mockStatic(DeckBody.class); - oldProjectiles=new ArrayList<>(Cache.projectiles);Cache.projectiles.clear();when(world.getName()).thenReturn("weapons");when(world.getUID()).thenReturn(UUID.randomUUID()); - when(world.getBlockAt(anyInt(),anyInt(),anyInt())).thenAnswer(c->block(c.getArgument(0),c.getArgument(1),c.getArgument(2))); - when(world.getBlockAt(any(Location.class))).thenAnswer(c->{Location l=c.getArgument(0);return block(l.getBlockX(),l.getBlockY(),l.getBlockZ());}); - when(world.getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble())).thenAnswer(c->{Location center=c.getArgument(0);double x=c.getArgument(1),y=c.getArgument(2),z=c.getArgument(3);BoundingBox query=new BoundingBox(center.getX()-x,center.getY()-y,center.getZ()-z,center.getX()+x,center.getY()+y,center.getZ()+z);return nearby.stream().filter(e->e.getBoundingBox().overlaps(query)).toList();}); - when(world.rayTraceEntities(any(Location.class),any(Vector.class),anyDouble(),anyDouble(),any())).thenAnswer(c->{Location from=c.getArgument(0);double range=c.getArgument(2);Predicate filter=c.getArgument(4);return rayTargets.stream().filter(filter).filter(e->e.getLocation().distance(from)<=range).min(Comparator.comparingDouble(e->e.getLocation().distanceSquared(from))).map(e->new RayTraceResult(e.getLocation().toVector(),e)).orElse(null);}); - when(world.spawn(any(Location.class),any(Class.class))).thenAnswer(c->{Entity e=entity(c.getArgument(1),c.getArgument(0));spawned.add(e);return e;}); - when(world.spawn(any(Location.class),any(Class.class),any(Consumer.class))).thenAnswer(c->{Entity e=entity(c.getArgument(1),c.getArgument(0));((Consumer)c.getArgument(2)).accept(e);spawned.add(e);return e;}); - when(scheduler.runTaskTimer(any(),any(Runnable.class),anyLong(),anyLong())).thenAnswer(c->schedule(c.getArgument(1),c.getArgument(2),c.getArgument(3))); - when(scheduler.runTaskLater(any(),any(Runnable.class),anyLong())).thenAnswer(c->schedule(c.getArgument(1),c.getArgument(2),0)); - doAnswer(c->{int id=c.getArgument(0);scheduled.stream().filter(s->s.id==id).forEach(s->s.cancelled=true);return null;}).when(scheduler).cancelTask(anyInt()); - } - private BukkitTask schedule(Runnable run,long delay,long period) {Scheduled s=new Scheduled(nextId++,run,tick+Math.max(1,delay),period);scheduled.add(s);BukkitTask task=mock(BukkitTask.class);when(task.getTaskId()).thenReturn(s.id);return task;} - public void tick(){tick++;for(Scheduled s:new ArrayList<>(scheduled))if(!s.cancelled&&s.due<=tick){s.run.run();if(s.period==0)s.cancelled=true;else s.due=tick+s.period;}} - public void ticks(int count){for(int i=0;i!s.cancelled).count();} - public Block block(int x,int y,int z,Material type){Block b=block(x,y,z);b.setType(type);return b;} - public Block block(int x,int y,int z){return blocks.computeIfAbsent(x+":"+y+":"+z,key->{Block b=mock(Block.class);AtomicReference type=new AtomicReference<>(Material.AIR);AtomicReference data=new AtomicReference<>(mock(BlockData.class));when(b.getType()).thenAnswer(c->type.get());doAnswer(c->{type.set(c.getArgument(0));if(type.get()==Material.LIGHT)data.set(mock(Levelled.class));return null;}).when(b).setType(any());when(b.getBlockData()).thenAnswer(c->data.get());doAnswer(c->{data.set(c.getArgument(0));return null;}).when(b).setBlockData(any(),anyBoolean());when(b.getLocation()).thenAnswer(c->new Location(world,x,y,z));when(b.getWorld()).thenReturn(world);when(b.isPassable()).thenAnswer(c->Set.of(Material.AIR,Material.LIGHT,Material.WATER,Material.KELP,Material.KELP_PLANT,Material.SEAGRASS,Material.TALL_SEAGRASS).contains(type.get()));VoxelShape shape=mock(VoxelShape.class);when(shape.getBoundingBoxes()).thenReturn(List.of(new BoundingBox(0,0,0,1,1,1)));when(b.getCollisionShape()).thenReturn(shape);return b;});} - public T entity(Class type,Location start){T e=mock(type,RETURNS_DEEP_STUBS);AtomicReference location=new AtomicReference<>(start.clone());AtomicReference velocity=new AtomicReference<>(new Vector());when(e.getLocation()).thenAnswer(c->location.get().clone());when(e.getWorld()).thenAnswer(c->location.get().getWorld());when(e.getUniqueId()).thenReturn(UUID.randomUUID());when(e.getBoundingBox()).thenAnswer(c->{Location l=location.get();return new BoundingBox(l.getX()-.2,l.getY()-.2,l.getZ()-.2,l.getX()+.2,l.getY()+.2,l.getZ()+.2);});when(e.getVelocity()).thenAnswer(c->velocity.get().clone());doAnswer(c->{velocity.set(((Vector)c.getArgument(0)).clone());return null;}).when(e).setVelocity(any());when(e.teleport(any(Location.class))).thenAnswer(c->{location.set(((Location)c.getArgument(0)).clone());return true;});when(e.isDead()).thenReturn(false);when(e.isOnGround()).thenReturn(false);when(e.isValid()).thenReturn(true);if(e instanceof Player p){when(p.getEyeLocation()).thenAnswer(c->location.get().clone());when(p.isOnline()).thenReturn(true);}return e;} - public ActiveVehicle vehicle(){ActiveVehicle v=mock(ActiveVehicle.class,RETURNS_DEEP_STUBS);Entity root=entity(Entity.class,new Location(world,0,64,0));when(v.getUUID()).thenReturn(UUID.randomUUID().toString());when(v.getEntity()).thenReturn(root);when(v.getSeatHandler().getPassengers()).thenReturn(List.of());when(v.getAccessPanel().getRotator(anyString())).thenReturn(null);return v;} - public Bullet bullet(boolean explosive){YamlConfiguration yaml=new YamlConfiguration();yaml.set("type","BULLET");yaml.set("explosive",explosive);return new Bullet("bullet",yaml);} - public static YamlConfiguration yaml(String text)throws Exception{YamlConfiguration y=new YamlConfiguration();y.loadFromString(text);return y;} - @Override public void close(){Cache.projectiles.clear();Cache.projectiles.addAll(oldProjectiles);decks.close();framework.close();VehicleFramework.plugin=oldPlugin;logger.close();bukkit.close();} - private static final class Scheduled {final int id;final Runnable run;long due;final long period;boolean cancelled;Scheduled(int id,Runnable run,long due,long period){this.id=id;this.run=run;this.due=due;this.period=period;}} + private static final class Scheduled { + final int id; + final Runnable run; + long due; + final long period; + boolean cancelled; + + Scheduled(int id, Runnable run, long due, long period) { + this.id = id; + this.run = run; + this.due = due; + this.period = period; + } } + } } diff --git a/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackPersistenceCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackPersistenceCoverageTest.java index 5b22dea..a7ca567 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackPersistenceCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/tracks/TrackPersistenceCoverageTest.java @@ -49,10 +49,14 @@ class TrackPersistenceCoverageTest { TrackSpline track = line(0, 20, 0); store.save(track); Path root = directory.resolve("data/tracks"), world = root.resolve("world"), junctions = world.resolve("junctions"); Files.createDirectories(junctions); + org.junit.jupiter.api.Assumptions.assumeTrue(Files.getFileStore(root).supportsFileAttributeView("posix"), + "Requires POSIX directory permissions"); for (Path unreadable : List.of(root, world, junctions)) { Set old = Files.getPosixFilePermissions(unreadable); try { Files.setPosixFilePermissions(unreadable, Set.of()); + org.junit.jupiter.api.Assumptions.assumeTrue(unreadable.toFile().listFiles() == null, + "Requires enforced read permissions (not a privileged process)"); assertTrue(store.loadAllJunctions().isEmpty()); if (!unreadable.equals(junctions)) assertTrue(store.loadAll().isEmpty()); } finally { Files.setPosixFilePermissions(unreadable, old); } @@ -155,7 +159,13 @@ class TrackPersistenceCoverageTest { TrackJunction junction = registry.junctionByBranch(branch.getId()).orElseThrow(); assertEquals(-1, junction.facingSign); assertThrows(TrackLayException.class, () -> registry.layBranch(junction.id, "world", null, 3, 64, 20)); - assertTrue(registry.digTarget("world", branch.first().x, 64, branch.first().z).isPresent()); + // The frog overlaps the stem; use the distinct branch endpoint so UUID/map order + // cannot select the stem and silently bypass turnout protection. + TrackSample end = branch.last(); + TrackRegistry.DigTarget target = registry.digTarget("world", end.x, end.y, end.z).orElseThrow(); + assertEquals(branch.getId(), target.spline().getId()); + assertEquals(branch.getSamples().size() - 1, target.index()); + assertEquals(List.of(new TrackRegistry.Span(branch.getId(), branch.length()/2, branch.length()/2 + TrackGenerate.STEP)), target.spans()); } @Test void legacyClosedPolylinesArePromotedAndForwardLoopClosurePreservesDirection() throws Exception { diff --git a/src/test/java/net/tfminecraft/vehicleframework/util/RuntimeUtilitiesCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/util/RuntimeUtilitiesCoverageTest.java index b8883ee..943270f 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/util/RuntimeUtilitiesCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/util/RuntimeUtilitiesCoverageTest.java @@ -7,6 +7,21 @@ import java.util.*; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import net.tfminecraft.tlibs.enums.NSEW; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.database.LogWriter; +import net.tfminecraft.vehicleframework.enums.*; +import net.tfminecraft.vehicleframework.enums.Input; +import net.tfminecraft.vehicleframework.events.*; +import net.tfminecraft.vehicleframework.loaders.VehicleLoader; +import net.tfminecraft.vehicleframework.permissions.Permissions; +import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vehicleframework.vehicles.Vehicle; +import net.tfminecraft.vehicleframework.vehicles.component.Engine; +import net.tfminecraft.vehicleframework.vehicles.component.Wings; +import net.tfminecraft.vehicleframework.vehicles.component.propulsion.Throttle; import org.bukkit.*; import org.bukkit.attribute.Attribute; import org.bukkit.block.Block; @@ -23,341 +38,1263 @@ import org.bukkit.util.Vector; import org.junit.jupiter.api.*; import org.mockito.*; -import net.tfminecraft.tlibs.enums.NSEW; -import net.tfminecraft.vehicleframework.VehicleFramework; -import net.tfminecraft.vehicleframework.cache.Cache; -import net.tfminecraft.vehicleframework.database.LogWriter; -import net.tfminecraft.vehicleframework.enums.*; -import net.tfminecraft.vehicleframework.enums.Input; -import net.tfminecraft.vehicleframework.events.*; -import net.tfminecraft.vehicleframework.loaders.VehicleLoader; -import net.tfminecraft.vehicleframework.permissions.Permissions; -import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig; -import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; -import net.tfminecraft.vehicleframework.vehicles.Vehicle; -import net.tfminecraft.vehicleframework.vehicles.component.Engine; -import net.tfminecraft.vehicleframework.vehicles.component.Wings; -import net.tfminecraft.vehicleframework.vehicles.component.propulsion.Throttle; class RuntimeUtilitiesCoverageTest { - Rig r; - final Map blocks = new HashMap<>(); - final List debris = new ArrayList<>(); - final List online = new ArrayList<>(); - final List otherWorlds = new ArrayList<>(); - Consumer onEvent = e -> {}; - MockedStatic logs; - List oldExplode, oldLands, oldGround; - Map oldConvert; - Set oldLights; - boolean oldDamage; - int oldDistance; - - @BeforeEach void setup() { - r = new Rig(); logs = mockStatic(LogWriter.class); - oldExplode = new ArrayList<>(Cache.ignoreExplode); oldLands = new ArrayList<>(Cache.ignoreLands); oldGround = new ArrayList<>(Cache.ignoreGround); - oldConvert = new HashMap<>(Cache.convertExplode); oldLights = new HashSet<>(Cache.lightLocations); oldDamage = Cache.blockDamage; oldDistance = Cache.despawnDistance; - Cache.ignoreExplode.clear(); Cache.ignoreLands.clear(); Cache.ignoreGround.clear(); Cache.convertExplode.clear(); Cache.lightLocations.clear(); Cache.blockDamage = false; - PluginManager plugins = mock(PluginManager.class); r.bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); - doAnswer(c -> { onEvent.accept(c.getArgument(0)); return null; }).when(plugins).callEvent(any(Event.class)); - r.bukkit.when(Bukkit::getOnlinePlayers).thenReturn(online); - r.bukkit.when(() -> Bukkit.createBlockData(any(Material.class))).thenAnswer(c -> data(c.getArgument(0))); - doAnswer(c -> block(c.getArgument(0), c.getArgument(1), c.getArgument(2))).when(r.world).getBlockAt(anyInt(), anyInt(), anyInt()); - doAnswer(c -> { Location l = c.getArgument(0); return block(l.getBlockX(), l.getBlockY(), l.getBlockZ()); }).when(r.world).getBlockAt(any(Location.class)); - when(r.world.spawnFallingBlock(any(Location.class), any(BlockData.class))).thenAnswer(c -> { - FallingBlock falling = r.entity(FallingBlock.class, c.getArgument(0)); BlockData material = c.getArgument(1); - when(falling.getBlockData()).thenReturn(material); debris.add(falling); return falling; - }); - } + Rig r; + final Map blocks = new HashMap<>(); + final List debris = new ArrayList<>(); + final List online = new ArrayList<>(); + final List otherWorlds = new ArrayList<>(); + Consumer onEvent = e -> {}; + MockedStatic logs; + List oldExplode, oldLands, oldGround; + Map oldConvert; + Set oldLights; + boolean oldDamage; + int oldDistance; - @AfterEach void cleanup() { - Cache.ignoreExplode.clear(); Cache.ignoreExplode.addAll(oldExplode); Cache.ignoreLands.clear(); Cache.ignoreLands.addAll(oldLands); Cache.ignoreGround.clear(); Cache.ignoreGround.addAll(oldGround); - Cache.convertExplode.clear(); Cache.convertExplode.putAll(oldConvert); Cache.lightLocations.clear(); Cache.lightLocations.addAll(oldLights); Cache.blockDamage = oldDamage; Cache.despawnDistance = oldDistance; - logs.close(); r.close(); - } + @BeforeEach + void setup() { + r = new Rig(); + logs = mockStatic(LogWriter.class); + oldExplode = new ArrayList<>(Cache.ignoreExplode); + oldLands = new ArrayList<>(Cache.ignoreLands); + oldGround = new ArrayList<>(Cache.ignoreGround); + oldConvert = new HashMap<>(Cache.convertExplode); + oldLights = new HashSet<>(Cache.lightLocations); + oldDamage = Cache.blockDamage; + oldDistance = Cache.despawnDistance; + Cache.ignoreExplode.clear(); + Cache.ignoreLands.clear(); + Cache.ignoreGround.clear(); + Cache.convertExplode.clear(); + Cache.lightLocations.clear(); + Cache.blockDamage = false; + PluginManager plugins = mock(PluginManager.class); + r.bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + doAnswer( + c -> { + onEvent.accept(c.getArgument(0)); + return null; + }) + .when(plugins) + .callEvent(any(Event.class)); + r.bukkit.when(Bukkit::getOnlinePlayers).thenReturn(online); + r.bukkit + .when(() -> Bukkit.createBlockData(any(Material.class))) + .thenAnswer(c -> data(c.getArgument(0))); + doAnswer(c -> block(c.getArgument(0), c.getArgument(1), c.getArgument(2))) + .when(r.world) + .getBlockAt(anyInt(), anyInt(), anyInt()); + doAnswer( + c -> { + Location l = c.getArgument(0); + return block(l.getBlockX(), l.getBlockY(), l.getBlockZ()); + }) + .when(r.world) + .getBlockAt(any(Location.class)); + when(r.world.spawnFallingBlock(any(Location.class), any(BlockData.class))) + .thenAnswer( + c -> { + FallingBlock falling = r.entity(FallingBlock.class, c.getArgument(0)); + BlockData material = c.getArgument(1); + when(falling.getBlockData()).thenReturn(material); + debris.add(falling); + return falling; + }); + } - @Test void lavaDoesNotProvideWaterBuoyancy() { - Block lava = block(0, 64, 0, Material.LAVA); - assertTrue(lava.isLiquid()); - assertFalse(LocationChecker.isWaterBlock(lava), "Lava must not satisfy water-only flotation or wading checks"); - } + @AfterEach + void cleanup() { + Cache.ignoreExplode.clear(); + Cache.ignoreExplode.addAll(oldExplode); + Cache.ignoreLands.clear(); + Cache.ignoreLands.addAll(oldLands); + Cache.ignoreGround.clear(); + Cache.ignoreGround.addAll(oldGround); + Cache.convertExplode.clear(); + Cache.convertExplode.putAll(oldConvert); + Cache.lightLocations.clear(); + Cache.lightLocations.addAll(oldLights); + Cache.blockDamage = oldDamage; + Cache.despawnDistance = oldDistance; + logs.close(); + r.close(); + } - @Test void temporaryLightPreservesReplacementAndReleasesItsReservation() { - Location location = new Location(r.world, 0, 64, 0); - new LightEffect().createTemporaryLight(location, 12); - block(0, 64, 0).setType(Material.STONE); r.ticks(2); - assertEquals(Material.STONE, location.getBlock().getType()); assertFalse(Cache.lightLocations.contains(location)); - } + @Test + void lavaDoesNotProvideWaterBuoyancy() { + Block lava = block(0, 64, 0, Material.LAVA); + assertTrue(lava.isLiquid()); + assertFalse( + LocationChecker.isWaterBlock(lava), + "Lava must not satisfy water-only flotation or wading checks"); + } - @Test void missingSeatCannotFulfilOccupiedSeatCondition() { - ActiveVehicle vehicle = r.vehicle(); when(vehicle.getAccessPanel().getSeat("missing")).thenReturn(null); - assertFalse(ConditionChecker.checkCondition(vehicle, "seat_filled", "missing")); - } + @Test + void temporaryLightPreservesReplacementAndReleasesItsReservation() { + Location location = new Location(r.world, 0, 64, 0); + new LightEffect().createTemporaryLight(location, 12); + block(0, 64, 0).setType(Material.STONE); + r.ticks(2); + assertEquals(Material.STONE, location.getBlock().getType()); + assertFalse(Cache.lightLocations.contains(location)); + } - @Test void invalidConditionsFailClosedWithoutCrashingStateUpdates() { - ActiveVehicle vehicle = r.vehicle(); - assertFalse(ConditionChecker.checkCondition(vehicle, "unknown", "true")); - assertFalse(ConditionChecker.checkConditions(vehicle, List.of("passengers"))); - assertFalse(ConditionChecker.checkCondition(vehicle, "OR", "has_fuel")); - assertFalse(ConditionChecker.checkCondition(vehicle, "AND", "has_fuel")); - assertFalse(ConditionChecker.checkConditions(vehicle, Arrays.asList((String)null))); - for(String condition:List.of("(true)","has_fuel(true","passengers(maybe)"))assertFalse(ConditionChecker.checkConditions(vehicle,List.of(condition))); - assertFalse(ConditionChecker.checkCondition(vehicle,null,"true")); - assertFalse(ConditionChecker.checkCondition(vehicle,"has_fuel",null)); - assertFalse(ConditionChecker.checkCondition(vehicle,"has_fuel","sometimes")); - assertFalse(ConditionChecker.checkCondition(vehicle,"throttle_less_than","many")); - assertFalse(ConditionChecker.checkCondition(vehicle,"throttle_more_than","many")); - when(vehicle.getAccessPanel().getSeat("missing")).thenReturn(null);assertFalse(ConditionChecker.checkCondition(vehicle,"seat_empty","missing")); - } + @Test + void missingSeatCannotFulfilOccupiedSeatCondition() { + ActiveVehicle vehicle = r.vehicle(); + when(vehicle.getAccessPanel().getSeat("missing")).thenReturn(null); + assertFalse(ConditionChecker.checkCondition(vehicle, "seat_filled", "missing")); + } - @Test void healthConditionIdentifiersAreIndependentOfServerLocale() { - ActiveVehicle vehicle = r.vehicle(); when(vehicle.hasComponent(Component.WINGS)).thenReturn(true); when(vehicle.getComponent(Component.WINGS).getHealthData().getHealthPercentage()).thenReturn(50); - Locale saved = Locale.getDefault(); try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); assertTrue(ConditionChecker.checkCondition(vehicle, "health_percent", "wings;at_least=50")); } finally { Locale.setDefault(saved); } - } + @Test + void invalidConditionsFailClosedWithoutCrashingStateUpdates() { + ActiveVehicle vehicle = r.vehicle(); + assertFalse(ConditionChecker.checkCondition(vehicle, "unknown", "true")); + assertFalse(ConditionChecker.checkConditions(vehicle, List.of("passengers"))); + assertFalse(ConditionChecker.checkCondition(vehicle, "OR", "has_fuel")); + assertFalse(ConditionChecker.checkCondition(vehicle, "AND", "has_fuel")); + assertFalse(ConditionChecker.checkConditions(vehicle, Arrays.asList((String) null))); + for (String condition : List.of("(true)", "has_fuel(true", "passengers(maybe)")) + assertFalse(ConditionChecker.checkConditions(vehicle, List.of(condition))); + assertFalse(ConditionChecker.checkCondition(vehicle, null, "true")); + assertFalse(ConditionChecker.checkCondition(vehicle, "has_fuel", null)); + assertFalse(ConditionChecker.checkCondition(vehicle, "has_fuel", "sometimes")); + assertFalse(ConditionChecker.checkCondition(vehicle, "throttle_less_than", "many")); + assertFalse(ConditionChecker.checkCondition(vehicle, "throttle_more_than", "many")); + when(vehicle.getAccessPanel().getSeat("missing")).thenReturn(null); + assertFalse(ConditionChecker.checkCondition(vehicle, "seat_empty", "missing")); + } - @Test void waterDepthWadingAirAndGroundUseActualNeighbourBlocks() { - assertFalse(LocationChecker.isWaterBlock(null)); assertFalse(LocationChecker.isMostlyShallowWadableWater(null, .5)); assertFalse(LocationChecker.isMostlyShallowWadableWater(Set.of(), .5)); - Block floor = block(0, 63, 0, Material.STONE), water = block(0, 64, 0, Material.WATER), above = block(0, 65, 0, Material.WATER); - assertTrue(LocationChecker.isInWater(water.getLocation())); assertTrue(LocationChecker.hasDeepWaterAtCentre(floor)); assertTrue(LocationChecker.hasDeepWaterAtCentre(water)); - assertTrue(LocationChecker.isShallowWadableBlock(water)); assertFalse(LocationChecker.isShallowWadableBlock(above)); assertFalse(LocationChecker.isShallowWadableBlock(floor)); - assertTrue(LocationChecker.isMostlyShallowWadableWater(Set.of(floor, water, above), .5)); assertFalse(LocationChecker.isMostlyShallowWadableWater(Set.of(water, above), .75)); assertFalse(LocationChecker.isMostlyShallowWadableWater(Set.of(floor), .5)); - above.setType(Material.AIR); assertFalse(LocationChecker.hasDeepWaterAtCentre(floor)); assertFalse(LocationChecker.hasDeepWaterAtCentre(water)); assertFalse(LocationChecker.hasDeepWaterAtCentre(above)); - assertTrue(LocationChecker.isOnGround(floor.getLocation())); assertFalse(LocationChecker.isOnGround(water.getLocation())); assertTrue(LocationChecker.isInAir(above.getLocation())); assertFalse(LocationChecker.isOnGround(above.getLocation())); - above.setType(Material.LIGHT); assertTrue(LocationChecker.isInAir(above.getLocation())); assertFalse(LocationChecker.isInAir(water.getLocation())); - Waterlogged logged = mock(Waterlogged.class); Block slab = block(1, 64, 0, Material.OAK_SLAB); slab.setBlockData(logged, false); when(logged.isWaterlogged()).thenReturn(true); assertTrue(LocationChecker.isWaterBlock(slab)); when(logged.isWaterlogged()).thenReturn(false); assertFalse(LocationChecker.isWaterBlock(slab)); - for (Material material : List.of(Material.KELP, Material.KELP_PLANT, Material.SEAGRASS, Material.TALL_SEAGRASS)) { water.setType(material); assertTrue(LocationChecker.isWaterBlock(water)); } + @Test + void healthConditionIdentifiersAreIndependentOfServerLocale() { + ActiveVehicle vehicle = r.vehicle(); + when(vehicle.hasComponent(Component.WINGS)).thenReturn(true); + when(vehicle.getComponent(Component.WINGS).getHealthData().getHealthPercentage()) + .thenReturn(50); + Locale saved = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertTrue(ConditionChecker.checkCondition(vehicle, "health_percent", "wings;at_least=50")); + } finally { + Locale.setDefault(saved); } + } - @Test void railStepsFollowStraightSlopesAndEachCurveExit() { - record Route(Rail.Shape shape, NSEW direction, float yaw, int dx, int dy, int dz) {} - List routes = new ArrayList<>(); - routes.addAll(List.of(new Route(Rail.Shape.NORTH_SOUTH,NSEW.NORTH,0,0,0,-1),new Route(Rail.Shape.NORTH_SOUTH,NSEW.SOUTH,0,0,0,1),new Route(Rail.Shape.NORTH_SOUTH,NSEW.EAST,0,0,0,1),new Route(Rail.Shape.NORTH_SOUTH,NSEW.EAST,-90,0,0,-1),new Route(Rail.Shape.NORTH_SOUTH,NSEW.WEST,0,0,0,1),new Route(Rail.Shape.NORTH_SOUTH,NSEW.WEST,90,0,0,-1),new Route(Rail.Shape.EAST_WEST,NSEW.EAST,0,1,0,0),new Route(Rail.Shape.EAST_WEST,NSEW.WEST,0,-1,0,0),new Route(Rail.Shape.EAST_WEST,NSEW.NORTH,90,-1,0,0),new Route(Rail.Shape.EAST_WEST,NSEW.NORTH,0,1,0,0),new Route(Rail.Shape.EAST_WEST,NSEW.SOUTH,90,-1,0,0),new Route(Rail.Shape.EAST_WEST,NSEW.SOUTH,0,1,0,0))); - routes.addAll(List.of(new Route(Rail.Shape.ASCENDING_NORTH,NSEW.NORTH,0,0,1,-1),new Route(Rail.Shape.ASCENDING_NORTH,NSEW.SOUTH,0,0,0,1),new Route(Rail.Shape.ASCENDING_SOUTH,NSEW.NORTH,0,0,0,-1),new Route(Rail.Shape.ASCENDING_SOUTH,NSEW.SOUTH,0,0,1,1),new Route(Rail.Shape.ASCENDING_EAST,NSEW.EAST,0,1,1,0),new Route(Rail.Shape.ASCENDING_EAST,NSEW.WEST,0,-1,0,0),new Route(Rail.Shape.ASCENDING_WEST,NSEW.EAST,0,1,0,0),new Route(Rail.Shape.ASCENDING_WEST,NSEW.WEST,0,-1,1,0))); - for (Rail.Shape shape : List.of(Rail.Shape.NORTH_EAST,Rail.Shape.NORTH_WEST,Rail.Shape.SOUTH_EAST,Rail.Shape.SOUTH_WEST)) { - int x = shape == Rail.Shape.NORTH_EAST || shape == Rail.Shape.SOUTH_EAST ? 1 : -1, z = shape == Rail.Shape.NORTH_EAST || shape == Rail.Shape.NORTH_WEST ? -1 : 1; - for (NSEW dir : List.of(NSEW.NORTH,NSEW.SOUTH,NSEW.EAST,NSEW.WEST)) { - boolean alongZ = (z < 0 && dir == NSEW.NORTH) || (z > 0 && dir == NSEW.SOUTH) || (x > 0 && dir == NSEW.WEST) || (x < 0 && dir == NSEW.EAST); - routes.add(new Route(shape,dir,0,alongZ?0:x,0,alongZ?z:0)); - } - } - for (Route route : routes) { - blocks.clear(); rail(block(0,64,0,Material.RAIL), route.shape()); - Rail.Shape next = route.dx() != 0 ? Rail.Shape.EAST_WEST : Rail.Shape.NORTH_SOUTH; - // The first step selects the configured exit, then three aligned straight rails continue it. - for(int i=1;i<4;i++) rail(block(route.dx()*i,64+route.dy(),route.dz()*i,Material.RAIL),next); - float yaw = route.yaw(); - // For a turn the heading preference is chosen from the actual exit; test all curve arms separately below. - if (route.shape().name().contains("ASCENDING") || route.shape()==Rail.Shape.NORTH_SOUTH || route.shape()==Rail.Shape.EAST_WEST) { - Location result=LocationChecker.getNextTrackedLocation(new Location(r.world,0.2,64,0.2),List.of(route.direction()),yaw); - assertNotNull(result, route.toString()); assertEquals(64+route.dy(),result.getY(),route.toString()); assertEquals(route.dx()*4+.5,result.getX(),route.toString()); assertEquals(route.dz()*4+.5,result.getZ(),route.toString()); - } else { - // A single curve is followed by no rail: the observable result is a stopped route, with the exit sampled. - blocks.clear(); rail(block(0,64,0,Material.RAIL),route.shape()); assertNull(LocationChecker.getNextTrackedLocation(new Location(r.world,0,64,0),List.of(route.direction()),0)); verify(block(route.dx(),64,route.dz()),atLeastOnce()).getBlockData(); - } - } - blocks.clear(); assertNull(LocationChecker.getNextTrackedLocation(new Location(r.world,0,64,0),List.of(NSEW.NORTH),0)); - for(int z=0;z>-4;z--)rail(block(0,63,z,Material.RAIL),Rail.Shape.NORTH_SOUTH);assertEquals(63,LocationChecker.getNextTrackedLocation(new Location(r.world,0,64,0),List.of(NSEW.NORTH),0).getY()); - for(Rail.Shape shape:Rail.Shape.values()){ - blocks.clear();rail(block(0,64,0,Material.RAIL),shape); - assertEquals(new Location(r.world,.5,64,.5),LocationChecker.getNextTrackedLocation(new Location(r.world,0,64,0),List.of(NSEW.NORTH_EAST),0),"Unsupported diagonal preference must not invent rail movement"); - } + @Test + void waterDepthWadingAirAndGroundUseActualNeighbourBlocks() { + assertFalse(LocationChecker.isWaterBlock(null)); + assertFalse(LocationChecker.isMostlyShallowWadableWater(null, .5)); + assertFalse(LocationChecker.isMostlyShallowWadableWater(Set.of(), .5)); + Block floor = block(0, 63, 0, Material.STONE), + water = block(0, 64, 0, Material.WATER), + above = block(0, 65, 0, Material.WATER); + assertTrue(LocationChecker.isInWater(water.getLocation())); + assertTrue(LocationChecker.hasDeepWaterAtCentre(floor)); + assertTrue(LocationChecker.hasDeepWaterAtCentre(water)); + assertTrue(LocationChecker.isShallowWadableBlock(water)); + assertFalse(LocationChecker.isShallowWadableBlock(above)); + assertFalse(LocationChecker.isShallowWadableBlock(floor)); + assertTrue(LocationChecker.isMostlyShallowWadableWater(Set.of(floor, water, above), .5)); + assertFalse(LocationChecker.isMostlyShallowWadableWater(Set.of(water, above), .75)); + assertFalse(LocationChecker.isMostlyShallowWadableWater(Set.of(floor), .5)); + above.setType(Material.AIR); + assertFalse(LocationChecker.hasDeepWaterAtCentre(floor)); + assertFalse(LocationChecker.hasDeepWaterAtCentre(water)); + assertFalse(LocationChecker.hasDeepWaterAtCentre(above)); + assertTrue(LocationChecker.isOnGround(floor.getLocation())); + assertFalse(LocationChecker.isOnGround(water.getLocation())); + assertTrue(LocationChecker.isInAir(above.getLocation())); + assertFalse(LocationChecker.isOnGround(above.getLocation())); + above.setType(Material.LIGHT); + assertTrue(LocationChecker.isInAir(above.getLocation())); + assertFalse(LocationChecker.isInAir(water.getLocation())); + Waterlogged logged = mock(Waterlogged.class); + Block slab = block(1, 64, 0, Material.OAK_SLAB); + slab.setBlockData(logged, false); + when(logged.isWaterlogged()).thenReturn(true); + assertTrue(LocationChecker.isWaterBlock(slab)); + when(logged.isWaterlogged()).thenReturn(false); + assertFalse(LocationChecker.isWaterBlock(slab)); + for (Material material : + List.of(Material.KELP, Material.KELP_PLANT, Material.SEAGRASS, Material.TALL_SEAGRASS)) { + water.setType(material); + assertTrue(LocationChecker.isWaterBlock(water)); } + } - @Test void conditionsGateStateLiftPassengersFuelSeatsAndThrottle() { - ActiveVehicle v=r.vehicle(); when(v.getStateHandler().getCurrentState().getType()).thenReturn(State.GROUND); - assertTrue(ConditionChecker.checkConditions(v,List.of())); assertTrue(ConditionChecker.checkConditions(v,List.of("state(ground)","passengers(false)"))); assertFalse(ConditionChecker.checkConditions(v,List.of("state(flying)","unknown()"))); - assertFalse(ConditionChecker.checkCondition(v,"lift","true")); Wings wings=mock(Wings.class); Engine engine=mock(Engine.class,RETURNS_DEEP_STUBS); when(v.hasComponent(Component.WINGS)).thenReturn(true); when(v.getComponent(Component.WINGS)).thenReturn(wings);assertFalse(ConditionChecker.checkCondition(v,"lift","true"));when(v.hasComponent(Component.ENGINE)).thenReturn(true);when(v.getComponent(Component.ENGINE)).thenReturn(engine);when(wings.getLift()).thenReturn(1d);when(engine.getSpeed()).thenReturn(1d);when(engine.getThrottle().getCurrent()).thenReturn(100);assertTrue(ConditionChecker.checkCondition(v,"lift","true"));assertFalse(ConditionChecker.checkCondition(v,"lift","false"));when(engine.getThrottle().getCurrent()).thenReturn(-10);assertFalse(ConditionChecker.checkCondition(v,"lift","true"));assertTrue(ConditionChecker.checkCondition(v,"lift","false")); - when(v.getSeatHandler().hasPassengers()).thenReturn(true);assertTrue(ConditionChecker.checkCondition(v,"passengers","true"));assertFalse(ConditionChecker.checkCondition(v,"passengers","false")); - when(v.getAccessPanel().getSeat("pilot").isOccupied()).thenReturn(true);assertTrue(ConditionChecker.checkCondition(v,"seat_filled","pilot"));assertFalse(ConditionChecker.checkCondition(v,"seat_empty","pilot"));when(v.getAccessPanel().getSeat("pilot").isOccupied()).thenReturn(false);assertFalse(ConditionChecker.checkCondition(v,"seat_filled","pilot"));assertTrue(ConditionChecker.checkCondition(v,"seat_empty","pilot")); - for(boolean flag:List.of(false,true)){when(v.hasParent()).thenReturn(flag);when(v.hasFuel()).thenReturn(flag);for(String type:List.of("is_passenger","has_fuel")){assertTrue(ConditionChecker.checkCondition(v,type,Boolean.toString(flag)));assertFalse(ConditionChecker.checkCondition(v,type,Boolean.toString(!flag)));}} - when(v.getThrottle()).thenReturn(null);assertFalse(ConditionChecker.checkCondition(v,"throttle_less_than","50"));assertFalse(ConditionChecker.checkCondition(v,"throttle_more_than","50"));Throttle throttle=mock(Throttle.class);when(v.getThrottle()).thenReturn(throttle);when(throttle.getCurrent()).thenReturn(50);assertTrue(ConditionChecker.checkCondition(v,"throttle_less_than","50"));assertFalse(ConditionChecker.checkCondition(v,"throttle_less_than","49"));assertTrue(ConditionChecker.checkCondition(v,"throttle_more_than","50"));assertFalse(ConditionChecker.checkCondition(v,"throttle_more_than","51")); - assertTrue(ConditionChecker.checkCondition(v,"OR","has_fuel=false;passengers=true;state=flying"));assertFalse(ConditionChecker.checkCondition(v,"OR","has_fuel=false;passengers=false"));assertTrue(ConditionChecker.checkCondition(v,"AND","has_fuel=true;passengers=true"));assertFalse(ConditionChecker.checkCondition(v,"AND","has_fuel=false;passengers=true")); + @Test + void railStepsFollowStraightSlopesAndEachCurveExit() { + record Route(Rail.Shape shape, NSEW direction, float yaw, int dx, int dy, int dz) {} + List routes = new ArrayList<>(); + routes.addAll( + List.of( + new Route(Rail.Shape.NORTH_SOUTH, NSEW.NORTH, 0, 0, 0, -1), + new Route(Rail.Shape.NORTH_SOUTH, NSEW.SOUTH, 0, 0, 0, 1), + new Route(Rail.Shape.NORTH_SOUTH, NSEW.EAST, 0, 0, 0, 1), + new Route(Rail.Shape.NORTH_SOUTH, NSEW.EAST, -90, 0, 0, -1), + new Route(Rail.Shape.NORTH_SOUTH, NSEW.WEST, 0, 0, 0, 1), + new Route(Rail.Shape.NORTH_SOUTH, NSEW.WEST, 90, 0, 0, -1), + new Route(Rail.Shape.EAST_WEST, NSEW.EAST, 0, 1, 0, 0), + new Route(Rail.Shape.EAST_WEST, NSEW.WEST, 0, -1, 0, 0), + new Route(Rail.Shape.EAST_WEST, NSEW.NORTH, 90, -1, 0, 0), + new Route(Rail.Shape.EAST_WEST, NSEW.NORTH, 0, 1, 0, 0), + new Route(Rail.Shape.EAST_WEST, NSEW.SOUTH, 90, -1, 0, 0), + new Route(Rail.Shape.EAST_WEST, NSEW.SOUTH, 0, 1, 0, 0))); + routes.addAll( + List.of( + new Route(Rail.Shape.ASCENDING_NORTH, NSEW.NORTH, 0, 0, 1, -1), + new Route(Rail.Shape.ASCENDING_NORTH, NSEW.SOUTH, 0, 0, 0, 1), + new Route(Rail.Shape.ASCENDING_SOUTH, NSEW.NORTH, 0, 0, 0, -1), + new Route(Rail.Shape.ASCENDING_SOUTH, NSEW.SOUTH, 0, 0, 1, 1), + new Route(Rail.Shape.ASCENDING_EAST, NSEW.EAST, 0, 1, 1, 0), + new Route(Rail.Shape.ASCENDING_EAST, NSEW.WEST, 0, -1, 0, 0), + new Route(Rail.Shape.ASCENDING_WEST, NSEW.EAST, 0, 1, 0, 0), + new Route(Rail.Shape.ASCENDING_WEST, NSEW.WEST, 0, -1, 1, 0))); + for (Rail.Shape shape : + List.of( + Rail.Shape.NORTH_EAST, + Rail.Shape.NORTH_WEST, + Rail.Shape.SOUTH_EAST, + Rail.Shape.SOUTH_WEST)) { + int x = shape == Rail.Shape.NORTH_EAST || shape == Rail.Shape.SOUTH_EAST ? 1 : -1, + z = shape == Rail.Shape.NORTH_EAST || shape == Rail.Shape.NORTH_WEST ? -1 : 1; + for (NSEW dir : List.of(NSEW.NORTH, NSEW.SOUTH, NSEW.EAST, NSEW.WEST)) { + boolean alongZ = + (z < 0 && dir == NSEW.NORTH) + || (z > 0 && dir == NSEW.SOUTH) + || (x > 0 && dir == NSEW.WEST) + || (x < 0 && dir == NSEW.EAST); + routes.add(new Route(shape, dir, 0, alongZ ? 0 : x, 0, alongZ ? z : 0)); + } } - - @Test void healthComparisonsValidateConfigurationAndTheirBoundaries() { - ActiveVehicle v=r.vehicle();when(v.hasComponent(Component.HULL)).thenReturn(true);when(v.getComponent(Component.HULL).getHealthData().getHealthPercentage()).thenReturn(50); - for(String value:Arrays.asList(null,""," ","hull;wings;at_most=50","hull;at_most=50;more_than=20","hull;at_most=abc","hull;bogus=20","hull","at_least=50","absent;at_least=50","engine;at_least=50")) assertFalse(ConditionChecker.checkCondition(v,"health_percent",value),String.valueOf(value)); - assertTrue(ConditionChecker.checkCondition(v,"health_percent"," ;component=hull;;less_than=51"));assertFalse(ConditionChecker.checkCondition(v,"health_percent","hull;less_than=50"));assertTrue(ConditionChecker.checkCondition(v,"health_percent","hull;more_than=49"));assertFalse(ConditionChecker.checkCondition(v,"health_percent","hull;more_than=50"));assertTrue(ConditionChecker.checkCondition(v,"health_percent","hull;at_most=50"));assertFalse(ConditionChecker.checkCondition(v,"health_percent","hull;at_most=49"));assertTrue(ConditionChecker.checkCondition(v,"health_percent","hull;at_least=50"));assertFalse(ConditionChecker.checkCondition(v,"health_percent","hull;at_least=51")); + for (Route route : routes) { + blocks.clear(); + rail(block(0, 64, 0, Material.RAIL), route.shape()); + Rail.Shape next = route.dx() != 0 ? Rail.Shape.EAST_WEST : Rail.Shape.NORTH_SOUTH; + // The first step selects the configured exit, then three aligned straight rails continue it. + for (int i = 1; i < 4; i++) + rail(block(route.dx() * i, 64 + route.dy(), route.dz() * i, Material.RAIL), next); + float yaw = route.yaw(); + // For a turn the heading preference is chosen from the actual exit; test all curve arms + // separately below. + if (route.shape().name().contains("ASCENDING") + || route.shape() == Rail.Shape.NORTH_SOUTH + || route.shape() == Rail.Shape.EAST_WEST) { + Location result = + LocationChecker.getNextTrackedLocation( + new Location(r.world, 0.2, 64, 0.2), List.of(route.direction()), yaw); + assertNotNull(result, route.toString()); + assertEquals(64 + route.dy(), result.getY(), route.toString()); + assertEquals(route.dx() * 4 + .5, result.getX(), route.toString()); + assertEquals(route.dz() * 4 + .5, result.getZ(), route.toString()); + } else { + // A single curve is followed by no rail: the observable result is a stopped route, with the + // exit sampled. + blocks.clear(); + rail(block(0, 64, 0, Material.RAIL), route.shape()); + assertNull( + LocationChecker.getNextTrackedLocation( + new Location(r.world, 0, 64, 0), List.of(route.direction()), 0)); + verify(block(route.dx(), 64, route.dz()), atLeastOnce()).getBlockData(); + } } - - @Test void textAndControlLabelsStayReadable() { - assertNull(Text.capitalize(null));assertEquals("",Text.capitalize(""));assertEquals(" Hello\tWorld\nAgain",Text.capitalize(" hello\tworld\nagain"));assertEquals("AlreadyUPPER",Text.capitalize("AlreadyUPPER")); - String[] inputLabels={"Speed Up","Speed Down","Turn Left","Turn Right","Turn Left","Turn Right","Junction Left","Junction Right","Seat Selection","Move","Forward","Backward","UP","DOWN","Pitch Down","Pitch Up","Roll Left","Roll Right","Weapon Aim Up","Weapon Aim Down","Weapon Aim Left","Weapon Aim Right","Weapon Reload","Weapon Shoot","Reload & Shoot","Switch Weapon","Toggle Lights","Horn","None"}; - for(int i=0;i -4; z--) rail(block(0, 63, z, Material.RAIL), Rail.Shape.NORTH_SOUTH); + assertEquals( + 63, + LocationChecker.getNextTrackedLocation( + new Location(r.world, 0, 64, 0), List.of(NSEW.NORTH), 0) + .getY()); + for (Rail.Shape shape : Rail.Shape.values()) { + blocks.clear(); + rail(block(0, 64, 0, Material.RAIL), shape); + assertEquals( + new Location(r.world, .5, 64, .5), + LocationChecker.getNextTrackedLocation( + new Location(r.world, 0, 64, 0), List.of(NSEW.NORTH_EAST), 0), + "Unsupported diagonal preference must not invent rail movement"); } + } - @Test void legacyModelIdReplacesAllOldComponentChannels() { - ItemMeta meta=mock(ItemMeta.class);CustomModelDataComponent component=mock(CustomModelDataComponent.class);when(meta.getCustomModelDataComponent()).thenReturn(component);when(component.getFloats()).thenReturn(List.of());assertFalse(LegacyModelData.has(meta));assertThrows(IllegalStateException.class,()->LegacyModelData.get(meta));when(component.getFloats()).thenReturn(List.of(123f,99f));assertTrue(LegacyModelData.has(meta));assertEquals(123,LegacyModelData.get(meta));LegacyModelData.set(meta,42);verify(component).setFloats(List.of(42f));verify(component).setFlags(List.of());verify(component).setStrings(List.of());verify(component).setColors(List.of());verify(meta).setCustomModelDataComponent(component);LegacyModelData.set(meta,null);verify(meta).setCustomModelDataComponent(null); + @Test + void conditionsGateStateLiftPassengersFuelSeatsAndThrottle() { + ActiveVehicle v = r.vehicle(); + when(v.getStateHandler().getCurrentState().getType()).thenReturn(State.GROUND); + assertTrue(ConditionChecker.checkConditions(v, List.of())); + assertTrue(ConditionChecker.checkConditions(v, List.of("state(ground)", "passengers(false)"))); + assertFalse(ConditionChecker.checkConditions(v, List.of("state(flying)", "unknown()"))); + assertFalse(ConditionChecker.checkCondition(v, "lift", "true")); + Wings wings = mock(Wings.class); + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + when(v.hasComponent(Component.WINGS)).thenReturn(true); + when(v.getComponent(Component.WINGS)).thenReturn(wings); + assertFalse(ConditionChecker.checkCondition(v, "lift", "true")); + when(v.hasComponent(Component.ENGINE)).thenReturn(true); + when(v.getComponent(Component.ENGINE)).thenReturn(engine); + when(wings.getLift()).thenReturn(1d); + when(engine.getSpeed()).thenReturn(1d); + when(engine.getThrottle().getCurrent()).thenReturn(100); + assertTrue(ConditionChecker.checkCondition(v, "lift", "true")); + assertFalse(ConditionChecker.checkCondition(v, "lift", "false")); + when(engine.getThrottle().getCurrent()).thenReturn(-10); + assertFalse(ConditionChecker.checkCondition(v, "lift", "true")); + assertTrue(ConditionChecker.checkCondition(v, "lift", "false")); + when(v.getSeatHandler().hasPassengers()).thenReturn(true); + assertTrue(ConditionChecker.checkCondition(v, "passengers", "true")); + assertFalse(ConditionChecker.checkCondition(v, "passengers", "false")); + when(v.getAccessPanel().getSeat("pilot").isOccupied()).thenReturn(true); + assertTrue(ConditionChecker.checkCondition(v, "seat_filled", "pilot")); + assertFalse(ConditionChecker.checkCondition(v, "seat_empty", "pilot")); + when(v.getAccessPanel().getSeat("pilot").isOccupied()).thenReturn(false); + assertFalse(ConditionChecker.checkCondition(v, "seat_filled", "pilot")); + assertTrue(ConditionChecker.checkCondition(v, "seat_empty", "pilot")); + for (boolean flag : List.of(false, true)) { + when(v.hasParent()).thenReturn(flag); + when(v.hasFuel()).thenReturn(flag); + for (String type : List.of("is_passenger", "has_fuel")) { + assertTrue(ConditionChecker.checkCondition(v, type, Boolean.toString(flag))); + assertFalse(ConditionChecker.checkCondition(v, type, Boolean.toString(!flag))); + } } + when(v.getThrottle()).thenReturn(null); + assertFalse(ConditionChecker.checkCondition(v, "throttle_less_than", "50")); + assertFalse(ConditionChecker.checkCondition(v, "throttle_more_than", "50")); + Throttle throttle = mock(Throttle.class); + when(v.getThrottle()).thenReturn(throttle); + when(throttle.getCurrent()).thenReturn(50); + assertTrue(ConditionChecker.checkCondition(v, "throttle_less_than", "50")); + assertFalse(ConditionChecker.checkCondition(v, "throttle_less_than", "49")); + assertTrue(ConditionChecker.checkCondition(v, "throttle_more_than", "50")); + assertFalse(ConditionChecker.checkCondition(v, "throttle_more_than", "51")); + assertTrue( + ConditionChecker.checkCondition(v, "OR", "has_fuel=false;passengers=true;state=flying")); + assertFalse(ConditionChecker.checkCondition(v, "OR", "has_fuel=false;passengers=false")); + assertTrue(ConditionChecker.checkCondition(v, "AND", "has_fuel=true;passengers=true")); + assertFalse(ConditionChecker.checkCondition(v, "AND", "has_fuel=false;passengers=true")); + } - @Test void mergingConfigMakesIndependentNestedCopiesAndWritableYaml()throws Exception { - var source=Rig.yaml("engine:\n speed: 2\n sounds: [a,b]\nitems: [{id: stone}]\n");Map base=ConfigMerger.fromSection(source);Map overlay=Map.of("template","base","engine",Map.of("speed",4),"new",true);Map merged=ConfigMerger.overlay(base,overlay);var yaml=ConfigMerger.toConfiguration(merged);assertEquals(4,yaml.getInt("engine.speed"));assertEquals(List.of("a","b"),yaml.getStringList("engine.sounds"));assertFalse(yaml.contains("template"));assertTrue(yaml.getBoolean("new"));((List)((Map)merged.get("engine")).get("sounds")).clear();assertEquals(List.of("a","b"),source.getStringList("engine.sounds"));assertEquals(2,((Map)base.get("engine")).get("speed"));assertTrue(ConfigMerger.fromSection(null).isEmpty());assertTrue(ConfigMerger.overlay(null,null).isEmpty());assertTrue(ConfigMerger.toConfiguration(null).getKeys(false).isEmpty());assertEquals(base,ConfigMerger.overlay(base,null)); - } + @Test + void healthComparisonsValidateConfigurationAndTheirBoundaries() { + ActiveVehicle v = r.vehicle(); + when(v.hasComponent(Component.HULL)).thenReturn(true); + when(v.getComponent(Component.HULL).getHealthData().getHealthPercentage()).thenReturn(50); + for (String value : + Arrays.asList( + null, + "", + " ", + "hull;wings;at_most=50", + "hull;at_most=50;more_than=20", + "hull;at_most=abc", + "hull;bogus=20", + "hull", + "at_least=50", + "absent;at_least=50", + "engine;at_least=50")) + assertFalse( + ConditionChecker.checkCondition(v, "health_percent", value), String.valueOf(value)); + assertTrue( + ConditionChecker.checkCondition(v, "health_percent", " ;component=hull;;less_than=51")); + assertFalse(ConditionChecker.checkCondition(v, "health_percent", "hull;less_than=50")); + assertTrue(ConditionChecker.checkCondition(v, "health_percent", "hull;more_than=49")); + assertFalse(ConditionChecker.checkCondition(v, "health_percent", "hull;more_than=50")); + assertTrue(ConditionChecker.checkCondition(v, "health_percent", "hull;at_most=50")); + assertFalse(ConditionChecker.checkCondition(v, "health_percent", "hull;at_most=49")); + assertTrue(ConditionChecker.checkCondition(v, "health_percent", "hull;at_least=50")); + assertFalse(ConditionChecker.checkCondition(v, "health_percent", "hull;at_least=51")); + } - @Test void soundAndParticleListsReadConfigurationAndSkipMalformedParticleRows()throws Exception { - var sounds=SoundLoader.getSoundsFromConfig(Rig.yaml("one: {sound: test.horn, volume: 2, pitch: 0.8}\n"));assertEquals(1,sounds.size());assertEquals("test.horn",sounds.getFirst().getSound());assertEquals(2,sounds.getFirst().getVolume());var particles=ParticleLoader.getParticlesFromConfig(Rig.yaml("one: {particle: FLAME, amount: 3}\nbroken: scalar\n"));assertEquals(1,particles.size());assertEquals(Particle.FLAME,particles.getFirst().getParticle());assertEquals(3,particles.getFirst().getAmount()); - } + @Test + void textAndControlLabelsStayReadable() { + assertNull(Text.capitalize(null)); + assertEquals("", Text.capitalize("")); + assertEquals(" Hello\tWorld\nAgain", Text.capitalize(" hello\tworld\nagain")); + assertEquals("AlreadyUPPER", Text.capitalize("AlreadyUPPER")); + String[] inputLabels = { + "Speed Up", + "Speed Down", + "Turn Left", + "Turn Right", + "Turn Left", + "Turn Right", + "Junction Left", + "Junction Right", + "Seat Selection", + "Move", + "Forward", + "Backward", + "UP", + "DOWN", + "Pitch Down", + "Pitch Up", + "Roll Left", + "Roll Right", + "Weapon Aim Up", + "Weapon Aim Down", + "Weapon Aim Left", + "Weapon Aim Right", + "Weapon Reload", + "Weapon Shoot", + "Reload & Shoot", + "Switch Weapon", + "Toggle Lights", + "Horn", + "None" + }; + for (int i = 0; i < Input.values().length; i++) + assertEquals(inputLabels[i], EnumDisplayConverter.getInputDisplayName(Input.values()[i])); + String[] keyLabels = { + "W", + "A", + "S", + "D", + "SWAP", + "Right Click", + "Left Click", + "Shift + Right Click", + "Shift + Left Click", + "Space", + "Space + W", + "Space + A", + "Space + S", + "Space + D", + "Shift", + "Shift + W", + "Shift + A", + "Shift + S", + "Shift + D" + }; + for (int i = 0; i < Keybind.values().length; i++) + assertEquals(keyLabels[i], EnumDisplayConverter.getKeybindDisplayName(Keybind.values()[i])); + new EnumDisplayConverter(); + new ConditionChecker(); + new LocationChecker(); + new SoundLoader(); + new ParticleLoader(); + new ExplosionCreator(); + new Damager(); + } - @Test void temporaryLightsRespectSolidBlocksReservationsAndExpire() { - LightEffect effect=new LightEffect();Location location=new Location(r.world,0,64,0);block(0,64,0,Material.STONE);effect.createTemporaryLight(location,12);assertEquals(0,r.pending());block(0,64,0).setType(Material.AIR);Cache.lightLocations.add(location);effect.createTemporaryLight(location,12);assertEquals(0,r.pending());Cache.lightLocations.clear();effect.createTemporaryLight(location,12);assertEquals(Material.LIGHT,location.getBlock().getType());verify((Levelled)location.getBlock().getBlockData()).setLevel(12);assertTrue(Cache.lightLocations.contains(location));r.ticks(2);assertEquals(Material.AIR,location.getBlock().getType());assertTrue(Cache.lightLocations.isEmpty()); - } + @Test + void legacyModelIdReplacesAllOldComponentChannels() { + ItemMeta meta = mock(ItemMeta.class); + CustomModelDataComponent component = mock(CustomModelDataComponent.class); + when(meta.getCustomModelDataComponent()).thenReturn(component); + when(component.getFloats()).thenReturn(List.of()); + assertFalse(LegacyModelData.has(meta)); + assertThrows(IllegalStateException.class, () -> LegacyModelData.get(meta)); + when(component.getFloats()).thenReturn(List.of(123f, 99f)); + assertTrue(LegacyModelData.has(meta)); + assertEquals(123, LegacyModelData.get(meta)); + LegacyModelData.set(meta, 42); + verify(component).setFloats(List.of(42f)); + verify(component).setFlags(List.of()); + verify(component).setStrings(List.of()); + verify(component).setColors(List.of()); + verify(meta).setCustomModelDataComponent(component); + LegacyModelData.set(meta, null); + verify(meta).setCustomModelDataComponent(null); + } - @Test void spawnPresenceUsesSameWorldAndConfiguredSquaredDistance() { - Chunk chunk=mock(Chunk.class);Location location=new Location(r.world,0,64,0);SpawnLocation spawn=new SpawnLocation(chunk,location,"wagon.yml");assertSame(chunk,spawn.getChunk());assertSame(location,spawn.getLoc());assertEquals("wagon.yml",spawn.getFile());Cache.despawnDistance=100;assertFalse(spawn.hasNearby());online.add(r.entity(Player.class,new Location(otherWorld(),0,64,0)));online.add(r.entity(Player.class,new Location(r.world,20,64,0)));assertFalse(spawn.hasNearby());online.add(r.entity(Player.class,new Location(r.world,8,64,0)));assertTrue(spawn.hasNearby()); - } + @Test + void mergingConfigMakesIndependentNestedCopiesAndWritableYaml() throws Exception { + var source = Rig.yaml("engine:\n speed: 2\n sounds: [a,b]\nitems: [{id: stone}]\n"); + Map base = ConfigMerger.fromSection(source); + Map overlay = + Map.of("template", "base", "engine", Map.of("speed", 4), "new", true); + Map merged = ConfigMerger.overlay(base, overlay); + var yaml = ConfigMerger.toConfiguration(merged); + assertEquals(4, yaml.getInt("engine.speed")); + assertEquals(List.of("a", "b"), yaml.getStringList("engine.sounds")); + assertFalse(yaml.contains("template")); + assertTrue(yaml.getBoolean("new")); + ((List) ((Map) merged.get("engine")).get("sounds")).clear(); + assertEquals(List.of("a", "b"), source.getStringList("engine.sounds")); + assertEquals(2, ((Map) base.get("engine")).get("speed")); + assertTrue(ConfigMerger.fromSection(null).isEmpty()); + assertTrue(ConfigMerger.overlay(null, null).isEmpty()); + assertTrue(ConfigMerger.toConfiguration(null).getKeys(false).isEmpty()); + assertEquals(base, ConfigMerger.overlay(base, null)); + } - @Test void disablingBlockDamageAlsoProtectsBlocksFromExplosionFire() { - block(0,63,0,Material.STONE); - var display=mock(net.tfminecraft.vehicleframework.tracks.TrackDisplayManager.class);r.framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(display); - onEvent=e->{if(e instanceof VFExplosionEvent explosion)explosion.setBlockDamage(false);}; - try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(0d))){ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,2,0,"test",true);} - assertEquals(Material.AIR,block(0,64,0).getType(),"A protected explosion must not place damaging fire"); - verifyNoInteractions(display); - } + @Test + void soundAndParticleListsReadConfigurationAndSkipMalformedParticleRows() throws Exception { + var sounds = + SoundLoader.getSoundsFromConfig( + Rig.yaml("one: {sound: test.horn, volume: 2, pitch: 0.8}\n")); + assertEquals(1, sounds.size()); + assertEquals("test.horn", sounds.getFirst().getSound()); + assertEquals(2, sounds.getFirst().getVolume()); + var particles = + ParticleLoader.getParticlesFromConfig( + Rig.yaml("one: {particle: FLAME, amount: 3}\nbroken: scalar\n")); + assertEquals(1, particles.size()); + assertEquals(Particle.FLAME, particles.getFirst().getParticle()); + assertEquals(3, particles.getFirst().getAmount()); + } + + @Test + void temporaryLightsRespectSolidBlocksReservationsAndExpire() { + LightEffect effect = new LightEffect(); + Location location = new Location(r.world, 0, 64, 0); + block(0, 64, 0, Material.STONE); + effect.createTemporaryLight(location, 12); + assertEquals(0, r.pending()); + block(0, 64, 0).setType(Material.AIR); + Cache.lightLocations.add(location); + effect.createTemporaryLight(location, 12); + assertEquals(0, r.pending()); + Cache.lightLocations.clear(); + effect.createTemporaryLight(location, 12); + assertEquals(Material.LIGHT, location.getBlock().getType()); + verify((Levelled) location.getBlock().getBlockData()).setLevel(12); + assertTrue(Cache.lightLocations.contains(location)); + r.ticks(2); + assertEquals(Material.AIR, location.getBlock().getType()); + assertTrue(Cache.lightLocations.isEmpty()); + } - @Test void zeroRadiusExplosionNeverAppliesANonFiniteVelocity() { - LivingEntity entity=r.entity(LivingEntity.class,new Location(r.world,0,64,0));r.nearby.add(entity); - ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,0,4,"test"); - Vector velocity=entity.getVelocity();assertTrue(Double.isFinite(velocity.getX())&&Double.isFinite(velocity.getY())&&Double.isFinite(velocity.getZ()),"A zero-radius blast must not send NaN velocity to Bukkit"); - for(double radius:List.of(-1d,Double.NaN,Double.POSITIVE_INFINITY))assertDoesNotThrow(()->ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,radius,4,"test"));verify(entity,never()).setVelocity(any()); + @Test + void spawnPresenceUsesSameWorldAndConfiguredSquaredDistance() { + Chunk chunk = mock(Chunk.class); + Location location = new Location(r.world, 0, 64, 0); + SpawnLocation spawn = new SpawnLocation(chunk, location, "wagon.yml"); + assertSame(chunk, spawn.getChunk()); + assertSame(location, spawn.getLoc()); + assertEquals("wagon.yml", spawn.getFile()); + Cache.despawnDistance = 100; + assertFalse(spawn.hasNearby()); + online.add(r.entity(Player.class, new Location(otherWorld(), 0, 64, 0))); + online.add(r.entity(Player.class, new Location(r.world, 20, 64, 0))); + assertFalse(spawn.hasNearby()); + online.add(r.entity(Player.class, new Location(r.world, 8, 64, 0))); + assertTrue(spawn.hasNearby()); + } + + @Test + void disablingBlockDamageAlsoProtectsBlocksFromExplosionFire() { + block(0, 63, 0, Material.STONE); + var display = mock(net.tfminecraft.vehicleframework.tracks.TrackDisplayManager.class); + r.framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(display); + onEvent = + e -> { + if (e instanceof VFExplosionEvent explosion) explosion.setBlockDamage(false); + }; + try (var random = + mockConstruction(Random.class, (rng, context) -> when(rng.nextDouble()).thenReturn(0d))) { + ExplosionCreator.triggerExplosion(new Location(r.world, 0, 64, 0), 1, 2, 0, "test", true); } + assertEquals( + Material.AIR, + block(0, 64, 0).getType(), + "A protected explosion must not place damaging fire"); + verifyNoInteractions(display); + } + + @Test + void zeroRadiusExplosionNeverAppliesANonFiniteVelocity() { + LivingEntity entity = r.entity(LivingEntity.class, new Location(r.world, 0, 64, 0)); + r.nearby.add(entity); + ExplosionCreator.triggerExplosion(new Location(r.world, 0, 64, 0), 1, 0, 4, "test"); + Vector velocity = entity.getVelocity(); + assertTrue( + Double.isFinite(velocity.getX()) + && Double.isFinite(velocity.getY()) + && Double.isFinite(velocity.getZ()), + "A zero-radius blast must not send NaN velocity to Bukkit"); + for (double radius : List.of(-1d, Double.NaN, Double.POSITIVE_INFINITY)) + assertDoesNotThrow( + () -> + ExplosionCreator.triggerExplosion( + new Location(r.world, 0, 64, 0), 1, radius, 4, "test")); + verify(entity, never()).setVelocity(any()); + } - @Test void entityAtBlastCentreGetsFiniteVerticalLift() { - LivingEntity centered=r.entity(LivingEntity.class,new Location(r.world,0,64,0));r.nearby.add(centered);ExplosionCreator.triggerExplosion(centered.getLocation(),2,4,10,"test");assertEquals(new Vector(0,1,0),centered.getVelocity());verify(centered).damage(10); + @Test + void entityAtBlastCentreGetsFiniteVerticalLift() { + LivingEntity centered = r.entity(LivingEntity.class, new Location(r.world, 0, 64, 0)); + r.nearby.add(centered); + ExplosionCreator.triggerExplosion(centered.getLocation(), 2, 4, 10, "test"); + assertEquals(new Vector(0, 1, 0), centered.getVelocity()); + verify(centered).damage(10); + } + + @Test + void explosionsRespectCancellationObserversDamageFalloffArmourAndVehicleKnockback() { + Cache.blockDamage = true; + Location center = new Location(r.world, 0, 64, 0); + LivingEntity near = r.entity(LivingEntity.class, center.clone().add(0, 0, 1)), + far = r.entity(LivingEntity.class, center.clone().add(0, 0, 3)), + outside = r.entity(LivingEntity.class, center.clone().add(3.5, 0, 3.5)), + deck = r.entity(LivingEntity.class, center.clone()); + Player creative = r.entity(Player.class, center.clone()), + spectator = r.entity(Player.class, center.clone()), + rider = r.entity(Player.class, center.clone()), + survival = r.entity(Player.class, center.clone().add(0, 0, 1)); + when(creative.getGameMode()).thenReturn(GameMode.CREATIVE); + when(spectator.getGameMode()).thenReturn(GameMode.SPECTATOR); + when(rider.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(survival.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(survival.getAttribute(Attribute.ARMOR).getValue()).thenReturn(20d); + Entity vehicleEntity = r.entity(Entity.class, center.clone()); + ActiveVehicle vehicle = r.vehicle(); + when(r.manager.get(vehicleEntity)).thenReturn(vehicle); + when(vehicle.getSeatHandler().getPassengers()).thenReturn(List.of(rider)); + r.decks + .when(() -> net.tfminecraft.vehicleframework.vehicles.handlers.train.DeckBody.isPart(deck)) + .thenReturn(true); + r.nearby.addAll( + List.of(near, far, outside, deck, creative, spectator, rider, survival, vehicleEntity)); + Player observer = r.entity(Player.class, center.clone().add(0, 0, 100)), + distant = r.entity(Player.class, center.clone().add(0, 0, 321)), + other = r.entity(Player.class, new Location(otherWorld(), 0, 64, 0)); + online.addAll(List.of(observer, distant, other)); + var display = mock(net.tfminecraft.vehicleframework.tracks.TrackDisplayManager.class); + r.framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(display); + onEvent = + e -> { + if (e instanceof VFExplosionEvent explosion) explosion.setCancelled(true); + }; + ExplosionCreator.triggerExplosion(center, 2, 4, 20, "test"); + verify(near, never()).damage(anyDouble()); + verifyNoInteractions(display); + onEvent = e -> {}; + ExplosionCreator.triggerExplosion(center, 2, 4, 20, "test"); + verify(display).breakInRadius(center, 4); + verify(near).damage(20); + verify(far).damage(10); + verify(survival).damage(15); + verify(outside, never()).damage(anyDouble()); + verify(deck, never()).damage(anyDouble()); + verify(creative, never()).setVelocity(any()); + verify(spectator, never()).setVelocity(any()); + verify(rider, never()).setVelocity(any()); + assertEquals(new Vector(0, 1, 2.5), near.getVelocity()); + verify(observer).spawnParticle(Particle.EXPLOSION_EMITTER, center, 30, 0, 0, 0, 0); + verify(distant, never()) + .spawnParticle( + any(Particle.class), + any(Location.class), + anyInt(), + anyDouble(), + anyDouble(), + anyDouble(), + anyDouble()); + verify(other, never()) + .spawnParticle( + any(Particle.class), + any(Location.class), + anyInt(), + anyDouble(), + anyDouble(), + anyDouble(), + anyDouble()); + } + + @Test + void directDamageAllowsEventCancellationAndAlwaysClearsItsGuard() { + Entity decoration = r.entity(Entity.class, new Location(r.world, 0, 64, 0)); + ExplosionCreator.applyDamage(decoration, 10, "test"); + LivingEntity target = r.entity(LivingEntity.class, new Location(r.world, 0, 64, 0)); + onEvent = + e -> { + if (e instanceof VFEntityDamageEvent damage) damage.setCancelled(true); + }; + ExplosionCreator.applyDamage(target, 10, "test"); + verify(target, never()).damage(anyDouble()); + onEvent = + e -> { + if (e instanceof VFEntityDamageEvent damage) damage.setDamage(3); + }; + ExplosionCreator.applyDamage(target, 10, "test"); + verify(target).damage(3); + var order = inOrder(r.manager, target); + order.verify(r.manager).setDamaged(target, true); + order.verify(target).damage(3); + order.verify(r.manager).setDamaged(target, false); + doThrow(new IllegalStateException("external entity rejected damage")).when(target).damage(8); + assertDoesNotThrow(() -> Damager.damage(target, 8)); + verify(r.manager, times(2)).setDamaged(target, false); + } + + @Test + void blastConvertsSoftGroundAndRespectfullySkipsProtectedBlocks() { + Cache.blockDamage = true; + Cache.ignoreExplode.add(Material.GOLD_BLOCK); + Cache.ignoreLands.add(Material.DIRT); + Cache.convertExplode.put(Material.DIRT, Material.COARSE_DIRT); + List soils = + List.of( + Material.DIRT, + Material.GRASS_BLOCK, + Material.PODZOL, + Material.COARSE_DIRT, + Material.ROOTED_DIRT, + Material.MYCELIUM, + Material.FARMLAND, + Material.SNOW, + Material.SNOW_BLOCK); + int i = 0; + for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) block(x, 64, z, soils.get(i++)); + Block bedrock = block(2, 64, 0, Material.BEDROCK), + protectedBlock = block(-2, 64, 0, Material.GOLD_BLOCK), + stone = block(0, 64, 2, Material.STONE); + try (var random = + mockConstruction(Random.class, (rng, context) -> when(rng.nextDouble()).thenReturn(.1d))) { + ExplosionCreator.triggerExplosion(new Location(r.world, 0, 64, 0), 2, 4, 0, "artillery"); + assertEquals(Material.BEDROCK, bedrock.getType()); + assertEquals(Material.GOLD_BLOCK, protectedBlock.getType()); + assertEquals(Material.AIR, stone.getType()); + assertEquals(10, debris.size()); + for (FallingBlock falling : debris) { + verify(falling).setDropItem(false); + assertTrue(falling.getVelocity().length() > 0); + } + assertTrue( + debris.stream() + .anyMatch(falling -> falling.getBlockData().getMaterial() == Material.COARSE_DIRT)); + verify(debris.getFirst()).setCancelDrop(true); + logs.verify(() -> LogWriter.logBreak(eq("artillery"), any(Block.class)), times(10)); + r.tick(); + verify(r.world, times(10)) + .spawnParticle( + eq(Particle.BLOCK), + any(Location.class), + eq(8), + eq(.1d), + eq(.1d), + eq(.1d), + eq(0d), + any(BlockData.class)); + for (FallingBlock falling : debris) when(falling.isDead()).thenReturn(true); + r.ticks(2); + assertEquals(0, r.pending()); } + } - @Test void explosionsRespectCancellationObserversDamageFalloffArmourAndVehicleKnockback() { - Cache.blockDamage=true; - Location center=new Location(r.world,0,64,0);LivingEntity near=r.entity(LivingEntity.class,center.clone().add(0,0,1)),far=r.entity(LivingEntity.class,center.clone().add(0,0,3)),outside=r.entity(LivingEntity.class,center.clone().add(3.5,0,3.5)),deck=r.entity(LivingEntity.class,center.clone());Player creative=r.entity(Player.class,center.clone()),spectator=r.entity(Player.class,center.clone()),rider=r.entity(Player.class,center.clone()),survival=r.entity(Player.class,center.clone().add(0,0,1));when(creative.getGameMode()).thenReturn(GameMode.CREATIVE);when(spectator.getGameMode()).thenReturn(GameMode.SPECTATOR);when(rider.getGameMode()).thenReturn(GameMode.SURVIVAL);when(survival.getGameMode()).thenReturn(GameMode.SURVIVAL);when(survival.getAttribute(Attribute.ARMOR).getValue()).thenReturn(20d); - Entity vehicleEntity=r.entity(Entity.class,center.clone());ActiveVehicle vehicle=r.vehicle();when(r.manager.get(vehicleEntity)).thenReturn(vehicle);when(vehicle.getSeatHandler().getPassengers()).thenReturn(List.of(rider));r.decks.when(()->net.tfminecraft.vehicleframework.vehicles.handlers.train.DeckBody.isPart(deck)).thenReturn(true);r.nearby.addAll(List.of(near,far,outside,deck,creative,spectator,rider,survival,vehicleEntity)); - Player observer=r.entity(Player.class,center.clone().add(0,0,100)),distant=r.entity(Player.class,center.clone().add(0,0,321)),other=r.entity(Player.class,new Location(otherWorld(),0,64,0));online.addAll(List.of(observer,distant,other)); - var display=mock(net.tfminecraft.vehicleframework.tracks.TrackDisplayManager.class);r.framework.when(VehicleFramework::getTrackDisplayManager).thenReturn(display); - onEvent=e->{if(e instanceof VFExplosionEvent explosion)explosion.setCancelled(true);};ExplosionCreator.triggerExplosion(center,2,4,20,"test");verify(near,never()).damage(anyDouble());verifyNoInteractions(display); - onEvent=e->{};ExplosionCreator.triggerExplosion(center,2,4,20,"test");verify(display).breakInRadius(center,4);verify(near).damage(20);verify(far).damage(10);verify(survival).damage(15);verify(outside,never()).damage(anyDouble());verify(deck,never()).damage(anyDouble());verify(creative,never()).setVelocity(any());verify(spectator,never()).setVelocity(any());verify(rider,never()).setVelocity(any());assertEquals(new Vector(0,1,2.5),near.getVelocity());verify(observer).spawnParticle(Particle.EXPLOSION_EMITTER,center,30,0,0,0,0);verify(distant,never()).spawnParticle(any(Particle.class),any(Location.class),anyInt(),anyDouble(),anyDouble(),anyDouble(),anyDouble());verify(other,never()).spawnParticle(any(Particle.class),any(Location.class),anyInt(),anyDouble(),anyDouble(),anyDouble(),anyDouble()); + @Test + void blastRandomnessCanLeaveHardBlocksAndSuppressDebris() { + Cache.blockDamage = true; + Block stone = block(0, 64, 0, Material.STONE), dirt = block(1, 64, 0, Material.DIRT); + try (var random = + mockConstruction(Random.class, (rng, context) -> when(rng.nextDouble()).thenReturn(.99d))) { + ExplosionCreator.triggerExplosion(new Location(r.world, 0, 64, 0), 1, 2, 0, "test"); + assertEquals(Material.STONE, stone.getType()); + assertEquals(Material.AIR, dirt.getType()); + assertTrue(debris.isEmpty()); + } + dirt.setType(Material.AIR); + try (var random = + mockConstruction( + Random.class, (rng, context) -> when(rng.nextDouble()).thenReturn(.1d, .9d))) { + ExplosionCreator.triggerExplosion(new Location(r.world, 0, 64, 0), 1, 2, 0, "test"); + assertEquals(Material.AIR, stone.getType()); + assertTrue(debris.isEmpty()); } + } - @Test void directDamageAllowsEventCancellationAndAlwaysClearsItsGuard() { - Entity decoration=r.entity(Entity.class,new Location(r.world,0,64,0));ExplosionCreator.applyDamage(decoration,10,"test");LivingEntity target=r.entity(LivingEntity.class,new Location(r.world,0,64,0));onEvent=e->{if(e instanceof VFEntityDamageEvent damage)damage.setCancelled(true);};ExplosionCreator.applyDamage(target,10,"test");verify(target,never()).damage(anyDouble());onEvent=e->{if(e instanceof VFEntityDamageEvent damage)damage.setDamage(3);};ExplosionCreator.applyDamage(target,10,"test");verify(target).damage(3);var order=inOrder(r.manager,target);order.verify(r.manager).setDamaged(target,true);order.verify(target).damage(3);order.verify(r.manager).setDamaged(target,false); - doThrow(new IllegalStateException("external entity rejected damage")).when(target).damage(8);assertDoesNotThrow(()->Damager.damage(target,8));verify(r.manager,times(2)).setDamaged(target,false); + @Test + void debrisLandingRejectsInvalidGroundPillarsAndDistantUnluckyDebris() { + Cache.blockDamage = true; + Cache.ignoreGround.add(Material.WATER); + for (String landing : List.of("water", "pillar", "supported", "far")) { + blocks.clear(); + debris.clear(); + block(0, 64, 0, Material.DIRT); + try (var random = + mockConstruction( + Random.class, (rng, context) -> when(rng.nextDouble()).thenReturn(.1d))) { + ExplosionCreator.triggerExplosion(new Location(r.world, 0, 64, 0), 1, 2, 0, "test"); + assertEquals(1, debris.size()); + FallingBlock falling = debris.getFirst(); + int x = landing.equals("far") ? 80 : 10; + falling.teleport(new Location(r.world, x, 64, 0)); + Block floor = block(x, 63, 0, landing.equals("water") ? Material.WATER : Material.STONE); + if (landing.equals("supported") || landing.equals("far")) + block(x + 1, 63, 0, Material.STONE); + when(falling.isOnGround()).thenReturn(true); + if (landing.equals("far")) + when(random.constructed().getFirst().nextDouble()).thenReturn(.99d); + r.tick(); + assertEquals(0, r.pending()); + if (landing.equals("supported")) { + verify(falling, never()).remove(); + Block landed = block(x, 64, 0); + logs.verify(() -> LogWriter.logPlace("test", landed)); + } else verify(falling).remove(); + } } + } - @Test void blastConvertsSoftGroundAndRespectfullySkipsProtectedBlocks() { - Cache.blockDamage=true;Cache.ignoreExplode.add(Material.GOLD_BLOCK);Cache.ignoreLands.add(Material.DIRT);Cache.convertExplode.put(Material.DIRT,Material.COARSE_DIRT); - List soils=List.of(Material.DIRT,Material.GRASS_BLOCK,Material.PODZOL,Material.COARSE_DIRT,Material.ROOTED_DIRT,Material.MYCELIUM,Material.FARMLAND,Material.SNOW,Material.SNOW_BLOCK); - int i=0;for(int x=-1;x<=1;x++)for(int z=-1;z<=1;z++)block(x,64,z,soils.get(i++));Block bedrock=block(2,64,0,Material.BEDROCK),protectedBlock=block(-2,64,0,Material.GOLD_BLOCK),stone=block(0,64,2,Material.STONE); - try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(.1d))){ - ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),2,4,0,"artillery"); - assertEquals(Material.BEDROCK,bedrock.getType());assertEquals(Material.GOLD_BLOCK,protectedBlock.getType());assertEquals(Material.AIR,stone.getType());assertEquals(10,debris.size());for(FallingBlock falling:debris){verify(falling).setDropItem(false);assertTrue(falling.getVelocity().length()>0);} - assertTrue(debris.stream().anyMatch(falling->falling.getBlockData().getMaterial()==Material.COARSE_DIRT));verify(debris.getFirst()).setCancelDrop(true);logs.verify(()->LogWriter.logBreak(eq("artillery"),any(Block.class)),times(10));r.tick();verify(r.world,times(10)).spawnParticle(eq(Particle.BLOCK),any(Location.class),eq(8),eq(.1d),eq(.1d),eq(.1d),eq(0d),any(BlockData.class));for(FallingBlock falling:debris)when(falling.isDead()).thenReturn(true);r.ticks(2);assertEquals(0,r.pending()); - } + @Test + void incendiaryBlastFindsSupportedAirButHonoursRandomChanceAndRadius() { + Cache.blockDamage = true; + block(0, 63, 0, Material.BEDROCK); + block(1, 63, 0, Material.BEDROCK); + block(1, 64, 0, Material.WATER); + Cache.ignoreExplode.add(Material.WATER); + try (var random = + mockConstruction(Random.class, (rng, context) -> when(rng.nextDouble()).thenReturn(0d))) { + ExplosionCreator.triggerExplosion(new Location(r.world, 0, 64, 0), 1, 2, 0, "test", true); + assertEquals(Material.FIRE, block(0, 64, 0).getType()); + assertEquals(Material.WATER, block(1, 64, 0).getType()); + assertEquals(Material.AIR, block(2, 64, 0).getType()); + } + block(0, 64, 0).setType(Material.AIR); + try (var random = + mockConstruction(Random.class, (rng, context) -> when(rng.nextDouble()).thenReturn(.99d))) { + ExplosionCreator.triggerExplosion(new Location(r.world, 0, 64, 0), 1, 2, 0, "test", true); + assertEquals(Material.AIR, block(0, 64, 0).getType()); } + } + + @Test + void impactEffectsUseSurfaceNormalsAndOnlyNearbyViewers() { + Location inside = new Location(r.world, 1, 64, 1); + Vector direction = new Vector(0, 0, 1); + assertNull(ImpactVfx.onBlockSurface(null, direction, null)); + Location nowhere = new Location(null, 0, 0, 0); + assertSame(nowhere, ImpactVfx.onBlockSurface(nowhere, direction, null)); + assertSame(inside, ImpactVfx.onBlockSurface(inside, null, null)); + assertEquals(inside, ImpactVfx.onBlockSurface(inside, new Vector(), null)); + assertEquals( + new Location(r.world, 1, 64, .94), ImpactVfx.onBlockSurface(inside, direction, null)); + assertEquals( + new Location(r.world, 1, 64, .94), + ImpactVfx.onBlockSurface(inside, direction, block(1, 64, 1))); + when(r.world.rayTraceBlocks( + any(Location.class), any(Vector.class), eq(3d), eq(FluidCollisionMode.NEVER), eq(true))) + .thenReturn(new RayTraceResult(new Vector(1, 64, .5), BlockFace.NORTH)); + assertEquals( + new Location(r.world, 1, 64, .44), ImpactVfx.onBlockSurface(inside, direction, null)); + when(r.world.rayTraceBlocks( + any(Location.class), any(Vector.class), eq(3d), eq(FluidCollisionMode.NEVER), eq(true))) + .thenReturn(new RayTraceResult(new Vector(1, 64, .5))); + assertEquals( + new Location(r.world, 1, 64, .44), ImpactVfx.onBlockSurface(inside, direction, null)); + assertEquals(new Vector(0, 0, -.06), ImpactVfx.surfaceOffset(direction, new Vector())); + Player near = r.entity(Player.class, inside.clone()), + far = r.entity(Player.class, inside.clone().add(321, 0, 0)); + when(r.world.getPlayers()).thenReturn(List.of(near, far)); + ImpactVfx.spawn(null, Particle.FLAME, 1, 0, 0, 0, 0, null); + ImpactVfx.spawn(nowhere, Particle.FLAME, 1, 0, 0, 0, 0, null); + ImpactVfx.spawn(inside, null, 1, 0, 0, 0, 0, null); + ImpactVfx.spawn(inside, Particle.FLAME, 2, .1, .2, .3, .4, null); + BlockData data = data(Material.STONE); + ImpactVfx.spawn(inside, Particle.BLOCK, 3, .1, .2, .3, .4, data); + verify(near).spawnParticle(Particle.FLAME, inside, 2, .1, .2, .3, .4); + verify(near).spawnParticle(Particle.BLOCK, inside, 3, .1, .2, .3, .4, data); + verify(far, never()) + .spawnParticle( + any(Particle.class), + any(Location.class), + anyInt(), + anyDouble(), + anyDouble(), + anyDouble(), + anyDouble()); + } - @Test void blastRandomnessCanLeaveHardBlocksAndSuppressDebris() { - Cache.blockDamage=true;Block stone=block(0,64,0,Material.STONE),dirt=block(1,64,0,Material.DIRT); - try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(.99d))){ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,2,0,"test");assertEquals(Material.STONE,stone.getType());assertEquals(Material.AIR,dirt.getType());assertTrue(debris.isEmpty());} - dirt.setType(Material.AIR); - try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(.1d,.9d))){ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,2,0,"test");assertEquals(Material.AIR,stone.getType());assertTrue(debris.isEmpty());} + @Test + void tabCompletionReflectsPermissionsVehiclesAndCurrentWorldTracks( + @org.junit.jupiter.api.io.TempDir java.nio.file.Path folder) throws Exception { + TabCompletion tabs = new TabCompletion(); + Command command = mock(Command.class); + when(command.getName()).thenReturn("vf"); + Player player = r.entity(Player.class, new Location(r.world, 0, 64, 0)); + assertNull(tabs.onTabComplete(mock(CommandSender.class), command, "vf", new String[] {""})); + assertEquals( + List.of("findvehicles", "keybinds"), + tabs.onTabComplete(player, command, "vf", new String[] {""})); + assertEquals(List.of(), tabs.onTabComplete(player, command, "vf", new String[] {"spawn", ""})); + when(player.hasPermission(Permissions.Permission_Spawn)).thenReturn(true); + assertEquals( + List.of("findvehicles", "keybinds", "spawn", "kill", "ammo"), + tabs.onTabComplete(player, command, "vf", new String[] {""})); + try (var loader = mockStatic(VehicleLoader.class)) { + Vehicle template = mock(Vehicle.class); + when(template.getId()).thenReturn("wagon"); + loader.when(VehicleLoader::get).thenReturn(new HashMap<>(Map.of("wagon", template))); + assertEquals( + List.of("wagon"), tabs.onTabComplete(player, command, "vf", new String[] {"spawn", ""})); } + when(player.hasPermission(Permissions.Permission_Admin)).thenReturn(true); + assertTrue( + tabs.onTabComplete(player, command, "vf", new String[] {""}) + .containsAll(List.of("takeover", "reload", "track"))); + assertEquals( + List.of( + "start", + "end", + "list", + "info", + "particles", + "clearance", + "dump", + "delete", + "bind", + "unbind", + "resync"), + tabs.onTabComplete(player, command, "vf", new String[] {"track", ""})); + assertEquals( + List.of(), tabs.onTabComplete(player, command, "vf", new String[] {"track", "delete", ""})); + var registry = new net.tfminecraft.vehicleframework.tracks.TrackRegistry(folder.toFile()); + var local = + net.tfminecraft.vehicleframework.tracks.TrackSpline.fromPoints( + UUID.randomUUID(), + r.world.getName(), + false, + List.of(new double[] {0, 64, 0}, new double[] {4, 64, 0})); + var elsewhere = + net.tfminecraft.vehicleframework.tracks.TrackSpline.fromPoints( + UUID.randomUUID(), + "elsewhere", + false, + List.of(new double[] {0, 64, 0}, new double[] {4, 64, 0})); + registry.replace(local); + registry.replace(elsewhere); + r.framework.when(VehicleFramework::getTrackRegistry).thenReturn(registry); + try { + for (String sub : List.of("delete", "clearance")) + assertEquals( + List.of(local.getId().toString()), + tabs.onTabComplete(player, command, "vf", new String[] {"track", sub, ""})); + } finally { + registry.close(); + } + assertEquals( + List.of("10", "20", "50"), + tabs.onTabComplete(player, command, "vf", new String[] {"kill", ""})); + assertNull(tabs.onTabComplete(player, command, "vf", new String[] {"track", "info", ""})); + assertNull(tabs.onTabComplete(player, command, "vf", new String[] {"unknown", ""})); + when(command.getName()).thenReturn("other"); + assertNull(tabs.onTabComplete(player, command, "other", new String[] {""})); + } - @Test void debrisLandingRejectsInvalidGroundPillarsAndDistantUnluckyDebris() { - Cache.blockDamage=true;Cache.ignoreGround.add(Material.WATER); - for(String landing:List.of("water","pillar","supported","far")){ - blocks.clear();debris.clear();block(0,64,0,Material.DIRT); - try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(.1d))){ - ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,2,0,"test");assertEquals(1,debris.size());FallingBlock falling=debris.getFirst();int x=landing.equals("far")?80:10;falling.teleport(new Location(r.world,x,64,0));Block floor=block(x,63,0,landing.equals("water")?Material.WATER:Material.STONE);if(landing.equals("supported")||landing.equals("far"))block(x+1,63,0,Material.STONE);when(falling.isOnGround()).thenReturn(true);if(landing.equals("far"))when(random.constructed().getFirst().nextDouble()).thenReturn(.99d);r.tick();assertEquals(0,r.pending()); - if(landing.equals("supported")){verify(falling,never()).remove();Block landed=block(x,64,0);logs.verify(()->LogWriter.logPlace("test",landed));}else verify(falling).remove(); - } - } + @Test + void mythicDespawnUnloadsVehiclesAndEntityCleanupAlwaysRemovesBase() { + var event = mock(io.lumine.mythic.bukkit.events.MythicMobDespawnEvent.class); + Entity entity = r.entity(Entity.class, new Location(r.world, 0, 64, 0)); + when(event.getEntity()).thenReturn(entity); + MythicMobsIntegration listener = new MythicMobsIntegration(); + listener.despawnEvent(event); + verify(r.manager, never()).unload(any()); + ActiveVehicle vehicle = r.vehicle(); + when(r.manager.get(entity)).thenReturn(vehicle); + listener.despawnEvent(event); + verify(r.manager).unload(vehicle); + try (var api = mockStatic(com.ticxo.modelengine.api.ModelEngineAPI.class)) { + VehicleEntityCleanup.remove(entity); + verify(entity).remove(); + var modeled = mock(com.ticxo.modelengine.api.model.ModeledEntity.class); + var engine = mock(com.ticxo.modelengine.api.ModelEngineAPI.class, RETURNS_DEEP_STUBS); + api.when(com.ticxo.modelengine.api.ModelEngineAPI::getAPI).thenReturn(engine); + api.when(() -> com.ticxo.modelengine.api.ModelEngineAPI.getModeledEntity(entity)) + .thenReturn(modeled); + VehicleEntityCleanup.remove(entity); + verify(engine.getModelUpdaters()).forceRemoveModeledEntity(modeled); + verify(entity, times(2)).remove(); + var updaters = engine.getModelUpdaters(); + doThrow(new IllegalStateException("model callback failed")) + .when(updaters) + .forceRemoveModeledEntity(modeled); + assertThrows(IllegalStateException.class, () -> VehicleEntityCleanup.remove(entity)); + verify(entity, times(3)).remove(); } + } - @Test void incendiaryBlastFindsSupportedAirButHonoursRandomChanceAndRadius() { - Cache.blockDamage=true;block(0,63,0,Material.BEDROCK);block(1,63,0,Material.BEDROCK);block(1,64,0,Material.WATER);Cache.ignoreExplode.add(Material.WATER); - try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(0d))){ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,2,0,"test",true);assertEquals(Material.FIRE,block(0,64,0).getType());assertEquals(Material.WATER,block(1,64,0).getType());assertEquals(Material.AIR,block(2,64,0).getType());} - block(0,64,0).setType(Material.AIR);try(var random=mockConstruction(Random.class,(rng,context)->when(rng.nextDouble()).thenReturn(.99d))){ExplosionCreator.triggerExplosion(new Location(r.world,0,64,0),1,2,0,"test",true);assertEquals(Material.AIR,block(0,64,0).getType());} + @Test + void relativeMoveSendsAllRelativeFieldsAndDisablesItselfAfterConnectionFailure( + @org.junit.jupiter.api.io.TempDir java.nio.file.Path folder) throws Exception { + try (var boundary = new MovementBoundary(folder)) { + assertTrue(boundary.send(1.5, -2, 3, 45)); + Object packet = boundary.connection.getClass().getField("last").get(boundary.connection); + assertEquals(-1, packet.getClass().getMethod("id").invoke(packet)); + Object change = packet.getClass().getMethod("change").invoke(packet), + position = change.getClass().getMethod("position").invoke(change), + velocity = change.getClass().getMethod("deltaMovement").invoke(change); + assertEquals(1.5, position.getClass().getMethod("x").invoke(position)); + assertEquals(-2d, position.getClass().getMethod("y").invoke(position)); + assertEquals(3d, position.getClass().getMethod("z").invoke(position)); + assertEquals(0d, velocity.getClass().getMethod("x").invoke(velocity)); + assertEquals(45f, change.getClass().getMethod("yRot").invoke(change)); + assertEquals(0f, change.getClass().getMethod("xRot").invoke(change)); + assertEquals(9, ((Set) packet.getClass().getMethod("relatives").invoke(packet)).size()); + assertTrue(boundary.send(0, 0, 0, 0)); + boundary.connection.getClass().getField("fail").setBoolean(boundary.connection, true); + assertFalse(boundary.send(1, 1, 1, 0)); + boundary.connection.getClass().getField("fail").setBoolean(boundary.connection, false); + assertFalse( + boundary.send(1, 1, 1, 0), + "Unsupported transport remains disabled instead of flooding logs every tick"); } + } - @Test void impactEffectsUseSurfaceNormalsAndOnlyNearbyViewers() { - Location inside=new Location(r.world,1,64,1);Vector direction=new Vector(0,0,1);assertNull(ImpactVfx.onBlockSurface(null,direction,null));Location nowhere=new Location(null,0,0,0);assertSame(nowhere,ImpactVfx.onBlockSurface(nowhere,direction,null));assertSame(inside,ImpactVfx.onBlockSurface(inside,null,null));assertEquals(inside,ImpactVfx.onBlockSurface(inside,new Vector(),null));assertEquals(new Location(r.world,1,64,.94),ImpactVfx.onBlockSurface(inside,direction,null));assertEquals(new Location(r.world,1,64,.94),ImpactVfx.onBlockSurface(inside,direction,block(1,64,1))); - when(r.world.rayTraceBlocks(any(Location.class),any(Vector.class),eq(3d),eq(FluidCollisionMode.NEVER),eq(true))).thenReturn(new RayTraceResult(new Vector(1,64,.5),BlockFace.NORTH));assertEquals(new Location(r.world,1,64,.44),ImpactVfx.onBlockSurface(inside,direction,null));when(r.world.rayTraceBlocks(any(Location.class),any(Vector.class),eq(3d),eq(FluidCollisionMode.NEVER),eq(true))).thenReturn(new RayTraceResult(new Vector(1,64,.5)));assertEquals(new Location(r.world,1,64,.44),ImpactVfx.onBlockSurface(inside,direction,null));assertEquals(new Vector(0,0,-.06),ImpactVfx.surfaceOffset(direction,new Vector())); - Player near=r.entity(Player.class,inside.clone()),far=r.entity(Player.class,inside.clone().add(321,0,0));when(r.world.getPlayers()).thenReturn(List.of(near,far));ImpactVfx.spawn(null,Particle.FLAME,1,0,0,0,0,null);ImpactVfx.spawn(nowhere,Particle.FLAME,1,0,0,0,0,null);ImpactVfx.spawn(inside,null,1,0,0,0,0,null);ImpactVfx.spawn(inside,Particle.FLAME,2,.1,.2,.3,.4,null);BlockData data=data(Material.STONE);ImpactVfx.spawn(inside,Particle.BLOCK,3,.1,.2,.3,.4,data);verify(near).spawnParticle(Particle.FLAME,inside,2,.1,.2,.3,.4);verify(near).spawnParticle(Particle.BLOCK,inside,3,.1,.2,.3,.4,data);verify(far,never()).spawnParticle(any(Particle.class),any(Location.class),anyInt(),anyDouble(),anyDouble(),anyDouble(),anyDouble()); + @Test + void relativeMoveUnsupportedServerFailsOnceWithoutTeleporting( + @org.junit.jupiter.api.io.TempDir java.nio.file.Path folder) throws Exception { + try (var boundary = new MovementBoundary(folder)) { + Player unsupported = mock(Player.class); + assertEquals(false, boundary.method.invoke(null, unsupported, 1d, 2d, 3d, 0f)); + assertFalse(boundary.send(1, 2, 3, 0)); + verifyNoInteractions(unsupported); } + } - @Test void tabCompletionReflectsPermissionsVehiclesAndCurrentWorldTracks(@org.junit.jupiter.api.io.TempDir java.nio.file.Path folder)throws Exception { - TabCompletion tabs=new TabCompletion();Command command=mock(Command.class);when(command.getName()).thenReturn("vf");Player player=r.entity(Player.class,new Location(r.world,0,64,0));assertNull(tabs.onTabComplete(mock(CommandSender.class),command,"vf",new String[]{""}));assertEquals(List.of("findvehicles","keybinds"),tabs.onTabComplete(player,command,"vf",new String[]{""}));assertEquals(List.of(),tabs.onTabComplete(player,command,"vf",new String[]{"spawn",""}));when(player.hasPermission(Permissions.Permission_Spawn)).thenReturn(true);assertEquals(List.of("findvehicles","keybinds","spawn","kill","ammo"),tabs.onTabComplete(player,command,"vf",new String[]{""})); - try(var loader=mockStatic(VehicleLoader.class)){Vehicle template=mock(Vehicle.class);when(template.getId()).thenReturn("wagon");loader.when(VehicleLoader::get).thenReturn(new HashMap<>(Map.of("wagon",template)));assertEquals(List.of("wagon"),tabs.onTabComplete(player,command,"vf",new String[]{"spawn",""}));} - when(player.hasPermission(Permissions.Permission_Admin)).thenReturn(true);assertTrue(tabs.onTabComplete(player,command,"vf",new String[]{""}).containsAll(List.of("takeover","reload","track")));assertEquals(List.of("start","end","list","info","particles","clearance","dump","delete","bind","unbind","resync"),tabs.onTabComplete(player,command,"vf",new String[]{"track",""}));assertEquals(List.of(),tabs.onTabComplete(player,command,"vf",new String[]{"track","delete",""})); - var registry=new net.tfminecraft.vehicleframework.tracks.TrackRegistry(folder.toFile());var local=net.tfminecraft.vehicleframework.tracks.TrackSpline.fromPoints(UUID.randomUUID(),r.world.getName(),false,List.of(new double[]{0,64,0},new double[]{4,64,0}));var elsewhere=net.tfminecraft.vehicleframework.tracks.TrackSpline.fromPoints(UUID.randomUUID(),"elsewhere",false,List.of(new double[]{0,64,0},new double[]{4,64,0}));registry.replace(local);registry.replace(elsewhere);r.framework.when(VehicleFramework::getTrackRegistry).thenReturn(registry);try{for(String sub:List.of("delete","clearance"))assertEquals(List.of(local.getId().toString()),tabs.onTabComplete(player,command,"vf",new String[]{"track",sub,""}));}finally{registry.close();} - assertEquals(List.of("10","20","50"),tabs.onTabComplete(player,command,"vf",new String[]{"kill",""}));assertNull(tabs.onTabComplete(player,command,"vf",new String[]{"track","info",""}));assertNull(tabs.onTabComplete(player,command,"vf",new String[]{"unknown",""}));when(command.getName()).thenReturn("other");assertNull(tabs.onTabComplete(player,command,"other",new String[]{""})); + @Test + void simultaneousFirstMovesSafelyShareTheReflectiveTransport( + @org.junit.jupiter.api.io.TempDir java.nio.file.Path folder) throws Exception { + try (var boundary = new MovementBoundary(folder)) { + boundary.loader.pause.set(true); + AtomicReference failure = new AtomicReference<>(); + List results = Collections.synchronizedList(new ArrayList<>()); + Runnable send = + () -> { + try { + results.add(boundary.send(1, 0, 0, 0)); + } catch (Throwable e) { + failure.compareAndSet(null, e); + } + }; + Thread first = new Thread(send, "relative-move-first"), + second = new Thread(send, "relative-move-second"); + first.start(); + assertTrue(boundary.loader.entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + second.start(); + try { + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); + while (second.getState() != Thread.State.BLOCKED && System.nanoTime() < deadline) + Thread.sleep(5); + assertEquals( + Thread.State.BLOCKED, + second.getState(), + "Second first-use request waits for the initializer"); + } finally { + boundary.loader.release.countDown(); + first.join(5000); + second.join(5000); + } + assertFalse(first.isAlive()); + assertFalse(second.isAlive()); + assertNull(failure.get()); + assertEquals(List.of(true, true), results); } + } - @Test void mythicDespawnUnloadsVehiclesAndEntityCleanupAlwaysRemovesBase() { - var event=mock(io.lumine.mythic.bukkit.events.MythicMobDespawnEvent.class);Entity entity=r.entity(Entity.class,new Location(r.world,0,64,0));when(event.getEntity()).thenReturn(entity);MythicMobsIntegration listener=new MythicMobsIntegration();listener.despawnEvent(event);verify(r.manager,never()).unload(any());ActiveVehicle vehicle=r.vehicle();when(r.manager.get(entity)).thenReturn(vehicle);listener.despawnEvent(event);verify(r.manager).unload(vehicle); - try(var api=mockStatic(com.ticxo.modelengine.api.ModelEngineAPI.class)){ - VehicleEntityCleanup.remove(entity);verify(entity).remove();var modeled=mock(com.ticxo.modelengine.api.model.ModeledEntity.class);var engine=mock(com.ticxo.modelengine.api.ModelEngineAPI.class,RETURNS_DEEP_STUBS);api.when(com.ticxo.modelengine.api.ModelEngineAPI::getAPI).thenReturn(engine);api.when(()->com.ticxo.modelengine.api.ModelEngineAPI.getModeledEntity(entity)).thenReturn(modeled);VehicleEntityCleanup.remove(entity);verify(engine.getModelUpdaters()).forceRemoveModeledEntity(modeled);verify(entity,times(2)).remove();var updaters=engine.getModelUpdaters();doThrow(new IllegalStateException("model callback failed")).when(updaters).forceRemoveModeledEntity(modeled);assertThrows(IllegalStateException.class,()->VehicleEntityCleanup.remove(entity));verify(entity,times(3)).remove(); - } + /** + * Minimal external NMS transport with actual reflective class loading, constructors and packet + * dispatch. + */ + private static final class MovementBoundary implements AutoCloseable { + final MovementLoader loader; + final Object connection; + final Player player; + final java.lang.reflect.Method method; + + MovementBoundary(java.nio.file.Path directory) throws Exception { + Map sources = + Map.of( + "net.minecraft.world.phys.Vec3", + "package net.minecraft.world.phys; public record Vec3(double x,double y,double z)" + + " {}", + "net.minecraft.world.entity.PositionMoveRotation", + "package net.minecraft.world.entity; public record" + + " PositionMoveRotation(net.minecraft.world.phys.Vec3" + + " position,net.minecraft.world.phys.Vec3 deltaMovement,float yRot,float" + + " xRot) {}", + "net.minecraft.world.entity.Relative", + "package net.minecraft.world.entity; public final class Relative { public static" + + " final java.util.Set" + + " ALL=java.util.Set.of(\"X\",\"Y\",\"Z\",\"Y_ROT\",\"X_ROT\",\"DELTA_X\",\"DELTA_Y\",\"DELTA_Z\",\"ROTATE_DELTA\");" + + " }", + "net.minecraft.network.protocol.Packet", + "package net.minecraft.network.protocol; public interface Packet {}", + "net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket", + "package net.minecraft.network.protocol.game; public record" + + " ClientboundPlayerPositionPacket(int" + + " id,net.minecraft.world.entity.PositionMoveRotation" + + " change,java.util.Set relatives) implements" + + " net.minecraft.network.protocol.Packet {}", + "fixture.Connection", + "package fixture; public class Connection { public Object last; public boolean" + + " fail; public void send(net.minecraft.network.protocol.Packet p){" + + " if(fail)throw new IllegalStateException(\"closed connection\"); last=p;}" + + " }", + "fixture.ServerPlayer", + "package fixture; public class ServerPlayer { public final Connection" + + " connection=new Connection(); }", + "fixture.CraftPlayer", + "package fixture; public interface CraftPlayer extends org.bukkit.entity.Player {" + + " ServerPlayer getHandle(); }"); + List arguments = + new ArrayList<>( + List.of( + "-classpath", + System.getProperty("java.class.path"), + "-d", + directory.toString(), + "-proc:none")); + for (var entry : sources.entrySet()) { + var path = directory.resolve(entry.getKey().replace('.', '/') + ".java"); + java.nio.file.Files.createDirectories(path.getParent()); + java.nio.file.Files.writeString(path, entry.getValue()); + arguments.add(path.toString()); + } + assertEquals( + 0, + javax.tools.ToolProvider.getSystemJavaCompiler() + .run(null, null, null, arguments.toArray(String[]::new))); + loader = + new MovementLoader( + new java.net.URL[] { + directory.toUri().toURL(), + RelativeMove.class.getProtectionDomain().getCodeSource().getLocation() + }); + Class server = loader.loadClass("fixture.ServerPlayer"); + Object handle = server.getConstructor().newInstance(); + connection = server.getField("connection").get(handle); + Class craft = loader.loadClass("fixture.CraftPlayer"); + player = + (Player) + mock( + craft, + invocation -> + invocation.getMethod().getName().equals("getHandle") + ? handle + : RETURNS_DEFAULTS.answer(invocation)); + method = + loader + .loadClass(RelativeMove.class.getName()) + .getMethod( + "send", Player.class, double.class, double.class, double.class, float.class); } - @Test void relativeMoveSendsAllRelativeFieldsAndDisablesItselfAfterConnectionFailure(@org.junit.jupiter.api.io.TempDir java.nio.file.Path folder)throws Exception { - try(var boundary=new MovementBoundary(folder)){ - assertTrue(boundary.send(1.5,-2,3,45));Object packet=boundary.connection.getClass().getField("last").get(boundary.connection);assertEquals(-1,packet.getClass().getMethod("id").invoke(packet));Object change=packet.getClass().getMethod("change").invoke(packet),position=change.getClass().getMethod("position").invoke(change),velocity=change.getClass().getMethod("deltaMovement").invoke(change);assertEquals(1.5,position.getClass().getMethod("x").invoke(position));assertEquals(-2d,position.getClass().getMethod("y").invoke(position));assertEquals(3d,position.getClass().getMethod("z").invoke(position));assertEquals(0d,velocity.getClass().getMethod("x").invoke(velocity));assertEquals(45f,change.getClass().getMethod("yRot").invoke(change));assertEquals(0f,change.getClass().getMethod("xRot").invoke(change));assertEquals(9,((Set)packet.getClass().getMethod("relatives").invoke(packet)).size()); - assertTrue(boundary.send(0,0,0,0));boundary.connection.getClass().getField("fail").setBoolean(boundary.connection,true);assertFalse(boundary.send(1,1,1,0));boundary.connection.getClass().getField("fail").setBoolean(boundary.connection,false);assertFalse(boundary.send(1,1,1,0),"Unsupported transport remains disabled instead of flooding logs every tick"); - } + boolean send(double x, double y, double z, float yaw) throws Exception { + return (boolean) method.invoke(null, player, x, y, z, yaw); } - @Test void relativeMoveUnsupportedServerFailsOnceWithoutTeleporting(@org.junit.jupiter.api.io.TempDir java.nio.file.Path folder)throws Exception { - try(var boundary=new MovementBoundary(folder)){ - Player unsupported=mock(Player.class);assertEquals(false,boundary.method.invoke(null,unsupported,1d,2d,3d,0f));assertFalse(boundary.send(1,2,3,0));verifyNoInteractions(unsupported); - } + public void close() throws Exception { + loader.release.countDown(); + loader.close(); } + } - @Test void simultaneousFirstMovesSafelyShareTheReflectiveTransport(@org.junit.jupiter.api.io.TempDir java.nio.file.Path folder)throws Exception { - try(var boundary=new MovementBoundary(folder)){ - boundary.loader.pause.set(true);AtomicReference failure=new AtomicReference<>();List results=Collections.synchronizedList(new ArrayList<>()); - Runnable send=()->{try{results.add(boundary.send(1,0,0,0));}catch(Throwable e){failure.compareAndSet(null,e);}}; - Thread first=new Thread(send,"relative-move-first"),second=new Thread(send,"relative-move-second");first.start();assertTrue(boundary.loader.entered.await(5,java.util.concurrent.TimeUnit.SECONDS));second.start(); - try{long deadline=System.nanoTime()+java.util.concurrent.TimeUnit.SECONDS.toNanos(5);while(second.getState()!=Thread.State.BLOCKED&&System.nanoTime() sources=Map.of( - "net.minecraft.world.phys.Vec3","package net.minecraft.world.phys; public record Vec3(double x,double y,double z) {}", - "net.minecraft.world.entity.PositionMoveRotation","package net.minecraft.world.entity; public record PositionMoveRotation(net.minecraft.world.phys.Vec3 position,net.minecraft.world.phys.Vec3 deltaMovement,float yRot,float xRot) {}", - "net.minecraft.world.entity.Relative","package net.minecraft.world.entity; public final class Relative { public static final java.util.Set ALL=java.util.Set.of(\"X\",\"Y\",\"Z\",\"Y_ROT\",\"X_ROT\",\"DELTA_X\",\"DELTA_Y\",\"DELTA_Z\",\"ROTATE_DELTA\"); }", - "net.minecraft.network.protocol.Packet","package net.minecraft.network.protocol; public interface Packet {}", - "net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket","package net.minecraft.network.protocol.game; public record ClientboundPlayerPositionPacket(int id,net.minecraft.world.entity.PositionMoveRotation change,java.util.Set relatives) implements net.minecraft.network.protocol.Packet {}", - "fixture.Connection","package fixture; public class Connection { public Object last; public boolean fail; public void send(net.minecraft.network.protocol.Packet p){ if(fail)throw new IllegalStateException(\"closed connection\"); last=p;} }", - "fixture.ServerPlayer","package fixture; public class ServerPlayer { public final Connection connection=new Connection(); }", - "fixture.CraftPlayer","package fixture; public interface CraftPlayer extends org.bukkit.entity.Player { ServerPlayer getHandle(); }"); - List arguments=new ArrayList<>(List.of("-classpath",System.getProperty("java.class.path"),"-d",directory.toString(),"-proc:none")); - for(var entry:sources.entrySet()){var path=directory.resolve(entry.getKey().replace('.','/')+".java");java.nio.file.Files.createDirectories(path.getParent());java.nio.file.Files.writeString(path,entry.getValue());arguments.add(path.toString());} - assertEquals(0,javax.tools.ToolProvider.getSystemJavaCompiler().run(null,null,null,arguments.toArray(String[]::new))); - loader=new MovementLoader(new java.net.URL[]{directory.toUri().toURL(),RelativeMove.class.getProtectionDomain().getCodeSource().getLocation()});Class server=loader.loadClass("fixture.ServerPlayer");Object handle=server.getConstructor().newInstance();connection=server.getField("connection").get(handle);Class craft=loader.loadClass("fixture.CraftPlayer");player=(Player)mock(craft,invocation->invocation.getMethod().getName().equals("getHandle")?handle:RETURNS_DEFAULTS.answer(invocation));method=loader.loadClass(RelativeMove.class.getName()).getMethod("send",Player.class,double.class,double.class,double.class,float.class); + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.equals("net.minecraft.world.phys.Vec3") && pause.compareAndSet(true, false)) { + entered.countDown(); + try { + if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) + throw new ClassNotFoundException("Fixture initialization timed out"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new ClassNotFoundException(name, interrupted); } - boolean send(double x,double y,double z,float yaw)throws Exception{return (boolean)method.invoke(null,player,x,y,z,yaw);} - public void close()throws Exception{loader.release.countDown();loader.close();} - } - private static final class MovementLoader extends java.net.URLClassLoader { - final java.util.concurrent.atomic.AtomicBoolean pause=new java.util.concurrent.atomic.AtomicBoolean();final java.util.concurrent.CountDownLatch entered=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1); - MovementLoader(java.net.URL[] urls){super(urls,RuntimeUtilitiesCoverageTest.class.getClassLoader());} - @Override protected Class loadClass(String name,boolean resolve)throws ClassNotFoundException { - if(name.equals("net.minecraft.world.phys.Vec3")&&pause.compareAndSet(true,false)){entered.countDown();try{if(!release.await(5,java.util.concurrent.TimeUnit.SECONDS))throw new ClassNotFoundException("Fixture initialization timed out");}catch(InterruptedException interrupted){Thread.currentThread().interrupt();throw new ClassNotFoundException(name,interrupted);}} - if(name.equals(RelativeMove.class.getName())){synchronized(getClassLoadingLock(name)){Class type=findLoadedClass(name);if(type==null)type=findClass(name);if(resolve)resolveClass(type);return type;}} - return super.loadClass(name,resolve); + } + if (name.equals(RelativeMove.class.getName())) { + synchronized (getClassLoadingLock(name)) { + Class type = findLoadedClass(name); + if (type == null) type = findClass(name); + if (resolve) resolveClass(type); + return type; } + } + return super.loadClass(name, resolve); } + } + + private World otherWorld() { + World world = mock(World.class); + when(world.getUID()).thenReturn(UUID.randomUUID()); + otherWorlds.add(world); + return world; + } - private World otherWorld(){World world=mock(World.class);when(world.getUID()).thenReturn(UUID.randomUUID());otherWorlds.add(world);return world;} - private void rail(Block block,Rail.Shape shape){Rail rail=mock(Rail.class);when(rail.getShape()).thenReturn(shape);block.setBlockData(rail,false);} - private BlockData data(Material material){BlockData d=material==Material.LIGHT?mock(Levelled.class):mock(BlockData.class);when(d.getMaterial()).thenReturn(material);return d;} - private Block block(int x,int y,int z,Material type){Block b=block(x,y,z);b.setType(type);return b;} - private Block block(int x,int y,int z){return blocks.computeIfAbsent(x+":"+y+":"+z,key->{ - Block b=mock(Block.class);AtomicReference type=new AtomicReference<>(Material.AIR);AtomicReference state=new AtomicReference<>(data(Material.AIR));when(b.getType()).thenAnswer(c->type.get());when(b.getBlockData()).thenAnswer(c->state.get());doAnswer(c->{Material m=c.getArgument(0);type.set(m);state.set(data(m));return null;}).when(b).setType(any(Material.class));doAnswer(c->{state.set(c.getArgument(0));return null;}).when(b).setBlockData(any(),anyBoolean());when(b.getLocation()).thenAnswer(c->new Location(r.world,x,y,z));when(b.getWorld()).thenReturn(r.world);when(b.getX()).thenReturn(x);when(b.getY()).thenReturn(y);when(b.getZ()).thenReturn(z);when(b.isLiquid()).thenAnswer(c->type.get()==Material.WATER||type.get()==Material.LAVA);when(b.isPassable()).thenAnswer(c->Set.of(Material.AIR,Material.LIGHT,Material.WATER,Material.LAVA,Material.KELP,Material.KELP_PLANT,Material.SEAGRASS,Material.TALL_SEAGRASS).contains(type.get()));when(b.getRelative(any(BlockFace.class))).thenAnswer(c->{BlockFace face=c.getArgument(0);return block(x+face.getModX(),y+face.getModY(),z+face.getModZ());});return b;});} + private void rail(Block block, Rail.Shape shape) { + Rail rail = mock(Rail.class); + when(rail.getShape()).thenReturn(shape); + block.setBlockData(rail, false); + } + + private BlockData data(Material material) { + BlockData d = material == Material.LIGHT ? mock(Levelled.class) : mock(BlockData.class); + when(d.getMaterial()).thenReturn(material); + return d; + } + + private Block block(int x, int y, int z, Material type) { + Block b = block(x, y, z); + b.setType(type); + return b; + } + + private Block block(int x, int y, int z) { + return blocks.computeIfAbsent( + x + ":" + y + ":" + z, + key -> { + Block b = mock(Block.class); + AtomicReference type = new AtomicReference<>(Material.AIR); + AtomicReference state = new AtomicReference<>(data(Material.AIR)); + when(b.getType()).thenAnswer(c -> type.get()); + when(b.getBlockData()).thenAnswer(c -> state.get()); + doAnswer( + c -> { + Material m = c.getArgument(0); + type.set(m); + state.set(data(m)); + return null; + }) + .when(b) + .setType(any(Material.class)); + doAnswer( + c -> { + state.set(c.getArgument(0)); + return null; + }) + .when(b) + .setBlockData(any(), anyBoolean()); + when(b.getLocation()).thenAnswer(c -> new Location(r.world, x, y, z)); + when(b.getWorld()).thenReturn(r.world); + when(b.getX()).thenReturn(x); + when(b.getY()).thenReturn(y); + when(b.getZ()).thenReturn(z); + when(b.isLiquid()) + .thenAnswer(c -> type.get() == Material.WATER || type.get() == Material.LAVA); + when(b.isPassable()) + .thenAnswer( + c -> + Set.of( + Material.AIR, + Material.LIGHT, + Material.WATER, + Material.LAVA, + Material.KELP, + Material.KELP_PLANT, + Material.SEAGRASS, + Material.TALL_SEAGRASS) + .contains(type.get())); + when(b.getRelative(any(BlockFace.class))) + .thenAnswer( + c -> { + BlockFace face = c.getArgument(0); + return block(x + face.getModX(), y + face.getModY(), z + face.getModZ()); + }); + return b; + }); + } } diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java index 92e2d2f..8a0ebea 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java @@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.when; @@ -20,8 +21,11 @@ import java.util.Optional; import org.bukkit.Location; +import org.bukkit.Bukkit; +import org.bukkit.Material; import org.bukkit.World; import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Entity; import org.bukkit.util.Vector; @@ -36,6 +40,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; import com.ticxo.modelengine.api.model.bone.SimpleManualAnimator; import com.ticxo.modelengine.api.model.bone.ModelBone; @@ -65,6 +70,7 @@ class TrainReversePlacementTest { private Object previousRegistry; private TrackRegistry registry; private TrackStore store; + private MockedStatic bukkit; @ParameterizedTest @ValueSource(doubles = {10, 20, 40}) @@ -117,6 +123,9 @@ private static Vector modelAnchor(TrainHandler car, float[] rotation, double z) @BeforeEach void setUp() throws Exception { + // Default track particles ask Paper to construct BlockData, even in a physics-only fixture. + bukkit = mockStatic(Bukkit.class); + bukkit.when(() -> Bukkit.createBlockData(any(Material.class))).thenReturn(mock(BlockData.class)); registryField = VehicleFramework.class.getDeclaredField("trackRegistry"); registryField.setAccessible(true); previousRegistry = registryField.get(null); @@ -128,6 +137,7 @@ void setUp() throws Exception { @AfterEach void restoreRegistry() throws Exception { registryField.set(null, previousRegistry); + bukkit.close(); } @Test diff --git a/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java index 91be8d6..f33debb 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java @@ -4,18 +4,6 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; -import org.bukkit.*; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.*; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; -import org.bukkit.potion.PotionEffectType; -import org.bukkit.util.RayTraceResult; -import org.bukkit.util.Vector; -import org.junit.jupiter.api.*; -import org.mockito.*; import com.ticxo.modelengine.api.ModelEngineAPI; import com.ticxo.modelengine.api.animation.BlueprintAnimation; import com.ticxo.modelengine.api.model.ActiveModel; @@ -24,6 +12,8 @@ import io.lumine.mythic.api.mobs.MythicMob; import io.lumine.mythic.bukkit.MythicBukkit; import io.lumine.mythic.core.mobs.ActiveMob; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.tlibs.objects.api.ItemAPI; import net.tfminecraft.vehicleframework.bones.*; @@ -33,7 +23,6 @@ import net.tfminecraft.vehicleframework.enums.Input; import net.tfminecraft.vehicleframework.loaders.AmmunitionLoader; import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig; -import net.tfminecraft.vehicleframework.projectiles.BulletRaycast; import net.tfminecraft.vehicleframework.util.ExplosionCreator; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; import net.tfminecraft.vehicleframework.vehicles.util.AccessPanel; @@ -44,37 +33,124 @@ import net.tfminecraft.vehicleframework.weapons.data.WeaponData; import net.tfminecraft.vehicleframework.weapons.handlers.AmmunitionHandler; import net.tfminecraft.vehicleframework.weapons.shooter.*; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.potion.PotionEffectType; +import org.bukkit.util.RayTraceResult; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.*; +import org.mockito.*; class WeaponsCoverageTest { - Rig r;ActiveModel model;Map bones;ActiveVehicle vehicle;Player player;AccessPanel panel;boolean oldDebug;double oldMultiplier;HashMap oldAmmo;MockedConstruction stacks; - @BeforeEach void setup(){r=new Rig();model=mock(ActiveModel.class,RETURNS_DEEP_STUBS);bones=new HashMap<>();when(model.getBone(anyString())).thenAnswer(c->Optional.ofNullable(bones.get(c.getArgument(0))));when(model.getBlueprint().getAnimations()).thenReturn(new HashMap<>());bone("muzzle",new Location(r.world,0,64,0));bone("aim",new Location(r.world,0,64,1));vehicle=r.vehicle();panel=new AccessPanel();when(vehicle.getAccessPanel()).thenReturn(panel);when(vehicle.getCurrentState().getType()).thenReturn(State.GROUND);player=r.entity(Player.class,new Location(r.world,0,64,0));ItemStack air=item(Material.AIR,1);when(player.getInventory().getItemInMainHand()).thenReturn(air);oldDebug=Cache.weaponAimDebug;oldMultiplier=Cache.weaponDegradedReloadMultiplier;oldAmmo=AmmunitionLoader.map;AmmunitionLoader.map=new HashMap<>();Cache.weaponAimDebug=false;Cache.weaponDegradedReloadMultiplier=2; - // ItemStack construction belongs to the unavailable CraftBukkit item factory. - stacks=mockConstruction(ItemStack.class,(stack,context)->{ItemMeta meta=mock(ItemMeta.class,RETURNS_DEEP_STUBS);when(stack.getItemMeta()).thenReturn(meta);when(stack.setItemMeta(any())).thenReturn(true);if(!context.arguments().isEmpty()&&context.arguments().getFirst() instanceof Material material)when(stack.getType()).thenReturn(material);}); - } - @AfterEach void cleanup(){stacks.close();Cache.weaponAimDebug=oldDebug;Cache.weaponDegradedReloadMultiplier=oldMultiplier;AmmunitionLoader.map=oldAmmo;r.close();} + Rig r; + ActiveModel model; + Map bones; + ActiveVehicle vehicle; + Player player; + AccessPanel panel; + boolean oldDebug; + double oldMultiplier; + HashMap oldAmmo; + MockedConstruction stacks; - @Test void configuredDelayDefersTheLastRoundAndPreservesItsAmmunition()throws Exception { - ActiveWeapon active=active("fixed: true\ndelay: 3\nbones: [muzzle.aim]\n");Bullet bullet=r.bullet(false);active.getAmmunitionHandler().setAmmo(bullet,1); - active.getAmmunitionHandler().shoot(player,List.of());assertEquals(0,active.getAmmunitionHandler().getCount());assertFalse(active.getAmmunitionHandler().hasAmmo()); - assertEquals(Material.AIR,r.block(0,64,0).getType(),"Muzzle flash and projectile must wait for the configured delay"); - r.ticks(2);assertEquals(Material.AIR,r.block(0,64,0).getType());assertDoesNotThrow(r::tick);assertEquals(Material.LIGHT,r.block(0,64,0).getType()); - } + @BeforeEach + void setup() { + r = new Rig(); + model = mock(ActiveModel.class, RETURNS_DEEP_STUBS); + bones = new HashMap<>(); + when(model.getBone(anyString())) + .thenAnswer(c -> Optional.ofNullable(bones.get(c.getArgument(0)))); + when(model.getBlueprint().getAnimations()).thenReturn(new HashMap<>()); + bone("muzzle", new Location(r.world, 0, 64, 0)); + bone("aim", new Location(r.world, 0, 64, 1)); + vehicle = r.vehicle(); + panel = new AccessPanel(); + when(vehicle.getAccessPanel()).thenReturn(panel); + when(vehicle.getCurrentState().getType()).thenReturn(State.GROUND); + player = r.entity(Player.class, new Location(r.world, 0, 64, 0)); + ItemStack air = item(Material.AIR, 1); + when(player.getInventory().getItemInMainHand()).thenReturn(air); + oldDebug = Cache.weaponAimDebug; + oldMultiplier = Cache.weaponDegradedReloadMultiplier; + oldAmmo = AmmunitionLoader.map; + AmmunitionLoader.map = new HashMap<>(); + Cache.weaponAimDebug = false; + Cache.weaponDegradedReloadMultiplier = 2; + // ItemStack construction belongs to the unavailable CraftBukkit item factory. + stacks = + mockConstruction( + ItemStack.class, + (stack, context) -> { + ItemMeta meta = mock(ItemMeta.class, RETURNS_DEEP_STUBS); + when(stack.getItemMeta()).thenReturn(meta); + when(stack.setItemMeta(any())).thenReturn(true); + if (!context.arguments().isEmpty() + && context.arguments().getFirst() instanceof Material material) + when(stack.getType()).thenReturn(material); + }); + } - @Test void fixedWeaponsIgnoreManualTurnInputs()throws Exception { - Weapon stored=new Weapon("fixed",Rig.yaml("fixed: true\n"));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null);WeaponMovementController controls=new WeaponMovementController(vehicle,model,active,stored,stored.getLimits()); - for(Input input:List.of(Input.WEAPON_UP,Input.WEAPON_DOWN,Input.WEAPON_LEFT,Input.WEAPON_RIGHT))assertDoesNotThrow(()->controls.input(List.of(),input,player)); - } + @AfterEach + void cleanup() { + stacks.close(); + Cache.weaponAimDebug = oldDebug; + Cache.weaponDegradedReloadMultiplier = oldMultiplier; + AmmunitionLoader.map = oldAmmo; + r.close(); + } - @Test void trailingDotInExitBoneIsRejectedWithoutCrashingWeaponCreation()throws Exception { - assertDoesNotThrow(()->active("fixed: true\nbones: [muzzle.]\n")); - } + @Test + void configuredDelayDefersTheLastRoundAndPreservesItsAmmunition() throws Exception { + ActiveWeapon active = active("fixed: true\ndelay: 3\nbones: [muzzle.aim]\n"); + Bullet bullet = r.bullet(false); + active.getAmmunitionHandler().setAmmo(bullet, 1); + active.getAmmunitionHandler().shoot(player, List.of()); + assertEquals(0, active.getAmmunitionHandler().getCount()); + assertFalse(active.getAmmunitionHandler().hasAmmo()); + assertEquals( + Material.AIR, + r.block(0, 64, 0).getType(), + "Muzzle flash and projectile must wait for the configured delay"); + r.ticks(2); + assertEquals(Material.AIR, r.block(0, 64, 0).getType()); + assertDoesNotThrow(r::tick); + assertEquals(Material.LIGHT, r.block(0, 64, 0).getType()); + } - @Test void unconfiguredMuzzlesCannotConsumeAmmunitionOrScheduleReload()throws Exception { - ActiveWeapon active=active("fixed: true\nbones: []\n");assertDoesNotThrow(()->active.getAmmunitionHandler().reloadStart(player,r.bullet(false)));assertFalse(active.getAmmunitionHandler().hasAmmo());assertEquals(0,r.pending()); - } + @Test + void fixedWeaponsIgnoreManualTurnInputs() throws Exception { + Weapon stored = new Weapon("fixed", Rig.yaml("fixed: true\n")); + ActiveWeapon active = new ActiveWeapon(model, vehicle, stored, null); + WeaponMovementController controls = + new WeaponMovementController(vehicle, model, active, stored, stored.getLimits()); + for (Input input : + List.of(Input.WEAPON_UP, Input.WEAPON_DOWN, Input.WEAPON_LEFT, Input.WEAPON_RIGHT)) + assertDoesNotThrow(() -> controls.input(List.of(), input, player)); + } - @Test void configurationAndActiveCopiesPreserveAllWeaponFields()throws Exception { - Weapon stored=new Weapon("cannon",Rig.yaml(""" + @Test + void trailingDotInExitBoneIsRejectedWithoutCrashingWeaponCreation() throws Exception { + assertDoesNotThrow(() -> active("fixed: true\nbones: [muzzle.]\n")); + } + + @Test + void unconfiguredMuzzlesCannotConsumeAmmunitionOrScheduleReload() throws Exception { + ActiveWeapon active = active("fixed: true\nbones: []\n"); + assertDoesNotThrow(() -> active.getAmmunitionHandler().reloadStart(player, r.bullet(false))); + assertFalse(active.getAmmunitionHandler().hasAmmo()); + assertEquals(0, r.pending()); + } + + @Test + void configurationAndActiveCopiesPreserveAllWeaponFields() throws Exception { + Weapon stored = + new Weapon( + "cannon", + Rig.yaml( + """ name: Deck Cannon health: 250 repair-time: 2 @@ -107,26 +183,152 @@ class WeaponsCoverageTest { reload-start-sounds: {begin: {sound: test.begin}} shoot-sounds: {bang: {sound: test.bang}} particles: {smoke: {particle: FLAME, amount: 1}} - """));bone("body",new Location(r.world,0,64,0));bone("head",new Location(r.world,0,64,0));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null); - assertEquals("cannon",stored.getId());assertEquals("Deck Cannon",stored.getName());assertEquals(250,stored.getHealthData().getHealth());assertFalse(stored.isFixed());assertEquals("body",stored.getBodyBone());assertEquals("head",stored.getHeadBone());assertEquals("z",stored.getAxis());assertEquals("captain",stored.getSeat());assertEquals(3,stored.getTurnRate());assertEquals(WeaponAimMode.CURSOR,stored.getAimMode());assertEquals(45,stored.getCursorRange());assertEquals("muzzle.aim",stored.getAimVector());assertEquals(1,stored.getAimOffset().getBodyYaw());assertEquals(2,stored.getAimOffset().getHeadPitch());assertEquals(3,stored.getAimOffset().getHeadYaw());assertEquals(4,stored.getAimOffset().getHeadRoll());assertEquals(90,stored.getLimits().getMaxYaw());assertEquals(20,stored.getProjectileDamage());assertEquals("FIRE",stored.getProjectileDamageType());assertEquals(5,stored.getProjectileSpeed());assertEquals(3,stored.getProjectileYield());assertEquals(8,stored.getProjectileRadius());assertTrue(stored.getProjectileExplosive());assertEquals(7,stored.getProjectileClusterAmount());assertEquals(List.of("muzzle.aim"),stored.getBones()); - assertEquals("cannon",active.getId());assertEquals("Deck Cannon",active.getName());assertEquals("captain",active.getSeat());assertSame(stored.getWeaponData(),active.getWeaponData());assertSame(stored.getDamageData(),active.getDamageData());assertNotSame(stored.getHealthData(),active.getHealthData());assertEquals(Input.WEAPON_UP,active.getInputHandler().getInput(Keybind.W));assertTrue(active.getAnimationHandler().hasModel());assertEquals(4,active.getWeaponData().getVelocity());assertEquals("reload",active.getWeaponData().getReloadAnimation());assertEquals("shoot",active.getWeaponData().getShootAnimation());assertEquals(1,active.getWeaponData().getParticles().size()); - assertEquals(20,Weapon.effectiveDamage(active,r.bullet(false).getData()));assertEquals("FIRE",Weapon.effectiveDamageType(active,null));assertEquals(5,Weapon.effectiveProjectileSpeed(active,r.bullet(false)));assertEquals(5,Weapon.effectiveProjectileVelocity(active));assertEquals(3,Weapon.effectiveYield(active,null));assertEquals(8,Weapon.effectiveRadius(active,null));assertTrue(Weapon.effectiveExplosive(active,null));assertEquals(7,Weapon.effectiveClusterAmount(active,null)); - active.damage("FIRE",20);active.damage("OTHER",5);assertEquals(15,active.getHealthData().getDamage());assertEquals(0,stored.getHealthData().getDamage());active.getHealthData().startRepair();active.tick();active.tick();assertFalse(active.getHealthData().isUnderRepair());active.input(List.of(),Keybind.W);active.setController(player);assertTrue(active.isControlled());assertSame(player,active.getController());active.input(List.of(),Keybind.W);active.tick();active.updateModel(model);active.disconnect();assertFalse(active.isControlled()); - } + """)); + bone("body", new Location(r.world, 0, 64, 0)); + bone("head", new Location(r.world, 0, 64, 0)); + ActiveWeapon active = new ActiveWeapon(model, vehicle, stored, null); + assertEquals("cannon", stored.getId()); + assertEquals("Deck Cannon", stored.getName()); + assertEquals(250, stored.getHealthData().getHealth()); + assertFalse(stored.isFixed()); + assertEquals("body", stored.getBodyBone()); + assertEquals("head", stored.getHeadBone()); + assertEquals("z", stored.getAxis()); + assertEquals("captain", stored.getSeat()); + assertEquals(3, stored.getTurnRate()); + assertEquals(WeaponAimMode.CURSOR, stored.getAimMode()); + assertEquals(45, stored.getCursorRange()); + assertEquals("muzzle.aim", stored.getAimVector()); + assertEquals(1, stored.getAimOffset().getBodyYaw()); + assertEquals(2, stored.getAimOffset().getHeadPitch()); + assertEquals(3, stored.getAimOffset().getHeadYaw()); + assertEquals(4, stored.getAimOffset().getHeadRoll()); + assertEquals(90, stored.getLimits().getMaxYaw()); + assertEquals(20, stored.getProjectileDamage()); + assertEquals("FIRE", stored.getProjectileDamageType()); + assertEquals(5, stored.getProjectileSpeed()); + assertEquals(3, stored.getProjectileYield()); + assertEquals(8, stored.getProjectileRadius()); + assertTrue(stored.getProjectileExplosive()); + assertEquals(7, stored.getProjectileClusterAmount()); + assertEquals(List.of("muzzle.aim"), stored.getBones()); + assertEquals("cannon", active.getId()); + assertEquals("Deck Cannon", active.getName()); + assertEquals("captain", active.getSeat()); + assertSame(stored.getWeaponData(), active.getWeaponData()); + assertSame(stored.getDamageData(), active.getDamageData()); + assertNotSame(stored.getHealthData(), active.getHealthData()); + assertEquals(Input.WEAPON_UP, active.getInputHandler().getInput(Keybind.W)); + assertTrue(active.getAnimationHandler().hasModel()); + assertEquals(4, active.getWeaponData().getVelocity()); + assertEquals("reload", active.getWeaponData().getReloadAnimation()); + assertEquals("shoot", active.getWeaponData().getShootAnimation()); + assertEquals(1, active.getWeaponData().getParticles().size()); + assertEquals(20, Weapon.effectiveDamage(active, r.bullet(false).getData())); + assertEquals("FIRE", Weapon.effectiveDamageType(active, null)); + assertEquals(5, Weapon.effectiveProjectileSpeed(active, r.bullet(false))); + assertEquals(5, Weapon.effectiveProjectileVelocity(active)); + assertEquals(3, Weapon.effectiveYield(active, null)); + assertEquals(8, Weapon.effectiveRadius(active, null)); + assertTrue(Weapon.effectiveExplosive(active, null)); + assertEquals(7, Weapon.effectiveClusterAmount(active, null)); + active.damage("FIRE", 20); + active.damage("OTHER", 5); + assertEquals(15, active.getHealthData().getDamage()); + assertEquals(0, stored.getHealthData().getDamage()); + active.getHealthData().startRepair(); + active.tick(); + active.tick(); + assertFalse(active.getHealthData().isUnderRepair()); + active.input(List.of(), Keybind.W); + active.setController(player); + assertTrue(active.isControlled()); + assertSame(player, active.getController()); + active.input(List.of(), Keybind.W); + active.tick(); + active.updateModel(model); + active.disconnect(); + assertFalse(active.isControlled()); + } - @Test void defaultsAndFallbacksSupportEveryProjectileType()throws Exception { - Weapon stored=new Weapon("default",new YamlConfiguration());assertEquals("default",stored.getName());assertEquals(WeaponAimMode.MANUAL,stored.getAimMode());assertEquals(80,stored.getCursorRange());assertNotNull(stored.getLimits());WeaponData none=new WeaponData(null),empty=new WeaponData(new YamlConfiguration());for(WeaponData data:List.of(none,empty)){assertEquals(3,data.getVelocity());assertEquals("none",data.getReloadAnimation());assertEquals("none",data.getShootAnimation());for(SoundArg sound:List.of(SoundArg.SHOOT,SoundArg.RELOAD,SoundArg.RELOAD_START))assertTrue(data.getSounds(sound).isEmpty());assertTrue(data.getParticles().isEmpty());} - for(net.tfminecraft.vehicleframework.enums.Projectile type:net.tfminecraft.vehicleframework.enums.Projectile.values()){YamlConfiguration config=Rig.yaml("type: "+type+"\ncluster: {}\n");Ammunition ammo=Ammunition.create(type.name().toLowerCase(Locale.ROOT),config);assertEquals(type,ammo.getType());assertEquals(type.name().toLowerCase(Locale.ROOT),ammo.getId());assertEquals(ammo.getId(),ammo.getName());if(ammo instanceof Bullet b){assertEquals(80,b.getRange());assertEquals(7,b.getSpeed());assertEquals(-.05,b.getGravity());}if(ammo instanceof FusedExplosive fused)assertEquals(60,fused.getFuse());if(ammo instanceof ClusterBomb cluster){assertEquals(40,cluster.getFuse());assertEquals(4,cluster.getAmount());assertEquals(2,cluster.getSpread());assertNotNull(cluster.getClusterData());}} - var ammo=r.bullet(false).getData();assertEquals(8,Weapon.effectiveDamage((ActiveWeapon)null,ammo));assertEquals(0,Weapon.effectiveDamage((Integer)null,null));assertEquals("PROJECTILE",Weapon.effectiveDamageType((ActiveWeapon)null,null));assertEquals("PROJECTILE",Weapon.effectiveDamageType(" ",ammo));assertEquals(7,Weapon.effectiveProjectileSpeed((ActiveWeapon)null,null));assertEquals(3,Weapon.effectiveProjectileVelocity(null));assertEquals(0,Weapon.effectiveYield((ActiveWeapon)null,null));assertEquals(1,Weapon.effectiveYield((Float)null,ammo));assertEquals(0,Weapon.effectiveRadius((ActiveWeapon)null,null));assertEquals(5,Weapon.effectiveRadius((Integer)null,ammo));assertFalse(Weapon.effectiveExplosive((ActiveWeapon)null,null));assertFalse(Weapon.effectiveExplosive((Boolean)null,ammo));assertEquals(0,Weapon.effectiveClusterAmount((ActiveWeapon)null,null));assertEquals(6,Weapon.effectiveClusterAmount(6,null)); - ActiveWeapon active=active("fixed: true\nprojectile-velocity: 9\n");assertEquals(9,active.effectiveProjectileVelocity());active.setController(player);active.tick();assertEquals(WeaponAimMode.MANUAL,WeaponAimMode.fromConfig("unknown")); + @Test + void defaultsAndFallbacksSupportEveryProjectileType() throws Exception { + Weapon stored = new Weapon("default", new YamlConfiguration()); + assertEquals("default", stored.getName()); + assertEquals(WeaponAimMode.MANUAL, stored.getAimMode()); + assertEquals(80, stored.getCursorRange()); + assertNotNull(stored.getLimits()); + WeaponData none = new WeaponData(null), empty = new WeaponData(new YamlConfiguration()); + for (WeaponData data : List.of(none, empty)) { + assertEquals(3, data.getVelocity()); + assertEquals("none", data.getReloadAnimation()); + assertEquals("none", data.getShootAnimation()); + for (SoundArg sound : List.of(SoundArg.SHOOT, SoundArg.RELOAD, SoundArg.RELOAD_START)) + assertTrue(data.getSounds(sound).isEmpty()); + assertTrue(data.getParticles().isEmpty()); } + for (net.tfminecraft.vehicleframework.enums.Projectile type : + net.tfminecraft.vehicleframework.enums.Projectile.values()) { + YamlConfiguration config = Rig.yaml("type: " + type + "\ncluster: {}\n"); + Ammunition ammo = Ammunition.create(type.name().toLowerCase(Locale.ROOT), config); + assertEquals(type, ammo.getType()); + assertEquals(type.name().toLowerCase(Locale.ROOT), ammo.getId()); + assertEquals(ammo.getId(), ammo.getName()); + if (ammo instanceof Bullet b) { + assertEquals(80, b.getRange()); + assertEquals(7, b.getSpeed()); + assertEquals(-.05, b.getGravity()); + } + if (ammo instanceof FusedExplosive fused) assertEquals(60, fused.getFuse()); + if (ammo instanceof ClusterBomb cluster) { + assertEquals(40, cluster.getFuse()); + assertEquals(4, cluster.getAmount()); + assertEquals(2, cluster.getSpread()); + assertNotNull(cluster.getClusterData()); + } + } + var ammo = r.bullet(false).getData(); + assertEquals(8, Weapon.effectiveDamage((ActiveWeapon) null, ammo)); + assertEquals(0, Weapon.effectiveDamage((Integer) null, null)); + assertEquals("PROJECTILE", Weapon.effectiveDamageType((ActiveWeapon) null, null)); + assertEquals("PROJECTILE", Weapon.effectiveDamageType(" ", ammo)); + assertEquals(7, Weapon.effectiveProjectileSpeed((ActiveWeapon) null, null)); + assertEquals(3, Weapon.effectiveProjectileVelocity(null)); + assertEquals(0, Weapon.effectiveYield((ActiveWeapon) null, null)); + assertEquals(1, Weapon.effectiveYield((Float) null, ammo)); + assertEquals(0, Weapon.effectiveRadius((ActiveWeapon) null, null)); + assertEquals(5, Weapon.effectiveRadius((Integer) null, ammo)); + assertFalse(Weapon.effectiveExplosive((ActiveWeapon) null, null)); + assertFalse(Weapon.effectiveExplosive((Boolean) null, ammo)); + assertEquals(0, Weapon.effectiveClusterAmount((ActiveWeapon) null, null)); + assertEquals(6, Weapon.effectiveClusterAmount(6, null)); + ActiveWeapon active = active("fixed: true\nprojectile-velocity: 9\n"); + assertEquals(9, active.effectiveProjectileVelocity()); + active.setController(player); + active.tick(); + assertEquals(WeaponAimMode.MANUAL, WeaponAimMode.fromConfig("unknown")); + } - @Test void damageIdentifiersAreLocaleIndependent()throws Exception { - Locale old=Locale.getDefault();try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertEquals("FIRE",new Weapon("gun",Rig.yaml("projectile-damage-type: fire\n")).getProjectileDamageType());assertEquals("FIRE",new AmmunitionData(Rig.yaml("damage-type: fire\n")).getDamageType());}finally{Locale.setDefault(old);} + @Test + void damageIdentifiersAreLocaleIndependent() throws Exception { + Locale old = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertEquals( + "FIRE", + new Weapon("gun", Rig.yaml("projectile-damage-type: fire\n")).getProjectileDamageType()); + assertEquals("FIRE", new AmmunitionData(Rig.yaml("damage-type: fire\n")).getDamageType()); + } finally { + Locale.setDefault(old); } + } - @Test void ammunitionEffectsGettersAndTrailsPreserveConfiguredTiming()throws Exception { - AmmunitionData data=new AmmunitionData(Rig.yaml(""" + @Test + void ammunitionEffectsGettersAndTrailsPreserveConfiguredTiming() throws Exception { + AmmunitionData data = + new AmmunitionData( + Rig.yaml( + """ type: BULLET input: test:round yield: 2.5 @@ -141,116 +343,747 @@ class WeaponsCoverageTest { particles: {trail: {particle: FLAME, amount: 1, spread: 0, y: 1}} hit-sfx: {impact: {sound: test.impact}} hit-vfx: {impact: {particle: FLAME, amount: 1, spread: 0, y: 1}} - """));assertEquals("test:round",data.getInput());assertEquals(2.5f,data.getYield());assertEquals(7,data.getRadius());assertEquals(17,data.getDamage());assertEquals(4,data.getRounds());assertEquals("FIRE",data.getDamageType());assertTrue(data.isExplosive());assertTrue(data.isFire());assertEquals(1.5,data.getOffset());assertInstanceOf(ItemModel.class,data.getModel());assertInstanceOf(ArmorStand.class,data.spawn(new Location(r.world,0,64,0))); - Location from=new Location(r.world,0,64,0),to=new Location(r.world,0,64,2);data.fx(List.of(),from,1,0);verify(r.world,never()).playSound(any(Location.class),eq("test.flight"),anyFloat(),anyFloat());data.fx(List.of(),from,1,2);data.trailSegment(List.of(),from,to,1,3);verify(r.world,times(2)).playSound(any(Location.class),eq("test.flight"),eq(1f),eq(1f));data.hitFX(List.of(),to,1);verify(r.world).playSound(eq(to),eq("test.impact"),eq(1f),eq(1f));assertEquals(new Location(r.world,0,64,0),from);assertEquals(new Location(r.world,0,64,2),to); - AmmunitionData empty=new AmmunitionData(new YamlConfiguration());empty.trailSegment(List.of(),null,to,1,0);empty.trailSegment(List.of(),from,null,1,0);empty.trailSegment(List.of(),new Location(null,0,0,0),to,1,0);empty.trailSegment(List.of(),from,new Location(mock(World.class),0,0,0),1,0);empty.trailSegment(List.of(),from,from.clone().add(0,0,.1),1,0);empty.trailSegment(List.of(),from,to,1,0);assertFalse(empty.isFire());assertTrue(empty.isExplosive()); - } + """)); + assertEquals("test:round", data.getInput()); + assertEquals(2.5f, data.getYield()); + assertEquals(7, data.getRadius()); + assertEquals(17, data.getDamage()); + assertEquals(4, data.getRounds()); + assertEquals("FIRE", data.getDamageType()); + assertTrue(data.isExplosive()); + assertTrue(data.isFire()); + assertEquals(1.5, data.getOffset()); + assertInstanceOf(ItemModel.class, data.getModel()); + assertInstanceOf(ArmorStand.class, data.spawn(new Location(r.world, 0, 64, 0))); + Location from = new Location(r.world, 0, 64, 0), to = new Location(r.world, 0, 64, 2); + data.fx(List.of(), from, 1, 0); + verify(r.world, never()) + .playSound(any(Location.class), eq("test.flight"), anyFloat(), anyFloat()); + data.fx(List.of(), from, 1, 2); + data.trailSegment(List.of(), from, to, 1, 3); + verify(r.world, times(2)).playSound(any(Location.class), eq("test.flight"), eq(1f), eq(1f)); + data.hitFX(List.of(), to, 1); + verify(r.world).playSound(eq(to), eq("test.impact"), eq(1f), eq(1f)); + assertEquals(new Location(r.world, 0, 64, 0), from); + assertEquals(new Location(r.world, 0, 64, 2), to); + AmmunitionData empty = new AmmunitionData(new YamlConfiguration()); + empty.trailSegment(List.of(), null, to, 1, 0); + empty.trailSegment(List.of(), from, null, 1, 0); + empty.trailSegment(List.of(), new Location(null, 0, 0, 0), to, 1, 0); + empty.trailSegment(List.of(), from, new Location(mock(World.class), 0, 0, 0), 1, 0); + empty.trailSegment(List.of(), from, from.clone().add(0, 0, .1), 1, 0); + empty.trailSegment(List.of(), from, to, 1, 0); + assertFalse(empty.isFire()); + assertTrue(empty.isExplosive()); + } - @Test void lingeringCloudConfigurationParsesEffectsColorsDurationsAndMalformedFallbacks()throws Exception { - var yaml=new YamlConfiguration();yaml.set("potion-effects",List.of("","potion(speed,2,8) color(300,10,20) 12","potion(unknown,1,2)","cloud","potion(poison,999999999999999,5)"));var data=new AmmunitionData(yaml);var clouds=data.getLingeringClouds();assertEquals(4,clouds.size());var first=clouds.getFirst();assertEquals(PotionEffectType.SPEED,first.getEffect().getType());assertEquals(2,first.getEffect().getAmplifier());assertEquals(160,first.getEffect().getDuration());assertEquals(Color.fromRGB(255,10,20),first.getColor());assertEquals(240,first.getCloudDurationTicks());assertEquals(PotionEffectType.POISON,clouds.get(1).getEffect().getType());assertEquals(600,clouds.get(2).getCloudDurationTicks());assertEquals(100,clouds.get(3).getEffect().getDuration());assertEquals(Color.fromRGB(0,255,0),clouds.get(3).getColor()); - try(var explosions=mockStatic(ExplosionCreator.class)){new ProjectileShooter().triggerExplosion(List.of(),new Location(r.world,0,64,0),data,null);assertEquals(4,r.spawned.stream().filter(AreaEffectCloud.class::isInstance).count());AreaEffectCloud cloud=(AreaEffectCloud)r.spawned.getFirst();verify(cloud).setDuration(240);verify(cloud).setRadiusPerTick(-2.5f/240);verify(cloud).addCustomEffect(first.getEffect(),true);} + @Test + void lingeringCloudConfigurationParsesEffectsColorsDurationsAndMalformedFallbacks() + throws Exception { + var yaml = new YamlConfiguration(); + yaml.set( + "potion-effects", + List.of( + "", + "potion(speed,2,8) color(300,10,20) 12", + "potion(unknown,1,2)", + "cloud", + "potion(poison,999999999999999,5)")); + var data = new AmmunitionData(yaml); + var clouds = data.getLingeringClouds(); + assertEquals(4, clouds.size()); + var first = clouds.getFirst(); + assertEquals(PotionEffectType.SPEED, first.getEffect().getType()); + assertEquals(2, first.getEffect().getAmplifier()); + assertEquals(160, first.getEffect().getDuration()); + assertEquals(Color.fromRGB(255, 10, 20), first.getColor()); + assertEquals(240, first.getCloudDurationTicks()); + assertEquals(PotionEffectType.POISON, clouds.get(1).getEffect().getType()); + assertEquals(600, clouds.get(2).getCloudDurationTicks()); + assertEquals(100, clouds.get(3).getEffect().getDuration()); + assertEquals(Color.fromRGB(0, 255, 0), clouds.get(3).getColor()); + try (var explosions = mockStatic(ExplosionCreator.class)) { + new ProjectileShooter() + .triggerExplosion(List.of(), new Location(r.world, 0, 64, 0), data, null); + assertEquals(4, r.spawned.stream().filter(AreaEffectCloud.class::isInstance).count()); + AreaEffectCloud cloud = (AreaEffectCloud) r.spawned.getFirst(); + verify(cloud).setDuration(240); + verify(cloud).setRadiusPerTick(-2.5f / 240); + verify(cloud).addCustomEffect(first.getEffect(), true); } + } - @Test void itemModelsSpawnAtTheirOffsetsWithConfiguredEquipment()throws Exception { - for(boolean small:List.of(false,true)){ItemModel itemModel=new ItemModel(Rig.yaml("material: stone\nsmall: "+small+"\nmodel-data: 123\n"));Location origin=new Location(r.world,2,70,4);ArmorStand stand=(ArmorStand)itemModel.spawn(origin);assertEquals(Material.STONE,itemModel.getMaterial());assertEquals(123,itemModel.getModel());assertEquals(small,itemModel.isSmall());assertEquals(small?1:1.5,itemModel.getOffset());assertEquals(70-itemModel.getOffset(),stand.getLocation().getY());assertEquals(70,origin.getY());verify(stand).setInvisible(true);verify(stand).setSmall(small);verify(stand).setMarker(false);verify(stand).setGravity(true);var stack=stacks.constructed().getLast();verify(stand.getEquipment()).setHelmet(stack);verify(stack.getItemMeta().getCustomModelDataComponent()).setFloats(List.of(123f));} - var disabled=new ItemModel(Rig.yaml("material: STONE\nmodel-data: -1\n"));disabled.spawn(new Location(r.world,0,64,0));verify(stacks.constructed().getLast(),never()).getItemMeta(); + @Test + void itemModelsSpawnAtTheirOffsetsWithConfiguredEquipment() throws Exception { + for (boolean small : List.of(false, true)) { + ItemModel itemModel = + new ItemModel(Rig.yaml("material: stone\nsmall: " + small + "\nmodel-data: 123\n")); + Location origin = new Location(r.world, 2, 70, 4); + ArmorStand stand = (ArmorStand) itemModel.spawn(origin); + assertEquals(Material.STONE, itemModel.getMaterial()); + assertEquals(123, itemModel.getModel()); + assertEquals(small, itemModel.isSmall()); + assertEquals(small ? 1 : 1.5, itemModel.getOffset()); + assertEquals(70 - itemModel.getOffset(), stand.getLocation().getY()); + assertEquals(70, origin.getY()); + verify(stand).setInvisible(true); + verify(stand).setSmall(small); + verify(stand).setMarker(false); + verify(stand).setGravity(true); + var stack = stacks.constructed().getLast(); + verify(stand.getEquipment()).setHelmet(stack); + verify(stack.getItemMeta().getCustomModelDataComponent()).setFloats(List.of(123f)); } + var disabled = new ItemModel(Rig.yaml("material: STONE\nmodel-data: -1\n")); + disabled.spawn(new Location(r.world, 0, 64, 0)); + verify(stacks.constructed().getLast(), never()).getItemMeta(); + } - @Test void modelEngineProjectilesHandleMissingMobModelAnimationAndInvalidAngles()throws Exception { - MythicBukkit mythic=mock(MythicBukkit.class,RETURNS_DEEP_STUBS);MythicMob mob=mock(MythicMob.class);ActiveMob spawned=mock(ActiveMob.class,RETURNS_DEEP_STUBS);Entity entity=r.entity(Entity.class,new Location(r.world,0,64,0));when(spawned.getEntity().getBukkitEntity()).thenReturn(entity);when(mob.spawn(any(),anyDouble())).thenReturn(spawned);ModeledEntity modeled=mock(ModeledEntity.class); - try(var mythicScope=mockStatic(MythicBukkit.class);var engine=mockStatic(ModelEngineAPI.class)){mythicScope.when(MythicBukkit::inst).thenReturn(mythic);when(mythic.getMobManager().getMythicMob("VehicleFrameworkDummy")).thenReturn(Optional.empty());var empty=new MEGModel(new YamlConfiguration());assertEquals("none",empty.getModel());assertEquals(0,empty.getOffset());assertNull(empty.spawn(new Location(r.world,0,64,0)));when(mythic.getMobManager().getMythicMob("VehicleFrameworkDummy")).thenReturn(Optional.of(mob));var engineConfig=mock(com.ticxo.modelengine.api.utils.config.ConfigCache.class);engine.when(ModelEngineAPI::getConfigCache).thenReturn(engineConfig);engine.when(()->ModelEngineAPI.createModeledEntity(entity)).thenReturn(modeled);engine.when(()->ModelEngineAPI.createActiveModel(anyString())).thenReturn(model); - var simple=new MEGModel(Rig.yaml("model: shell\n"));Location invalid=new Location(r.world,0,64,0,Float.NaN,Float.POSITIVE_INFINITY);assertSame(entity,simple.spawn(invalid));verify(entity).setRotation(0,0);verify(modeled).addModel(model,true);var animated=new MEGModel(Rig.yaml("model: shell\nanimation: fly\n"));when(model.getBlueprint().getAnimations()).thenReturn(new HashMap<>());assertSame(entity,animated.spawn(new Location(r.world,0,64,0,30,15)));var animation=new BlueprintAnimation(model.getBlueprint(),"fly");animation.setLength(1);animation.setLoopMode(BlueprintAnimation.LoopMode.LOOP);when(model.getBlueprint().getAnimations()).thenReturn(Map.of("fly",animation));assertSame(entity,animated.spawn(new Location(r.world,0,64,0)));verify(model.getAnimationHandler()).playAnimation(any(),eq(true));assertInstanceOf(MEGModel.class,new AmmunitionData(Rig.yaml("model: {type: meg, model: shell}\n")).getModel());} + @Test + void modelEngineProjectilesHandleMissingMobModelAnimationAndInvalidAngles() throws Exception { + MythicBukkit mythic = mock(MythicBukkit.class, RETURNS_DEEP_STUBS); + MythicMob mob = mock(MythicMob.class); + ActiveMob spawned = mock(ActiveMob.class, RETURNS_DEEP_STUBS); + Entity entity = r.entity(Entity.class, new Location(r.world, 0, 64, 0)); + when(spawned.getEntity().getBukkitEntity()).thenReturn(entity); + when(mob.spawn(any(), anyDouble())).thenReturn(spawned); + ModeledEntity modeled = mock(ModeledEntity.class); + try (var mythicScope = mockStatic(MythicBukkit.class); + var engine = mockStatic(ModelEngineAPI.class)) { + mythicScope.when(MythicBukkit::inst).thenReturn(mythic); + when(mythic.getMobManager().getMythicMob("VehicleFrameworkDummy")) + .thenReturn(Optional.empty()); + var empty = new MEGModel(new YamlConfiguration()); + assertEquals("none", empty.getModel()); + assertEquals(0, empty.getOffset()); + assertNull(empty.spawn(new Location(r.world, 0, 64, 0))); + when(mythic.getMobManager().getMythicMob("VehicleFrameworkDummy")) + .thenReturn(Optional.of(mob)); + var engineConfig = mock(com.ticxo.modelengine.api.utils.config.ConfigCache.class); + engine.when(ModelEngineAPI::getConfigCache).thenReturn(engineConfig); + engine.when(() -> ModelEngineAPI.createModeledEntity(entity)).thenReturn(modeled); + engine.when(() -> ModelEngineAPI.createActiveModel(anyString())).thenReturn(model); + var simple = new MEGModel(Rig.yaml("model: shell\n")); + Location invalid = new Location(r.world, 0, 64, 0, Float.NaN, Float.POSITIVE_INFINITY); + assertSame(entity, simple.spawn(invalid)); + verify(entity).setRotation(0, 0); + verify(modeled).addModel(model, true); + var animated = new MEGModel(Rig.yaml("model: shell\nanimation: fly\n")); + when(model.getBlueprint().getAnimations()).thenReturn(new HashMap<>()); + assertSame(entity, animated.spawn(new Location(r.world, 0, 64, 0, 30, 15))); + var animation = new BlueprintAnimation(model.getBlueprint(), "fly"); + animation.setLength(1); + animation.setLoopMode(BlueprintAnimation.LoopMode.LOOP); + when(model.getBlueprint().getAnimations()).thenReturn(Map.of("fly", animation)); + assertSame(entity, animated.spawn(new Location(r.world, 0, 64, 0))); + verify(model.getAnimationHandler()).playAnimation(any(), eq(true)); + assertInstanceOf( + MEGModel.class, + new AmmunitionData(Rig.yaml("model: {type: meg, model: shell}\n")).getModel()); } + } - @Test void reloadConsumesOneRoundPerMuzzleAndCompletesAfterConfiguredTime()throws Exception { - ActiveWeapon active=active("fixed: true\nbones: [muzzle.aim, muzzle.aim]\naccepted-ammunition: [bullet]\nreload-time: 1\ndata:\n reload-start-sounds: {a: {sound: test.reload.begin}}\n reload-sounds: {a: {sound: test.reload.done}}\n");Bullet ammo=new Bullet("bullet",Rig.yaml("type: BULLET\ninput: test:round\nrounds: 3\n"));AmmunitionLoader.map.put(ammo.getId(),ammo);ItemStack held=item(Material.TNT,5);ItemAPI api=mock(ItemAPI.class,RETURNS_DEEP_STUBS);when(api.getChecker().getAsStringPath(any())).thenReturn("test:round"); - try(var libs=mockStatic(TLibs.class)){libs.when(TLibs::getItemAPI).thenReturn(api);AmmunitionHandler handler=active.getAmmunitionHandler();assertEquals(2,handler.getAmmoAmount());assertEquals(1,handler.getBaseReloadTime());assertEquals(-1,handler.getReloadTime());handler.load(player,held,true);assertEquals(3,held.getAmount());assertEquals(1,handler.getReloadTime());handler.load(player,held,true);assertEquals(3,held.getAmount());verify(player).sendMessage("§eAlready reloading");r.tick();assertEquals(0,handler.getReloadTime());r.ticks(20);assertEquals(6,handler.getCount());assertSame(ammo,handler.getAmmo());assertEquals(-1,handler.getReloadTime());handler.load(player,held,true);verify(player).sendMessage("§eWeapon already loaded");handler.updateModel(model);} + @Test + void reloadConsumesOneRoundPerMuzzleAndCompletesAfterConfiguredTime() throws Exception { + ActiveWeapon active = + active( + "fixed: true\n" + + "bones: [muzzle.aim, muzzle.aim]\n" + + "accepted-ammunition: [bullet]\n" + + "reload-time: 1\n" + + "data:\n" + + " reload-start-sounds: {a: {sound: test.reload.begin}}\n" + + " reload-sounds: {a: {sound: test.reload.done}}\n"); + Bullet ammo = new Bullet("bullet", Rig.yaml("type: BULLET\ninput: test:round\nrounds: 3\n")); + AmmunitionLoader.map.put(ammo.getId(), ammo); + ItemStack held = item(Material.TNT, 5); + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + when(api.getChecker().getAsStringPath(any())).thenReturn("test:round"); + try (var libs = mockStatic(TLibs.class)) { + libs.when(TLibs::getItemAPI).thenReturn(api); + AmmunitionHandler handler = active.getAmmunitionHandler(); + assertEquals(2, handler.getAmmoAmount()); + assertEquals(1, handler.getBaseReloadTime()); + assertEquals(-1, handler.getReloadTime()); + handler.load(player, held, true); + assertEquals(3, held.getAmount()); + assertEquals(1, handler.getReloadTime()); + handler.load(player, held, true); + assertEquals(3, held.getAmount()); + verify(player).sendMessage("§eAlready reloading"); + r.tick(); + assertEquals(0, handler.getReloadTime()); + r.ticks(20); + assertEquals(6, handler.getCount()); + assertSame(ammo, handler.getAmmo()); + assertEquals(-1, handler.getReloadTime()); + handler.load(player, held, true); + verify(player).sendMessage("§eWeapon already loaded"); + handler.updateModel(model); } + } - @Test void reloadRejectsAirUnknownWrongStateUnsupportedAndInsufficientRounds()throws Exception { - ActiveWeapon active=active("fixed: true\nbones: [muzzle.aim, invalid, absent.aim, muzzle.absent]\naccepted-ammunition: [bullet]\nreload-states: [ground, invalid]\n");var handler=active.getAmmunitionHandler();assertEquals(1,handler.getAmmoAmount());assertEquals(List.of(State.GROUND),handler.getReloadStates());assertEquals(List.of("bullet"),handler.getAcceptedAmmunition());assertEquals(10,handler.getCooldown());assertEquals(0,handler.getActiveCooldown());ItemAPI api=mock(ItemAPI.class,RETURNS_DEEP_STUBS);when(api.getChecker().getAsStringPath(any())).thenReturn("test:round");ItemStack held=item(Material.TNT,1); - try(var libs=mockStatic(TLibs.class)){libs.when(TLibs::getItemAPI).thenReturn(api);handler.load(player,item(Material.AIR,1),true);handler.load(player,held,true);Bullet ammo=new Bullet("other",Rig.yaml("type: BULLET\ninput: test:round\n"));AmmunitionLoader.map.put("other",ammo);when(vehicle.getCurrentState().getType()).thenReturn(State.FLYING);handler.load(player,held,true);verify(player).sendMessage("§cCannot reload in the flying state");handler.load(player,held,false);when(vehicle.getCurrentState().getType()).thenReturn(State.GROUND);handler.load(player,held,true);verify(player).sendMessage("§eCannot use other for this weapon");AmmunitionLoader.map.clear();AmmunitionLoader.map.put("bullet",new Bullet("bullet",Rig.yaml("type: BULLET\ninput: test:round\n")));held.setAmount(0);handler.load(player,held,true);verify(player).sendMessage("§eYou need 1 of that ammunition to reload this weapon");assertFalse(handler.hasAmmo());assertEquals(0,r.pending());} + @Test + void reloadRejectsAirUnknownWrongStateUnsupportedAndInsufficientRounds() throws Exception { + ActiveWeapon active = + active( + "fixed: true\n" + + "bones: [muzzle.aim, invalid, absent.aim, muzzle.absent]\n" + + "accepted-ammunition: [bullet]\n" + + "reload-states: [ground, invalid]\n"); + var handler = active.getAmmunitionHandler(); + assertEquals(1, handler.getAmmoAmount()); + assertEquals(List.of(State.GROUND), handler.getReloadStates()); + assertEquals(List.of("bullet"), handler.getAcceptedAmmunition()); + assertEquals(10, handler.getCooldown()); + assertEquals(0, handler.getActiveCooldown()); + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + when(api.getChecker().getAsStringPath(any())).thenReturn("test:round"); + ItemStack held = item(Material.TNT, 1); + try (var libs = mockStatic(TLibs.class)) { + libs.when(TLibs::getItemAPI).thenReturn(api); + handler.load(player, item(Material.AIR, 1), true); + handler.load(player, held, true); + Bullet ammo = new Bullet("other", Rig.yaml("type: BULLET\ninput: test:round\n")); + AmmunitionLoader.map.put("other", ammo); + when(vehicle.getCurrentState().getType()).thenReturn(State.FLYING); + handler.load(player, held, true); + verify(player).sendMessage("§cCannot reload in the flying state"); + handler.load(player, held, false); + when(vehicle.getCurrentState().getType()).thenReturn(State.GROUND); + handler.load(player, held, true); + verify(player).sendMessage("§eCannot use other for this weapon"); + AmmunitionLoader.map.clear(); + AmmunitionLoader.map.put( + "bullet", new Bullet("bullet", Rig.yaml("type: BULLET\ninput: test:round\n"))); + held.setAmount(0); + handler.load(player, held, true); + verify(player).sendMessage("§eYou need 1 of that ammunition to reload this weapon"); + assertFalse(handler.hasAmmo()); + assertEquals(0, r.pending()); } + } - @Test void firingAlternatesMuzzlesConsumesRoundsAndRespectsCooldown()throws Exception { - ActiveWeapon active=active("fixed: true\nbones: [muzzle.aim, muzzle.aim]\ncooldown: 0\nanimations: {shoot: [left,right]}\ndata:\n shoot-sounds: {shot: {sound: test.shot}}\n particles: {smoke: {particle: FLAME, amount: 1}}\n");var handler=active.getAmmunitionHandler();handler.shoot(player,List.of());verify(player).sendMessage("§cNo ammo");handler.setAmmo(r.bullet(false),3);for(int i=0;i<3;i++)handler.shoot(player,List.of());assertFalse(handler.hasAmmo());assertEquals(0,handler.getCount());verify(vehicle,times(3)).updateBoard();verify(r.world,times(3)).playSound(any(Location.class),eq("test.shot"),eq(1f),eq(1f)); - ActiveWeapon cooling=active("fixed: true\nbones: [muzzle.aim]\ncooldown: 100\n");cooling.getAmmunitionHandler().setAmmo(r.bullet(false),2);cooling.getAmmunitionHandler().shoot(player,List.of());long until=cooling.getAmmunitionHandler().getActiveCooldown();cooling.getAmmunitionHandler().shoot(player,List.of());assertEquals(1,cooling.getAmmunitionHandler().getCount());assertTrue(until>System.currentTimeMillis()); - } + @Test + void firingAlternatesMuzzlesConsumesRoundsAndRespectsCooldown() throws Exception { + ActiveWeapon active = + active( + "fixed: true\n" + + "bones: [muzzle.aim, muzzle.aim]\n" + + "cooldown: 0\n" + + "animations: {shoot: [left,right]}\n" + + "data:\n" + + " shoot-sounds: {shot: {sound: test.shot}}\n" + + " particles: {smoke: {particle: FLAME, amount: 1}}\n"); + var handler = active.getAmmunitionHandler(); + handler.shoot(player, List.of()); + verify(player).sendMessage("§cNo ammo"); + handler.setAmmo(r.bullet(false), 3); + for (int i = 0; i < 3; i++) handler.shoot(player, List.of()); + assertFalse(handler.hasAmmo()); + assertEquals(0, handler.getCount()); + verify(vehicle, times(3)).updateBoard(); + verify(r.world, times(3)).playSound(any(Location.class), eq("test.shot"), eq(1f), eq(1f)); + ActiveWeapon cooling = active("fixed: true\nbones: [muzzle.aim]\ncooldown: 100\n"); + cooling.getAmmunitionHandler().setAmmo(r.bullet(false), 2); + cooling.getAmmunitionHandler().shoot(player, List.of()); + long until = cooling.getAmmunitionHandler().getActiveCooldown(); + cooling.getAmmunitionHandler().shoot(player, List.of()); + assertEquals(1, cooling.getAmmunitionHandler().getCount()); + assertTrue(until > System.currentTimeMillis()); + } - @Test void manualControllersRotateAllAxesAndBrokenWeaponsNotifyOnlyOnce()throws Exception { - bone("body",new Location(r.world,0,64,0));bone("head",new Location(r.world,0,64,0)); - for(String axis:List.of("x","y","z")){Weapon stored=new Weapon("turret",Rig.yaml("body-bone: body\nhead-bone: head\nhead-axis: "+axis+"\nbones: [muzzle.aim]\nturn-rate: 2\n"));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null);WeaponMovementController controls=new WeaponMovementController(vehicle,model,active,stored,stored.getLimits());assertSame(bones.get("body"),controls.getBodyBone());assertSame(bones.get("head"),controls.getHeadBone());for(Input input:List.of(Input.WEAPON_UP,Input.WEAPON_DOWN,Input.WEAPON_LEFT,Input.WEAPON_RIGHT,Input.NONE)){var bodyBefore=new org.joml.Quaternionf(panel.getRotator("body").getAnimator().getRotation());var headBefore=new org.joml.Quaternionf(panel.getRotator("head").getAnimator().getRotation());controls.input(List.of(),input,player);if(input==Input.WEAPON_UP||input==Input.WEAPON_DOWN)assertNotEquals(headBefore,panel.getRotator("head").getAnimator().getRotation());else if(input==Input.WEAPON_LEFT||input==Input.WEAPON_RIGHT)assertNotEquals(bodyBefore,panel.getRotator("body").getAnimator().getRotation());else{assertEquals(bodyBefore,panel.getRotator("body").getAnimator().getRotation());assertEquals(headBefore,panel.getRotator("head").getAnimator().getRotation());}}controls.move();controls.normalize();controls.updateModel(model);controls.trackCursor(player);assertTrue(Float.isFinite(panel.getRotator("body").getDriveYaw()));active.getHealthData().setDamage(100);controls.input(List.of(),Input.WEAPON_UP,player);controls.input(List.of(),Input.WEAPON_UP,player);controls.input(List.of(),Input.WEAPON_UP,null);} - verify(player,times(3)).sendMessage(contains("is broken")); + @Test + void manualControllersRotateAllAxesAndBrokenWeaponsNotifyOnlyOnce() throws Exception { + bone("body", new Location(r.world, 0, 64, 0)); + bone("head", new Location(r.world, 0, 64, 0)); + for (String axis : List.of("x", "y", "z")) { + Weapon stored = + new Weapon( + "turret", + Rig.yaml( + "body-bone: body\nhead-bone: head\nhead-axis: " + + axis + + "\nbones: [muzzle.aim]\nturn-rate: 2\n")); + ActiveWeapon active = new ActiveWeapon(model, vehicle, stored, null); + WeaponMovementController controls = + new WeaponMovementController(vehicle, model, active, stored, stored.getLimits()); + assertSame(bones.get("body"), controls.getBodyBone()); + assertSame(bones.get("head"), controls.getHeadBone()); + for (Input input : + List.of( + Input.WEAPON_UP, + Input.WEAPON_DOWN, + Input.WEAPON_LEFT, + Input.WEAPON_RIGHT, + Input.NONE)) { + var bodyBefore = + new org.joml.Quaternionf(panel.getRotator("body").getAnimator().getRotation()); + var headBefore = + new org.joml.Quaternionf(panel.getRotator("head").getAnimator().getRotation()); + controls.input(List.of(), input, player); + if (input == Input.WEAPON_UP || input == Input.WEAPON_DOWN) + assertNotEquals(headBefore, panel.getRotator("head").getAnimator().getRotation()); + else if (input == Input.WEAPON_LEFT || input == Input.WEAPON_RIGHT) + assertNotEquals(bodyBefore, panel.getRotator("body").getAnimator().getRotation()); + else { + assertEquals(bodyBefore, panel.getRotator("body").getAnimator().getRotation()); + assertEquals(headBefore, panel.getRotator("head").getAnimator().getRotation()); + } + } + controls.move(); + controls.normalize(); + controls.updateModel(model); + controls.trackCursor(player); + assertTrue(Float.isFinite(panel.getRotator("body").getDriveYaw())); + active.getHealthData().setDamage(100); + controls.input(List.of(), Input.WEAPON_UP, player); + controls.input(List.of(), Input.WEAPON_UP, player); + controls.input(List.of(), Input.WEAPON_UP, null); } + verify(player, times(3)).sendMessage(contains("is broken")); + } - @Test void cursorControllersTrackTargetsOffsetsAndDebugWithoutManualTurning()throws Exception { - bone("body",new Location(r.world,0,64,0));bone("head",new Location(r.world,0,64,0));Cache.weaponAimDebug=true; - for(String axis:List.of("x","z","y")){Weapon stored=new Weapon("cursor",Rig.yaml("body-bone: body\nhead-bone: head\nhead-axis: "+axis+"\naim-mode: cursor\naim-vector: muzzle.aim\nturn-rate: 2\n"));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null);WeaponMovementController controls=new WeaponMovementController(vehicle,model,active,stored,stored.getLimits());controls.trackCursor(null);controls.trackCursor(player);controls.trackCursor(player);player.teleport(new Location(r.world,0,64,0,90,-30));var beforeTarget=new org.joml.Quaternionf(panel.getRotator("head").getAnimator().getRotation());controls.trackCursor(player);assertNotEquals(beforeTarget,panel.getRotator("head").getAnimator().getRotation());controls.move();for(Input input:List.of(Input.WEAPON_UP,Input.WEAPON_DOWN,Input.WEAPON_LEFT,Input.WEAPON_RIGHT))controls.input(List.of(),input,player);active.getHealthData().setDamage(100);controls.trackCursor(player);controls.updateModel(model);assertNotEquals(0,panel.getRotator("body").getDriveYaw());player.teleport(new Location(r.world,0,64,0));} - verify(player,atLeastOnce()).spawnParticle(eq(Particle.END_ROD),any(Location.class),eq(1),eq(0d),eq(0d),eq(0d),eq(0d)); + @Test + void cursorControllersTrackTargetsOffsetsAndDebugWithoutManualTurning() throws Exception { + bone("body", new Location(r.world, 0, 64, 0)); + bone("head", new Location(r.world, 0, 64, 0)); + Cache.weaponAimDebug = true; + for (String axis : List.of("x", "z", "y")) { + Weapon stored = + new Weapon( + "cursor", + Rig.yaml( + "body-bone: body\nhead-bone: head\nhead-axis: " + + axis + + "\naim-mode: cursor\naim-vector: muzzle.aim\nturn-rate: 2\n")); + ActiveWeapon active = new ActiveWeapon(model, vehicle, stored, null); + WeaponMovementController controls = + new WeaponMovementController(vehicle, model, active, stored, stored.getLimits()); + controls.trackCursor(null); + controls.trackCursor(player); + controls.trackCursor(player); + player.teleport(new Location(r.world, 0, 64, 0, 90, -30)); + var beforeTarget = + new org.joml.Quaternionf(panel.getRotator("head").getAnimator().getRotation()); + controls.trackCursor(player); + assertNotEquals(beforeTarget, panel.getRotator("head").getAnimator().getRotation()); + controls.move(); + for (Input input : + List.of(Input.WEAPON_UP, Input.WEAPON_DOWN, Input.WEAPON_LEFT, Input.WEAPON_RIGHT)) + controls.input(List.of(), input, player); + active.getHealthData().setDamage(100); + controls.trackCursor(player); + controls.updateModel(model); + assertNotEquals(0, panel.getRotator("body").getDriveYaw()); + player.teleport(new Location(r.world, 0, 64, 0)); } + verify(player, atLeastOnce()) + .spawnParticle( + eq(Particle.END_ROD), any(Location.class), eq(1), eq(0d), eq(0d), eq(0d), eq(0d)); + } - @Test void aimingIgnoresMissingModelBonesAndInvalidVectorDefinitions()throws Exception { - for(String spec:List.of("absent","absent.aim","muzzle.absent","muzzle.aim")){Weapon stored=new Weapon("bad",Rig.yaml("aim-mode: cursor\naim-vector: "+spec+"\n"));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null);active.setController(player);active.tick();active.updateModel(model);} - bone("weapon_body",new Location(r.world,0,64,0));active("aim-mode: cursor\nbones: []\n").tick();bone("cannon_controller",new Location(r.world,0,64,0));active("aim-mode: cursor\nbones: []\n").tick();active("aim-mode: cursor\nbones: [muzzle.aim]\n").tick();active("fixed: true\naim-mode: cursor\n").tick(); + @Test + void aimingIgnoresMissingModelBonesAndInvalidVectorDefinitions() throws Exception { + for (String spec : List.of("absent", "absent.aim", "muzzle.absent", "muzzle.aim")) { + Weapon stored = new Weapon("bad", Rig.yaml("aim-mode: cursor\naim-vector: " + spec + "\n")); + ActiveWeapon active = new ActiveWeapon(model, vehicle, stored, null); + active.setController(player); + active.tick(); + active.updateModel(model); } + bone("weapon_body", new Location(r.world, 0, 64, 0)); + active("aim-mode: cursor\nbones: []\n").tick(); + bone("cannon_controller", new Location(r.world, 0, 64, 0)); + active("aim-mode: cursor\nbones: []\n").tick(); + active("aim-mode: cursor\nbones: [muzzle.aim]\n").tick(); + active("fixed: true\naim-mode: cursor\n").tick(); + } - @Test void controllerCommandsRouteReloadShootCombinedAndWeaponSwitch()throws Exception { - Weapon stored=new Weapon("fixed",Rig.yaml("fixed: true\nbones: [muzzle.aim]\n"));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null);WeaponMovementController controls=new WeaponMovementController(vehicle,model,active,stored,stored.getLimits());for(Input input:List.of(Input.WEAPON_RELOAD,Input.WEAPON_SHOOT,Input.WEAPON_RELOAD_AND_SHOOT,Input.WEAPON_SWITCH,Input.NONE))controls.input(List.of(),input,player);verify(vehicle.getSeat(player)).changeWeapon();verify(vehicle).updateBoard();verify(player,times(2)).sendMessage("§cNo ammo"); - } + @Test + void controllerCommandsRouteReloadShootCombinedAndWeaponSwitch() throws Exception { + Weapon stored = new Weapon("fixed", Rig.yaml("fixed: true\nbones: [muzzle.aim]\n")); + ActiveWeapon active = new ActiveWeapon(model, vehicle, stored, null); + WeaponMovementController controls = + new WeaponMovementController(vehicle, model, active, stored, stored.getLimits()); + for (Input input : + List.of( + Input.WEAPON_RELOAD, + Input.WEAPON_SHOOT, + Input.WEAPON_RELOAD_AND_SHOOT, + Input.WEAPON_SWITCH, + Input.NONE)) controls.input(List.of(), input, player); + verify(vehicle.getSeat(player)).changeWeapon(); + verify(vehicle).updateBoard(); + verify(player, times(2)).sendMessage("§cNo ammo"); + } - @Test void targetResolverSelectsNearestSolidOrLivingHitAndFiltersPassengers() { - assertNull(WeaponTargetResolver.resolveTarget(null,vehicle,80));Location eye=player.getEyeLocation();Entity root=vehicle.getEntity();LivingEntity passenger=r.entity(LivingEntity.class,eye.clone().add(0,0,1));when(vehicle.getSeatHandler().getPassengers()).thenReturn(List.of(passenger));assertEquals(Set.of(player,root,passenger),WeaponEntityFilters.buildShooterIgnoreSet(player,root,vehicle));assertTrue(WeaponEntityFilters.buildShooterIgnoreSet(null,null,null).isEmpty()); - LivingEntity target=r.entity(LivingEntity.class,eye.clone().add(0,0,5));r.rayTargets.addAll(List.of(player,root,passenger,target));assertEquals(target.getLocation(),WeaponTargetResolver.resolveTarget(player,vehicle,80));when(r.world.rayTraceBlocks(any(),any(),anyDouble(),any(),anyBoolean())).thenReturn(new RayTraceResult(eye.clone().add(0,0,2).toVector()));assertEquals(eye.clone().add(0,0,2),WeaponTargetResolver.resolveTarget(player,vehicle,80));r.rayTargets.clear();assertEquals(eye.clone().add(0,0,2),WeaponTargetResolver.resolveTarget(player,null,80));when(r.world.rayTraceBlocks(any(),any(),anyDouble(),any(),anyBoolean())).thenReturn(null);assertEquals(eye.clone().add(0,0,80),WeaponTargetResolver.resolveTarget(player,null,-1));when(player.getEyeLocation()).thenReturn(new Location(null,1,2,3));assertEquals(new Location(null,1,2,13),WeaponTargetResolver.resolveTarget(player,null,10)); - } + @Test + void targetResolverSelectsNearestSolidOrLivingHitAndFiltersPassengers() { + assertNull(WeaponTargetResolver.resolveTarget(null, vehicle, 80)); + Location eye = player.getEyeLocation(); + Entity root = vehicle.getEntity(); + LivingEntity passenger = r.entity(LivingEntity.class, eye.clone().add(0, 0, 1)); + when(vehicle.getSeatHandler().getPassengers()).thenReturn(List.of(passenger)); + assertEquals( + Set.of(player, root, passenger), + WeaponEntityFilters.buildShooterIgnoreSet(player, root, vehicle)); + assertTrue(WeaponEntityFilters.buildShooterIgnoreSet(null, null, null).isEmpty()); + LivingEntity target = r.entity(LivingEntity.class, eye.clone().add(0, 0, 5)); + r.rayTargets.addAll(List.of(player, root, passenger, target)); + assertEquals(target.getLocation(), WeaponTargetResolver.resolveTarget(player, vehicle, 80)); + when(r.world.rayTraceBlocks(any(), any(), anyDouble(), any(), anyBoolean())) + .thenReturn(new RayTraceResult(eye.clone().add(0, 0, 2).toVector())); + assertEquals(eye.clone().add(0, 0, 2), WeaponTargetResolver.resolveTarget(player, vehicle, 80)); + r.rayTargets.clear(); + assertEquals(eye.clone().add(0, 0, 2), WeaponTargetResolver.resolveTarget(player, null, 80)); + when(r.world.rayTraceBlocks(any(), any(), anyDouble(), any(), anyBoolean())).thenReturn(null); + assertEquals(eye.clone().add(0, 0, 80), WeaponTargetResolver.resolveTarget(player, null, -1)); + when(player.getEyeLocation()).thenReturn(new Location(null, 1, 2, 3)); + assertEquals( + new Location(null, 1, 2, 13), WeaponTargetResolver.resolveTarget(player, null, 10)); + } - @Test void aimMathHandlesMissingTargetsOffsetsHysteresisAndHealthScaling() { - var offset=new WeaponAimOffset(10,20,30,40);assertEquals(10,offset.applyToAngles(null,"x").getYaw());assertEquals(25,offset.applyToAngles(new ConvertedAngle(1,5,0),null).getPitch());assertEquals(45,offset.applyToAngles(new ConvertedAngle(1,5,0),"z").getPitch());assertEquals(41,offset.applyToAngles(new ConvertedAngle(1,5,0),"y").getYaw());assertEquals(0,WeaponAimAligner.yawError(null,new Vector(0,0,1)));assertEquals(0,WeaponAimAligner.yawError(new Vector(0,0,1),(ConvertedAngle)null));assertEquals(0,WeaponAimAligner.elevationError(null,new Vector(0,0,1),"x"));assertEquals(0,WeaponAimAligner.elevationError(new Vector(0,0,1),(ConvertedAngle)null,"x"));assertEquals(0,WeaponAimAligner.followStep(2,0,false));assertEquals(0,WeaponAimAligner.followStep(2,1,true));assertEquals(0,WeaponAimAligner.followStep(.5f,1,false));assertTrue(WeaponAimAligner.followStep(-20,2,false)<0);assertTrue(WeaponAimAligner.updateSettled(.5f,false));assertFalse(WeaponAimAligner.updateSettled(20,false));assertTrue(WeaponAimAligner.updateSettled(2,true));assertFalse(WeaponAimAligner.updateSettled(8,true));assertEquals(-2,WeaponAimAligner.toBoneYawStep(2));assertEquals(-2,WeaponAimAligner.toBoneElevationStep(2));assertEquals(3,WeaponPerformance.effectiveTurnRate(3,null));assertEquals(1.5,WeaponPerformance.effectiveTurnRate(3,new HealthData(100,50,1)));assertEquals(1,WeaponPerformance.effectiveReloadSeconds(0,null,2));assertEquals(2,WeaponPerformance.effectiveReloadSeconds(2,null,2));assertEquals(4,WeaponPerformance.effectiveReloadSeconds(2,new HealthData(100,100,1),2));var cooldown=new NoticeCooldown(10);UUID id=UUID.randomUUID();assertTrue(cooldown.tryAcquire(id,0));assertFalse(cooldown.tryAcquire(id,1));assertTrue(cooldown.tryAcquire(id,10)); - } + @Test + void aimMathHandlesMissingTargetsOffsetsHysteresisAndHealthScaling() { + var offset = new WeaponAimOffset(10, 20, 30, 40); + assertEquals(10, offset.applyToAngles(null, "x").getYaw()); + assertEquals(25, offset.applyToAngles(new ConvertedAngle(1, 5, 0), null).getPitch()); + assertEquals(45, offset.applyToAngles(new ConvertedAngle(1, 5, 0), "z").getPitch()); + assertEquals(41, offset.applyToAngles(new ConvertedAngle(1, 5, 0), "y").getYaw()); + assertEquals(0, WeaponAimAligner.yawError(null, new Vector(0, 0, 1))); + assertEquals(0, WeaponAimAligner.yawError(new Vector(0, 0, 1), (ConvertedAngle) null)); + assertEquals(0, WeaponAimAligner.elevationError(null, new Vector(0, 0, 1), "x")); + assertEquals( + 0, WeaponAimAligner.elevationError(new Vector(0, 0, 1), (ConvertedAngle) null, "x")); + assertEquals(0, WeaponAimAligner.followStep(2, 0, false)); + assertEquals(0, WeaponAimAligner.followStep(2, 1, true)); + assertEquals(0, WeaponAimAligner.followStep(.5f, 1, false)); + assertTrue(WeaponAimAligner.followStep(-20, 2, false) < 0); + assertTrue(WeaponAimAligner.updateSettled(.5f, false)); + assertFalse(WeaponAimAligner.updateSettled(20, false)); + assertTrue(WeaponAimAligner.updateSettled(2, true)); + assertFalse(WeaponAimAligner.updateSettled(8, true)); + assertEquals(-2, WeaponAimAligner.toBoneYawStep(2)); + assertEquals(-2, WeaponAimAligner.toBoneElevationStep(2)); + assertEquals(3, WeaponPerformance.effectiveTurnRate(3, null)); + assertEquals(1.5, WeaponPerformance.effectiveTurnRate(3, new HealthData(100, 50, 1))); + assertEquals(1, WeaponPerformance.effectiveReloadSeconds(0, null, 2)); + assertEquals(2, WeaponPerformance.effectiveReloadSeconds(2, null, 2)); + assertEquals(4, WeaponPerformance.effectiveReloadSeconds(2, new HealthData(100, 100, 1), 2)); + var cooldown = new NoticeCooldown(10); + UUID id = UUID.randomUUID(); + assertTrue(cooldown.tryAcquire(id, 0)); + assertFalse(cooldown.tryAcquire(id, 1)); + assertTrue(cooldown.tryAcquire(id, 10)); + } - @Test void muzzleFlashDoesNotDeleteAReplacementBlock() { - ProjectileShooter shooter=new ProjectileShooter();Location location=new Location(r.world,0,64,0);shooter.lightEffect(location);r.block(0,64,0,Material.STONE);r.ticks(4);assertEquals(Material.STONE,location.getBlock().getType(),"The temporary muzzle light must not erase a subsequently placed block"); - } + @Test + void muzzleFlashDoesNotDeleteAReplacementBlock() { + ProjectileShooter shooter = new ProjectileShooter(); + Location location = new Location(r.world, 0, 64, 0); + shooter.lightEffect(location); + r.block(0, 64, 0, Material.STONE); + r.ticks(4); + assertEquals( + Material.STONE, + location.getBlock().getType(), + "The temporary muzzle light must not erase a subsequently placed block"); + } - @Test void cursorTrackingHandlesAnUnavailableMuzzleCoincidentTargetAndBrokenElevation()throws Exception { - bone("body",new Location(r.world,0,64,0));bone("head",new Location(r.world,0,64,0));Weapon stored=new Weapon("cursor",Rig.yaml("body-bone: body\nhead-bone: head\naim-mode: cursor\naim-vector: muzzle.aim\n"));ActiveWeapon active=new ActiveWeapon(model,vehicle,stored,null);WeaponMovementController controls=new WeaponMovementController(vehicle,model,active,stored,stored.getLimits()); - when(bones.get("muzzle").getLocation()).thenReturn(null);assertDoesNotThrow(()->controls.trackCursor(player));when(bones.get("muzzle").getLocation()).thenAnswer(c->new Location(r.world,0,64,0));LivingEntity close=r.entity(LivingEntity.class,new Location(r.world,0,64,0));r.rayTargets.add(close);controls.trackCursor(player);assertEquals(0,panel.getRotator("body").getDriveYaw());r.rayTargets.clear();active.getHealthData().setDamage(100);player.teleport(new Location(r.world,0,64,0,0,-45));controls.trackCursor(player);verify(player).sendMessage(contains("is broken")); - Entity decoration=r.entity(Entity.class,new Location(r.world,0,64,1));LivingEntity dead=r.entity(LivingEntity.class,new Location(r.world,0,64,2));when(dead.isDead()).thenReturn(true);r.rayTargets.addAll(List.of(decoration,dead));assertNotNull(WeaponTargetResolver.resolveTarget(player,vehicle,80)); - } + @Test + void cursorTrackingHandlesAnUnavailableMuzzleCoincidentTargetAndBrokenElevation() + throws Exception { + bone("body", new Location(r.world, 0, 64, 0)); + bone("head", new Location(r.world, 0, 64, 0)); + Weapon stored = + new Weapon( + "cursor", + Rig.yaml( + "body-bone: body\nhead-bone: head\naim-mode: cursor\naim-vector: muzzle.aim\n")); + ActiveWeapon active = new ActiveWeapon(model, vehicle, stored, null); + WeaponMovementController controls = + new WeaponMovementController(vehicle, model, active, stored, stored.getLimits()); + when(bones.get("muzzle").getLocation()).thenReturn(null); + assertDoesNotThrow(() -> controls.trackCursor(player)); + when(bones.get("muzzle").getLocation()).thenAnswer(c -> new Location(r.world, 0, 64, 0)); + LivingEntity close = r.entity(LivingEntity.class, new Location(r.world, 0, 64, 0)); + r.rayTargets.add(close); + controls.trackCursor(player); + assertEquals(0, panel.getRotator("body").getDriveYaw()); + r.rayTargets.clear(); + active.getHealthData().setDamage(100); + player.teleport(new Location(r.world, 0, 64, 0, 0, -45)); + controls.trackCursor(player); + verify(player).sendMessage(contains("is broken")); + Entity decoration = r.entity(Entity.class, new Location(r.world, 0, 64, 1)); + LivingEntity dead = r.entity(LivingEntity.class, new Location(r.world, 0, 64, 2)); + when(dead.isDead()).thenReturn(true); + r.rayTargets.addAll(List.of(decoration, dead)); + assertNotNull(WeaponTargetResolver.resolveTarget(player, vehicle, 80)); + } - @Test void muzzleLightAnimatesThenRemovesOnlyItsTemporaryLight() { - ProjectileShooter shooter=new ProjectileShooter();Location location=new Location(r.world,0,64,0);r.block(0,64,0,Material.STONE);shooter.lightEffect(location);assertEquals(0,r.pending());r.block(0,64,0,Material.AIR);shooter.lightEffect(location);var level=(org.bukkit.block.data.Levelled)location.getBlock().getBlockData();verify(level).setLevel(10);r.tick();r.tick();verify(level).setLevel(15);r.tick();verify(level).setLevel(11);r.tick();assertEquals(Material.AIR,location.getBlock().getType());assertEquals(0,r.pending()); - } + @Test + void muzzleLightAnimatesThenRemovesOnlyItsTemporaryLight() { + ProjectileShooter shooter = new ProjectileShooter(); + Location location = new Location(r.world, 0, 64, 0); + r.block(0, 64, 0, Material.STONE); + shooter.lightEffect(location); + assertEquals(0, r.pending()); + r.block(0, 64, 0, Material.AIR); + shooter.lightEffect(location); + var level = (org.bukkit.block.data.Levelled) location.getBlock().getBlockData(); + verify(level).setLevel(10); + r.tick(); + r.tick(); + verify(level).setLevel(15); + r.tick(); + verify(level).setLevel(11); + r.tick(); + assertEquals(Material.AIR, location.getBlock().getType()); + assertEquals(0, r.pending()); + } - @Test void cannonballMovesWithGravityAndIsRemovedOnImpact()throws Exception { - Ammunition ammo=ammo("CANNONBALL","explosive: false\n");ProjectileShooter shooter=new ProjectileShooter();shooter.shoot(List.of(),vehicle.getEntity(),new Location(r.world,0,64,0),new Vector(0,0,1),ammo,null);Entity shot=r.spawned.getLast();assertTrue(Cache.projectiles.contains(shot));r.tick();assertEquals(3,shot.getLocation().getZ(),1e-9);assertEquals(63.951,shot.getLocation().getY(),1e-9);when(shot.isOnGround()).thenReturn(true);r.ticks(5);verify(shot).remove();assertFalse(Cache.projectiles.contains(shot));assertEquals(0,r.pending()); - } + @Test + void cannonballMovesWithGravityAndIsRemovedOnImpact() throws Exception { + Ammunition ammo = ammo("CANNONBALL", "explosive: false\n"); + ProjectileShooter shooter = new ProjectileShooter(); + shooter.shoot( + List.of(), + vehicle.getEntity(), + new Location(r.world, 0, 64, 0), + new Vector(0, 0, 1), + ammo, + null); + Entity shot = r.spawned.getLast(); + assertTrue(Cache.projectiles.contains(shot)); + r.tick(); + assertEquals(3, shot.getLocation().getZ(), 1e-9); + assertEquals(63.951, shot.getLocation().getY(), 1e-9); + when(shot.isOnGround()).thenReturn(true); + r.ticks(5); + verify(shot).remove(); + assertFalse(Cache.projectiles.contains(shot)); + assertEquals(0, r.pending()); + } - @Test void airborneCannonAndBombSoundsNeverDropBelowTheirPitchFloor()throws Exception { - for(String type:List.of("CANNONBALL","BOMB")){Ammunition ammo=ammo(type,"explosive: false\nfuse: 80\nsounds: {flight: {sound: test.flight, pitched: true}}\n");new ProjectileShooter().shoot(List.of(),vehicle.getEntity(),new Location(r.world,0,1000,0),new Vector(0,0,1),ammo,null);Entity shot=r.spawned.getLast();r.ticks(85);ArgumentCaptor pitches=ArgumentCaptor.forClass(Float.class);verify(r.world,atLeast(85)).playSound(any(Location.class),eq("test.flight"),eq(1f),pitches.capture());assertTrue(pitches.getAllValues().stream().allMatch(p->p>=0.3f));assertTrue(pitches.getAllValues().contains(0.3f));when(shot.isDead()).thenReturn(true);r.tick();} + @Test + void airborneCannonAndBombSoundsNeverDropBelowTheirPitchFloor() throws Exception { + for (String type : List.of("CANNONBALL", "BOMB")) { + Ammunition ammo = + ammo( + type, + "explosive: false\n" + + "fuse: 80\n" + + "sounds: {flight: {sound: test.flight, pitched: true}}\n"); + new ProjectileShooter() + .shoot( + List.of(), + vehicle.getEntity(), + new Location(r.world, 0, 1000, 0), + new Vector(0, 0, 1), + ammo, + null); + Entity shot = r.spawned.getLast(); + r.ticks(85); + ArgumentCaptor pitches = ArgumentCaptor.forClass(Float.class); + verify(r.world, atLeast(85)) + .playSound(any(Location.class), eq("test.flight"), eq(1f), pitches.capture()); + assertTrue(pitches.getAllValues().stream().allMatch(p -> p >= 0.3f)); + assertTrue(pitches.getAllValues().contains(0.3f)); + when(shot.isDead()).thenReturn(true); + r.tick(); } + } - @Test void bombsInheritVehicleVelocityAndWaitForFuseBeforeGroundImpact()throws Exception { - vehicle.getEntity().setVelocity(new Vector(2,.2,0));Ammunition ammo=ammo("BOMB","fuse: 1\nexplosive: false\n");new ProjectileShooter().shoot(List.of(),vehicle.getEntity(),new Location(r.world,0,64,0),new Vector(0,0,1),ammo,null);Entity shot=r.spawned.getLast();when(shot.isOnGround()).thenReturn(true);r.tick();assertEquals(2,shot.getLocation().getX(),1e-9);assertEquals(64.151,shot.getLocation().getY(),1e-9);verify(shot,never()).remove();r.tick();verify(shot).remove();assertFalse(Cache.projectiles.contains(shot)); - } + @Test + void bombsInheritVehicleVelocityAndWaitForFuseBeforeGroundImpact() throws Exception { + vehicle.getEntity().setVelocity(new Vector(2, .2, 0)); + Ammunition ammo = ammo("BOMB", "fuse: 1\nexplosive: false\n"); + new ProjectileShooter() + .shoot( + List.of(), + vehicle.getEntity(), + new Location(r.world, 0, 64, 0), + new Vector(0, 0, 1), + ammo, + null); + Entity shot = r.spawned.getLast(); + when(shot.isOnGround()).thenReturn(true); + r.tick(); + assertEquals(2, shot.getLocation().getX(), 1e-9); + assertEquals(64.151, shot.getLocation().getY(), 1e-9); + verify(shot, never()).remove(); + r.tick(); + verify(shot).remove(); + assertFalse(Cache.projectiles.contains(shot)); + } - @Test void clusterFuseCreatesConfiguredChildrenAndCleansThemOnImpact()throws Exception { - ClusterBomb ammo=(ClusterBomb)ammo("CLUSTER","fuse: 2\namount: 2\nspread: 0\nexplosive: false\ncluster:\n explosive: false\n model: {type: item, material: STONE}\n");new ProjectileShooter().shoot(List.of(player),vehicle.getEntity(),new Location(r.world,0,64,0),new Vector(0,0,1),ammo,null);Entity shell=r.spawned.getFirst();r.ticks(2);verify(shell).remove();assertEquals(3,r.spawned.size());assertEquals(2,Cache.projectiles.size());for(Entity child:r.spawned.subList(1,3)){assertEquals(new Vector(0,-3.5,0),child.getVelocity());when(child.isOnGround()).thenReturn(true);}r.ticks(7);assertTrue(Cache.projectiles.isEmpty());for(Entity child:r.spawned.subList(1,3))verify(child).remove();verify(player).spawnParticle(eq(Particle.EXPLOSION_EMITTER),any(Location.class),eq(15),eq(0d),eq(0d),eq(0d),eq(0d)); + @Test + void clusterFuseCreatesConfiguredChildrenAndCleansThemOnImpact() throws Exception { + ClusterBomb ammo = + (ClusterBomb) + ammo( + "CLUSTER", + "fuse: 2\n" + + "amount: 2\n" + + "spread: 0\n" + + "explosive: false\n" + + "cluster:\n" + + " explosive: false\n" + + " model: {type: item, material: STONE}\n"); + new ProjectileShooter() + .shoot( + List.of(player), + vehicle.getEntity(), + new Location(r.world, 0, 64, 0), + new Vector(0, 0, 1), + ammo, + null); + Entity shell = r.spawned.getFirst(); + r.ticks(2); + verify(shell).remove(); + assertEquals(3, r.spawned.size()); + assertEquals(2, Cache.projectiles.size()); + for (Entity child : r.spawned.subList(1, 3)) { + assertEquals(new Vector(0, -3.5, 0), child.getVelocity()); + when(child.isOnGround()).thenReturn(true); } + r.ticks(7); + assertTrue(Cache.projectiles.isEmpty()); + for (Entity child : r.spawned.subList(1, 3)) verify(child).remove(); + verify(player) + .spawnParticle( + eq(Particle.EXPLOSION_EMITTER), + any(Location.class), + eq(15), + eq(0d), + eq(0d), + eq(0d), + eq(0d)); + } - @Test void clusterImpactAtFuseBoundaryDetonatesOnlyOnce()throws Exception { - ClusterBomb ammo=(ClusterBomb)ammo("CLUSTER","fuse: 6\namount: 0\ncluster: {model: {type: item, material: STONE}}\n");try(var explosions=mockStatic(ExplosionCreator.class)){new ProjectileShooter().shoot(List.of(),vehicle.getEntity(),new Location(r.world,0,64,0),new Vector(0,0,1),ammo,null);Entity shell=r.spawned.getFirst();when(shell.isOnGround()).thenReturn(true);r.ticks(6);explosions.verify(()->ExplosionCreator.triggerExplosion(any(Location.class),eq(1d),eq(5d),eq(8d),eq("PROJECTILE"),eq(false)),times(1));verify(shell,times(1)).remove();} + @Test + void clusterImpactAtFuseBoundaryDetonatesOnlyOnce() throws Exception { + ClusterBomb ammo = + (ClusterBomb) + ammo( + "CLUSTER", "fuse: 6\namount: 0\ncluster: {model: {type: item, material: STONE}}\n"); + try (var explosions = mockStatic(ExplosionCreator.class)) { + new ProjectileShooter() + .shoot( + List.of(), + vehicle.getEntity(), + new Location(r.world, 0, 64, 0), + new Vector(0, 0, 1), + ammo, + null); + Entity shell = r.spawned.getFirst(); + when(shell.isOnGround()).thenReturn(true); + r.ticks(6); + explosions.verify( + () -> + ExplosionCreator.triggerExplosion( + any(Location.class), eq(1d), eq(5d), eq(8d), eq("PROJECTILE"), eq(false)), + times(1)); + verify(shell, times(1)).remove(); } + } - @Test void torpedoesLevelAccelerateInWaterAndExpireAfterTheirFuse()throws Exception { - for(boolean waterlogged:List.of(false,true)){Location launch=new Location(r.world,waterlogged?10:0,64,0);var block=r.block(launch.getBlockX(),63,0,waterlogged?Material.OAK_SLAB:Material.WATER);if(waterlogged){var data=mock(org.bukkit.block.data.Waterlogged.class);when(data.isWaterlogged()).thenReturn(true);when(block.getBlockData()).thenReturn(data);}Ammunition ammo=ammo("TORPEDO","fuse: 10\nexplosive: false\nmodel: {type: item, material: STONE, small: true}\n");new ProjectileShooter().shoot(List.of(),vehicle.getEntity(),launch,new Vector(0,.1,1),ammo,null);Entity torpedo=r.spawned.getLast();r.tick();assertEquals(0,torpedo.getVelocity().getY(),1e-9);assertEquals(3.4,torpedo.getVelocity().getZ(),.003);r.ticks(10);verify(torpedo).remove();assertFalse(Cache.projectiles.contains(torpedo));} + @Test + void torpedoesLevelAccelerateInWaterAndExpireAfterTheirFuse() throws Exception { + for (boolean waterlogged : List.of(false, true)) { + Location launch = new Location(r.world, waterlogged ? 10 : 0, 64, 0); + var block = + r.block(launch.getBlockX(), 63, 0, waterlogged ? Material.OAK_SLAB : Material.WATER); + if (waterlogged) { + var data = mock(org.bukkit.block.data.Waterlogged.class); + when(data.isWaterlogged()).thenReturn(true); + when(block.getBlockData()).thenReturn(data); + } + Ammunition ammo = + ammo( + "TORPEDO", + "fuse: 10\nexplosive: false\nmodel: {type: item, material: STONE, small: true}\n"); + new ProjectileShooter() + .shoot(List.of(), vehicle.getEntity(), launch, new Vector(0, .1, 1), ammo, null); + Entity torpedo = r.spawned.getLast(); + r.tick(); + assertEquals(0, torpedo.getVelocity().getY(), 1e-9); + assertEquals(3.4, torpedo.getVelocity().getZ(), .003); + r.ticks(10); + verify(torpedo).remove(); + assertFalse(Cache.projectiles.contains(torpedo)); } + } + + @Test + void bulletShooterStopsAtRangeOrCollisionAndProducesInterpolatedTrails() throws Exception { + Bullet shortRange = + new Bullet("short", Rig.yaml("type: BULLET\nrange: 1\nspeed: 2\ngravity: 0\n")); + new ProjectileShooter() + .shoot( + List.of(), + vehicle.getEntity(), + new Location(r.world, 0, 64, 0), + new Vector(0, 0, 1), + shortRange, + null); + r.tick(); + r.ticks(3); + assertEquals(0, r.pending()); + r.block(0, 64, 2, Material.STONE); + new ProjectileShooter() + .shoot( + List.of(), + vehicle.getEntity(), + new Location(r.world, 0, 64, 0), + new Vector(0, 0, 1), + shortRange, + null); + r.tick(); + r.ticks(3); + assertEquals(0, r.pending()); + verify(r.world).playSound(any(Location.class), eq(Sound.BLOCK_STONE_BREAK), eq(1f), eq(2f)); + } - @Test void bulletShooterStopsAtRangeOrCollisionAndProducesInterpolatedTrails()throws Exception { - Bullet shortRange=new Bullet("short",Rig.yaml("type: BULLET\nrange: 1\nspeed: 2\ngravity: 0\n"));new ProjectileShooter().shoot(List.of(),vehicle.getEntity(),new Location(r.world,0,64,0),new Vector(0,0,1),shortRange,null);r.tick();r.ticks(3);assertEquals(0,r.pending());r.block(0,64,2,Material.STONE);new ProjectileShooter().shoot(List.of(),vehicle.getEntity(),new Location(r.world,0,64,0),new Vector(0,0,1),shortRange,null);r.tick();r.ticks(3);assertEquals(0,r.pending());verify(r.world).playSound(any(Location.class),eq(Sound.BLOCK_STONE_BREAK),eq(1f),eq(2f)); + private ActiveWeapon active(String yaml) throws Exception { + return new ActiveWeapon(model, vehicle, new Weapon("test", Rig.yaml(yaml)), null); + } + + private Ammunition ammo(String type, String yaml) throws Exception { + YamlConfiguration config = Rig.yaml("type: " + type + "\n" + yaml); + if (!config.contains("model")) { + config.set("model.type", "item"); + config.set("model.material", "STONE"); } + return Ammunition.create("round", config); + } + + private ItemStack item(Material material, int amount) { + ItemStack stack = mock(ItemStack.class); + AtomicInteger count = new AtomicInteger(amount); + when(stack.getType()).thenReturn(material); + when(stack.getAmount()).thenAnswer(c -> count.get()); + doAnswer( + c -> { + count.set(c.getArgument(0)); + return null; + }) + .when(stack) + .setAmount(anyInt()); + return stack; + } - private ActiveWeapon active(String yaml)throws Exception{return new ActiveWeapon(model,vehicle,new Weapon("test",Rig.yaml(yaml)),null);} - private Ammunition ammo(String type,String yaml)throws Exception{YamlConfiguration config=Rig.yaml("type: "+type+"\n"+yaml);if(!config.contains("model")){config.set("model.type","item");config.set("model.material","STONE");}return Ammunition.create("round",config);} - private ItemStack item(Material material,int amount){ItemStack stack=mock(ItemStack.class);AtomicInteger count=new AtomicInteger(amount);when(stack.getType()).thenReturn(material);when(stack.getAmount()).thenAnswer(c->count.get());doAnswer(c->{count.set(c.getArgument(0));return null;}).when(stack).setAmount(anyInt());return stack;} - private ModelBone bone(String id,Location location){ModelBone b=mock(ModelBone.class,RETURNS_DEEP_STUBS);when(b.getLocalTransform().getSafeLeftQuaternion()).thenReturn(new org.joml.Quaternionf());when(b.getLocalTransform().getSafePosition()).thenReturn(new org.joml.Vector3f());when(b.getLocalTransform().getSafeScale()).thenReturn(new org.joml.Vector3f(1));when(b.getBoneId()).thenReturn(id);when(b.getLocation()).thenAnswer(c->location.clone());when(b.getActiveModel()).thenReturn(model);bones.put(id,b);return b;} + private ModelBone bone(String id, Location location) { + ModelBone b = mock(ModelBone.class, RETURNS_DEEP_STUBS); + when(b.getLocalTransform().getSafeLeftQuaternion()).thenReturn(new org.joml.Quaternionf()); + when(b.getLocalTransform().getSafePosition()).thenReturn(new org.joml.Vector3f()); + when(b.getLocalTransform().getSafeScale()).thenReturn(new org.joml.Vector3f(1)); + when(b.getBoneId()).thenReturn(id); + when(b.getLocation()).thenAnswer(c -> location.clone()); + when(b.getActiveModel()).thenReturn(model); + bones.put(id, b); + return b; + } } From cd3676e2135f62ce3e72a5f234f9558c61636275 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:32:22 +0000 Subject: [PATCH 3/5] fix: load valid definitions alongside broken startup files --- .../loaders/AmmunitionLoader.java | 110 +++++++++-------- .../vehicleframework/loaders/FuelLoader.java | 108 ++++++++--------- .../loaders/VehicleLoader.java | 112 ++++++++++-------- .../VehicleFrameworkCoverageTest.java | 42 +++++++ 4 files changed, 215 insertions(+), 157 deletions(-) diff --git a/src/main/java/net/tfminecraft/vehicleframework/loaders/AmmunitionLoader.java b/src/main/java/net/tfminecraft/vehicleframework/loaders/AmmunitionLoader.java index d6810ed..061b50b 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/loaders/AmmunitionLoader.java +++ b/src/main/java/net/tfminecraft/vehicleframework/loaders/AmmunitionLoader.java @@ -13,61 +13,69 @@ public class AmmunitionLoader implements LoaderInterface { - public static HashMap map = new HashMap<>(); + public static HashMap map = new HashMap<>(); - public static HashMap get() { - return map; - } + public static HashMap get() { + return map; + } - @Override - public void load(File configFile) { - try { - map.putAll(read(configFile)); - } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { - VFLogger.log("Failed to load ammunition from " + configFile + ": " + e.getMessage()); - } - } + @Override + public void load(File configFile) { + try { + map.putAll(read(configFile)); + } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { + VFLogger.log("Failed to load ammunition from " + configFile + ": " + e.getMessage()); + } + } - public void reload(File folder) { - try { - File[] files = folder.listFiles(File::isFile); - if (files == null) throw new IOException("Cannot list " + folder); - Map replacement = new HashMap<>(); - for (File file : files) replacement.putAll(read(file)); - map.clear(); - map.putAll(replacement); - } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { - VFLogger.log( - "Failed to reload ammunition from " - + folder - + "; keeping existing definitions: " - + e.getMessage()); - } - } + public void reload(File folder) { + try { + File[] files = folder.listFiles(File::isFile); + if (files == null) throw new IOException("Cannot list " + folder); + Map replacement = new HashMap<>(); + for (File file : files) { + try { + replacement.putAll(read(file)); + } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { + VFLogger.log("Failed to load ammunition from " + file + ": " + e.getMessage()); + // Keep a working registry intact; with none yet, recover the valid sibling files. + if (!map.isEmpty()) return; + } + } + map.clear(); + map.putAll(replacement); + } catch (IOException e) { + VFLogger.log( + "Failed to reload ammunition from " + + folder + + "; keeping existing definitions: " + + e.getMessage()); + } + } - private Map read(File configFile) - throws IOException, InvalidConfigurationException { - FileConfiguration config = new YamlConfiguration(); - config.load(configFile); - Map loaded = new HashMap<>(); - for (String key : config.getKeys(false)) { - if (!config.isConfigurationSection(key)) { - throw new IllegalArgumentException("Ammunition '" + key + "' must be a section"); - } - loaded.put(key, Ammunition.create(key, config.getConfigurationSection(key))); - } - return loaded; - } + private Map read(File configFile) + throws IOException, InvalidConfigurationException { + FileConfiguration config = new YamlConfiguration(); + config.load(configFile); + Map loaded = new HashMap<>(); + for (String key : config.getKeys(false)) { + if (!config.isConfigurationSection(key)) { + throw new IllegalArgumentException("Ammunition '" + key + "' must be a section"); + } + loaded.put(key, Ammunition.create(key, config.getConfigurationSection(key))); + } + return loaded; + } - public static Ammunition getByString(String id) { - if (map.containsKey(id)) return map.get(id); - return null; - } + public static Ammunition getByString(String id) { + if (map.containsKey(id)) return map.get(id); + return null; + } - public static Ammunition getByInput(String s) { - for (Map.Entry entry : map.entrySet()) { - if (entry.getValue().getData().getInput().equalsIgnoreCase(s)) return entry.getValue(); - } - return null; - } + public static Ammunition getByInput(String s) { + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue().getData().getInput().equalsIgnoreCase(s)) return entry.getValue(); + } + return null; + } } diff --git a/src/main/java/net/tfminecraft/vehicleframework/loaders/FuelLoader.java b/src/main/java/net/tfminecraft/vehicleframework/loaders/FuelLoader.java index f439541..8a86ee2 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/loaders/FuelLoader.java +++ b/src/main/java/net/tfminecraft/vehicleframework/loaders/FuelLoader.java @@ -12,65 +12,65 @@ import org.bukkit.configuration.file.YamlConfiguration; public class FuelLoader implements LoaderInterface { - public static HashMap map = new HashMap<>(); + public static HashMap map = new HashMap<>(); - public static HashMap get() { - return map; - } + public static HashMap get() { + return map; + } - @Override - public void load(File configFile) { - try { - map.putAll(read(configFile)); - } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { - VFLogger.log("Failed to load fuel from " + configFile + ": " + e.getMessage()); - } - } + @Override + public void load(File configFile) { + try { + map.putAll(read(configFile)); + } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { + VFLogger.log("Failed to load fuel from " + configFile + ": " + e.getMessage()); + } + } - public void reload(File configFile) { - try { - Map replacement = read(configFile); - map.clear(); - map.putAll(replacement); - } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { - VFLogger.log( - "Failed to reload fuel from " - + configFile - + "; keeping existing definitions: " - + e.getMessage()); - } - } + public void reload(File configFile) { + try { + Map replacement = read(configFile); + map.clear(); + map.putAll(replacement); + } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { + VFLogger.log( + "Failed to reload fuel from " + + configFile + + "; keeping existing definitions: " + + e.getMessage()); + } + } - private Map read(File configFile) - throws IOException, InvalidConfigurationException { - FileConfiguration config = new YamlConfiguration(); - config.load(configFile); - Map loaded = new HashMap<>(); - for (String key : config.getKeys(false)) { - if (!config.isConfigurationSection(key)) { - throw new IllegalArgumentException("Fuel '" + key + "' must be a section"); - } - loaded.put(key, new Fuel(key, config.getConfigurationSection(key))); - } - return loaded; - } + private Map read(File configFile) + throws IOException, InvalidConfigurationException { + FileConfiguration config = new YamlConfiguration(); + config.load(configFile); + Map loaded = new HashMap<>(); + for (String key : config.getKeys(false)) { + if (!config.isConfigurationSection(key)) { + throw new IllegalArgumentException("Fuel '" + key + "' must be a section"); + } + loaded.put(key, new Fuel(key, config.getConfigurationSection(key))); + } + return loaded; + } - public static Fuel getByString(String id) { - if (map.containsKey(id)) return map.get(id); - return null; - } + public static Fuel getByString(String id) { + if (map.containsKey(id)) return map.get(id); + return null; + } - public static Fuel getByInput(String item) { - for (Map.Entry entry : map.entrySet()) { - if (entry.getValue().getItem().equalsIgnoreCase(item)) return entry.getValue(); - } - return null; - } + public static Fuel getByInput(String item) { + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue().getItem().equalsIgnoreCase(item)) return entry.getValue(); + } + return null; + } - public static boolean itemIsFuel(String item) { - for (Map.Entry entry : map.entrySet()) { - if (entry.getValue().getItem().equalsIgnoreCase(item)) return true; - } - return false; - } + public static boolean itemIsFuel(String item) { + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue().getItem().equalsIgnoreCase(item)) return true; + } + return false; + } } diff --git a/src/main/java/net/tfminecraft/vehicleframework/loaders/VehicleLoader.java b/src/main/java/net/tfminecraft/vehicleframework/loaders/VehicleLoader.java index a5a00fe..898a39b 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/loaders/VehicleLoader.java +++ b/src/main/java/net/tfminecraft/vehicleframework/loaders/VehicleLoader.java @@ -12,61 +12,69 @@ import org.bukkit.configuration.file.YamlConfiguration; public class VehicleLoader implements LoaderInterface { - public static HashMap map = new HashMap<>(); + public static HashMap map = new HashMap<>(); - public static HashMap get() { - return map; - } + public static HashMap get() { + return map; + } - @Override - public void load(File configFile) { - try { - map.putAll(read(configFile)); - } catch (IOException | InvalidConfigurationException e) { - VFLogger.log("Failed to load vehicles from " + configFile + ": " + e.getMessage()); - } - } + @Override + public void load(File configFile) { + try { + map.putAll(read(configFile)); + } catch (IOException | InvalidConfigurationException e) { + VFLogger.log("Failed to load vehicles from " + configFile + ": " + e.getMessage()); + } + } - public void reload(File folder) { - try { - File[] files = folder.listFiles(File::isFile); - if (files == null) throw new IOException("Cannot list " + folder); - Map replacement = new HashMap<>(); - for (File file : files) replacement.putAll(read(file)); - map.clear(); - map.putAll(replacement); - } catch (IOException | InvalidConfigurationException e) { - VFLogger.log( - "Failed to reload vehicles from " - + folder - + "; keeping existing definitions: " - + e.getMessage()); - } - } + public void reload(File folder) { + try { + File[] files = folder.listFiles(File::isFile); + if (files == null) throw new IOException("Cannot list " + folder); + Map replacement = new HashMap<>(); + for (File file : files) { + try { + replacement.putAll(read(file)); + } catch (IOException | InvalidConfigurationException e) { + VFLogger.log("Failed to load vehicles from " + file + ": " + e.getMessage()); + // Keep a working registry intact; with none yet, recover the valid sibling files. + if (!map.isEmpty()) return; + } + } + map.clear(); + map.putAll(replacement); + } catch (IOException e) { + VFLogger.log( + "Failed to reload vehicles from " + + folder + + "; keeping existing definitions: " + + e.getMessage()); + } + } - private Map read(File configFile) - throws IOException, InvalidConfigurationException { - FileConfiguration config = new YamlConfiguration(); - config.load(configFile); - Map loaded = new HashMap<>(); - for (String key : config.getKeys(false)) { - try { - if (!config.isConfigurationSection(key)) { - throw new IllegalArgumentException("Vehicle must be a section"); - } - loaded.put(key, new Vehicle(key, config.getConfigurationSection(key))); - } catch (RuntimeException e) { - VFLogger.log( - "Skipping vehicle " + key + " in " + configFile.getName() + ": " + e.getMessage()); - // A bad edit must not discard a definition already used by saved vehicles. - if (map.containsKey(key)) loaded.put(key, map.get(key)); - } - } - return loaded; - } + private Map read(File configFile) + throws IOException, InvalidConfigurationException { + FileConfiguration config = new YamlConfiguration(); + config.load(configFile); + Map loaded = new HashMap<>(); + for (String key : config.getKeys(false)) { + try { + if (!config.isConfigurationSection(key)) { + throw new IllegalArgumentException("Vehicle must be a section"); + } + loaded.put(key, new Vehicle(key, config.getConfigurationSection(key))); + } catch (RuntimeException e) { + VFLogger.log( + "Skipping vehicle " + key + " in " + configFile.getName() + ": " + e.getMessage()); + // A bad edit must not discard a definition already used by saved vehicles. + if (map.containsKey(key)) loaded.put(key, map.get(key)); + } + } + return loaded; + } - public static Vehicle getByString(String id) { - if (map.containsKey(id)) return map.get(id); - return null; - } + public static Vehicle getByString(String id) { + if (map.containsKey(id)) return map.get(id); + return null; + } } diff --git a/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java index 65cba3f..065b297 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java @@ -160,6 +160,48 @@ void malformedReloadRetainsTheLastWorkingDefinitions(String failedFile) throws E assertSame(previous, retained, "A failed load must not discard the last usable registry"); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({ + "vehicles, broken-yaml", + "vehicles, scalar-document", + "vehicles, scalar-definition", + "ammunition, broken-yaml", + "ammunition, scalar-document", + "ammunition, scalar-definition", + "ammunition, unknown-type" + }) + void coldStartupLoadsValidDefinitionsDespiteABrokenSiblingFile(String folder, String failure) throws Exception { + VehicleLoader.get().clear(); + AmmunitionLoader.get().clear(); + plugin.createFolders(); + plugin.createConfigs(); + Files.writeString(temp.resolve("plugin/vehicles/good.yml"), + "good_cart: {model: default, skins: {}, states: {}, components: {}, seats: []}\n"); + Files.writeString(temp.resolve("plugin/ammunition/good.yml"), + "good_shell: {input: custom.good_shell}\n"); + String invalid = switch (failure) { + case "broken-yaml" -> "bad: [unterminated\n"; + case "scalar-document" -> "not_a_mapping\n"; + case "scalar-definition" -> "bad: not_a_section\n"; + case "unknown-type" -> "bad: {type: unknown_ammunition}\n"; + default -> throw new AssertionError("Unexpected fixture: " + failure); + }; + Files.writeString(temp.resolve("plugin/" + folder + "/bad.yml"), invalid); + assertTrue(VehicleLoader.get().isEmpty()); + assertTrue(AmmunitionLoader.get().isEmpty()); + + assertDoesNotThrow(plugin::onEnable); + + assertAll("A cold startup must retain valid files and identify the rejected file", + () -> assertEquals(Set.of("good_cart"), VehicleLoader.get().keySet()), + () -> assertEquals(Set.of("good_shell"), AmmunitionLoader.get().keySet()), + () -> verify(log, atLeastOnce()).warning(contains("bad.yml")), + () -> assertNotNull(VehicleFramework.getVehicleRepository()), + () -> verify(plugins, times(6)).registerEvents(any(Listener.class), same(plugin)), + () -> verify(plugins, never()).disablePlugin(plugin), + () -> verify(log).info(contains("Setup complete!"))); + } + @Test void databaseOpenFailureDisablesThePluginBeforeRegisteringListeners() throws Exception { Files.createDirectory(temp.resolve("plugin")); Files.writeString(temp.resolve("plugin/data"), "not a directory"); plugin.onEnable(); assertNull(VehicleFramework.getVehicleRepository()); From 4f4917e73ad0c0ffc4a3f5b55ca25620420d5df7 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:42:27 +0000 Subject: [PATCH 4/5] fix: contain malformed ammunition configuration failures --- .../loaders/AmmunitionLoader.java | 4 +-- .../VehicleFrameworkCoverageTest.java | 8 +++++- .../ConfigurationLoadersCoverageTest.java | 28 +++++++++++++------ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/main/java/net/tfminecraft/vehicleframework/loaders/AmmunitionLoader.java b/src/main/java/net/tfminecraft/vehicleframework/loaders/AmmunitionLoader.java index 061b50b..57efa76 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/loaders/AmmunitionLoader.java +++ b/src/main/java/net/tfminecraft/vehicleframework/loaders/AmmunitionLoader.java @@ -23,7 +23,7 @@ public static HashMap get() { public void load(File configFile) { try { map.putAll(read(configFile)); - } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { + } catch (IOException | InvalidConfigurationException | RuntimeException e) { VFLogger.log("Failed to load ammunition from " + configFile + ": " + e.getMessage()); } } @@ -36,7 +36,7 @@ public void reload(File folder) { for (File file : files) { try { replacement.putAll(read(file)); - } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { + } catch (IOException | InvalidConfigurationException | RuntimeException e) { VFLogger.log("Failed to load ammunition from " + file + ": " + e.getMessage()); // Keep a working registry intact; with none yet, recover the valid sibling files. if (!map.isEmpty()) return; diff --git a/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java index 065b297..4b6847b 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java @@ -168,7 +168,10 @@ void malformedReloadRetainsTheLastWorkingDefinitions(String failedFile) throws E "ammunition, broken-yaml", "ammunition, scalar-document", "ammunition, scalar-definition", - "ammunition, unknown-type" + "ammunition, unknown-type", + "ammunition, missing-cluster", + "ammunition, item-without-material", + "ammunition, scalar-hit-sfx" }) void coldStartupLoadsValidDefinitionsDespiteABrokenSiblingFile(String folder, String failure) throws Exception { VehicleLoader.get().clear(); @@ -184,6 +187,9 @@ void coldStartupLoadsValidDefinitionsDespiteABrokenSiblingFile(String folder, St case "scalar-document" -> "not_a_mapping\n"; case "scalar-definition" -> "bad: not_a_section\n"; case "unknown-type" -> "bad: {type: unknown_ammunition}\n"; + case "missing-cluster" -> "bad: {type: cluster}\n"; + case "item-without-material" -> "bad: {model: {type: item}}\n"; + case "scalar-hit-sfx" -> "bad: {hit-sfx: scalar}\n"; default -> throw new AssertionError("Unexpected fixture: " + failure); }; Files.writeString(temp.resolve("plugin/" + folder + "/bad.yml"), invalid); diff --git a/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java index e862b1d..0ef18f3 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java @@ -720,21 +720,33 @@ void unreadableFuelFileKeepsItsPreviousDefinitions() throws Exception { } @ParameterizedTest - @ValueSource(strings = {"scalar", "unknown-type"}) + @ValueSource(strings = { + "scalar", "unknown-type", "missing-cluster", "item-without-material", "scalar-hit-sfx" + }) void invalidAmmunitionEntriesCannotPartiallyReplaceOrAddDefinitions(String kind) throws Exception { AmmunitionLoader loader = new AmmunitionLoader(); loader.load(registryFile("ammunition", "initial-ammunition.yml", "old")); Ammunition previous = AmmunitionLoader.getByString("old"); - String bad = kind.equals("scalar") ? "not_a_section" : "{type: unknown_ammunition}"; + String bad = switch (kind) { + case "scalar" -> "not_a_section"; + case "unknown-type" -> "{type: unknown_ammunition}"; + case "missing-cluster" -> "{type: cluster}"; + case "item-without-material" -> "{model: {type: item}}"; + case "scalar-hit-sfx" -> "{hit-sfx: scalar}"; + default -> throw new AssertionError("Unexpected fixture: " + kind); + }; File mixed = yaml("invalid-ammunition/definitions.yml", "new: {input: custom.new}\nbad: " + bad + "\n"); - assertDoesNotThrow(() -> loader.load(mixed)); - assertEquals(Set.of("old"), AmmunitionLoader.get().keySet()); - assertSame(previous, AmmunitionLoader.getByString("old")); - assertDoesNotThrow(() -> loader.reload(mixed.getParentFile())); - assertEquals(Set.of("old"), AmmunitionLoader.get().keySet()); - assertSame(previous, AmmunitionLoader.getByString("old")); + assertAll("Failed additive loads and reloads must preserve the working ammunition registry", + () -> assertDoesNotThrow(() -> loader.load(mixed)), + () -> assertEquals(Set.of("old"), AmmunitionLoader.get().keySet()), + () -> assertSame(previous, AmmunitionLoader.getByString("old")), + () -> assertDoesNotThrow(() -> loader.reload(mixed.getParentFile())), + () -> assertEquals(Set.of("old"), AmmunitionLoader.get().keySet()), + () -> assertSame(previous, AmmunitionLoader.getByString("old")), + () -> assertEquals("custom.old", previous.getData().getInput()), + () -> logger.verify(() -> VFLogger.log(contains("definitions.yml")), times(2))); } private File registryFile(String kind, String filename, String id) throws Exception { From 63255f0e21136b8923d5c51287291d3f4f139297 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:46:12 +0000 Subject: [PATCH 5/5] test: preserve error checks and stabilize cooldown assertions --- .../loaders/ConfigurationLoadersCoverageTest.java | 5 +++-- .../vehicleframework/weapons/WeaponsCoverageTest.java | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java index 0ef18f3..f103768 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java @@ -352,6 +352,9 @@ void templateFoldersFilterFilesPreserveFirstDefinitionAndSkipNonSections(String loadFolder.accept(null); loadFolder.accept(first); loadFolder.accept(temp.resolve("missing").toFile()); + load.accept(temp.resolve("missing.yml").toFile()); + load.accept(yaml("bad.yml", "key: [unterminated")); + assertEquals(Set.of("base", "second"), target.keySet()); Path inaccessible = Files.createDirectory(temp.resolve("unreadable")); Assumptions.assumeTrue( Files.getFileStore(inaccessible).supportsFileAttributeView("posix"), @@ -366,8 +369,6 @@ void templateFoldersFilterFilesPreserveFirstDefinitionAndSkipNonSections(String } finally { Files.setPosixFilePermissions(inaccessible, permissions); } - load.accept(temp.resolve("missing.yml").toFile()); - load.accept(yaml("bad.yml", "key: [unterminated")); assertEquals(Set.of("base", "second"), target.keySet()); } diff --git a/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java index f33debb..bfa2fae 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java @@ -584,13 +584,15 @@ void firingAlternatesMuzzlesConsumesRoundsAndRespectsCooldown() throws Exception assertEquals(0, handler.getCount()); verify(vehicle, times(3)).updateBoard(); verify(r.world, times(3)).playSound(any(Location.class), eq("test.shot"), eq(1f), eq(1f)); - ActiveWeapon cooling = active("fixed: true\nbones: [muzzle.aim]\ncooldown: 100\n"); + ActiveWeapon cooling = active("fixed: true\nbones: [muzzle.aim]\ncooldown: 12000\n"); cooling.getAmmunitionHandler().setAmmo(r.bullet(false), 2); + long beforeShot = System.currentTimeMillis(); cooling.getAmmunitionHandler().shoot(player, List.of()); long until = cooling.getAmmunitionHandler().getActiveCooldown(); cooling.getAmmunitionHandler().shoot(player, List.of()); assertEquals(1, cooling.getAmmunitionHandler().getCount()); - assertTrue(until > System.currentTimeMillis()); + assertTrue(until >= beforeShot + 600_000L); + assertEquals(until, cooling.getAmmunitionHandler().getActiveCooldown()); } @Test