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..f23048d
--- /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(),26823);
+ try { assertEquals(original,Files.readString(config)); }
+ finally { metrics.shutdown(); }
+ }
+
+ @Test void dependencyCreatesACompatibleConfigurationOnFirstStartup() {
+ 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"));
+ 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..4b6847b
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java
@@ -0,0 +1,270 @@
+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.bstats.bukkit.Metrics;
+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 {
+ 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());
+ 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());
+ }
+
+ @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");
+ }
+
+ @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",
+ "ammunition, missing-cluster",
+ "ammunition, item-without-material",
+ "ammunition, scalar-hit-sfx"
+ })
+ 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";
+ 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);
+ 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());
+ 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..f1d1831
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/bones/BoneRuntimeCoverageTest.java
@@ -0,0 +1,348 @@
+package net.tfminecraft.vehicleframework.bones;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+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();
+ }
+
+ @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\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 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..282ade8
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java
@@ -0,0 +1,513 @@
+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 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);
+ }
+
+ @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 < percentages.length; i++) {
+ health.setDamage(100 - percentages[i]);
+ assertEquals(percentages[i], health.getHealthPercentage());
+ assertEquals(
+ "§fHealth: " + colors[i] + percentages[i] + "%", health.getHealthPercentageString());
+ }
+ }
+
+ @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 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\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 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..a39c661
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/database/VehicleBackupCoverageTest.java
@@ -0,0 +1,297 @@
+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");
+ 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); }
+ }
+
+ 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..e79b8bd
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/effects/EffectsCoverageTest.java
@@ -0,0 +1,197 @@
+package net.tfminecraft.vehicleframework.effects;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+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 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;
+
+ @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..f103768
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java
@@ -0,0 +1,915 @@
+package net.tfminecraft.vehicleframework.loaders;
+
+import static org.junit.jupiter.api.Assertions.*;
+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;
+
+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]\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 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]
+ 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 = {"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");
+ }
+
+ 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())));
+
+ 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())));
+ }
+ }
+
+ @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());
+ 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"),
+ "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);
+ }
+ 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