entry : map.entrySet()) {
- valuesBuilder.appendField(entry.getKey(), new int[] {entry.getValue()});
- }
- return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build();
- }
- }
-
- /**
- * An extremely simple JSON builder.
- *
- * While this class is neither feature-rich nor the most performant one, it's sufficient enough
- * for its use-case.
- */
- public static class JsonObjectBuilder {
-
- private StringBuilder builder = new StringBuilder();
-
- private boolean hasAtLeastOneField = false;
-
- public JsonObjectBuilder() {
- builder.append("{");
- }
-
- /**
- * Appends a null field to the JSON.
- *
- * @param key The key of the field.
- * @return A reference to this object.
- */
- public JsonObjectBuilder appendNull(String key) {
- appendFieldUnescaped(key, "null");
- return this;
- }
-
- /**
- * Appends a string field to the JSON.
- *
- * @param key The key of the field.
- * @param value The value of the field.
- * @return A reference to this object.
- */
- public JsonObjectBuilder appendField(String key, String value) {
- if (value == null) {
- throw new IllegalArgumentException("JSON value must not be null");
- }
- appendFieldUnescaped(key, "\"" + escape(value) + "\"");
- return this;
- }
-
- /**
- * Appends an integer field to the JSON.
- *
- * @param key The key of the field.
- * @param value The value of the field.
- * @return A reference to this object.
- */
- public JsonObjectBuilder appendField(String key, int value) {
- appendFieldUnescaped(key, String.valueOf(value));
- return this;
- }
-
- /**
- * Appends an object to the JSON.
- *
- * @param key The key of the field.
- * @param object The object.
- * @return A reference to this object.
- */
- public JsonObjectBuilder appendField(String key, JsonObject object) {
- if (object == null) {
- throw new IllegalArgumentException("JSON object must not be null");
- }
- appendFieldUnescaped(key, object.toString());
- return this;
- }
-
- /**
- * Appends a string array to the JSON.
- *
- * @param key The key of the field.
- * @param values The string array.
- * @return A reference to this object.
- */
- public JsonObjectBuilder appendField(String key, String[] values) {
- if (values == null) {
- throw new IllegalArgumentException("JSON values must not be null");
- }
- String escapedValues =
- Arrays.stream(values)
- .map(value -> "\"" + escape(value) + "\"")
- .collect(Collectors.joining(","));
- appendFieldUnescaped(key, "[" + escapedValues + "]");
- return this;
- }
-
- /**
- * Appends an integer array to the JSON.
- *
- * @param key The key of the field.
- * @param values The integer array.
- * @return A reference to this object.
- */
- public JsonObjectBuilder appendField(String key, int[] values) {
- if (values == null) {
- throw new IllegalArgumentException("JSON values must not be null");
- }
- String escapedValues =
- Arrays.stream(values).mapToObj(String::valueOf).collect(Collectors.joining(","));
- appendFieldUnescaped(key, "[" + escapedValues + "]");
- return this;
- }
-
- /**
- * Appends an object array to the JSON.
- *
- * @param key The key of the field.
- * @param values The integer array.
- * @return A reference to this object.
- */
- public JsonObjectBuilder appendField(String key, JsonObject[] values) {
- if (values == null) {
- throw new IllegalArgumentException("JSON values must not be null");
- }
- String escapedValues =
- Arrays.stream(values).map(JsonObject::toString).collect(Collectors.joining(","));
- appendFieldUnescaped(key, "[" + escapedValues + "]");
- return this;
- }
-
- /**
- * Appends a field to the object.
- *
- * @param key The key of the field.
- * @param escapedValue The escaped value of the field.
- */
- private void appendFieldUnescaped(String key, String escapedValue) {
- if (builder == null) {
- throw new IllegalStateException("JSON has already been built");
- }
- if (key == null) {
- throw new IllegalArgumentException("JSON key must not be null");
- }
- if (hasAtLeastOneField) {
- builder.append(",");
- }
- builder.append("\"").append(escape(key)).append("\":").append(escapedValue);
- hasAtLeastOneField = true;
- }
-
- /**
- * Builds the JSON string and invalidates this builder.
- *
- * @return The built JSON string.
- */
- public JsonObject build() {
- if (builder == null) {
- throw new IllegalStateException("JSON has already been built");
- }
- JsonObject object = new JsonObject(builder.append("}").toString());
- builder = null;
- return object;
- }
-
- /**
- * Escapes the given string like stated in https://www.ietf.org/rfc/rfc4627.txt.
- *
- *
This method escapes only the necessary characters '"', '\'. and '\u0000' - '\u001F'.
- * Compact escapes are not used (e.g., '\n' is escaped as "\u000a" and not as "\n").
- *
- * @param value The value to escape.
- * @return The escaped value.
- */
- private static String escape(String value) {
- final StringBuilder builder = new StringBuilder();
- for (int i = 0; i < value.length(); i++) {
- char c = value.charAt(i);
- if (c == '"') {
- builder.append("\\\"");
- } else if (c == '\\') {
- builder.append("\\\\");
- } else if (c <= '\u000F') {
- builder.append("\\u000").append(Integer.toHexString(c));
- } else if (c <= '\u001F') {
- builder.append("\\u00").append(Integer.toHexString(c));
- } else {
- builder.append(c);
- }
- }
- return builder.toString();
- }
-
- /**
- * A super simple representation of a JSON object.
- *
- *
This class only exists to make methods of the {@link JsonObjectBuilder} type-safe and not
- * allow a raw string inputs for methods like {@link JsonObjectBuilder#appendField(String,
- * JsonObject)}.
- */
- public static class JsonObject {
-
- private final String value;
-
- private JsonObject(String value) {
- this.value = value;
- }
-
- @Override
- public String toString() {
- return value;
- }
- }
- }
-}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/ActiveVehicle.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/ActiveVehicle.java
index 83d8dff..37ef84c 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/ActiveVehicle.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/ActiveVehicle.java
@@ -368,7 +368,6 @@ private void loadIncomplete(IncompleteVehicle inc) {
initializeRotations(inc.getRotations());
initializeContainers(inc.getContainers());
name = inc.getName();
- uuid = inc.getUUID();
ownerData.setOwner(inc.getOwner());
ownerData.setWhiteListed(inc.isWhitelisted());
ownerData.setWhiteList(inc.getWhitelist());
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/Vehicle.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/Vehicle.java
index acd23b4..b8ebc4c 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/Vehicle.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/Vehicle.java
@@ -75,18 +75,20 @@ public class Vehicle {
public Vehicle(String key, ConfigurationSection config) {
id = key;
+ for (String required : List.of("skins", "states", "components")) {
+ if (!config.isConfigurationSection(required)) {
+ throw new IllegalArgumentException("Vehicle '" + key + "' requires a " + required + " section");
+ }
+ }
name = config.getString("name", key);
fixed = config.getBoolean("fixed", false);
towable = config.getBoolean("towable", false);
if(!config.contains("model")) VFLogger.log(key+" does not contain a model field");
model = config.getString("model");
- if(!config.isConfigurationSection("skins")) VFLogger.log(key+" has no skins section");
skinHandler = new SkinHandler(model, config.getConfigurationSection("skins"));
- if(!config.isConfigurationSection("states")) VFLogger.log(key+" has no states section");
stateHandler = new StateHandler(config.getConfigurationSection("states"));
- if(!config.isConfigurationSection("components")) VFLogger.log(key+" has no components section");
componentHandler = new ComponentHandler(config.getConfigurationSection("components"));
if(!config.contains("seats")) VFLogger.log(key+" has no seats section");
@@ -144,6 +146,9 @@ private void loadDeathData(ConfigurationSection config) {
ConfigurationSection sink = config.getConfigurationSection("sink");
deathData.add(new DeathData(VehicleDeath.SINK, sink));
}
+ if(config.isConfigurationSection("die")) {
+ deathData.add(new DeathData(VehicleDeath.DIE, config.getConfigurationSection("die")));
+ }
if(config.isConfigurationSection("crash")) {
ConfigurationSection crash = config.getConfigurationSection("crash");
deathData.add(new DeathData(VehicleDeath.CRASH, crash));
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/VehicleHealthDecay.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/VehicleHealthDecay.java
index f531dd5..cc8f422 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/VehicleHealthDecay.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/VehicleHealthDecay.java
@@ -36,7 +36,7 @@ public static double nextDamage(
}
double fraction = Math.max(0.0, fractionOfMax);
double minRemaining = Math.max(0.0, Math.min(1.0, minHealthFraction));
- double maxAllowedDamage = maxHealth * (1.0 - minRemaining);
+ double maxAllowedDamage = Math.max(currentDamage, maxHealth * (1.0 - minRemaining));
double next = currentDamage + maxHealth * fraction;
if (next > maxAllowedDamage) {
next = maxAllowedDamage;
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/Engine.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/Engine.java
index 012fc84..9db29c4 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/Engine.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/Engine.java
@@ -220,8 +220,6 @@ public void tick(List nearby) {
}
int current = throttle.getCurrent();
AccessPanel panel = v.getAccessPanel();
- panel.setSpeed(getSpeed());
- panel.setTurnRate(getTurnRate());
boolean reverse = false;
if(current < 0) {
current = current*-1;
@@ -234,7 +232,7 @@ public void tick(List nearby) {
}
throttle.setThrottle(current);
}
- panel.setReverse(reverse);
+ if(healthData.getHealthPercentage() < 1 && started) stop();
if (tapeThrottle == null) {
if (v == null || !v.isTrain() || v.hasParent() || !v.getTrainHandler().isRecording()) {
normalize();
@@ -243,6 +241,13 @@ public void tick(List nearby) {
v.getTrainHandler().maybeRecordSample(throttle.getCurrent());
}
}
+ if (tank.useFuel() && tank.getCurrent() == 0) {
+ if (started) stop();
+ throttle.setThrottle(0);
+ }
+ panel.setSpeed(getSpeed());
+ panel.setTurnRate(getTurnRate());
+ panel.setReverse(throttle.getCurrent() < 0);
if(throttle.getCurrent() == 0) {
return;
}
@@ -255,14 +260,6 @@ public void tick(List nearby) {
if (v != null && v.getEntity() != null && !bones.isEmpty()) {
PersistenceLog.particleOffset(v, bones.get(0).getBaseLocation(), v.getEntity().getLocation());
}
- if(healthData.getHealthPercentage() < 1 && started) stop();
- if(tank.getCurrent() == 0 && tank.useFuel()) {
- if(started) {
- stop();
- } else {
- throttle.setThrottle(0);
- }
- }
}
public void start(Player p) {
@@ -277,7 +274,7 @@ public void start(Player p) {
}
starting = true;
p.sendMessage("§e["+alias+"] starting...");
- if(Math.random()*100 > healthData.getHealthPercentage()) {
+ if(rollStartChance() > healthData.getHealthPercentage()) {
if(!v.hasEffect(CustomAction.ENGINE_START_FAIL)) defaultFail(p);
else {
CustomEffect effect = v.playEffect(CustomAction.ENGINE_START_FAIL);
@@ -307,6 +304,10 @@ public void run() {
}.runTaskTimer(VehicleFramework.plugin, 0L, 1L);
}
}
+ double rollStartChance() {
+ return Math.random() * 100;
+ }
+
private void defaultStart(Player p) {
p.sendMessage("§e["+alias+"] §astarted!");
started = true;
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/GearedEngine.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/GearedEngine.java
index 62cf503..989b95b 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/GearedEngine.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/GearedEngine.java
@@ -65,7 +65,9 @@ public GearedEngine(ConfigurationSection config) {
ConfigurationSection particleConfig = config.getConfigurationSection("particles");
particles = ParticleLoader.getParticlesFromConfig(particleConfig);
}
- if(!config.isConfigurationSection("gears")) VFLogger.log("Geared engine has no gears!");
+ if (!config.isConfigurationSection("gears") || config.getConfigurationSection("gears").getKeys(false).isEmpty()) {
+ throw new IllegalArgumentException("Geared engine requires at least one gear");
+ }
Set set = config.getConfigurationSection("gears").getKeys(false);
List list = new ArrayList(set);
@@ -74,6 +76,9 @@ public GearedEngine(ConfigurationSection config) {
gears.add(new Gear(this, config.getConfigurationSection("gears."+key)));
}
currentGear = config.getInt("start-gear", 0);
+ if (currentGear < 0 || currentGear >= gears.size()) {
+ throw new IllegalArgumentException("start-gear must refer to a configured gear");
+ }
defaultGear = currentGear;
boneList = (List) config.getList("particle-bones", new ArrayList());
tank = new FuelTank(config);
@@ -187,7 +192,6 @@ public void throttle(boolean down) {
}
private void shift(int dir) {
- if(shifting) return;
shifting = true;
new BukkitRunnable() {
int i = 0;
@@ -276,8 +280,6 @@ public void tick(List nearby) {
}
int current = g.getThrottle().getCurrent();
AccessPanel panel = v.getAccessPanel();
- panel.setSpeed(getSpeed());
- panel.setTurnRate(getTurnRate());
boolean reverse = false;
if(current < 0) {
current = current*-1;
@@ -290,7 +292,7 @@ public void tick(List nearby) {
}
getGear().getThrottle().setThrottle(current);
}
- panel.setReverse(reverse);
+ if(healthData.getHealthPercentage() < 1 && started) stop();
if (tapeThrottle == null) {
if (v == null || !v.isTrain() || v.hasParent() || !v.getTrainHandler().isRecording()) {
normalize(g);
@@ -299,6 +301,13 @@ public void tick(List nearby) {
v.getTrainHandler().maybeRecordSample(g.getThrottle().getCurrent());
}
}
+ if (tank.useFuel() && tank.getCurrent() == 0) {
+ if (started) stop();
+ g.getThrottle().setThrottle(0);
+ }
+ panel.setSpeed(getSpeed());
+ panel.setTurnRate(getTurnRate());
+ panel.setReverse(g.getThrottle().getCurrent() < 0);
if(g.getThrottle().getCurrent() == 0) {
return;
}
@@ -311,16 +320,12 @@ public void tick(List nearby) {
if (v != null && v.getEntity() != null && !bones.isEmpty()) {
PersistenceLog.particleOffset(v, bones.get(0).getBaseLocation(), v.getEntity().getLocation());
}
- if(healthData.getHealthPercentage() < 1 && started) stop();
- if(tank.getCurrent() == 0 && tank.useFuel()) {
- if(started) {
- stop();
- } else {
- getGear().getThrottle().setThrottle(0);
- }
- }
}
+ double rollStartChance() {
+ return Math.random() * 100;
+ }
+
public void start(Player p) {
if(starting) return;
if(cooldown.containsKey(p)) {
@@ -334,7 +339,7 @@ public void start(Player p) {
}
starting = true;
p.sendMessage("§e["+alias+"] starting...");
- if(Math.random()*100 > healthData.getHealthPercentage()) {
+ if(rollStartChance() > healthData.getHealthPercentage()) {
if(!v.hasEffect(CustomAction.ENGINE_START_FAIL)) defaultFail(p);
else {
CustomEffect effect = v.playEffect(CustomAction.ENGINE_START_FAIL);
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/SinkableHull.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/SinkableHull.java
index c02abac..55ff126 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/SinkableHull.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/SinkableHull.java
@@ -77,7 +77,7 @@ public void slowTick(List nearby) {
sinking = false;
if(sinkProgress > 0) {
if(hasPump()) {
- sinkProgress -= pump.getPower()/10;
+ sinkProgress = Math.max(0, sinkProgress - pump.getPower()/10);
} else {
sinkProgress -= 1;
}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/propulsion/Throttle.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/propulsion/Throttle.java
index b35edbe..1156efc 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/propulsion/Throttle.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/component/propulsion/Throttle.java
@@ -14,7 +14,7 @@ public class Throttle {
public Throttle(String name, int x, int n, VehicleComponent en) {
if(name != null) this.name = name;
- else name = "Throttle";
+ else this.name = "Throttle";
max = x;
min = n;
current = 0;
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/FloatController.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/FloatController.java
index 263a05e..60f0de1 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/FloatController.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/FloatController.java
@@ -105,10 +105,6 @@ private double calculateUpriverLift(ActiveVehicle v) {
}
Entity entity = v.getEntity();
- if (entity == null || !entity.isValid()) {
- return 0;
- }
-
Location loc = entity.getLocation();
Vector forward = loc.getDirection().clone();
forward.setY(0);
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/GroundEngineLog.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/GroundEngineLog.java
index a8b8b48..903223d 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/GroundEngineLog.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/GroundEngineLog.java
@@ -43,15 +43,12 @@ public static void configure(boolean loggingEnabled, boolean wipeLog, File dataF
if (dataFolder != null) {
logFile = dataFolder.toPath().resolve(LOG_DIRECTORY).resolve(LOG_FILE_NAME);
}
- if (wipeLog) {
+ if (wipeLog && logFile != null) {
wipeLogFile();
}
}
private static void wipeLogFile() {
- if (logFile == null) {
- return;
- }
synchronized (LOCK) {
try {
Files.deleteIfExists(logFile);
@@ -81,15 +78,11 @@ public static String formatGearedEngine(GearedEngine engine) {
return "";
}
Gear gear = engine.getGear();
- Throttle throttle = gear == null ? null : gear.getThrottle();
- String thr = formatThrottle(throttle);
- if (thr.isEmpty()) {
- return "";
- }
+ String thr = formatThrottle(gear.getThrottle());
return String.format(
"gear=%d gearName=%s %s shifting=%s started=%s",
engine.getCurrentGear(),
- gear == null ? "null" : gear.getName(),
+ gear.getName(),
thr,
engine.isShifting(),
engine.isStarted());
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowEngine.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowEngine.java
index 92ce2fe..073be86 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowEngine.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowEngine.java
@@ -347,17 +347,23 @@ private static Result airborneStep(
rt.vz = tick.vz;
double dx = rt.vx;
double dz = rt.vz;
- double destY = current.getY() + rt.vy;
- boolean yBlocked = destinationBlocked(entity, current, locAt(current, current.getX(), destY, current.getZ()));
+ double verticalDistance = Math.abs(rt.vy);
+ double verticalDirection = Math.signum(rt.vy);
+ double clearVertical = TerrainFollowMath.farthestUnblocked(
+ verticalDistance,
+ TerrainFollowMath.SLIDE_STEP,
+ d -> destinationBlocked(entity, current,
+ locAt(current, current.getX(), current.getY() + verticalDirection * d, current.getZ())));
+ double destY = current.getY() + verticalDirection * clearVertical;
+ boolean yBlocked = clearVertical + 1e-6 < verticalDistance;
if (yBlocked) {
- destY = current.getY();
rt.vy = 0;
}
final double slideY = destY;
double dist = Math.hypot(dx, dz);
double ox = 0;
double oz = 0;
- boolean aabbBlocked = false;
+ boolean aabbBlocked = yBlocked;
if (dist > 1e-9) {
double far = TerrainFollowMath.farthestUnblocked(
dist,
@@ -368,7 +374,7 @@ private static Result airborneStep(
locAt(current, current.getX() + dx * d / dist, slideY, current.getZ() + dz * d / dist)));
ox = dx * far / dist;
oz = dz * far / dist;
- aabbBlocked = far + 1e-6 < dist;
+ aabbBlocked |= far + 1e-6 < dist;
rt.vx = ox;
rt.vz = oz;
}
@@ -500,9 +506,6 @@ private static void spawnTerrainDebug(ActiveVehicle v, List starts, Li
}
private static void spawnEndRod(List nearby, Location loc) {
- if (loc == null || loc.getWorld() == null) {
- return;
- }
for (Player p : nearby) {
if (p == null || !p.isOnline() || p.getWorld() == null) {
continue;
@@ -515,9 +518,6 @@ private static void spawnEndRod(List nearby, Location loc) {
}
private static RayTraceResult rayDown(Location start) {
- if (start == null || start.getWorld() == null) {
- return null;
- }
RayTraceResult down = start.getWorld().rayTraceBlocks(
start,
new Vector(0, -1, 0),
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowMath.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowMath.java
index 38f8044..d486b32 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowMath.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/TerrainFollowMath.java
@@ -64,16 +64,13 @@ public static Vector headingFromYaw(float yawDeg) {
}
/**
- * Farthest distance in [0, maxDist] that is not blocked, sampled every {@code step}.
- * Full {@code maxDist} is used when it is clear. {@code blockedAtDist} is true when that distance clips.
+ * Farthest distance in [0, maxDist] with a clear path, sampled every {@code step}.
+ * Full {@code maxDist} is used only when all samples are clear. {@code blockedAtDist} is true when that distance clips.
*/
public static double farthestUnblocked(double maxDist, double step, DoublePredicate blockedAtDist) {
if (maxDist <= 1e-9 || blockedAtDist == null) {
return 0;
}
- if (!blockedAtDist.test(maxDist)) {
- return maxDist;
- }
double lastClear = 0;
double increment = Math.max(1e-6, step);
for (double d = increment; d < maxDist - 1e-12; d += increment) {
@@ -82,7 +79,7 @@ public static double farthestUnblocked(double maxDist, double step, DoublePredic
}
lastClear = d;
}
- return lastClear;
+ return blockedAtDist.test(maxDist) ? lastClear : maxDist;
}
public static double approachY(double currentY, double targetY, double maxDelta) {
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleMovementController.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleMovementController.java
index ce31fa1..c3cb6a0 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleMovementController.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/controller/VehicleMovementController.java
@@ -270,9 +270,6 @@ private void down(Player p) {
}
private boolean applyTerrainFollow(Direction dir) {
- if (state.isBreakState()) {
- return false;
- }
if (state.isDefault()) {
return false;
}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/BehaviourHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/BehaviourHandler.java
index 052dd2a..96a9ed4 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/BehaviourHandler.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/BehaviourHandler.java
@@ -99,8 +99,8 @@ private void getFloat(ConfigurationSection config) {
if(!shouldFloat) return;
if(config.contains("float-in")) {
for(String s : config.getStringList("float-in")) {
- if(Material.valueOf(s.toUpperCase()) == null) continue;
- floatsIn.add(Material.valueOf(s.toUpperCase()));
+ Material material = Material.matchMaterial(s);
+ if (material != null) floatsIn.add(material);
}
} else {
floatsIn.add(Material.WATER);
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/ComponentHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/ComponentHandler.java
index a2c5ee1..59141b7 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/ComponentHandler.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/ComponentHandler.java
@@ -33,7 +33,7 @@ public ComponentHandler(ConfigurationSection config) {
List list = new ArrayList(set);
for(String c : list) {
- Component cType = Component.valueOf(c.toUpperCase());
+ Component cType = Component.valueOf(c.toUpperCase(java.util.Locale.ROOT));
ConfigurationSection compSection = config.getConfigurationSection(c);
switch (cType) {
case ENGINE:
@@ -81,13 +81,18 @@ public void fireSpread() {
if(c.isOnFire()) {
for(VehicleComponent o : getComponents()) {
if(o.isOnFire()) continue;
- if(Math.floor(Math.random()*100)>c.getFire().getProgress()/2) continue;
+ if(rollFireSpreadChance()>c.getFire().getProgress()/2) continue;
o.startFire();
}
}
}
}
+ double rollFireSpreadChance() {
+ return Math.floor(Math.random() * 100);
+ }
+
public void randomFire() {
+ if (components.isEmpty()) return;
int i = (int) Math.floor(Math.random()*components.size());
int safeguard = 0;
while(components.get(i).isOnFire() && safeguard < 10) {
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/StateHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/StateHandler.java
index 7dfb2f7..80ad007 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/StateHandler.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/StateHandler.java
@@ -120,7 +120,7 @@ public void tick() {
swapState(State.FLOATING, "water");
} else if (VehicleStateRules.shouldSwapToFlying(
flying != null && !flying.isDefault(),
- checkAllBlocks(blocksBelow, "air"))) {
+ allBlocksInAir(blocksBelow))) {
swapState(State.FLYING, "air");
} else {
swapState(State.GROUND, "ground");
@@ -160,22 +160,14 @@ private Set getBlocksBoundingBox(BoundingBox box, World world, double yOf
return blocks;
}
- private boolean checkAllBlocks(Set blocks, String type) {
- if(type.equalsIgnoreCase("air")) {
- for (Block block : blocks) {
- if(!LocationChecker.isInAir(block.getLocation())) return false;
- }
- } else if(type.equalsIgnoreCase("water")) {
- for (Block block : blocks) {
- if(!LocationChecker.isInWater(block.getLocation())) return false;
- }
+ private boolean allBlocksInAir(Set blocks) {
+ for (Block block : blocks) {
+ if (!LocationChecker.isInAir(block.getLocation())) return false;
}
-
- return true;
+ return true;
}
-
-
+
private void swapState(State s, String reason) {
if(!hasState(s)) return;
if(states.get(s) == state) return;
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java
index 7960ce4..0494779 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java
@@ -219,11 +219,15 @@ public boolean attach(Player p, ActiveVehicle target) {
p.sendMessage("§cTarget train already has a car attached");
return false;
}
+ ActiveVehicle loco = locoOf(target);
+ if (loco == v) {
+ p.sendMessage("§cA train cannot be attached to itself or one of its own cars");
+ return false;
+ }
target.getTrainHandler().setChild(v);
v.setParent(target);
p.sendMessage("§aConnected car to train");
p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f);
- ActiveVehicle loco = locoOf(target);
if (loco.getTrainHandler().isBound()) {
loco.getTrainHandler().placeLoadedCars();
} else {
@@ -350,17 +354,11 @@ public void holdJunction(TrackJunction.Side side) {
return;
}
TrackRegistry registry = VehicleFramework.getTrackRegistry();
- if (registry == null) {
- return;
- }
Map used = new LinkedHashMap<>();
List cars = planCars(used);
retainRoutes(cars, used);
int direction = controlDirection();
Position lead = leadingPosition(cars, direction);
- if (lead == null) {
- return;
- }
TrackSpline spline = registry.get(lead.splineId()).orElse(null);
TrackJunction next = null;
double nearest = Double.POSITIVE_INFINITY;
@@ -531,9 +529,7 @@ public void maybeRecordSample(int throttle) {
.map(branch -> branch.id.toString()).orElse(null);
ThrottleTape.AppendResult result = recordingTape.tryAppend(
s, travelSign, throttle, spline.getId().toString(), junction, orientation);
- int hold = recordingTape.getSamples().isEmpty()
- ? 0
- : recordingTape.getSamples().get(recordingTape.getSamples().size() - 1).holdTicks;
+ int hold = recordingTape.getSamples().get(recordingTape.getSamples().size() - 1).holdTicks;
RecorderLog.sample(v, result, throttle, hold);
if (result == ThrottleTape.AppendResult.CAPPED) {
finishRecordingCappedOrUnbound("Recording stopped: tape is full");
@@ -545,9 +541,6 @@ public void maybeRecordSample(int throttle) {
}
private boolean onRecordedCircuit(TrackRegistry registry, TrackSpline spline) {
- if (recordingTape == null || spline == null) {
- return false;
- }
if (recordingTape.matchesSpline(spline.getId())) {
return true;
}
@@ -1431,9 +1424,6 @@ private void applyPose(ActiveVehicle vehicle, TrackPose pose) {
}
private static int facingSign(Location loc, TrackPose pose) {
- if (loc == null || pose == null) {
- return 1;
- }
Vector look = loc.getDirection().clone();
look.setY(0);
if (look.lengthSquared() < 1e-8) {
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java
index cdb6c43..a4e0044 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java
@@ -299,7 +299,11 @@ public void loadFromJson(JsonObject json) {
items.add(null);
}
- if (!json.has("items")) return;
+ if (!json.has("items")) {
+ pushLive();
+ updateBoneVisibility();
+ return;
+ }
JsonArray itemsArray = json.getAsJsonArray("items");
for (JsonElement elem : itemsArray) {
@@ -332,12 +336,7 @@ private static int readSlot(JsonElement saved) {
private ItemStack readItem(int slot, JsonElement saved) {
Exception failure = null;
- List candidates = List.of();
- try {
- candidates = snbtCandidates(saved);
- } catch (RuntimeException e) {
- failure = e;
- }
+ List candidates = snbtCandidates(saved);
for (String snbt : candidates) {
try {
ItemStack stack = NBT.itemStackFromNBT(NBT.parseNBT(snbt));
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/AnimationHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/AnimationHandler.java
index 2329610..45ee400 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/AnimationHandler.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/AnimationHandler.java
@@ -23,7 +23,7 @@ public class AnimationHandler {
public AnimationHandler(ConfigurationSection config) {
for (Animation animation : Animation.values()) {
- String enumString = animation.name().toLowerCase();
+ String enumString = animation.name().toLowerCase(java.util.Locale.ROOT);
if (config.contains(enumString)) {
animations.put(animation, config.getStringList(enumString));
} else {
@@ -202,8 +202,8 @@ public void setSpeed(Animation a, double speed) {
public void stop(Animation a) {
for(String s : animations.get(a)) {
BlueprintAnimation anim = m.getBlueprint().getAnimations().get(s);
- if(anim == null) return;
- if(!m.getAnimationHandler().isPlayingAnimation(s)) return;
+ if(anim == null) continue;
+ if(!m.getAnimationHandler().isPlayingAnimation(s)) continue;
m.getAnimationHandler().stopAnimation(s);
}
}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/InputHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/InputHandler.java
index 2dc5fd8..241e457 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/InputHandler.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/state/InputHandler.java
@@ -12,17 +12,15 @@ public class InputHandler {
public InputHandler(ConfigurationSection config) {
for (Keybind keybind : Keybind.values()) {
- String enumString = keybind.name();
- if (config.contains(enumString)) {
- if(Input.valueOf(config.getString(enumString).toUpperCase()) != null) {
- keybinds.put(keybind, Input.valueOf(config.getString(enumString).toUpperCase()));
- }
- } else {
- keybinds.put(keybind, Input.NONE);
- }
- }
+ String enumString = keybind.name();
+ if (config.contains(enumString)) {
+ keybinds.put(keybind, Input.valueOf(config.getString(enumString).toUpperCase(java.util.Locale.ROOT)));
+ } else {
+ keybinds.put(keybind, Input.NONE);
+ }
+ }
}
-
+
public InputHandler() {
for (Keybind keybind : Keybind.values()) {
keybinds.put(keybind, Input.NONE);
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/ConsistRelinker.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/ConsistRelinker.java
index 76f90ac..fc5bb19 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/ConsistRelinker.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/ConsistRelinker.java
@@ -29,7 +29,7 @@ public static void tryLink(ActiveVehicle vehicle) {
if (child == null) {
PersistenceLog.append("TRY_LINK child-missing pending=" + childId + " uuid=" + vehicle.getUUID());
}
- if (child != null && child.isTrain() && !child.equals(vehicle)) {
+ if (child != null && child.isTrain() && canLink(vehicle, child)) {
train.setChild(child);
child.setParent(vehicle);
train.setPendingChild(null);
@@ -42,7 +42,7 @@ public static void tryLink(ActiveVehicle vehicle) {
if (parent == null) {
PersistenceLog.append("TRY_LINK parent-missing pending=" + parentId + " uuid=" + vehicle.getUUID());
}
- if (parent != null && parent.isTrain() && !parent.equals(vehicle)) {
+ if (parent != null && parent.isTrain() && canLink(parent, vehicle)) {
parent.getTrainHandler().setChild(vehicle);
vehicle.setParent(parent);
train.setPendingParent(null);
@@ -50,7 +50,9 @@ public static void tryLink(ActiveVehicle vehicle) {
}
}
ActiveVehicle loco = vehicle;
+ java.util.Set visited = new java.util.HashSet<>();
while (loco != null && loco.hasParent()) {
+ if (!visited.add(loco)) return;
loco = loco.getParent();
}
if (loco != null && loco.isTrain() && loco.getTrainHandler().isBound()) {
@@ -59,6 +61,16 @@ public static void tryLink(ActiveVehicle vehicle) {
PersistenceLog.tryLink("after", vehicle);
}
+ private static boolean canLink(ActiveVehicle parent, ActiveVehicle child) {
+ java.util.Set visited = new java.util.HashSet<>();
+ ActiveVehicle ancestor = parent;
+ while (ancestor != null) {
+ if (ancestor.equals(child) || !visited.add(ancestor)) return false;
+ ancestor = ancestor.hasParent() ? ancestor.getParent() : null;
+ }
+ return true;
+ }
+
public static void onRemove(ActiveVehicle vehicle, VehicleRemovePayload payload) {
if (vehicle == null || !vehicle.isTrain()) {
return;
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/DeckBody.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/DeckBody.java
index e1b53e1..e68b3f5 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/DeckBody.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/DeckBody.java
@@ -217,6 +217,7 @@ private void spawn(World world, Deck.Box box) {
display.getPersistentDataContainer().set(k, PersistentDataType.STRING, owner);
}
});
+ carriers.add(carrier);
Shulker shulker = world.spawn(at, Shulker.class, s -> {
s.setPersistent(false);
s.setRemoveWhenFarAway(false);
@@ -235,14 +236,13 @@ private void spawn(World world, Deck.Box box) {
s.getPersistentDataContainer().set(k, PersistentDataType.STRING, owner);
}
});
+ boxes.add(shulker);
if (!carrier.isValid() || !shulker.isValid()) {
- carrier.remove();
- shulker.remove();
throw new IllegalStateException("deck box spawn was cancelled");
}
- carrier.addPassenger(shulker);
- carriers.add(carrier);
- boxes.add(shulker);
+ if (!carrier.addPassenger(shulker)) {
+ throw new IllegalStateException("deck box mount was cancelled");
+ }
PARTS.put(carrier.getUniqueId(), v);
PARTS.put(shulker.getUniqueId(), v);
}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/seat/Seat.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/seat/Seat.java
index e4bf415..19a2b1f 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/seat/Seat.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/seat/Seat.java
@@ -27,17 +27,17 @@ public class Seat {
private String bone;
public Seat(String s, String id) {
- if(s.split("\\(").length < 2) VFLogger.log("invalid seat detected for vehicle "+id);
-
- String t = s.split("\\(")[0];
- String seat = s.split("\\(")[1].replace(")", "");
-
- if(SeatType.valueOf(t.toUpperCase()) == null) {
- type = SeatType.PASSENGER; //handles malinput in config
- } else {
- type = SeatType.valueOf(t.toUpperCase());
+ int open = s.indexOf('(');
+ if (open <= 0 || !s.endsWith(")") || open == s.length() - 2) {
+ throw new IllegalArgumentException("Invalid seat '" + s + "' for vehicle " + id);
}
- bone = seat;
+ String seatType = s.substring(0, open).trim();
+ try {
+ type = SeatType.valueOf(seatType.toUpperCase(java.util.Locale.ROOT));
+ } catch (IllegalArgumentException invalidType) {
+ type = SeatType.PASSENGER;
+ }
+ bone = s.substring(open + 1, s.length() - 1);
}
public Seat(SeatType t, String bone) {
@@ -60,6 +60,7 @@ public Entity getEntity() {
return e;
}
public void mount(ActiveVehicle v, ActiveVehicle parent) {
+ if (isOccupied()) return;
mount(v.getEntity());
mountedVehicle = v;
v.setParent(parent);
diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/util/VFX.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/util/VFX.java
index 408e203..e05e02b 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/util/VFX.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/util/VFX.java
@@ -33,9 +33,7 @@ public VFX(VFX another, ActiveModel m) {
}
public void updateModel(ActiveModel m) {
- for(ModelBone bone : modelBones) {
- bone = m.getBone(bone.getBoneId()).get();
- }
+ modelBones.replaceAll(bone -> m.getBone(bone.getBoneId()).orElse(bone));
}
public List getBoneList() {
diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/Weapon.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/Weapon.java
index 31d7c5b..a9e2422 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/weapons/Weapon.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/Weapon.java
@@ -72,7 +72,7 @@ public Weapon(String key, ConfigurationSection config) {
projectileDamage = config.getInt("projectile-damage");
}
if (config.contains("projectile-damage-type")) {
- projectileDamageType = config.getString("projectile-damage-type").toUpperCase();
+ projectileDamageType = config.getString("projectile-damage-type").toUpperCase(java.util.Locale.ROOT);
}
if (config.contains("projectile-speed")) {
projectileSpeed = config.getDouble("projectile-speed");
diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/WeaponAimOffset.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/WeaponAimOffset.java
index b5263ea..1895c88 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/weapons/WeaponAimOffset.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/WeaponAimOffset.java
@@ -38,7 +38,7 @@ public ConvertedAngle applyToAngles(ConvertedAngle desired, String headAxis) {
float yaw = ConvertedAngle.wrapDegrees(desired.getYaw() + bodyYaw);
float pitch = desired.getPitch();
float roll = desired.getRoll();
- String axis = headAxis == null ? "x" : headAxis.toLowerCase();
+ String axis = headAxis == null ? "x" : headAxis.toLowerCase(java.util.Locale.ROOT);
if (axis.equals("z")) {
pitch = ConvertedAngle.wrapDegrees(pitch + headRoll);
} else if (axis.equals("y")) {
diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/Ammunition.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/Ammunition.java
index 4ec7ae2..bde4ec1 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/Ammunition.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/Ammunition.java
@@ -14,23 +14,13 @@ public class Ammunition {
public static Ammunition create(String key, ConfigurationSection config) {
String t = config.getString("type", "CANNONBALL");
- if(Projectile.valueOf(t.toUpperCase()) == null) VFLogger.log(key+" ammunition has malformed type");
- Projectile type = Projectile.valueOf(t.toUpperCase());
- switch(type) {
- case BULLET:
- return new Bullet(key, config);
- case CANNONBALL:
- return new Ammunition(key, config);
- case CLUSTER:
- return new ClusterBomb(key, config);
- case TORPEDO:
- return new FusedExplosive(key, config);
- case BOMB:
- return new FusedExplosive(key, config);
- default:
- return new Ammunition(key, config);
-
- }
+ Projectile type = Projectile.valueOf(t.toUpperCase(java.util.Locale.ROOT));
+ return switch (type) {
+ case BULLET -> new Bullet(key, config);
+ case CANNONBALL -> new Ammunition(key, config);
+ case CLUSTER -> new ClusterBomb(key, config);
+ case TORPEDO, BOMB -> new FusedExplosive(key, config);
+ };
}
public Ammunition (String key, ConfigurationSection config) {
@@ -38,8 +28,7 @@ public Ammunition (String key, ConfigurationSection config) {
name = config.getString("name", key);
data = new AmmunitionData(config);
String t = config.getString("type", "CANNONBALL");
- if(Projectile.valueOf(t.toUpperCase()) == null) VFLogger.log(key+" ammunition has malformed type");
- type = Projectile.valueOf(t.toUpperCase());
+ type = Projectile.valueOf(t.toUpperCase(java.util.Locale.ROOT));
}
public Projectile getType() {
diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/AmmunitionData.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/AmmunitionData.java
index 62045df..128f9bc 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/AmmunitionData.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/AmmunitionData.java
@@ -53,7 +53,7 @@ public AmmunitionData(ConfigurationSection config) {
radius = config.getInt("radius", 5);
damage = config.getInt("damage", 8);
rounds = config.getInt("rounds", 1);
- damageType = config.getString("damage-type", "PROJECTILE").toUpperCase();
+ damageType = config.getString("damage-type", "PROJECTILE").toUpperCase(java.util.Locale.ROOT);
if(config.isConfigurationSection("sounds")) {
ConfigurationSection soundConfig = config.getConfigurationSection("sounds");
sfx = SoundLoader.getSoundsFromConfig(soundConfig);
@@ -256,7 +256,7 @@ private LingeringCloudData parseLingeringCloud(String input) {
int effectDurationSeconds = 5;
if (potionMatcher.find()) {
- String potionId = potionMatcher.group(1).trim().toUpperCase();
+ String potionId = potionMatcher.group(1).trim().toUpperCase(java.util.Locale.ROOT);
amplifier = Integer.parseInt(potionMatcher.group(2));
effectDurationSeconds = Integer.parseInt(potionMatcher.group(3));
diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/projectile/ItemModel.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/projectile/ItemModel.java
index c5f51bc..6c7a805 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/projectile/ItemModel.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/ammunition/data/projectile/ItemModel.java
@@ -16,7 +16,7 @@ public class ItemModel implements ProjectileModel{
private int model;
public ItemModel(ConfigurationSection config) {
- material = Material.valueOf(config.getString("material").toUpperCase());
+ material = Material.valueOf(config.getString("material").toUpperCase(java.util.Locale.ROOT));
small = config.getBoolean("small", false);
model = config.getInt("model-data", 0);
}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/controller/WeaponMovementController.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/controller/WeaponMovementController.java
index 00926ad..3dd011b 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/weapons/controller/WeaponMovementController.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/controller/WeaponMovementController.java
@@ -96,10 +96,6 @@ private void initiateBones(ActiveModel m, String body, String head, String axis,
} else {
headRotator = new BoneRotator(v, v.getEntity(), m.getBone(head).get(), limits);
}
- if(axis == null) {
- VFLogger.log("weapon has no axis");
- return;
- }
this.axis = axis;
}
@@ -206,9 +202,6 @@ public void trackCursor(Player player) {
}
Location target = WeaponTargetResolver.resolveTarget(player, v, cursorRange);
- if (target == null) {
- return;
- }
if (Cache.weaponAimDebug && player.getWorld() != null && target.getWorld() != null
&& player.getWorld().equals(target.getWorld())) {
@@ -229,9 +222,6 @@ public void trackCursor(Player player) {
ConvertedAngle desiredAngles = offset.applyToAngles(ConvertedAngle.fromDirection(desired), axis);
Vector current = aimVector.getVector();
- if (current == null || current.lengthSquared() < 1e-12) {
- return;
- }
float rate = effectiveTurnRate();
float yawErr = WeaponAimAligner.yawError(current, desiredAngles);
@@ -250,9 +240,6 @@ public void trackCursor(Player player) {
yawSettled = WeaponAimAligner.updateSettled(boneYawErr, yawSettled);
Vector afterBody = aimVector.getVector();
- if (afterBody == null || afterBody.lengthSquared() < 1e-12) {
- return;
- }
float boneElevErr = WeaponAimAligner.toBoneElevationStep(
WeaponAimAligner.elevationError(afterBody, desiredAngles, axis));
float headStep = WeaponAimAligner.followStep(boneElevErr, rate, elevationSettled);
@@ -332,7 +319,8 @@ private void applyHeadStep(float headStep) {
* instead of silently ignoring the input.
*/
private boolean canTurn(Player p) {
- if (fixed || effectiveTurnRate() > 0f) {
+ if (fixed || bodyRotator == null || headRotator == null || axis == null) return false;
+ if (effectiveTurnRate() > 0f) {
return true;
}
notifyBroken(p);
diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/handlers/AmmunitionHandler.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/handlers/AmmunitionHandler.java
index b76cd59..07de727 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/weapons/handlers/AmmunitionHandler.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/handlers/AmmunitionHandler.java
@@ -62,7 +62,7 @@ public AmmunitionHandler(ConfigurationSection config) {
if(config.contains("reload-states")) {
for(String s : config.getStringList("reload-states")) {
try {
- reloadStates.add(State.valueOf(s.toUpperCase()));
+ reloadStates.add(State.valueOf(s.toUpperCase(java.util.Locale.ROOT)));
} catch (IllegalArgumentException e) {
System.out.println("Invalid state in reload-states: " + s);
}
@@ -81,16 +81,18 @@ public AmmunitionHandler(ActiveModel m, ActiveVehicle vehicle, ActiveWeapon weap
reloadTime = -1;
baseReloadTime = another.getBaseReloadTime();
cooldown = another.getCooldown();
+ delay = another.delay;
activeCooldown = 0L;
currentBone = 0;
reloadStates = another.getReloadStates();
for(String bone : bones) {
- if(!bone.contains(".")) {
+ String[] parts = bone.split("\\.", -1);
+ if(parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) {
VFLogger.log(weapon.getId()+" has an invalid exit bone: "+bone);
continue;
}
- String base = bone.split("\\.")[0];
- String alignment = bone.split("\\.")[1];
+ String base = parts[0];
+ String alignment = parts[1];
if(m.getBone(base).isEmpty()) {
VFLogger.log(weapon.getId()+" has an invalid exit bone "+base);
continue;
@@ -177,6 +179,7 @@ private void reload(Player p, Ammunition a) {
}
public void reloadStart(Player p, Ammunition a) {
+ if (exitBones.isEmpty()) return;
reloadTime = WeaponPerformance.effectiveReloadSeconds(
baseReloadTime, w.getHealthData(), Cache.weaponDegradedReloadMultiplier);
playSound(exitBones.get(0), SoundArg.RELOAD_START);
@@ -199,6 +202,7 @@ public void run() {
}
public void load(Player p, ItemStack i, boolean message) {
+ if (exitBones.isEmpty()) return;
if(i.getType().equals(Material.AIR)) return;
@SuppressWarnings("deprecation")
ItemAPI api = TLibs.getItemAPI();
@@ -206,7 +210,7 @@ public void load(Player p, ItemStack i, boolean message) {
if(AmmunitionLoader.getByInput(input) == null) return;
Ammunition a = AmmunitionLoader.getByInput(input);
if(!reloadStates.contains(v.getCurrentState().getType())) {
- if(message) p.sendMessage("§cCannot reload in the "+v.getCurrentState().getType().toString().toLowerCase().replace("_", " ")+ " state");
+ if(message) p.sendMessage("§cCannot reload in the "+v.getCurrentState().getType().toString().toLowerCase(java.util.Locale.ROOT).replace("_", " ")+ " state");
return;
}
if(hasAmmo()) {
@@ -240,11 +244,12 @@ private void shootProjectiles(List nearby) {
w.getAnimationHandler().animate(Animation.SHOOT);
}
VectorBone bone = exitBones.get(currentBone);
+ Ammunition shotAmmo = ammo;
if(delay > 0) {
new BukkitRunnable() {
@Override
public void run() {
- projectileShooter.shoot(nearby, v.getEntity(), bone.getBaseLocation(), bone.getVector(), ammo, w);
+ projectileShooter.shoot(nearby, v.getEntity(), bone.getBaseLocation(), bone.getVector(), shotAmmo, w);
particle(bone);
playSound(bone, SoundArg.SHOOT);
}
@@ -265,6 +270,7 @@ private void decrease() {
v.updateBoard();
}
public void shoot(Player p, List nearby) {
+ if (exitBones.isEmpty()) return;
if(!hasAmmo()) {
p.sendMessage("§cNo ammo");
return;
diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/DefaultShooter.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/DefaultShooter.java
index 0a82cce..33662a6 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/DefaultShooter.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/DefaultShooter.java
@@ -66,7 +66,8 @@ public void run() {
e.remove();
projectiles.remove(e);
Cache.projectiles.remove(e);
- cancel();
+ cancel();
+ return;
}
// Add the offset for visual effects
@@ -125,6 +126,7 @@ public void run() {
projectiles.remove(armorStand);
Cache.projectiles.remove(armorStand);
cancel();
+ return;
}
a.getData().fx(players, armorStand.getLocation(), 1f, i);
i++;
diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/ProjectileShooter.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/ProjectileShooter.java
index d5004cd..a64f0b0 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/ProjectileShooter.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/ProjectileShooter.java
@@ -63,6 +63,10 @@ public void lightEffect(Location loc) {
Integer i = 0;
public void run()
{
+ if (b.getType() != Material.LIGHT) {
+ cancel();
+ return;
+ }
if(i == 1) {
level.setLevel(15);
b.setBlockData(level, true);
diff --git a/src/test/java/net/tfminecraft/vehicleframework/MetricsIntegrationTest.java b/src/test/java/net/tfminecraft/vehicleframework/MetricsIntegrationTest.java
new file mode 100644
index 0000000..0b0b732
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/MetricsIntegrationTest.java
@@ -0,0 +1,43 @@
+package net.tfminecraft.vehicleframework;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.logging.Logger;
+import org.bstats.bukkit.Metrics;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.plugin.Plugin;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class MetricsIntegrationTest {
+ @TempDir Path directory;
+
+ @Test void dependencyPreservesExistingOptOutAndServerIdentity() throws Exception {
+ Path config=directory.resolve("bStats/config.yml"); Files.createDirectories(config.getParent());
+ String original="enabled: false\nserverUuid: retained-server-id\nlogFailedRequests: true\n";
+ Files.writeString(config,original);
+ Metrics metrics=new Metrics(plugin(),23136);
+ try { assertEquals(original,Files.readString(config)); }
+ finally { metrics.shutdown(); }
+ }
+
+ @Test void dependencyCreatesACompatibleConfigurationOnFirstStartup() {
+ Metrics metrics=new Metrics(plugin(),23136);
+ try {
+ YamlConfiguration config=YamlConfiguration.loadConfiguration(directory.resolve("bStats/config.yml").toFile());
+ assertTrue(config.getBoolean("enabled")); assertNotNull(config.getString("serverUuid"));
+ assertFalse(config.getBoolean("logSentData")); assertFalse(config.getBoolean("logFailedRequests"));
+ } finally { metrics.shutdown(); }
+ }
+
+ private Plugin plugin() {
+ Plugin plugin=mock(Plugin.class);
+ when(plugin.getDataFolder()).thenReturn(directory.resolve("VehicleFramework").toFile());
+ when(plugin.getLogger()).thenReturn(Logger.getLogger("metrics-integration-test"));
+ when(plugin.isEnabled()).thenReturn(true);
+ return plugin;
+ }
+}
diff --git a/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java
new file mode 100644
index 0000000..6ae8cfc
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java
@@ -0,0 +1,192 @@
+package net.tfminecraft.vehicleframework;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.io.File;
+import java.nio.file.*;
+import java.util.*;
+import java.util.logging.Logger;
+import org.bukkit.*;
+import org.bukkit.command.PluginCommand;
+import org.bukkit.event.Listener;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.PluginDescriptionFile;
+import org.bukkit.plugin.PluginLoader;
+import org.bukkit.plugin.PluginManager;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.bukkit.scheduler.BukkitScheduler;
+import org.bukkit.scheduler.BukkitTask;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.*;
+import io.papermc.paper.plugin.configuration.PluginMeta;
+import io.papermc.paper.plugin.provider.classloader.ConfiguredPluginClassLoader;
+import io.papermc.paper.plugin.provider.classloader.PluginClassLoaderGroup;
+import net.bytebuddy.ByteBuddy;
+import net.bytebuddy.dynamic.loading.ClassLoadingStrategy;
+import com.comphenix.protocol.ProtocolLibrary;
+import com.comphenix.protocol.ProtocolManager;
+import net.tfminecraft.tlibs.TLibs;
+import net.tfminecraft.tlibs.objects.api.ItemAPI;
+import net.tfminecraft.tlibs.database.SqliteDatabase;
+import net.tfminecraft.coreprotect.CoreProtect;
+import net.tfminecraft.coreprotect.CoreProtectAPI;
+import net.tfminecraft.vehicleframework.cache.Cache;
+import net.tfminecraft.vehicleframework.loaders.*;
+
+class VehicleFrameworkCoverageTest {
+ @BeforeAll static void bootstrapRegistries() {
+ net.tfminecraft.vehicleframework.test.RegistryFixture.initialize();
+ }
+ @TempDir Path temp;
+ private final List scopes = new ArrayList<>();
+ private final Server server = mock(Server.class);
+ private final PluginManager plugins = mock(PluginManager.class);
+ private final BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ private final Logger log = mock(Logger.class);
+ private final ProtocolManager protocol = mock(ProtocolManager.class);
+ private VehicleFramework plugin;
+ private VehicleFramework previousPlugin;
+ private boolean previousCoreProtect;
+
+ @BeforeEach void setup() throws Exception {
+ previousPlugin = VehicleFramework.plugin;
+ previousCoreProtect = Cache.coreProtect; Cache.coreProtect = false;
+ MockedStatic bukkit = keep(mockStatic(Bukkit.class));
+ bukkit.when(Bukkit::getLogger).thenReturn(log); bukkit.when(Bukkit::getServer).thenReturn(server);
+ bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); bukkit.when(Bukkit::getScheduler).thenReturn(scheduler);
+ bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of());
+ bukkit.when(Bukkit::getVersion).thenReturn("Paper (MC: 1.21.10)");
+ bukkit.when(Bukkit::getBukkitVersion).thenReturn("1.21.10-R0.1-SNAPSHOT");
+ bukkit.when(Bukkit::getUnsafe).thenReturn(mock(UnsafeValues.class, RETURNS_DEEP_STUBS));
+ when(server.getPluginManager()).thenReturn(plugins); when(server.getLogger()).thenReturn(log);
+ when(server.getScheduler()).thenReturn(scheduler); when(server.getWorlds()).thenReturn(List.of());
+ when(server.getVersion()).thenReturn("Paper (MC: 1.21.10)");
+ when(server.getBukkitVersion()).thenReturn("1.21.10-R0.1-SNAPSHOT");
+ when(scheduler.runTaskTimer(any(Plugin.class), any(Runnable.class), anyLong(), anyLong())).thenReturn(mock(BukkitTask.class));
+ when(scheduler.runTaskLater(any(Plugin.class), any(Runnable.class), anyLong())).thenReturn(mock(BukkitTask.class));
+ keep(mockStatic(TLibs.class)).when(TLibs::getItemAPI).thenReturn(mock(ItemAPI.class, RETURNS_DEEP_STUBS));
+ keep(mockStatic(ProtocolLibrary.class)).when(ProtocolLibrary::getProtocolManager).thenReturn(protocol);
+ keep(mockStatic(net.kyori.adventure.util.Services.class, CALLS_REAL_METHODS))
+ .when(() -> net.kyori.adventure.util.Services.service(PluginLoader.class)).thenReturn(Optional.of(mock(PluginLoader.class)));
+ var description = new PluginDescriptionFile("VehicleFramework", "test", VehicleFramework.class.getName());
+ var loader = new TestPluginLoader(description);
+ // The platform fixture supplies JavaPlugin's required configured classloader; the production constructor runs.
+ plugin = new ByteBuddy().subclass(VehicleFramework.class).make()
+ .load(loader, ClassLoadingStrategy.Default.INJECTION).getLoaded().getConstructor().newInstance();
+ PluginCommand command = mock(PluginCommand.class); when(command.getPlugin()).thenReturn(plugin);
+ when(server.getPluginCommand("vf")).thenReturn(command);
+ Files.createDirectories(temp.resolve("bStats"));
+ Files.writeString(temp.resolve("bStats/config.yml"), "enabled: false\nserverUuid: lifecycle-test\n");
+ }
+
+ @AfterEach void cleanup() throws Exception {
+ if (plugin != null) plugin.onDisable();
+ VehicleFramework.plugin = previousPlugin;
+ Cache.coreProtect = previousCoreProtect;
+ VehicleLoader.get().clear(); AmmunitionLoader.get().clear(); FuelLoader.get().clear();
+ new WeaponTemplateLoader().clear(); new DeathTemplateLoader().clear(); new ArmorTemplateLoader().clear();
+ Collections.reverse(scopes); for (AutoCloseable scope : scopes) scope.close();
+ }
+
+ @Test void startupCreatesDefaultsRegistersSystemsAndClosesItsDatabase() throws Exception {
+ plugin.onEnable();
+ assertSame(plugin, VehicleFramework.getInstance()); assertNotNull(VehicleFramework.getVehicleRepository());
+ assertNotNull(VehicleFramework.getTrackRegistry()); assertNotNull(VehicleFramework.getTrackDisplayManager());
+ assertNotNull(VehicleFramework.getPacketListener()); assertNotNull(VehicleFramework.getLog());
+ assertTrue(Files.isRegularFile(temp.resolve("plugin/data/vehicles.db")));
+ assertTrue(Files.isRegularFile(temp.resolve("plugin/templates/weapons/gun_turret.yml")));
+ assertNotNull(FuelLoader.getByString("coal")); assertFalse(WeaponTemplateLoader.map.isEmpty());
+ verify(plugins, times(6)).registerEvents(any(Listener.class), same(plugin));
+ verify(protocol).addPacketListener(any()); verify(plugins, never()).disablePlugin(plugin);
+ plugin.onDisable(); assertNull(VehicleFramework.getVehicleRepository());
+ }
+
+ @Test void reloadRemovesDeletedFuelVehicleAndAmmunitionDefinitions() throws Exception {
+ plugin.createFolders(); plugin.createConfigs();
+ Path oldVehicle = temp.resolve("plugin/vehicles/old.yml"), oldAmmo = temp.resolve("plugin/ammunition/old.yml");
+ Files.writeString(oldVehicle, "old_cart:\n model: default\n skins: {}\n states: {}\n components: {}\n seats: []\n");
+ Files.writeString(oldAmmo, "old_shell: {}\n"); Files.writeString(temp.resolve("plugin/fuel.yml"), "old_fuel: {}\n");
+ plugin.onEnable();
+ assertNotNull(VehicleLoader.getByString("old_cart")); assertNotNull(AmmunitionLoader.getByString("old_shell"));
+ assertNotNull(FuelLoader.getByString("old_fuel"));
+ Files.delete(oldVehicle); Files.delete(oldAmmo); Files.writeString(temp.resolve("plugin/fuel.yml"), "{}\n");
+ plugin.reload();
+ assertAll(() -> assertNull(VehicleLoader.getByString("old_cart")),
+ () -> assertNull(AmmunitionLoader.getByString("old_shell")), () -> assertNull(FuelLoader.getByString("old_fuel")));
+ }
+
+ @Test void defaultFilesAreCreatedOnceAndUserEditsArePreserved() throws Exception {
+ plugin.createFolders(); plugin.createConfigs();
+ for (String folder : List.of("data/tracks", "data/backups", "vehicles", "ammunition", "templates/weapons", "templates/armor", "templates/roles", "templates/death")) {
+ assertTrue(Files.isDirectory(temp.resolve("plugin/" + folder)));
+ }
+ Path config = temp.resolve("plugin/config.yml"); Files.writeString(config, "skin-item: user.changed\n");
+ plugin.createFolders(); plugin.createConfigs(); assertEquals("skin-item: user.changed\n", Files.readString(config));
+ Files.createDirectory(temp.resolve("plugin/vehicles/nested")); Files.createDirectory(temp.resolve("plugin/ammunition/nested"));
+ plugin.loadConfigs(); assertEquals("user.changed", Cache.skinItem);
+ assertTrue(VehicleLoader.get().isEmpty()); assertTrue(AmmunitionLoader.get().isEmpty());
+ }
+
+ @Test void databaseOpenFailureDisablesThePluginBeforeRegisteringListeners() throws Exception {
+ Files.createDirectory(temp.resolve("plugin")); Files.writeString(temp.resolve("plugin/data"), "not a directory");
+ plugin.onEnable(); assertNull(VehicleFramework.getVehicleRepository());
+ verify(plugins).disablePlugin(plugin); verify(plugins, never()).registerEvents(any(), any());
+ verify(log).warning(contains("Failed to open SQLite vehicles.db"));
+ }
+
+ @Test void databaseCloseFailureStillReleasesTheRepositoryReference() throws Exception {
+ SqliteDatabase actual = new SqliteDatabase(temp.resolve("connection-fault.db").toFile());
+ try {
+ MockedConstruction database = keep(mockConstruction(SqliteDatabase.class,
+ withSettings().defaultAnswer(AdditionalAnswers.delegatesTo(actual)), (db, context) ->
+ doThrow(new IllegalStateException("connection close failed")).when(db).close()));
+ plugin.onEnable(); assertNotNull(VehicleFramework.getVehicleRepository());
+ assertDoesNotThrow(plugin::onDisable); assertNull(VehicleFramework.getVehicleRepository());
+ verify(database.constructed().get(0)).close();
+ verify(log).warning(contains("Failed to close SQLite vehicles.db: connection close failed"));
+ } finally { actual.close(); }
+ }
+
+ @Test void integrationDetectionRegistersMythicMobsAndOnlyEnablesCoreProtectForItsActivePlugin() {
+ VehicleFramework.plugin = plugin;
+ plugin.setPlugins(); assertFalse(Cache.coreProtect);
+ Plugin unrelated = mock(Plugin.class); when(unrelated.isEnabled()).thenReturn(true);
+ when(plugins.getPlugin("CoreProtect")).thenReturn(unrelated); plugin.setPlugins(); assertFalse(Cache.coreProtect);
+ CoreProtect core = mock(CoreProtect.class); when(plugins.getPlugin("CoreProtect")).thenReturn(core);
+ plugin.setPlugins(); assertFalse(Cache.coreProtect);
+ when(core.isEnabled()).thenReturn(true); when(plugins.isPluginEnabled("MythicMobs")).thenReturn(true);
+ plugin.setPlugins(); assertTrue(Cache.coreProtect);
+ verify(plugins).registerEvents(isA(net.tfminecraft.vehicleframework.util.MythicMobsIntegration.class), same(plugin));
+ }
+
+ @Test void coreProtectApiRejectsMissingWrongDisabledAndOutdatedImplementations() {
+ VehicleFramework.plugin = plugin; assertNull(VehicleFramework.getCoreProtect());
+ when(plugins.getPlugin("CoreProtect")).thenReturn(mock(Plugin.class)); assertNull(VehicleFramework.getCoreProtect());
+ CoreProtect core = mock(CoreProtect.class); CoreProtectAPI api = mock(CoreProtectAPI.class);
+ when(plugins.getPlugin("CoreProtect")).thenReturn(core); when(core.getAPI()).thenReturn(api);
+ assertNull(VehicleFramework.getCoreProtect()); when(api.isEnabled()).thenReturn(true); when(api.APIVersion()).thenReturn(9);
+ assertNull(VehicleFramework.getCoreProtect()); when(api.APIVersion()).thenReturn(10); assertSame(api, VehicleFramework.getCoreProtect());
+ }
+
+ private T keep(T scope) { scopes.add(scope); return scope; }
+
+ private final class TestPluginLoader extends ClassLoader implements ConfiguredPluginClassLoader {
+ private final PluginDescriptionFile description;
+ private JavaPlugin loaded;
+ TestPluginLoader(PluginDescriptionFile description) { super(VehicleFramework.class.getClassLoader()); this.description = description; }
+ @Override public PluginMeta getConfiguration() { return description; }
+ @Override public Class> loadClass(String name, boolean resolve, boolean global, boolean libraries) throws ClassNotFoundException {
+ return super.loadClass(name, resolve);
+ }
+ @Override public void init(JavaPlugin value) {
+ loaded = value;
+ value.init(server, description, temp.resolve("plugin").toFile(), temp.resolve("plugin.jar").toFile(), this, description, log);
+ }
+ @Override public JavaPlugin getPlugin() { return loaded; }
+ @Override public PluginClassLoaderGroup getGroup() { return null; }
+ @Override public void close() {}
+ }
+}
diff --git a/src/test/java/net/tfminecraft/vehicleframework/bones/BoneRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/bones/BoneRuntimeCoverageTest.java
new file mode 100644
index 0000000..f5ee995
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/bones/BoneRuntimeCoverageTest.java
@@ -0,0 +1,77 @@
+package net.tfminecraft.vehicleframework.bones;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.util.*;
+import org.bukkit.*;
+import org.bukkit.entity.Player;
+import org.bukkit.util.Vector;
+import org.joml.Quaternionf;
+import org.junit.jupiter.api.*;
+import org.mockito.ArgumentCaptor;
+import com.ticxo.modelengine.api.model.ActiveModel;
+import com.ticxo.modelengine.api.model.bone.ModelBone;
+import com.comphenix.protocol.ProtocolLibrary;
+import com.comphenix.protocol.ProtocolManager;
+import com.comphenix.protocol.events.*;
+import com.comphenix.protocol.reflect.StructureModifier;
+import net.tfminecraft.vehicleframework.enums.Keybind;
+import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig;
+import net.tfminecraft.vehicleframework.protocol.PacketConverter;
+import net.tfminecraft.vehicleframework.protocol.VehiclePacketListener;
+import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
+import net.tfminecraft.vehicleframework.vehicles.util.AccessPanel;
+
+class BoneRuntimeCoverageTest {
+ Rig r;ActiveVehicle vehicle;ActiveModel model;AccessPanel panel;Map bones;
+ @BeforeEach void setup(){r=new Rig();vehicle=r.vehicle();panel=new AccessPanel();when(vehicle.getAccessPanel()).thenReturn(panel);model=mock(ActiveModel.class,RETURNS_DEEP_STUBS);bones=new HashMap<>();when(model.getBone(anyString())).thenAnswer(c->Optional.ofNullable(bones.get(c.getArgument(0))));}
+ @AfterEach void cleanup(){r.close();}
+
+ @Test void normalizationPreservesUnselectedAxes() {
+ BoneRotator rotator=rotator("body",new RotationLimits());rotator.setRotation(30,0,0,true,true,true);rotator.normalize(true,false,false);assertEquals(30,rotator.getConvertedAngles().getYaw(),.001,"Normalizing X must preserve Y rotation");rotator.normalize(false,true,false);assertEquals(0,rotator.getConvertedAngles().getYaw(),.001);
+ }
+
+ @Test void normalizationSnapsToNeutralWithoutOvershooting() {
+ BoneRotator rotator=rotator("body",new RotationLimits());rotator.setRotation(40,0,0,true,true,true);rotator.normalize(true,true,true);assertEquals(0,rotator.getConvertedAngles().getYaw(),.001,"Interpolation past 1 mirrors the angle instead of resetting it");assertEquals(0,rotator.getDriveYaw(),.001);
+ }
+
+ @Test void directionAnglesWrapAndKeepInputVectorsIntact() {
+ ConvertedAngle explicit=new ConvertedAngle(10,20,30);assertEquals(10,explicit.getYaw());assertEquals(20,explicit.getPitch());assertEquals(30,explicit.getRoll());assertEquals(90,new ConvertedAngle(new Quaternionf().rotateY((float)Math.PI/2)).getYaw(),.001);assertEquals(30,new ConvertedAngle(new Quaternionf().rotateX((float)Math.PI/6)).getPitch(),.001);assertEquals(45,new ConvertedAngle(new Quaternionf().rotateZ((float)Math.PI/4)).getRoll(),.001);assertEquals(0,ConvertedAngle.fromDirection(null).getYaw());assertEquals(0,ConvertedAngle.fromDirection(new Vector()).getPitch());Vector direction=new Vector(2,0,0);assertEquals(-90,ConvertedAngle.fromDirection(direction).getYaw(),.001);assertEquals(new Vector(2,0,0),direction);assertEquals(-90,ConvertedAngle.fromDirection(new Vector(0,1,0)).getPitch(),.001);assertEquals(-90,ConvertedAngle.wrapDegrees(270));assertEquals(90,ConvertedAngle.wrapDegrees(-270));assertEquals(180,ConvertedAngle.wrapDegrees(-180));assertEquals(20,ConvertedAngle.shortestDelta(170,-170));
+ }
+
+ @Test void rotationLimitsApplyOptionalAxesDefaultsWrappingAndClamps()throws Exception {
+ RotationLimits none=new RotationLimits();assertTrue(none.withinAll(300,100,200));assertEquals(300,none.clampYaw(300));assertEquals(100,none.clampPitch(100));assertEquals(200,none.clampRoll(200));RotationLimits configured=new RotationLimits(Rig.yaml("min-yaw: -30\nmax-yaw: 30\nmin-pitch: -45\nmax-pitch: 45\nmin-roll: -20\nmax-roll: 20\n"));assertEquals(-30,configured.getMinYaw());assertEquals(30,configured.getMaxYaw());assertEquals(-45,configured.getMinPitch());assertEquals(45,configured.getMaxPitch());assertEquals(-20,configured.getMinRoll());assertEquals(20,configured.getMaxRoll());assertTrue(configured.withinAll(30,45,20));assertTrue(configured.withinYaw(350));assertTrue(configured.withinYaw(-350));assertFalse(configured.withinYaw(40));assertFalse(configured.withinYaw(-40));assertFalse(configured.withinAll(0,46,0));assertFalse(configured.withinAll(0,0,21));assertEquals(30,configured.clampYaw(90));assertEquals(-45,configured.clampPitch(-90));assertEquals(20,configured.clampRoll(50));assertEquals(10,configured.clampYaw(10));
+ RotationLimits partial=new RotationLimits(Rig.yaml("max-yaw: 30\nmin-pitch: -40\nmax-roll: 10\n"));assertEquals(-180,partial.getMinYaw());assertEquals(90,partial.getMaxPitch());assertEquals(-180,partial.getMinRoll());assertTrue(new RotationLimits(Rig.yaml("")).withinAll(400,200,300));assertFalse(RotationLimits.yawOnly(-1,1).withinYaw(2));assertTrue(RotationLimits.yawOnly(-1,1).withinPitch(200));assertFalse(RotationLimits.pitchOnly(-1,1).withinPitch(2));assertTrue(RotationLimits.pitchOnly(-1,1).withinRoll(200));assertFalse(RotationLimits.rollOnly(-1,1).withinRoll(2));assertTrue(RotationLimits.rollOnly(-1,1).withinYaw(200));
+ }
+
+ @Test void boneRotationsMaintainDriveHeadingLimitsSmoothingAndEntityFacing()throws Exception {
+ BoneRotator rotator=rotator("body",RotationLimits.yawOnly(-5,5));assertEquals("body",rotator.getId());assertSame(bones.get("body"),rotator.getBone());assertSame(rotator,panel.getRotator("body"));rotator.rotate(0,2,0);assertEquals(0,rotator.getDriveYaw(),.001);rotator.rotate(0,.1,0);assertEquals(Math.toDegrees(.01),rotator.getDriveYaw(),.001);rotator.rotate(0,0,0);rotator.reset();assertEquals(0,rotator.getDriveYaw(),.001);rotator.rotateSmoothed(0,10,0);assertEquals(Math.toDegrees(.05),rotator.getDriveYaw(),.001);rotator.resetSmoothing();rotator.reset();rotator.rotateSmoothed(0,10,0);assertEquals(Math.toDegrees(.05),rotator.getDriveYaw(),.001);rotator.rotateEntity(25,-10);assertEquals(25,vehicle.getEntity().getLocation().getYaw());assertEquals(-10,vehicle.getEntity().getLocation().getPitch());assertEquals(64,vehicle.getEntity().getLocation().getY());assertTrue(Double.isFinite(rotator.getAngles().angle));Quaternionf heading=new Quaternionf().rotateY((float)Math.toRadians(20));rotator.rawSet(heading.x,heading.y,heading.z,heading.w);assertEquals(20,rotator.getDriveYaw(),.001);ModelBone replacement=bone("body",new Location(r.world,0,64,0));rotator.updateModel(model);assertSame(replacement,rotator.getBone());verify(replacement).setManualAnimator(rotator.getAnimator());assertEquals(20,rotator.getConvertedAngles().getYaw(),.001);assertTrue(rotator.rotateToTarget(20,0,0,1,true,true,true));assertFalse(rotator.rotateToTarget(0,10,0,.5f,false,true,false));assertTrue(rotator.getConvertedAngles().getPitch()>0);
+ }
+
+ @Test void vectorBonesTrackReplacementModelsAndCoincidentFallbacks() {
+ ModelBone base=bone("base",new Location(r.world,1,64,1)),align=bone("aim",new Location(r.world,1,64,3));VectorBone vector=new VectorBone(base,align);assertEquals("base",vector.getId());assertSame(base,vector.getBase());assertSame(align,vector.getAlign());assertSame(model,vector.getModel());assertEquals(new Location(r.world,1,64,1),vector.getBaseLocation());assertEquals(new Location(r.world,1,64,3),vector.getAlignLocation());assertEquals(new Vector(0,0,1),vector.getVector());ModelBone replacement=bone("base",new Location(r.world,1,64,3));vector.updateModel(model);assertSame(replacement,vector.getBase());assertEquals(new Vector(1,0,0),vector.getVector());
+ }
+
+ @Test void rotationTargetsBecomeActiveAtTheTargetAndReactivateAfterConditionsChange()throws Exception {
+ BoneRotator base=rotator("base",new RotationLimits());RotationTarget stored=new RotationTarget(Rig.yaml("yaw: 30\npitch: 0\nroll: 0\ninterval: 1\nconditions: [has_fuel(true)]\n"));assertEquals("none",stored.getRotatorOverride());assertEquals(List.of("has_fuel(true)"),stored.getConditions());assertEquals(30,stored.getYaw());assertEquals(0,stored.getPitch());assertEquals(0,stored.getRoll());assertTrue(stored.shouldYaw());assertTrue(stored.shouldPitch());assertTrue(stored.shouldRoll());assertEquals(1,stored.getInterval());RotationTarget active=new RotationTarget(vehicle,stored,base);assertSame(base,active.getRotator());assertFalse(active.isActive());active.run(vehicle);assertEquals(0,base.getDriveYaw());when(vehicle.hasFuel()).thenReturn(true);active.run(vehicle);assertFalse(active.isActive());active.run(vehicle);assertTrue(active.isActive());active.run(vehicle);assertEquals(30,base.getDriveYaw(),.001);active.updateModel();when(vehicle.hasFuel()).thenReturn(false);active.run(vehicle);assertFalse(active.isActive());when(vehicle.hasFuel()).thenReturn(true);active.run(vehicle);assertTrue(active.isActive());
+ BoneRotator override=rotator("override",new RotationLimits());RotationTarget selected=new RotationTarget(vehicle,new RotationTarget(Rig.yaml("rotator: override\n")),base);assertSame(override,selected.getRotator());assertFalse(selected.shouldYaw());assertFalse(selected.shouldPitch());assertFalse(selected.shouldRoll());RotationTarget missing=new RotationTarget(vehicle,new RotationTarget(Rig.yaml("rotator: missing\n")),base);assertSame(base,missing.getRotator());selected.setRotator(base);assertSame(base,selected.getRotator());
+ }
+
+ @Test void packetConversionDistinguishesHeldJumpSneakAndOppositeAxes() {
+ PacketConverter converter=new PacketConverter();assertEquals(List.of(Keybind.A,Keybind.W),converter.convert(1,1,false,false));assertEquals(List.of(Keybind.D,Keybind.S),converter.convert(-1,-1,false,false));assertEquals(List.of(),converter.convert(0,0,false,false));assertEquals(List.of(Keybind.SPACE_A,Keybind.SPACE_W,Keybind.SPACE),converter.convert(1,1,true,false));assertEquals(List.of(Keybind.SPACE_D,Keybind.SPACE_S,Keybind.SPACE),converter.convert(-1,-1,true,false));assertEquals(List.of(Keybind.SHIFT_A,Keybind.SHIFT_W,Keybind.SHIFT,Keybind.SPACE),converter.convert(1,1,true,true));assertEquals(List.of(Keybind.SHIFT_D,Keybind.SHIFT_S,Keybind.SHIFT),converter.convert(-1,-1,false,true));assertEquals(List.of(Keybind.SHIFT),converter.convert(0,0,false,true));
+ }
+
+ @Test void packetListenerReplaysHeldInputsUntilQuitAndCancelsOppositeDirections() {
+ Server server=mock(Server.class);when(server.getVersion()).thenReturn("Paper (MC: 1.21.10)");when(server.getBukkitVersion()).thenReturn("1.21.10-R0.1-SNAPSHOT");r.bukkit.when(Bukkit::getServer).thenReturn(server);r.bukkit.when(Bukkit::getVersion).thenReturn("Paper (MC: 1.21.10)");r.bukkit.when(Bukkit::getBukkitVersion).thenReturn("1.21.10-R0.1-SNAPSHOT");r.bukkit.when(Bukkit::getLogger).thenReturn(java.util.logging.Logger.getAnonymousLogger());ProtocolManager protocol=mock(ProtocolManager.class);
+ try(var library=mockStatic(ProtocolLibrary.class);var reflection=mockStatic(com.comphenix.protocol.utility.MinecraftReflection.class);var nativeStacks=mockConstruction(org.bukkit.inventory.ItemStack.class)){
+ reflection.when(com.comphenix.protocol.utility.MinecraftReflection::getItemStackClass).thenReturn(org.bukkit.inventory.ItemStack.class);
+ reflection.when(com.comphenix.protocol.utility.MinecraftReflection::getBlockClass).thenReturn(org.bukkit.block.Block.class);
+ library.when(ProtocolLibrary::getProtocolManager).thenReturn(protocol);VehiclePacketListener listener=new VehiclePacketListener(r.manager);listener.register();ArgumentCaptor capture=ArgumentCaptor.forClass(PacketListener.class);verify(protocol).addPacketListener(capture.capture());PacketListener adapter=capture.getValue();Player driver=r.entity(Player.class,new Location(r.world,0,64,0)),other=r.entity(Player.class,new Location(r.world,1,64,0));r.bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(driver,other));PacketEvent event=mock(PacketEvent.class,RETURNS_DEEP_STUBS);when(event.getPlayer()).thenReturn(driver);StructureModifier structures=mock(StructureModifier.class);when(event.getPacket().getStructures()).thenReturn(structures);when(structures.size()).thenReturn(0);adapter.onPacketReceiving(event);r.tick();verify(r.manager,never()).inputPacket(any(),anyFloat(),anyFloat(),anyBoolean(),anyBoolean());InternalStructure input=mock(InternalStructure.class,RETURNS_DEEP_STUBS);when(structures.size()).thenReturn(1);when(structures.read(0)).thenReturn(input);boolean[] flags={true,false,true,false,true,false};when(input.getBooleans().read(anyInt())).thenAnswer(c->flags[c.getArgument(0)]);adapter.onPacketReceiving(event);r.ticks(2);verify(r.manager,times(2)).inputPacket(driver,1,1,true,false);Arrays.fill(flags,true);adapter.onPacketReceiving(event);r.tick();verify(r.manager).inputPacket(driver,0,0,true,true);Arrays.fill(flags,false);flags[1]=true;flags[3]=true;adapter.onPacketReceiving(event);r.tick();verify(r.manager).inputPacket(driver,-1,-1,false,false);listener.unregisterPlayer(driver);clearInvocations(r.manager);r.tick();verifyNoInteractions(r.manager);
+ }
+ }
+
+ private BoneRotator rotator(String id,RotationLimits limits){return new BoneRotator(vehicle,vehicle.getEntity(),bone(id,new Location(r.world,0,64,0)),limits);}
+ private ModelBone bone(String id,Location location){ModelBone bone=mock(ModelBone.class,RETURNS_DEEP_STUBS);when(bone.getBoneId()).thenReturn(id);when(bone.getActiveModel()).thenReturn(model);when(bone.getLocation()).thenAnswer(c->location.clone());when(bone.getLocalTransform().getSafeLeftQuaternion()).thenReturn(new Quaternionf());when(bone.getLocalTransform().getSafePosition()).thenReturn(new org.joml.Vector3f());when(bone.getLocalTransform().getSafeScale()).thenReturn(new org.joml.Vector3f(1));bones.put(id,bone);return bone;}
+}
diff --git a/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java
new file mode 100644
index 0000000..838d6a7
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java
@@ -0,0 +1,102 @@
+package net.tfminecraft.vehicleframework.data;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.util.*;
+import java.util.logging.Logger;
+import org.bukkit.*;
+import org.bukkit.entity.*;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.bukkit.util.Vector;
+import org.junit.jupiter.api.*;
+import net.tfminecraft.vehicleframework.VFLogger;
+import net.tfminecraft.vehicleframework.cache.Cache;
+import net.tfminecraft.vehicleframework.enums.*;
+import net.tfminecraft.vehicleframework.events.*;
+import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig;
+import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
+
+class RuntimeValuesCoverageTest {
+ Rig r;
+ @BeforeEach void setup(){r=new Rig();}
+ @AfterEach void cleanup(){if(r!=null)r.close();}
+
+ @Test void colocatedSoundSourceKeepsItsConfiguredPitch()throws Exception {
+ Location source=new Location(r.world,0,64,0);Player player=r.entity(Player.class,source);
+ SoundData sound=new SoundData(Rig.yaml("sound: engine\ndoppler: true\npitch: 1.2\n"));sound.playSound(List.of(player),source,new Vector(),1);
+ verify(player).playSound(source,"engine",1f,1.2f);
+ }
+
+ @Test void damageCauseIdsUseRootLocaleForBothConfigurationForms() {
+ Locale previous=Locale.getDefault();try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertTrue(new DamageData(List.of("fire(0.5)")).hasModifier("FIRE"));assertTrue(new DamageData(Map.of("fire",.5)).hasModifier("FIRE"));}finally{Locale.setDefault(previous);}
+ }
+
+ @Test void particleIdsUseRootLocaleRatherThanSilentlyChangingTheEffect()throws Exception {
+ Locale previous=Locale.getDefault();try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertEquals(Particle.CRIT,new ParticleData(Rig.yaml("particle: crit\n")).getParticle());}finally{Locale.setDefault(previous);}
+ }
+
+ @Test void deathOverrideIdsUseRootLocale()throws Exception {
+ Locale previous=Locale.getDefault();try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertEquals(VehicleDeath.SINK,new DeathOverride(Rig.yaml("type: sink\n")).getDeath());}finally{Locale.setDefault(previous);}
+ }
+
+ @Test void damageModifiersReadNumbersStringsNullMapsAndNullEntries() {
+ DamageData list=new DamageData(List.of("EXPLOSION(0.5)","FIRE(2)"));assertEquals(.5,list.getModifier("EXPLOSION"));assertTrue(list.hasModifier("FIRE"));assertFalse(list.hasModifier("OTHER"));assertEquals(2,list.getModifiers().size());assertTrue(new DamageData((List)null).getModifiers().isEmpty());assertTrue(new DamageData((Map)null).getModifiers().isEmpty());Map values=new LinkedHashMap<>();values.put(null,1);values.put("skip",null);values.put("projectile",2);values.put("fire","0.25");DamageData mapped=new DamageData(values);assertEquals(Map.of("PROJECTILE",2d,"FIRE",.25d),mapped.getModifiers());assertThrows(NumberFormatException.class,()->new DamageData(Map.of("fire","invalid")));
+ }
+
+ @Test void repairCountsDownClampsDamageAndReportsHealthBands() {
+ HealthData health=new HealthData(100,5,2);assertEquals(100,health.getHealth());assertEquals(5,health.getDamage());assertEquals(2,health.getBaseRepairTime());assertEquals(-1,health.getRepairTime());assertFalse(health.isUnderRepair());health.tick();health.startRepair();assertTrue(health.isUnderRepair());assertEquals("0.1s",health.getRepairString());health.tick();assertEquals(1,health.getRepairTime());health.tick();assertFalse(health.isUnderRepair());assertEquals(0,health.getDamage(),"Every configured repair restores at least seven health");health.startRepair();health.stopRepair();assertFalse(health.isUnderRepair());health.setDamage(120);assertEquals(100,health.getDamage());health.setDamage(95);health.damage(20);assertEquals(100,health.getDamage());
+ int[] percentages={90,70,50,30,10};String[] colors={"§2","§a","§e","§c","§4"};for(int i=0;i(List.of("alex")));owner.addToWhiteList("sam");owner.removeFromWhiteList("alex");assertEquals(List.of("sam"),owner.getWhiteList());owner.setWhiteListed(false);assertFalse(owner.isWhiteListed());assertFalse(owner.isTicketsEnabled());assertNull(owner.getTicketId());owner.setTicketId(" ");assertNull(owner.getTicketId());owner.setTicketsEnabled(true);String generated=owner.getTicketId();assertNotNull(UUID.fromString(generated));owner.toggleTickets();assertFalse(owner.isTicketsEnabled());owner.toggleTickets();assertEquals(generated,owner.getTicketId());owner.setTicketId("ticket-1");assertEquals("ticket-1",owner.getTicketId());owner.setTicketId(null);assertNull(owner.getTicketId());Cache.allowWhitelist=false;assertFalse(new OwnerData().isWhiteListed());Cache.allowWhitelist=true;Cache.whitelistedByDefault=false;assertFalse(new OwnerData().isWhiteListed());}finally{Cache.allowWhitelist=allow;Cache.whitelistedByDefault=defaults;}
+ }
+
+ @Test void namingTimeoutAndStoredSummariesRetainTheirPublicValues() {
+ ActiveVehicle vehicle=r.vehicle();NamingData naming=new NamingData(vehicle);assertSame(vehicle,naming.getVehicle());for(int i=0;i<30;i++)assertFalse(naming.tick());assertTrue(naming.tick());assertTrue(naming.tick());StoredVehicleMeta stored=new StoredVehicleMeta("uuid","Wagon","cart","player_alex");assertEquals("uuid",stored.getUuid());assertEquals("Wagon",stored.getName());assertEquals("cart",stored.getTypeId());assertEquals("player_alex",stored.getOwner());for(String owner:Arrays.asList(null,""," ","none","player_","player_none","company_acme"))assertFalse(StoredVehicleMeta.isPlayerOwner(owner));assertTrue(StoredVehicleMeta.isPlayerOwner("PLAYER_alex"));Location location=new Location(r.world,1,64,2);OwnedVehicleSummary summary=new OwnedVehicleSummary("uuid","Wagon","cart",Optional.of(location),true,"player_alex");assertEquals("uuid",summary.getUuid());assertEquals("Wagon",summary.getName());assertEquals("cart",summary.getTypeId());assertEquals(Optional.of(location),summary.getLocation());assertTrue(summary.isSpawned());assertEquals("player_alex",summary.getOwner());OwnedVehicleSummary unloaded=new OwnedVehicleSummary("id","Stored","cart",null,false);assertFalse(unloaded.isSpawned());assertTrue(unloaded.getLocation().isEmpty());assertNull(unloaded.getOwner());
+ }
+
+ @Test void deathConfigurationExposesSoundsFragmentsAndConditionalOverrides()throws Exception {
+ DeathData empty=new DeathData(VehicleDeath.DIE,Rig.yaml(""));assertEquals(VehicleDeath.DIE,empty.getType());assertEquals(0,empty.getDuration());assertEquals(0,empty.getFragments());assertTrue(empty.getSfx().isEmpty());assertFalse(empty.hasOverrides());DeathData configured=new DeathData(VehicleDeath.EXPLODE,Rig.yaml("duration: 20\nfragments: 3\nsounds: {bang: {sound: custom.bang}}\noverrides:\n water:\n type: sink\n conditions: [state(floating)]\n"));assertEquals(20,configured.getDuration());assertEquals(3,configured.getFragments());assertEquals("custom.bang",configured.getSfx().getFirst().getSound());assertTrue(configured.hasOverrides());assertEquals(VehicleDeath.SINK,configured.getOverrides().getFirst().getDeath());assertEquals(List.of("state(floating)"),configured.getOverrides().getFirst().getConditions());
+ }
+
+ @Test void removalPayloadsDistinguishDeathFromUnloadAndRequireDeathCause() {
+ assertThrows(IllegalArgumentException.class,()->VehicleRemovePayload.death(null));VehicleRemovePayload death=VehicleRemovePayload.death(VehicleDeath.CRASH);assertEquals(VehicleRemoveType.DEATH,death.getType());assertTrue(death.isDeath());assertFalse(death.isRemove());assertEquals(Optional.of(VehicleDeath.CRASH),death.getDeathCause());assertTrue(death.getRemoveReason().isEmpty());VehicleRemovePayload remove=VehicleRemovePayload.remove(VehicleRemoveReason.UNLOAD);assertEquals(VehicleRemoveType.REMOVE,remove.getType());assertTrue(remove.isRemove());assertFalse(remove.isDeath());assertEquals(Optional.of(VehicleRemoveReason.UNLOAD),remove.getRemoveReason());assertTrue(remove.getDeathCause().isEmpty());assertEquals(Optional.of(VehicleRemoveReason.GENERIC),VehicleRemovePayload.remove(null).getRemoveReason());
+ }
+
+ @Test void soundConfigurationControlsVolumePitchClampsAndDoppler()throws Exception {
+ Location source=new Location(r.world,0,64,0);Player player=r.entity(Player.class,new Location(r.world,10,64,0));SoundData regular=new SoundData(Rig.yaml("sound: custom.motor\nvolume: 2\npitch: 1.2\ndelay: 3\n"));assertEquals("custom.motor",regular.getSound());assertEquals(2,regular.getVolume());assertEquals(1.2f,regular.getPitch());assertEquals(3,regular.getDelay());assertFalse(regular.isPitched());assertFalse(regular.isDoppler());regular.playSound(source);regular.playSound(List.of(player),source,2f);regular.playSound(List.of(player),source,new Vector(),2f);verify(r.world,times(2)).playSound(source,"custom.motor",2f,1.2f);verify(player).playSound(source,"custom.motor",2f,1.2f);
+ SoundData pitched=new SoundData(Rig.yaml("sound: custom.pitched\npitched: true\nmin-pitch: 0.8\nmax-pitch: 1.5\n"));assertTrue(pitched.isPitched());pitched.playSound(List.of(player),source,new Vector(),.1f);pitched.playSound(List.of(player),source,new Vector(),3f);verify(player).playSound(source,"custom.pitched",1f,.8f);verify(player).playSound(source,"custom.pitched",1f,1.5f);pitched.playSound(List.of(player),source,.1f);pitched.playSound(List.of(player),source,3f);verify(r.world).playSound(source,"custom.pitched",1f,.8f);verify(r.world).playSound(source,"custom.pitched",1f,1.5f);
+ SoundData doppler=new SoundData(Rig.yaml("sound: custom.doppler\npitched: true\ndoppler: true\n"));assertTrue(doppler.isDoppler());doppler.playSound(List.of(player),source,new Vector(-1000,0,0),1);doppler.playSound(List.of(player),source,new Vector(300,0,0),1);verify(player).playSound(source,"custom.doppler",1f,.5f);verify(player).playSound(source,"custom.doppler",1f,2f);
+ }
+
+ @Test void particleConfigurationUsesOffsetsAndLimitsVisibleDelivery()throws Exception {
+ ParticleData fallback=new ParticleData(Rig.yaml("particle: missing\n"));assertEquals(Particle.FLAME,fallback.getParticle());ParticleData data=new ParticleData(Rig.yaml("particle: crit\namount: 2\nspread: 0\nspeed: 0.25\nx: 1\ny: 1\nz: 1\n"));assertEquals(Particle.CRIT,data.getParticle());assertEquals(2,data.getAmount());assertEquals(0,data.getSpread());assertEquals(.25,data.getSpeed());Location source=new Location(r.world,0,64,0);data.spawnParticle(null,new Vector(0,0,1));data.spawnParticle(new Location(null,0,0,0),new Vector(0,0,1));data.spawnParticle(source,new Vector(0,0,1));Vector expected=new Vector(1,1,2).normalize();verify(r.world,times(2)).spawnParticle(Particle.CRIT,source,0,expected.getX(),expected.getY(),expected.getZ(),.25,null,true);Player viewer=r.entity(Player.class,source);when(r.world.getPlayers()).thenReturn(List.of(viewer));data.spawnParticleVisible(source,new Vector(0,0,1));verify(viewer,times(2)).spawnParticle(Particle.CRIT,source,0,expected.getX(),expected.getY(),expected.getZ(),.25);
+ ParticleData negative=new ParticleData(Rig.yaml("amount: 1\nspread: 0\nx: -1\ny: -1\nz: -1\n"));negative.spawnParticle(source,new Vector());Vector backwards=new Vector(-1,-1,-1).normalize();verify(r.world).spawnParticle(Particle.FLAME,source,0,backwards.getX(),backwards.getY(),backwards.getZ(),.4,null,true);
+ }
+
+ @Test void timedSoundsPreventOverlapAndCopiesStartIdle()throws Exception {
+ TimedSound sound=new TimedSound(Rig.yaml("sound: timer\nduration: 3\n"));assertFalse(sound.isPlaying());assertEquals(3,sound.getDuration());Location location=new Location(r.world,0,64,0);sound.playSound(location);sound.playSound(location);assertTrue(sound.isPlaying());verify(r.world).playSound(location,"timer",1f,1f);TimedSound copy=new TimedSound(sound);assertSame(sound.getSound(),copy.getSound());assertEquals(3,copy.getDuration());assertFalse(copy.isPlaying());r.ticks(2);assertTrue(sound.isPlaying());r.tick();assertFalse(sound.isPlaying());sound.playSound(location);verify(r.world,times(2)).playSound(location,"timer",1f,1f);
+ }
+
+ @Test void eventsPreservePayloadsCancellationAndStableHandlerLists() {
+ ActiveVehicle vehicle=r.vehicle();Player player=r.entity(Player.class,new Location(r.world,0,64,0));VehicleSpawnEvent spawn=new VehicleSpawnEvent(vehicle);assertSame(vehicle,spawn.getVehicle());assertSame(VehicleSpawnEvent.getHandlerList(),spawn.getHandlers());VehicleRemovePayload payload=VehicleRemovePayload.death(VehicleDeath.DIE);VehicleRemoveEvent remove=new VehicleRemoveEvent(vehicle,payload);assertSame(vehicle,remove.getVehicle());assertSame(payload,remove.getPayload());assertSame(VehicleRemoveEvent.getHandlerList(),remove.getHandlers());assertTrue(new VehicleRemoveEvent(vehicle,null).getPayload().isRemove());VehicleOwnerClaimedEvent owner=new VehicleOwnerClaimedEvent(player,vehicle,"none","player_alex");assertSame(player,owner.getPlayer());assertSame(vehicle,owner.getVehicle());assertEquals("none",owner.getPreviousOwner());assertEquals("player_alex",owner.getNewOwner());assertFalse(owner.isCancelled());owner.setCancelled(true);assertTrue(owner.isCancelled());assertSame(VehicleOwnerClaimedEvent.getHandlerList(),owner.getHandlers());VehiclePreInteractEvent interact=new VehiclePreInteractEvent(player,vehicle);assertSame(vehicle,interact.getVehicle());assertFalse(interact.isCancelled());interact.setCancelled(true);assertTrue(interact.isCancelled());assertSame(VehiclePreInteractEvent.getHandlerList(),interact.getHandlers());VehicleRepairStartEvent repair=new VehicleRepairStartEvent(player,vehicle);assertSame(vehicle,repair.getVehicle());assertFalse(repair.isCancelled());repair.setCancelled(true);assertTrue(repair.isCancelled());assertSame(VehicleRepairStartEvent.getHandlerList(),repair.getHandlers());VFEntityDamageEvent damage=new VFEntityDamageEvent(vehicle.getEntity(),player,"FIRE",10);assertSame(vehicle.getEntity(),damage.getEntity());assertSame(player,damage.getDamager());assertEquals("FIRE",damage.getCause());assertEquals(10,damage.getDamage());damage.setDamage(5);assertEquals(5,damage.getDamage());assertFalse(damage.isCancelled());damage.setCancelled(true);assertTrue(damage.isCancelled());assertSame(VFEntityDamageEvent.getHandlerList(),damage.getHandlers());boolean before=Cache.blockDamage;try{Cache.blockDamage=true;Location center=player.getLocation();VFExplosionEvent explosion=new VFExplosionEvent(center);assertSame(center,explosion.getLocation());assertTrue(explosion.doesBlockDamage());explosion.setBlockDamage(false);assertFalse(explosion.doesBlockDamage());assertFalse(explosion.isCancelled());explosion.setCancelled(true);assertTrue(explosion.isCancelled());assertSame(VFExplosionEvent.getHandlerList(),explosion.getHandlers());}finally{Cache.blockDamage=before;}
+ }
+
+ @Test void cacheCleanupRemovesProjectilesAndOnlyTemporaryLights() {
+ Set old=new HashSet<>(Cache.lightLocations);try{new Cache();Entity projectile=r.entity(Entity.class,new Location(r.world,0,64,0));Cache.projectiles.add(null);Cache.projectiles.add(projectile);Cache.removeProjectiles();verify(projectile).remove();Location light=new Location(r.world,0,64,0),stone=new Location(r.world,1,64,0);r.block(0,64,0,Material.LIGHT);r.block(1,64,0,Material.STONE);Cache.lightLocations.clear();Cache.lightLocations.addAll(List.of(light,stone));Cache.removeLights();assertEquals(Material.AIR,light.getBlock().getType());assertEquals(Material.STONE,stone.getBlock().getType());}finally{Cache.lightLocations.clear();Cache.lightLocations.addAll(old);}
+ }
+
+ @Test void applyingTrackStylePublishesTheConfiguredModelsAndOffset() {
+ String small=Cache.trackItemSmall,medium=Cache.trackItemMedium,large=Cache.trackItemLarge,appliedSmall=Cache.appliedTrackItemSmall,appliedMedium=Cache.appliedTrackItemMedium,appliedLarge=Cache.appliedTrackItemLarge;double offset=Cache.trackDisplayYOffset,appliedOffset=Cache.appliedTrackDisplayYOffset;
+ try{Cache.trackItemSmall="small";Cache.trackItemMedium="medium";Cache.trackItemLarge="large";Cache.trackDisplayYOffset=.75;Cache.applyTrackDisplayStyle();assertEquals("small",Cache.appliedTrackItemSmall);assertEquals("medium",Cache.appliedTrackItemMedium);assertEquals("large",Cache.appliedTrackItemLarge);assertEquals(.75,Cache.appliedTrackDisplayYOffset);}finally{Cache.trackItemSmall=small;Cache.trackItemMedium=medium;Cache.trackItemLarge=large;Cache.appliedTrackItemSmall=appliedSmall;Cache.appliedTrackItemMedium=appliedMedium;Cache.appliedTrackItemLarge=appliedLarge;Cache.trackDisplayYOffset=offset;Cache.appliedTrackDisplayYOffset=appliedOffset;}
+ }
+
+ @Test void loggerRoutesSeverityPluginNamesAndOnlineCreatorMessages() {
+ r.close();r=null;try(var bukkit=mockStatic(Bukkit.class)){
+ Logger logger=mock(Logger.class);bukkit.when(Bukkit::getLogger).thenReturn(logger);JavaPlugin plugin=mock(JavaPlugin.class);when(plugin.getName()).thenReturn("TestPlugin");Player player=mock(Player.class);new VFLogger();VFLogger.log("broken");VFLogger.info("ready");VFLogger.log(plugin,"bad config");VFLogger.info(plugin,"loaded");verify(logger).warning("[VehicleFramework] Error! broken");verify(logger).info("[VehicleFramework] ready");verify(logger).warning("[TestPlugin] Error! bad config");verify(logger).info("[TestPlugin] loaded");VFLogger.message(player,"hello");verify(player).sendMessage("§a[VehicleFramework] §ehello");VFLogger.creatorLog("first");bukkit.when(()->Bukkit.getPlayerExact("drefvelin")).thenReturn(player);VFLogger.creatorLog("offline");verify(player,never()).sendMessage(contains("offline"));when(player.isOnline()).thenReturn(true);VFLogger.creatorLog("trace");verify(player).sendMessage("§a[VFLogger]§e: §btrace");
+ }
+ }
+}
diff --git a/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseLoggingCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseLoggingCoverageTest.java
new file mode 100644
index 0000000..913dbba
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseLoggingCoverageTest.java
@@ -0,0 +1,175 @@
+package net.tfminecraft.vehicleframework.database;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.*;
+import java.util.stream.Stream;
+import org.bukkit.*;
+import org.bukkit.block.Block;
+import org.bukkit.entity.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.*;
+import net.tfminecraft.vehicleframework.VehicleFramework;
+import net.tfminecraft.vehicleframework.cache.Cache;
+import net.tfminecraft.vehicleframework.enums.SeatType;
+import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
+import net.tfminecraft.vehicleframework.vehicles.seat.Seat;
+
+class DatabaseLoggingCoverageTest {
+ @TempDir Path folder;
+ private final World world = mock(World.class);
+ private final List vehicles = new ArrayList<>();
+
+ @BeforeEach void world() {
+ when(world.getName()).thenReturn("harbour"); Chunk chunk = mock(Chunk.class);
+ when(chunk.getX()).thenReturn(2); when(chunk.getZ()).thenReturn(-3); when(chunk.isLoaded()).thenReturn(true);
+ when(world.getChunkAt(any(Location.class))).thenReturn(chunk);
+ PersistenceLog.configure(false, folder.toFile());
+ }
+ @AfterEach void resetLogging() {
+ PersistenceLog.configure(false, folder.toFile()); for (ActiveVehicle vehicle : vehicles) PersistenceLog.unload(vehicle, "test cleanup");
+ }
+
+ @Test void countingLogLinesClosesTheFilesystemStream() throws Exception {
+ LogWriter log = new LogWriter(folder.toFile()); Path file = folder.resolve("logs/log.txt"); AtomicBoolean closed = new AtomicBoolean();
+ try (Stream stream = Files.lines(file).onClose(() -> closed.set(true)); MockedStatic files = mockStatic(Files.class, call -> {
+ if (call.getMethod().getName().equals("lines") && call.getArguments().length == 1 && file.equals(call.getArgument(0))) return stream;
+ return call.callRealMethod();
+ })) {
+ log.logEntry("resource probe");
+ assertTrue(closed.get(), "Line-count stream must close after each logged entry");
+ }
+ assertTrue(Files.readString(file).contains("resource probe"));
+ }
+
+ @Test void logRotationPreservesAllLinesAndAvoidsSameDayArchiveCollisions() throws Exception {
+ LogWriter writer = new LogWriter(folder.toFile()); Path current = folder.resolve("logs/log.txt");
+ assertTrue(Files.exists(folder.resolve("logs/older"))); new LogWriter(folder.toFile());
+ writer.logEntry("first"); assertTrue(Files.readString(current).contains("] first"));
+ String date = new SimpleDateFormat("dd-MM-yyyy").format(new Date()); Path archived = folder.resolve("logs/older/log_" + date + ".txt");
+ Files.writeString(current, "preserved\n".repeat(400)); writer.logEntry("last in first archive");
+ assertEquals(401, Files.readAllLines(archived).size()); assertEquals(0, Files.size(current));
+ Files.writeString(current, "second\n".repeat(400)); writer.logEntry("last in second archive");
+ Path second = folder.resolve("logs/older/log_" + date + "_1.txt");
+ assertEquals(401, Files.readAllLines(second).size()); assertEquals(0, Files.size(current));
+ assertTrue(Files.readString(archived).contains("last in first archive"));
+ }
+
+ @Test void logWriteAndRotationFailuresPreserveTheExistingFiles() throws Exception {
+ Files.createDirectories(folder.resolve("logs")); Files.writeString(folder.resolve("logs/older"), "directory obstruction");
+ LogWriter writer = new LogWriter(folder.toFile()); Path current = folder.resolve("logs/log.txt");
+ Files.writeString(current, "entry\n".repeat(400)); assertDoesNotThrow(() -> writer.logEntry("rotation fails"));
+ assertEquals(401, Files.readAllLines(current).size()); assertEquals("directory obstruction", Files.readString(folder.resolve("logs/older")));
+ Files.delete(current); Files.createDirectory(current); assertDoesNotThrow(() -> writer.logEntry("cannot append to directory"));
+ Path obstruction = folder.resolve("plugin-is-file"); Files.writeString(obstruction, "do not replace");
+ assertDoesNotThrow(() -> new LogWriter(obstruction.toFile())); assertEquals("do not replace", Files.readString(obstruction));
+ }
+
+ @Test void optionalCoreProtectIntegrationReportsEntryExitPlacementAndRemoval() {
+ boolean previous = Cache.coreProtect; Player pilot = player(); ActiveVehicle vehicle = vehicle(); Block block = mock(Block.class);
+ when(block.getLocation()).thenReturn(location(32, 64, -48)); when(block.getType()).thenReturn(Material.OAK_PLANKS);
+ try (var framework = mockStatic(VehicleFramework.class, RETURNS_DEEP_STUBS)) {
+ var core = VehicleFramework.getCoreProtect(); Cache.coreProtect = false;
+ LogWriter.logEnter(pilot, vehicle); LogWriter.logExit(pilot, vehicle); LogWriter.logPlace("pilot", block); LogWriter.logBreak("pilot", block);
+ verifyNoInteractions(core); Cache.coreProtect = true;
+ LogWriter.logEnter(pilot, vehicle); LogWriter.logExit(pilot, vehicle); LogWriter.logPlace("pilot", block); LogWriter.logBreak("pilot", block);
+ verify(core, times(2)).logInteraction("Pilot", vehicle.getEntity().getLocation()); verify(core).logChat(pilot, "+tug"); verify(core).logChat(pilot, "-tug");
+ verify(core).logPlacement("pilot", block.getLocation(), Material.OAK_PLANKS, null); verify(core).logRemoval("pilot", block.getLocation(), Material.OAK_PLANKS, null);
+ } finally { Cache.coreProtect = previous; }
+ }
+
+ @Test void persistenceLogAppendsAcrossConfigurationAndDescribesPositionsAndPlayers() throws Exception {
+ assertFalse(PersistenceLog.isEnabled()); PersistenceLog.append("disabled"); assertFalse(Files.exists(logFile()));
+ PersistenceLog.configure(true, folder.toFile()); assertTrue(PersistenceLog.isEnabled()); PersistenceLog.append(null);
+ PersistenceLog.append("before reload"); PersistenceLog.configure(true, null); PersistenceLog.append("after reload");
+ String log = log(); assertTrue(log.contains("before reload")); assertTrue(log.contains("after reload"));
+ assertEquals("null", PersistenceLog.xyz(null)); assertEquals("null", PersistenceLog.xyz(new Location(null, 0, 0, 0)));
+ assertEquals("1.250,2.500,-3.750", PersistenceLog.xyz(1.25, 2.5, -3.75));
+ assertTrue(PersistenceLog.xyz(location(32.25, 64, -48.5)).contains("harbour 32.250,64.000,-48.500 yaw=0.000 pitch=0.000 chunk=2,-3 loaded=true"));
+ assertEquals("player=null", PersistenceLog.player(null)); Player pilot = player(); assertTrue(PersistenceLog.player(pilot).contains("bukkitVehicle=none"));
+ Entity mount = mock(Entity.class); UUID mountId = UUID.randomUUID(); when(mount.getUniqueId()).thenReturn(mountId); when(pilot.getVehicle()).thenReturn(mount);
+ when(pilot.isInsideVehicle()).thenReturn(true); assertTrue(PersistenceLog.player(pilot).contains("inside=true bukkitVehicle=" + mountId));
+ assertEquals("vehicle=null", PersistenceLog.vehicle(null)); ActiveVehicle vehicle = vehicle();
+ assertTrue(PersistenceLog.vehicle(vehicle).contains("train=false")); when(vehicle.getSeatHandler()).thenReturn(null);
+ when(vehicle.getEntity()).thenReturn(null); assertTrue(PersistenceLog.vehicle(vehicle).contains("entity=null dead=true train=false vfPassengers=0"));
+ }
+
+ @Test void trainSummariesUseLiveRelationsOrPendingIdentifiers() {
+ ActiveVehicle vehicle = vehicle(); when(vehicle.isTrain()).thenReturn(true); var train = vehicle.getTrainHandler();
+ UUID track = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
+ when(train.isBound()).thenReturn(true); when(train.getSplineId()).thenReturn(track); when(train.getS()).thenReturn(4.5); when(train.getTravelSign()).thenReturn(-1);
+ when(train.getPendingParent()).thenReturn("pending-parent"); when(train.getPendingChild()).thenReturn("pending-child");
+ String pending = PersistenceLog.vehicle(vehicle); assertTrue(pending.contains("bound=true spline=" + track + " s=4.500 sign=-1 parent=pending-parent child=pending-child"));
+ when(vehicle.hasParent()).thenReturn(true); when(vehicle.getParent().getUUID()).thenReturn("live-parent");
+ when(train.hasChild()).thenReturn(true); when(train.getChild().getUUID()).thenReturn("live-child");
+ assertTrue(PersistenceLog.vehicle(vehicle).contains("parent=live-parent child=live-child liveParent=true liveChild=true"));
+ }
+
+ @Test void lifecycleAndMountEventsRecordActionDetailsAndSpawnCycle() throws Exception {
+ PersistenceLog.configure(true, folder.toFile()); ActiveVehicle vehicle = vehicle(); Player pilot = player(); Seat seat = new Seat(SeatType.CAPTAIN, "driver");
+ int before = PersistenceLog.spawnCycle(); PersistenceLog.spawnManagerStart(3); assertEquals(before + 1, PersistenceLog.spawnCycle());
+ assertEquals(before + 2, PersistenceLog.nextSpawnCycle()); Location here = location(32, 64, -48);
+ PersistenceLog.spawnLoad("cart.json", here); PersistenceLog.spawnQueued("cart.json", here, "harbour:2,-3"); PersistenceLog.spawned(vehicle, here);
+ PersistenceLog.saveVehicle(vehicle, here); PersistenceLog.saveSpawn("cart.json", here);
+ PersistenceLog.loadJson("cart.json", "uuid", "tug", null); PersistenceLog.loadJson("cart.json", "uuid", "tug", ConsistData.unbound());
+ PersistenceLog.loadJson("cart.json", "uuid", "tug", new ConsistData("p", "c", "track", 4.5, -1));
+ PersistenceLog.tryLink("reload", vehicle); PersistenceLog.mount(pilot, vehicle, seat, true, true); PersistenceLog.mount(null, null, null, false, false);
+ PersistenceLog.remount(pilot, vehicle, "driver"); PersistenceLog.remount(vehicle.getEntity(), vehicle, "harness");
+ PersistenceLog.dismount(pilot, vehicle, true); PersistenceLog.dismount(vehicle.getEntity(), vehicle, false);
+ PersistenceLog.unload(null, "absent"); PersistenceLog.unload(vehicle, "chunk unloaded");
+ String log = log();
+ for (String marker : List.of("SPAWN_MANAGER_START", "SPAWN_LOAD", "SPAWN_QUEUED", "SPAWNED", "SAVE_VEHICLE", "SAVE_SPAWN", "LOAD_JSON", "TRY_LINK", "MOUNT", "REMOUNT", "DISMOUNT", "UNLOAD")) assertTrue(log.contains(marker), marker);
+ assertTrue(log.contains("consist=parent=p child=c spline=track s=4.5 sign=-1")); assertTrue(log.contains("seat=driver type=CAPTAIN manager=true meSeatMap=true"));
+ }
+
+ @Test void posePlacementAndParticleLogsThrottleNoiseAndResetWhenVehicleUnloads() throws Exception {
+ PersistenceLog.configure(true, folder.toFile()); ActiveVehicle vehicle = vehicle(); when(vehicle.getSpawnTime()).thenReturn(System.currentTimeMillis() - 30000);
+ Location here = location(32, 64, -48), near = here.clone().add(.1, 0, 0), far = here.clone().add(2, 0, 0);
+ PersistenceLog.placeCars(null); PersistenceLog.applyPose(null, "ignored", here, far); PersistenceLog.particleOffset(null, here, far);
+ ActiveVehicle unidentified = mock(ActiveVehicle.class); PersistenceLog.particleOffset(unidentified, here, far);
+ for (int i = 0; i < 22; i++) { PersistenceLog.placeCars(vehicle); PersistenceLog.applyPose(vehicle, "settle", here, near); }
+ assertEquals(20, count("PLACE_CARS")); assertEquals(20, count("APPLY_POSE"));
+ PersistenceLog.applyPose(vehicle, "large correction", here, far); assertEquals(21, count("APPLY_POSE"));
+ PersistenceLog.particleOffset(vehicle, here, near); PersistenceLog.particleOffset(vehicle, here, far); PersistenceLog.particleOffset(vehicle, here, far);
+ assertEquals(1, count("SMOKE_OFFSET")); PersistenceLog.unload(vehicle, "reload");
+ PersistenceLog.placeCars(vehicle); PersistenceLog.applyPose(vehicle, "reload", here, near); PersistenceLog.particleOffset(vehicle, here, far);
+ assertEquals(21, count("PLACE_CARS")); assertEquals(22, count("APPLY_POSE")); assertEquals(2, count("SMOKE_OFFSET"));
+ PersistenceLog.applyPose(vehicle, "null endpoints", null, here); PersistenceLog.applyPose(vehicle, "worldless", new Location(null, 1, 2, 3), here);
+ World other = mock(World.class); Chunk otherChunk = mock(Chunk.class); when(other.getChunkAt(any(Location.class))).thenReturn(otherChunk);
+ PersistenceLog.applyPose(vehicle, "other world", new Location(other, 1, 2, 3), here); assertTrue(log().contains("gap=-1.000"));
+ when(vehicle.getEntity()).thenReturn(null); PersistenceLog.applyPose(vehicle, "removed entity", here, far); assertTrue(log().contains("after=null"));
+ }
+
+ @Test void filesystemFailureIsReportedWithoutCrashingTheCaller() throws Exception {
+ Path obstruction = folder.resolve("logs"); Files.writeString(obstruction, "keep this file");
+ Logger logger = Logger.getLogger(PersistenceLog.class.getName()); List records = new ArrayList<>();
+ Handler capture = new Handler() { public void publish(LogRecord record) { records.add(record); } public void flush() {} public void close() {} };
+ logger.addHandler(capture);
+ try { assertDoesNotThrow(() -> PersistenceLog.configure(true, folder.toFile())); } finally { logger.removeHandler(capture); }
+ assertTrue(records.stream().anyMatch(record -> record.getMessage().contains("Failed to write persistence.log")));
+ assertEquals("keep this file", Files.readString(obstruction));
+ }
+
+ private ActiveVehicle vehicle() {
+ ActiveVehicle vehicle = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); Entity entity = mock(Entity.class);
+ when(vehicle.getUUID()).thenReturn(UUID.randomUUID().toString()); when(vehicle.getId()).thenReturn("tug"); when(vehicle.getName()).thenReturn("Tug");
+ when(vehicle.getSpawnTime()).thenReturn(System.currentTimeMillis()); when(vehicle.getEntity()).thenReturn(entity);
+ when(entity.getUniqueId()).thenReturn(UUID.randomUUID()); when(entity.getLocation()).thenReturn(location(32, 64, -48)); when(entity.getPassengers()).thenReturn(List.of());
+ when(vehicle.getSeatHandler().getPassengers()).thenReturn(List.of()); vehicles.add(vehicle); return vehicle;
+ }
+ private Player player() {
+ Player player = mock(Player.class); when(player.getName()).thenReturn("Pilot"); when(player.getLocation()).thenReturn(location(32, 64, -48)); return player;
+ }
+ private Location location(double x, double y, double z) { return new Location(world, x, y, z); }
+ private Path logFile() { return folder.resolve("logs/persistence.log"); }
+ private String log() throws IOException { return Files.readString(logFile()); }
+ private long count(String marker) throws IOException { return log().lines().filter(line -> line.contains(marker)).count(); }
+}
diff --git a/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseRuntimeCoverageTest.java
new file mode 100644
index 0000000..80c1236
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseRuntimeCoverageTest.java
@@ -0,0 +1,223 @@
+package net.tfminecraft.vehicleframework.database;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.nio.file.*;
+import java.util.*;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import com.google.gson.JsonParser;
+import net.tfminecraft.vehicleframework.VFLogger;
+import net.tfminecraft.vehicleframework.VehicleFramework;
+import net.tfminecraft.vehicleframework.data.HealthData;
+import net.tfminecraft.vehicleframework.enums.Component;
+import net.tfminecraft.vehicleframework.loaders.VehicleLoader;
+import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
+import net.tfminecraft.vehicleframework.vehicles.Vehicle;
+import net.tfminecraft.vehicleframework.vehicles.component.VehicleComponent;
+
+class DatabaseRuntimeCoverageTest {
+ @TempDir Path directory;
+ static final String ID = VehiclePayloadCoverageTest.ID;
+ static final String PAYLOAD = "{\"id\":\"cart\",\"owner\":\"player_Alice\",\"whitelisted\":true,\"whitelist\":[\"Bob\"],\"components\":{\"hull\":{\"damage\":5}}}";
+
+ @Test void liveSaveReturnsPreciseStoredSavedAndFailureOutcomes() {
+ VehicleRepository repository = mock(VehicleRepository.class);
+ VehiclePersistence persistence = new VehiclePersistence(repository);
+ ActiveVehicle live = VehiclePayloadCoverageTest.liveVehicle();
+ assertSame(repository, persistence.repository());
+ when(repository.saveLive(any())).thenReturn(true);
+ assertTrue(persistence.saveLive(live));
+ when(repository.saveLive(any())).thenReturn(false);
+ assertTrue(persistence.saveLiveResult(live).isFailed());
+ when(repository.findLive(ID)).thenReturn(Optional.of(snapshot(PAYLOAD)));
+ assertTrue(persistence.saveLiveResult(live).isAlreadyStored());
+ doThrow(new IllegalStateException()).when(repository).saveLive(any());
+ assertEquals("SQLite save failed", persistence.saveLiveResult(live).reason());
+ doThrow(new IllegalStateException("locked")).when(repository).saveLive(any());
+ assertEquals("SQLite save failed: locked", persistence.saveLiveResult(live).reason());
+ when(live.getEntity()).thenReturn(null);
+ assertEquals("entity invalid", persistence.saveLiveResult(live).reason());
+ assertTrue(persistence.saveLiveResult(null).isFailed());
+ doThrow(new IllegalStateException()).when(repository).findLive(ID);
+ assertTrue(persistence.resolveFailedLiveSave(ID, " ").isFailed());
+ assertTrue(persistence.resolveFailedLiveSave(" ", null).reason().contains("unknown"));
+ assertTrue(VehiclePersistResult.failed(null).isFailed());
+ assertEquals("unknown", VehiclePersistResult.failed(" ").reason());
+ try (var framework = mockStatic(VehicleFramework.class)) {
+ assertNull(VehiclePersistence.current());
+ framework.when(VehicleFramework::getVehicleRepository).thenReturn(repository);
+ assertSame(repository, VehiclePersistence.current().repository());
+ }
+ }
+
+ @Test void missingRepositoryAndClosedDatabaseFailWithoutMutatingOrThrowing() {
+ VehiclePersistence missing = new VehiclePersistence(null);
+ assertTrue(missing.saveLiveResult(null).isFailed());
+ assertFalse(missing.saveLive(snapshot(PAYLOAD)));
+ assertFalse(missing.tombstone(ID));
+ assertFalse(missing.checkpointWal(true));
+ assertFalse(missing.vacuumIntoBackup());
+ VehicleRepository repository = VehicleRepository.open(directory.resolve("closed.db").toFile());
+ repository.saveLive(snapshot(PAYLOAD));
+ repository.close();
+ VehiclePersistence closed = new VehiclePersistence(repository);
+ try (var logger = mockStatic(VFLogger.class)) {
+ logger.when(() -> VFLogger.log(anyString())).thenThrow(new IllegalStateException("logger unavailable"));
+ assertTrue(closed.loadIncomplete(ID).isEmpty());
+ assertFalse(closed.tombstone(ID));
+ assertFalse(closed.checkpointWal(false));
+ assertTrue(closed.findChunk("world", 0, 0).isEmpty());
+ assertTrue(closed.findLive(ID).isEmpty());
+ assertTrue(closed.countByOwner("player_Alice", List.of()).isEmpty());
+ assertTrue(closed.listByOwner("player_Alice").isEmpty());
+ assertTrue(closed.listPlayerOwned().isEmpty());
+ assertTrue(closed.readMeta(ID).isEmpty());
+ assertFalse(closed.clearOwnership(ID));
+ assertFalse(closed.applyStoredDecay(ID, .1, .03));
+ assertFalse(closed.saveLive(snapshot(PAYLOAD)));
+ }
+ VehicleRepository broken = mock(VehicleRepository.class);
+ when(broken.hasLiveInChunk(anyString(), anyInt(), anyInt())).thenThrow(new IllegalStateException());
+ assertFalse(new VehiclePersistence(broken).hasLiveInChunk("world", 0, 0));
+ }
+
+ @Test void ownershipAndDecayChangesCommitTogetherAndSurviveRestart() {
+ Path file = directory.resolve("mutations.db");
+ VehicleRepository repository = VehicleRepository.open(file.toFile());
+ try (var templates = mockStatic(VehicleLoader.class)) {
+ VehiclePersistence persistence = new VehiclePersistence(repository);
+ assertFalse(persistence.clearOwnership("missing"));
+ assertFalse(persistence.applyStoredDecay("missing", .1, .03));
+ assertTrue(persistence.loadIncomplete("missing").isEmpty());
+ assertFalse(persistence.tombstone(null)); assertFalse(persistence.tombstone(" "));
+ repository.saveLive(snapshot(PAYLOAD));
+ assertFalse(persistence.applyStoredDecay(ID, .1, .03));
+ Vehicle template = mock(Vehicle.class, RETURNS_DEEP_STUBS);
+ VehicleComponent hull = mock(VehicleComponent.class);
+ when(hull.getType()).thenReturn(Component.HULL); when(hull.getHealthData()).thenReturn(new HealthData(100, 0, 1));
+ when(template.getComponentHandler().getComponents()).thenReturn(List.of(hull));
+ templates.when(() -> VehicleLoader.getByString("cart")).thenReturn(template);
+ assertTrue(persistence.applyStoredDecay(ID, 0, .03));
+ assertEquals(1, repository.findLive(ID).orElseThrow().getRevision());
+ assertTrue(persistence.applyStoredDecay(ID, .1, .03));
+ assertTrue(persistence.clearOwnership(ID));
+ assertTrue(persistence.checkpointWal(false)); assertTrue(persistence.checkpointWal(true));
+ } finally { repository.close(); }
+ repository = VehicleRepository.open(file.toFile());
+ try {
+ VehicleSnapshot row = repository.findLive(ID).orElseThrow();
+ var payload = JsonParser.parseString(row.getPayloadJson()).getAsJsonObject();
+ assertEquals(3, row.getRevision()); assertEquals("none", row.getOwner());
+ assertEquals("none", payload.get("owner").getAsString());
+ assertFalse(payload.get("whitelisted").getAsBoolean());
+ assertTrue(payload.getAsJsonArray("whitelist").isEmpty());
+ assertEquals(15, payload.getAsJsonObject("components").getAsJsonObject("hull").get("damage").getAsDouble());
+ repository.saveLive(snapshot("{"));
+ VehiclePersistence persistence = new VehiclePersistence(repository);
+ assertFalse(persistence.clearOwnership(ID));
+ assertFalse(persistence.applyStoredDecay(ID, .1, .03));
+ assertEquals("{", repository.findLive(ID).orElseThrow().getPayloadJson());
+ } finally { repository.close(); }
+ }
+
+ @Test void tombstoneAndBackupFailuresKeepTheirReturnContracts() {
+ VehicleRepository repository = mock(VehicleRepository.class);
+ VehiclePersistence persistence = new VehiclePersistence(repository);
+ when(repository.find(ID)).thenReturn(Optional.of(snapshot(PAYLOAD)));
+ assertFalse(persistence.tombstone(ID));
+ VehicleFramework previous = VehicleFramework.plugin;
+ try {
+ VehicleFramework.plugin = null;
+ assertFalse(persistence.vacuumIntoBackup());
+ VehicleFramework.plugin = mock(VehicleFramework.class);
+ assertFalse(persistence.vacuumIntoBackup());
+ when(VehicleFramework.plugin.getDataFolder()).thenReturn(directory.toFile());
+ when(repository.vacuumIntoBackup(any())).thenReturn(true);
+ assertTrue(persistence.vacuumIntoBackup());
+ doThrow(new IllegalStateException()).when(repository).vacuumIntoBackup(any());
+ assertFalse(persistence.vacuumIntoBackup());
+ } finally { VehicleFramework.plugin = previous; }
+ }
+
+ @Test void repositoryFiltersInvalidRowsAndRebuildsOccupancyOnRollback() throws Exception {
+ VehicleRepository repository = VehicleRepository.open(directory.resolve("filters.db").toFile());
+ try {
+ repository.upsert(null); assertFalse(repository.saveLive(null));
+ VehicleSnapshot invalid = new VehicleSnapshot(null, "cart", null, null, "world", 0, 0, 0, 0, 0, 0, "{}", 2, 1, false, 1);
+ repository.upsert(invalid); assertFalse(repository.saveLive(invalid));
+ for (String absent : Arrays.asList(null, " ")) {
+ assertTrue(repository.find(absent).isEmpty());
+ assertTrue(repository.findLive(absent).isEmpty());
+ assertTrue(repository.findNear(absent,0,0,1).isEmpty());
+ assertTrue(repository.listLiveByOwner(absent).isEmpty());
+ assertEquals(0,repository.tombstone(absent));
+ assertEquals(0,repository.tombstone(absent,1,1));
+ }
+ repository.runInTransaction(null);
+ repository.upsert(new VehicleSnapshot("legacy-untyped", " ", "Unknown", "player_Alice", "world", 0,64,0,0,0,0,"{}",1,1,false,1));
+ repository.saveLive(snapshot(PAYLOAD));
+ assertTrue(repository.countLiveByOwner(null, null).isEmpty());
+ assertTrue(repository.countLiveByOwner(" ", null).isEmpty());
+ assertTrue(repository.countLiveByOwner("player_Alice", Arrays.asList(null, " ", ID.toUpperCase(Locale.ROOT))).isEmpty());
+ assertEquals(Map.of("cart", 1), repository.countLiveByOwner("player_Alice", null));
+ assertThrows(IllegalStateException.class, () -> repository.runInTransaction(() -> {
+ repository.tombstone(ID);
+ throw new IllegalStateException("rollback");
+ }));
+ assertTrue(repository.hasLiveInChunk("world", 0, 0));
+ assertFalse(repository.vacuumIntoBackup(null));
+ assertEquals(1, repository.findLive(ID).orElseThrow().getSchemaVersion());
+ assertEquals(1,repository.tombstone(ID));
+ try (var connection=java.sql.DriverManager.getConnection("jdbc:sqlite:"+directory.resolve("filters.db"));
+ var statement=connection.createStatement()) {
+ statement.execute("CREATE TRIGGER reject_writes BEFORE INSERT ON vehicles BEGIN SELECT RAISE(IGNORE); END");
+ }
+ assertFalse(repository.saveLive(snapshot(PAYLOAD)), "A database-side rejection must not report persistence");
+ assertTrue(repository.find(ID).orElseThrow().isDeleted());
+ assertEquals(1,repository.findChunk("world",0,0).size(), "Only the unrelated stored row should remain live");
+ } finally { repository.close(); }
+ }
+
+ @Test void chunkIndexClearsDeletedAndInvalidLocationsWithoutLeakingCounts() {
+ OccupiedChunkIndex index = new OccupiedChunkIndex();
+ index.replace(null); index.putLive((VehicleSnapshot) null); index.putLive((OccupiedChunkIndex.LiveLocation) null);
+ index.putLive(null, "world", 0, 0); index.putLive(" ", "world", 0, 0); index.putLive(ID, null, 0, 0); index.putLive(ID, " ", 0, 0);
+ assertEquals(0, index.count(null, 0, 0)); assertEquals(0, index.count(" ", 0, 0));
+ index.putLive(snapshot(PAYLOAD));
+ index.putLive(new VehicleSnapshot(ID, "cart", "Cart", "none", "world", 0, 0, 0, 0, 0, 0, "{}", 1, 1, true, 1));
+ assertFalse(index.isOccupied("world", 0, 0)); index.remove(null); index.remove(" ");
+ }
+
+ @Test void failureLoggingAndRevisionCopiesPreserveUsefulState() {
+ VehicleRepository repository = mock(VehicleRepository.class);
+ when(repository.findChunk("world", 0, 0)).thenThrow(new IllegalStateException("offline"));
+ try (var logger = mockStatic(VFLogger.class)) {
+ assertTrue(new VehiclePersistence(repository).findChunk("world", 0, 0).isEmpty());
+ logger.verify(() -> VFLogger.log("SQLite chunk query failed: offline"));
+ }
+ VehicleSnapshot original = snapshot(PAYLOAD), next = original.withRevision(4, 123);
+ assertEquals(4, next.getRevision()); assertEquals(123, next.getUpdatedAt());
+ assertEquals(original.getPayloadJson(), next.getPayloadJson());
+ assertEquals(original.getUuid(), next.getUuid()); assertEquals(original.getOwner(), next.getOwner());
+ assertEquals(1, original.getRevision());
+ assertTrue(new IncompleteComponent(Component.HULL, 1, 0, 0).hasDamage());
+ assertFalse(new IncompleteComponent(Component.HULL, 0, 0, 0).hasDamage());
+ }
+
+ @Test void legacyConsistStringsAndMissingDataRemainCompatible() {
+ ConsistData empty = ConsistData.fromJson(null);
+ assertTrue(empty.isUnbound()); assertDoesNotThrow(() -> empty.put(null));
+ ConsistData legacy = ConsistData.fromJson(VehiclePayloadCoverageTest.object(
+ "{\"splineId\":\"track\",\"s\":\"12.5\",\"travelSign\":\"-1\",\"junction\":\"switch\",\"diverge\":\"true\"}"));
+ assertEquals(12.5, legacy.getS()); assertEquals(-1, legacy.getTravelSign()); assertTrue(legacy.isDiverge());
+ ConsistData invalid = ConsistData.fromJson(VehiclePayloadCoverageTest.object(
+ "{\"splineId\":\"track\",\"s\":\"invalid\",\"travelSign\":\"invalid\"}"));
+ assertNull(invalid.getS()); assertEquals(1, invalid.getTravelSign());
+ }
+
+ static VehicleSnapshot snapshot(String payload) {
+ return new VehicleSnapshot(ID, "cart", "Cart", "player_Alice", "world", 0, 64, 0, 45, 0, 0, payload, 1, 1, false, 1);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/vehicleframework/database/VehicleBackupCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/database/VehicleBackupCoverageTest.java
new file mode 100644
index 0000000..0926472
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/database/VehicleBackupCoverageTest.java
@@ -0,0 +1,293 @@
+package net.tfminecraft.vehicleframework.database;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.nio.file.attribute.FileTime;
+import java.sql.*;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.*;
+import net.tfminecraft.tlibs.database.SqliteDatabase;
+import net.tfminecraft.tlibs.database.SqliteDatabaseException;
+import net.tfminecraft.vehicleframework.VFLogger;
+
+class VehicleBackupCoverageTest {
+ @TempDir Path folder;
+
+ @Test void failedRestoreLeavesOriginalDatabaseAndItsSidecarsUntouched() throws Exception {
+ Path live = folder.resolve("vehicles.db"); Files.writeString(live, "recoverable original bytes");
+ Path wal = folder.resolve("vehicles.db-wal"), shm = folder.resolve("vehicles.db-shm"); Files.writeString(wal, "wal bytes"); Files.writeString(shm, "shm bytes");
+ assertThrows(IOException.class, () -> VehicleSqliteBackup.replaceLive(live.toFile(), folder.resolve("missing.db").toFile()));
+ assertTrue(Files.exists(live), "Failed backup reads must not remove the live database"); assertEquals("recoverable original bytes", Files.readString(live));
+ assertEquals("wal bytes", Files.readString(wal)); assertEquals("shm bytes", Files.readString(shm));
+ }
+
+ @Test void backupValidationRejectsEmptyFilesWithoutInitializingThem() throws Exception {
+ Path empty = Files.createFile(folder.resolve("vehicles-empty.db"));
+ assertFalse(VehicleSqliteBackup.isValidDatabase(empty.toFile()), "A zero-byte file is not a usable vehicle backup"); assertEquals(0, Files.size(empty));
+ }
+
+ @Test void backupValidationRejectsOtherSqliteDatabasesWithoutCreatingVehicleTables() throws Exception {
+ Path unrelated = folder.resolve("vehicles-unrelated.db");
+ try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + unrelated); Statement statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE unrelated(value TEXT)"); statement.execute("INSERT INTO unrelated VALUES ('preserve')");
+ }
+ byte[] original = Files.readAllBytes(unrelated); assertFalse(VehicleSqliteBackup.isValidDatabase(unrelated.toFile()));
+ assertArrayEquals(original, Files.readAllBytes(unrelated));
+ }
+
+ @Test void restoreSelectsAnOlderRealBackupOverANewerEmptySnapshot() throws Exception {
+ Path backups = Files.createDirectory(folder.resolve("backups")); File good = database(backups.resolve("vehicles-20260101-000000.db"));
+ Path empty = Files.createFile(backups.resolve("vehicles-20260102-000000.db"));
+ Files.setLastModifiedTime(good.toPath(), FileTime.fromMillis(1000)); Files.setLastModifiedTime(empty, FileTime.fromMillis(2000));
+ assertEquals(good, VehicleSqliteBackup.findNewestValid(backups.toFile())); assertEquals(0, Files.size(empty));
+ }
+
+ @Test void newestBackupUsesSequenceNumbersWhenFilesystemTimestampsTie() throws Exception {
+ Path backups = Files.createDirectory(folder.resolve("backups")); File old = database(backups.resolve("vehicles-20260101-000000.db"));
+ File newest = backups.resolve("vehicles-20260101-000000-10.db").toFile(); Files.copy(old.toPath(), newest.toPath());
+ Files.setLastModifiedTime(old.toPath(), FileTime.fromMillis(1000)); Files.setLastModifiedTime(newest.toPath(), FileTime.fromMillis(1000));
+ File directory = spy(backups.toFile()); doReturn(new File[]{old, newest}).when(directory).listFiles();
+ assertEquals(newest, VehicleSqliteBackup.findNewestValid(directory));
+ }
+
+ @Test void failedSidelineNeverDeletesTheOriginalDatabase() throws Exception {
+ Path live = folder.resolve("vehicles.db"); Files.writeString(live, "original"); File backup = database(folder.resolve("backup.db"));
+ File source = spy(live.toFile()); doReturn(false).when(source).renameTo(any(File.class));
+ try (var files = mockStatic(Files.class, call -> {
+ if (call.getMethod().getName().equals("move") && live.equals(call.getArgument(0))) throw new IOException("simulated move failure");
+ return call.callRealMethod();
+ })) {
+ assertThrows(IOException.class, () -> VehicleSqliteBackup.replaceLive(source, backup));
+ }
+ assertEquals("original", Files.readString(live));
+ }
+
+ @Test void failedFinalInstallationRestoresTheOriginalDatabaseAndRetainsItsJournals() throws Exception {
+ Path live = Files.writeString(folder.resolve("vehicles.db"), "original recoverable bytes"); File backup = database(folder.resolve("backup.db"));
+ Path wal = Files.writeString(folder.resolve("vehicles.db-wal"), "original wal"), shm = Files.writeString(folder.resolve("vehicles.db-shm"), "original shm");
+ try (var files = mockStatic(Files.class, call -> {
+ if (call.getMethod().getName().equals("move") && ((Path)call.getArgument(0)).getFileName().toString().startsWith("vehicles-restore-"))
+ throw new IOException("simulated final install failure");
+ return call.callRealMethod();
+ })) {
+ IOException failure = assertThrows(IOException.class, () -> VehicleSqliteBackup.replaceLive(live.toFile(), backup));
+ assertEquals("simulated final install failure", failure.getMessage());
+ }
+ assertTrue(Files.exists(live), "A failed final rename must restore the original live file"); assertEquals("original recoverable bytes", Files.readString(live));
+ assertEquals("original wal", Files.readString(wal)); assertEquals("original shm", Files.readString(shm));
+ try (Stream files = Files.list(folder)) { assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith("vehicles-restore-"))); }
+ }
+
+ @Test void refusedLegacyRenameCanFallBackToNioMoveWithoutLosingTheOriginal() throws Exception {
+ Path live = Files.writeString(folder.resolve("vehicles.db"), "original"); File backup = database(folder.resolve("backup.db"));
+ File source = spy(live.toFile()); doReturn(false).when(source).renameTo(any(File.class));
+ VehicleSqliteBackup.replaceLive(source, backup); assertTrue(VehicleSqliteBackup.isValidDatabase(live.toFile()));
+ try (Stream files = Files.list(folder)) {
+ assertTrue(files.filter(path -> path.getFileName().toString().startsWith("vehicles.db.corrupt-")).anyMatch(path -> read(path).equals("original")));
+ }
+ }
+
+ @Test void readonlyValidationPreservesLegacySchemaUntilAnActualRestoreMigratesIt() throws Exception {
+ Path backups = Files.createDirectory(folder.resolve("backups")); Path old = backups.resolve("vehicles-20250101-000000.db");
+ try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + old); Statement statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE vehicles(uuid TEXT PRIMARY KEY,type_id TEXT,world TEXT,x REAL,y REAL,z REAL,yaw REAL,chunk_x INTEGER,chunk_z INTEGER,payload_json TEXT,schema_version INTEGER,revision INTEGER,deleted INTEGER,updated_at INTEGER)");
+ statement.execute("INSERT INTO vehicles VALUES('legacy','tug','world',1,64,1,0,0,0,'{}',1,1,0,1)");
+ }
+ byte[] original = Files.readAllBytes(old); assertTrue(VehicleSqliteBackup.isValidDatabase(old.toFile())); assertArrayEquals(original, Files.readAllBytes(old));
+ Path live = Files.writeString(folder.resolve("vehicles.db"), "corrupt");
+ try (var logger = mockStatic(VFLogger.class)) {
+ VehicleRepository restored = VehicleRepository.openWithRestore(live.toFile(), backups.toFile());
+ try { assertEquals("", restored.findLive("legacy").orElseThrow().getName()); assertEquals("none", restored.findLive("legacy").orElseThrow().getOwner()); }
+ finally { restored.close(); }
+ }
+ assertArrayEquals(original, Files.readAllBytes(old));
+ }
+
+ @Test void validationRejectsJdbcResourceFailuresAndClosesTheRemainingResources() throws Exception {
+ File backup = database(folder.resolve("backup.db")); Connection connection = mock(Connection.class); Statement statement = mock(Statement.class);
+ ResultSet result = mock(ResultSet.class); when(connection.createStatement()).thenReturn(statement);
+ when(statement.executeQuery("PRAGMA quick_check")).thenReturn(result); when(result.next()).thenReturn(true, false); when(result.getString(1)).thenReturn("ok");
+ doThrow(new SQLException("failed to close integrity result")).when(result).close();
+ try (var driver = mockStatic(DriverManager.class)) {
+ driver.when(() -> DriverManager.getConnection(anyString())).thenReturn(connection);
+ assertFalse(VehicleSqliteBackup.isValidDatabase(backup));
+ }
+ verify(result).close(); verify(statement).close(); verify(connection).close(); assertTrue(VehicleSqliteBackup.isValidDatabase(backup));
+ }
+
+ @Test void integrityErrorRowsRejectABackupAndCloseEveryJdbcResource() throws Exception {
+ File backup = database(folder.resolve("backup.db")); Connection connection = mock(Connection.class); Statement statement = mock(Statement.class);
+ ResultSet result = mock(ResultSet.class); when(connection.createStatement()).thenReturn(statement);
+ when(statement.executeQuery("PRAGMA quick_check")).thenReturn(result); when(result.next()).thenReturn(true, false); when(result.getString(1)).thenReturn("page 5: invalid index");
+ try (var driver = mockStatic(DriverManager.class)) {
+ driver.when(() -> DriverManager.getConnection(anyString())).thenReturn(connection); assertFalse(VehicleSqliteBackup.isValidDatabase(backup));
+ }
+ verify(result).close(); verify(statement).close(); verify(connection).close();
+ verify(statement, times(1)).executeQuery(anyString());
+ }
+
+ @Test void rollbackFailurePreservesTheArchivedOriginalAndReportsBothIoFailures() throws Exception {
+ Path live = Files.writeString(folder.resolve("vehicles.db"), "original"); File backup = database(folder.resolve("backup.db"));
+ Path wal = Files.writeString(folder.resolve("vehicles.db-wal"), "wal");
+ try (var files = mockStatic(Files.class, call -> {
+ if (call.getMethod().getName().equals("move")) {
+ String name = ((Path)call.getArgument(0)).getFileName().toString();
+ if (name.startsWith("vehicles-restore-")) throw new IOException("install failed");
+ if (name.startsWith("vehicles.db.corrupt-")) throw new IOException("rollback failed");
+ }
+ return call.callRealMethod();
+ })) {
+ IOException failure = assertThrows(IOException.class, () -> VehicleSqliteBackup.replaceLive(live.toFile(), backup));
+ assertEquals("install failed", failure.getMessage()); assertEquals(1, failure.getSuppressed().length);
+ assertEquals("rollback failed", failure.getSuppressed()[0].getMessage());
+ }
+ assertEquals("wal", Files.readString(wal));
+ try (Stream files = Files.list(folder)) {
+ assertTrue(files.filter(path -> path.getFileName().toString().startsWith("vehicles.db.corrupt-")).anyMatch(path -> read(path).equals("original")));
+ }
+ }
+
+ @Test void cleanupFailureCannotHideTheInstallFailureOrItsFailedRollback() throws Exception {
+ Path live = Files.writeString(folder.resolve("vehicles.db"), "original database bytes");
+ File backup = database(folder.resolve("backup.db"));
+ Path wal = Files.writeString(folder.resolve("vehicles.db-wal"), "original wal");
+ Path shm = Files.writeString(folder.resolve("vehicles.db-shm"), "original shm");
+ IOException install = new IOException("install failed"), rollback = new IOException("rollback failed"), cleanup = new IOException("cleanup failed");
+ IOException reported;
+ try (var files = mockStatic(Files.class, call -> {
+ if (call.getMethod().getName().equals("move")) {
+ String name = ((Path)call.getArgument(0)).getFileName().toString();
+ if (name.startsWith("vehicles-restore-")) throw install;
+ if (name.startsWith("vehicles.db.corrupt-")) throw rollback;
+ }
+ if (call.getMethod().getName().equals("deleteIfExists")
+ && ((Path)call.getArgument(0)).getFileName().toString().startsWith("vehicles-restore-")) throw cleanup;
+ return call.callRealMethod();
+ })) {
+ reported = assertThrows(IOException.class, () -> VehicleSqliteBackup.replaceLive(live.toFile(), backup));
+ }
+ Path archived;
+ try (Stream files = Files.list(folder)) {
+ archived = files.filter(path -> path.getFileName().toString().startsWith("vehicles.db.corrupt-")).findFirst().orElseThrow();
+ }
+ assertAll(
+ () -> assertSame(install, reported, "Cleanup must not replace the primary restore error"),
+ () -> assertArrayEquals(new Throwable[]{rollback, cleanup}, reported.getSuppressed()),
+ () -> assertEquals("original database bytes", Files.readString(archived)),
+ () -> assertEquals("original wal", Files.readString(wal)),
+ () -> assertEquals("original shm", Files.readString(shm)));
+ }
+
+ @Test void backupNamesEscapeSqlQuotesAndRotationKeepsOnlyNewestSnapshots() throws Exception {
+ assertEquals(folder.resolve("data/backups").toFile(), VehicleSqliteBackup.backupDir(folder.toFile()));
+ assertTrue(VehicleSqliteBackup.vacuumIntoSql(folder.resolve("it's.db").toFile()).contains("it''s.db"));
+ assertFalse(VehicleSqliteBackup.isSnapshotName(null)); assertFalse(VehicleSqliteBackup.isSnapshotName("notes.db"));
+ assertTrue(VehicleSqliteBackup.isSnapshotName("vehicles-backup.DB"));
+ assertTrue(VehicleSqliteBackup.listSnapshots(null).isEmpty()); assertTrue(VehicleSqliteBackup.listSnapshots(folder.resolve("absent").toFile()).isEmpty());
+ Path snapshots = Files.createDirectory(folder.resolve("snapshots")); File first = VehicleSqliteBackup.nextBackupFile(snapshots.toFile()); Files.createFile(first.toPath());
+ File next = VehicleSqliteBackup.nextBackupFile(snapshots.toFile()); assertNotEquals(first, next);
+ Files.createDirectory(snapshots.resolve("vehicles-directory.db")); Files.writeString(snapshots.resolve("notes.txt"), "keep");
+ for (String name : List.of("vehicles-20250101-000000.db", "vehicles-20260101-000000.db", "vehicles-20260101-000000-9.db", "vehicles-20260101-000000-10.db", "vehicles-invalid.db", "vehicles-invalid2.db", "vehicles-20269999-000000.db")) {
+ Path file = Files.writeString(snapshots.resolve(name), name); Files.setLastModifiedTime(file, FileTime.fromMillis(1000));
+ }
+ Files.setLastModifiedTime(first.toPath(), FileTime.fromMillis(0)); VehicleSqliteBackup.rotate(snapshots.toFile());
+ Set kept = new HashSet<>(); for (File file : VehicleSqliteBackup.listSnapshots(snapshots.toFile())) kept.add(file.getName());
+ assertEquals(Set.of("vehicles-20260101-000000.db", "vehicles-20260101-000000-9.db", "vehicles-20260101-000000-10.db"), kept);
+ assertEquals("keep", Files.readString(snapshots.resolve("notes.txt"))); assertTrue(Files.isDirectory(snapshots.resolve("vehicles-directory.db")));
+ }
+
+ @Test void successfulReplacementPreservesCorruptCopiesAndClearsStaleWalSidecars() throws Exception {
+ File backup = database(folder.resolve("backup.db")); Path live = folder.resolve("nested/vehicles.db");
+ VehicleSqliteBackup.replaceLive(live.toFile(), backup); assertTrue(VehicleSqliteBackup.isValidDatabase(live.toFile()));
+ Files.writeString(live, "first corrupt bytes"); Files.writeString(live.resolveSibling("vehicles.db-wal"), "wal"); Files.writeString(live.resolveSibling("vehicles.db-shm"), "shm");
+ String stamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); Path collision = live.resolveSibling("vehicles.db.corrupt-" + stamp);
+ Files.writeString(collision, "older evidence"); VehicleSqliteBackup.replaceLive(live.toFile(), backup);
+ assertEquals("older evidence", Files.readString(collision));
+ try (Stream files = Files.list(live.getParent())) { assertTrue(files.filter(p -> p.getFileName().toString().startsWith("vehicles.db.corrupt-")).anyMatch(p -> read(p).equals("first corrupt bytes"))); }
+ assertFalse(Files.exists(live.resolveSibling("vehicles.db-wal"))); assertFalse(Files.exists(live.resolveSibling("vehicles.db-shm")));
+ assertTrue(VehicleSqliteBackup.isValidDatabase(live.toFile()));
+ }
+
+ @Test void repositoryRestoreRecoversRowsAndIgnoresLoggingFailures() throws Exception {
+ Path backups = Files.createDirectory(folder.resolve("backups")); File good = database(backups.resolve("vehicles-20260101-000000.db"));
+ Path live = Files.writeString(folder.resolve("vehicles.db"), "corrupt");
+ try (var logger = mockStatic(VFLogger.class)) {
+ logger.when(() -> VFLogger.log(anyString())).thenThrow(new IllegalStateException("logger unavailable"));
+ VehicleRepository restored = VehicleRepository.openWithRestore(live.toFile(), backups.toFile());
+ try { assertEquals("tug", restored.findLive("vehicle-1").orElseThrow().getTypeId()); assertEquals("wal", restored.journalMode()); assertEquals(5000, restored.busyTimeoutMillis()); }
+ finally { restored.close(); }
+ }
+ VehicleRepository normal = VehicleRepository.openWithRestore(good, folder.resolve("missing-backups").toFile()); normal.close();
+ }
+
+ @Test void failedVacuumPreservesExistingBackupsAndLiveRows() throws Exception {
+ File live = database(folder.resolve("vehicles.db")); VehicleRepository repository = VehicleRepository.open(live);
+ try {
+ assertFalse(repository.vacuumIntoBackup(null)); File backups = Files.createDirectory(folder.resolve("backups")).toFile();
+ assertTrue(repository.vacuumIntoBackup(backups)); File saved = VehicleSqliteBackup.listSnapshots(backups).get(0); byte[] original = Files.readAllBytes(saved.toPath());
+ Path blocked = Files.writeString(folder.resolve("blocked-directory"), "keep"); assertThrows(RuntimeException.class, () -> repository.vacuumIntoBackup(blocked.toFile()));
+ assertArrayEquals(original, Files.readAllBytes(saved.toPath())); assertTrue(repository.findLive("vehicle-1").isPresent());
+ repository.checkpointWal(false); repository.checkpointWal(true);
+ } finally { repository.close(); }
+ }
+
+ @Test void corruptOrMissingCandidatesCannotBeSelected() throws Exception {
+ assertFalse(VehicleSqliteBackup.isValidDatabase(null)); assertFalse(VehicleSqliteBackup.isValidDatabase(folder.toFile()));
+ Files.writeString(folder.resolve("vehicles-corrupt.db"), "not sqlite");
+ assertNull(VehicleSqliteBackup.findNewestValid(folder.toFile()));
+ Path live = Files.writeString(folder.resolve("broken-live.db"), "not sqlite");
+ assertThrows(RuntimeException.class, () -> VehicleRepository.openWithRestore(live.toFile(), folder.toFile()));
+ }
+
+ @Test void failedOpenReportsIntegrityAndPreservesTheConnectionCloseFailure() throws Exception {
+ Connection connection = mock(Connection.class); PreparedStatement statement = mock(PreparedStatement.class); ResultSet result = mock(ResultSet.class);
+ when(connection.prepareStatement("PRAGMA quick_check")).thenReturn(statement); when(statement.executeQuery()).thenReturn(result);
+ when(result.next()).thenReturn(true, false); when(result.getString(1)).thenReturn("corrupt page");
+ IllegalStateException closing = new IllegalStateException("close failure");
+ try (var databases = mockConstruction(SqliteDatabase.class, (database, context) -> {
+ when(database.getConnection()).thenReturn(connection); doThrow(closing).when(database).close();
+ })) {
+ SqliteDatabaseException failure = assertThrows(SqliteDatabaseException.class, () -> VehicleRepository.open(folder.resolve("invalid.db").toFile()));
+ assertTrue(failure.getMessage().contains("integrity check failed")); assertArrayEquals(new Throwable[]{closing}, failure.getSuppressed());
+ verify(databases.constructed().get(0)).close(); verify(statement).close(); verify(result).close();
+ }
+ }
+
+ @Test void databaseQueryFailuresAreReportedAndFailedConnectionsClose() throws Exception {
+ Connection connection = mock(Connection.class); when(connection.prepareStatement("PRAGMA quick_check")).thenThrow(new SQLException("storage read failure"));
+ try (var databases = mockConstruction(SqliteDatabase.class, (database, context) -> when(database.getConnection()).thenReturn(connection))) {
+ SqliteDatabaseException failure = assertThrows(SqliteDatabaseException.class, () -> VehicleRepository.open(folder.resolve("failed.db").toFile()));
+ assertTrue(failure.getMessage().contains("PRAGMA quick_check")); assertEquals("storage read failure", failure.getCause().getMessage());
+ verify(databases.constructed().get(0)).close();
+ }
+ }
+
+ @Test void restoreWriteFailureKeepsTheOriginalFailureAndDatabaseBytes() throws Exception {
+ Path backups = Files.createDirectory(folder.resolve("backups")); database(backups.resolve("vehicles-20260101-000000.db"));
+ Path restricted = Files.createDirectory(folder.resolve("read-only")); Path live = Files.writeString(restricted.resolve("vehicles.db"), "corrupt but preserved");
+ Set original = Files.getPosixFilePermissions(restricted);
+ Files.setPosixFilePermissions(restricted, java.nio.file.attribute.PosixFilePermissions.fromString("r-x------"));
+ try {
+ RuntimeException failure = assertThrows(RuntimeException.class, () -> VehicleRepository.openWithRestore(live.toFile(), backups.toFile()));
+ assertTrue(failure.getSuppressed().length > 0); assertEquals("corrupt but preserved", Files.readString(live));
+ } finally { Files.setPosixFilePermissions(restricted, original); }
+ }
+
+ private File database(Path path) {
+ VehicleRepository repository = VehicleRepository.open(path.toFile());
+ try { repository.saveLive(new VehicleSnapshot("vehicle-1", "tug", "world", 1, 64, 1, 0, 0, 0,
+ "{\"id\":\"tug\",\"components\":{}}", VehicleRepository.SCHEMA_VERSION, 1, false, 1)); }
+ finally { repository.close(); }
+ return path.toFile();
+ }
+ private String read(Path path) { try { return Files.readString(path); } catch (IOException e) { throw new UncheckedIOException(e); } }
+}
diff --git a/src/test/java/net/tfminecraft/vehicleframework/database/VehiclePayloadCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/database/VehiclePayloadCoverageTest.java
new file mode 100644
index 0000000..6227bd3
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/database/VehiclePayloadCoverageTest.java
@@ -0,0 +1,223 @@
+package net.tfminecraft.vehicleframework.database;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.*;
+import org.bukkit.Location;
+import org.bukkit.World;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.joml.Quaternionf;
+import org.json.simple.JSONObject;
+import org.json.simple.JSONValue;
+import org.junit.jupiter.api.Test;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import net.tfminecraft.vehicleframework.bones.BoneRotator;
+import net.tfminecraft.vehicleframework.enums.Component;
+import net.tfminecraft.vehicleframework.loaders.AmmunitionLoader;
+import net.tfminecraft.vehicleframework.tracks.ThrottleTape;
+import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
+import net.tfminecraft.vehicleframework.vehicles.component.Engine;
+import net.tfminecraft.vehicleframework.vehicles.component.GearedEngine;
+import net.tfminecraft.vehicleframework.vehicles.component.SinkableHull;
+import net.tfminecraft.vehicleframework.vehicles.handlers.container.Container;
+import net.tfminecraft.vehicleframework.vehicles.seat.Seat;
+import net.tfminecraft.vehicleframework.weapons.ActiveWeapon;
+import net.tfminecraft.vehicleframework.weapons.ammunition.Ammunition;
+
+@SuppressWarnings("unchecked")
+class VehiclePayloadCoverageTest {
+ static final String ID = "11111111-2222-3333-4444-555555555555";
+
+ @Test void completePayloadRoundTripsStateWithoutDependingOnDefaultLocale() {
+ Locale previous = Locale.getDefault();
+ Ammunition ammo = mock(Ammunition.class);
+ when(ammo.getId()).thenReturn("shell");
+ try (var loader = mockStatic(AmmunitionLoader.class)) {
+ loader.when(() -> AmmunitionLoader.getByString("shell")).thenReturn(ammo);
+ Locale.setDefault(Locale.forLanguageTag("tr-TR"));
+ IncompleteVehicle decoded = VehiclePayloadCodec.decode("""
+ {"id":"locomotive","name":"Express","skin":"blue","owner":"player_Alice",
+ "yaw":"45.5","uuid":"payload-id","whitelisted":true,"whitelist":["Bob",null],
+ "ticketsEnabled":true,"ticketId":"rail-pass",
+ "components":{"geared_engine":{"damage":"3.5","fire":"8","sinkprogress":2,"gear":"3","fuel":"12.5","throttle":"60"},"wings":{"damage":4}},
+ "rotators":{"turret":{"x":0.1,"y":0.2,"z":0.3,"w":0.9}},
+ "weapons":{"cannon":{"damage":"7.5","ammo":"shell","count":"9"},"empty":{"damage":0}},
+ "passengers":{"front":{"player":"Alice"},"back":{"entity":"11111111-2222-3333-4444-555555555555"}},
+ "containers":{"box":{"id":"box","items":[]}},
+ "parent":"parent","child":"child","splineId":"track","s":4.5,"travelSign":-1,
+ "throttleTape":{"splineId":"track","samples":[{"s":0,"sign":1,"throttle":60}]},
+ "locomotiveOverdrive":{"remaining":100}}
+ """, ID).orElseThrow();
+ assertEquals(2, decoded.getComponents().size());
+ String encoded = VehiclePayloadCodec.encode(decoded);
+ assertTrue(encoded.contains("geared_engine"));
+ assertTrue(encoded.contains("wings"));
+ IncompleteVehicle restored = VehiclePayloadCodec.decode(encoded, ID).orElseThrow();
+ assertEquals("Express", restored.getName());
+ assertEquals("blue", restored.getSkin());
+ assertEquals("player_Alice", restored.getOwner());
+ assertEquals(ID, restored.getUUID());
+ assertEquals(45.5f, restored.getYaw());
+ assertEquals(3, restored.getGear());
+ assertEquals(60, restored.getThrottle());
+ assertEquals(12.5, restored.getFuel());
+ assertEquals(List.of("Bob"), restored.getWhitelist());
+ assertTrue(restored.isWhitelisted());
+ assertTrue(restored.isTicketsEnabled());
+ assertEquals("rail-pass", restored.getTicketId());
+ assertEquals(2, restored.getPassengers().size());
+ assertEquals(9, restored.getWeapons().stream().filter(w -> w.getId().equals("cannon")).findFirst().orElseThrow().getCount());
+ assertEquals(0.9f, restored.getRotations().getFirst().getW());
+ assertEquals("box", restored.getContainers().getFirst().get("id").getAsString());
+ assertEquals("parent", restored.getConsist().getParent());
+ assertEquals(60, restored.getThrottleTape().lookup(0, 1));
+ assertNotNull(restored.getLocomotiveOverdrive());
+ } finally { Locale.setDefault(previous); }
+ }
+
+ @Test void malformedAndLegacyPayloadsAreHandledWithoutInventingVehicles() {
+ for (String input : Arrays.asList(null, "", " ", "{", "[]", "null", "{}", "{\"id\":\" \"}"))
+ assertTrue(VehiclePayloadCodec.decode(input, null).isEmpty());
+ assertTrue(VehiclePayloadCodec.decode((JSONObject) null, ID).isEmpty());
+ assertEquals("{}", VehiclePayloadCodec.encode(null));
+ IncompleteVehicle legacy = VehiclePayloadCodec.decode("{\"id\":\"cart\",\"uuid\":\"legacy\",\"name\":\" \",\"owner\":null}", " ").orElseThrow();
+ assertEquals("legacy", legacy.getUUID());
+ assertEquals("cart", legacy.getName());
+ assertEquals("none", legacy.getOwner());
+ JSONObject root = object("{\"id\":\"cart\",\"yaw\":\"bad\",\"components\":{},\"rotators\":{},\"weapons\":{},\"passengers\":{},\"containers\":{\"absent\":null}}");
+ for (String section : List.of("components", "rotators", "weapons", "passengers")) {
+ JSONObject entries = (JSONObject) root.get(section);
+ entries.put(1, new JSONObject()); entries.put("invalid", "text");
+ }
+ JSONObject components = (JSONObject) root.get("components");
+ components.put("unknown", new JSONObject());
+ components.put("engine", object("{\"damage\":\"bad\",\"throttle\":null,\"fuel\":null,\"fire\":\"bad\"}"));
+ components.put("hull", object("{\"damage\":null}"));
+ JSONObject passengers = (JSONObject) root.get("passengers");
+ passengers.put("bad-id", object("{\"entity\":\"bad\"}"));
+ passengers.put("empty", new JSONObject());
+ IncompleteVehicle result = VehiclePayloadCodec.decode(root, ID).orElseThrow();
+ assertEquals(2, result.getComponents().size());
+ assertEquals(0, result.getThrottle());
+ assertEquals(0, result.getFuel());
+ assertEquals(0, result.getYaw());
+ assertTrue(result.getPassengers().isEmpty());
+ root.put("yaw", null);
+ assertEquals(0, VehiclePayloadCodec.decode(root, ID).orElseThrow().getYaw());
+ ((JSONObject) root.get("rotators")).put("incomplete", new JSONObject());
+ assertTrue(VehiclePayloadCodec.decode(root, ID).isEmpty());
+ assertTrue(VehiclePayloadCodec.loadContainers(null).isEmpty());
+ }
+
+ @Test void nonFiniteNumbersCannotPoisonStoredVehicleState() {
+ for (Object invalid : List.of("NaN", "Infinity", Double.NaN, Double.POSITIVE_INFINITY)) {
+ JSONObject payload = object("{\"id\":\"cart\",\"components\":{\"engine\":{}}}");
+ payload.put("yaw", invalid);
+ JSONObject engine = (JSONObject) ((JSONObject) payload.get("components")).get("engine");
+ engine.put("fuel", invalid); engine.put("damage", invalid);
+ IncompleteVehicle loaded = VehiclePayloadCodec.decode(payload, ID).orElseThrow();
+ assertEquals(0f, loaded.getYaw());
+ assertEquals(0d, loaded.getFuel());
+ assertEquals(0d, loaded.getComponents().getFirst().getDamage());
+ assertDoesNotThrow(() -> VehiclePayloadCodec.encode(loaded));
+ }
+ }
+
+ @Test void incompleteCollectionsCanBeEmptyOrContainUnusableOptionalEntries() {
+ IncompleteVehicle empty = new IncompleteVehicle(ID, "cart", "Cart", "cart", null, null, null, null, null, 0, 1, 0, 0, "none", false, null, null);
+ assertTrue(VehiclePayloadCodec.decode(VehiclePayloadCodec.encode(empty), ID).isPresent());
+ JsonObject valid = JsonParser.parseString("{\"id\":\"box\",\"items\":[]}").getAsJsonObject();
+ IncompleteVehicle partial = new IncompleteVehicle(ID, "cart", "Cart", "cart",
+ Arrays.asList(null, new IncompleteComponent(null, 0, 0, 0), new IncompleteComponent(Component.ENGINE, 5, 4, 2)),
+ List.of(), List.of(), List.of(), Arrays.asList(null, new JsonObject(), valid), 40, 2, 25, 3, "none", false, List.of());
+ partial.setThrottleTape(new ThrottleTape("track"));
+ IncompleteVehicle reloaded = VehiclePayloadCodec.decode(VehiclePayloadCodec.encode(partial), ID).orElseThrow();
+ assertEquals(1, reloaded.getComponents().size());
+ assertEquals(40, reloaded.getThrottle());
+ assertEquals(1, reloaded.getContainers().size());
+ assertNull(reloaded.getThrottleTape());
+ }
+
+ @Test void liveSnapshotsRejectMissingIdentityOrUnreadableStateWithUsefulReasons() {
+ assertEquals("vehicle is null", ActiveVehicleSnapshotFactory.tryFromLive(null).failureReason());
+ ActiveVehicle v = mock(ActiveVehicle.class);
+ assertEquals("no UUID", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason());
+ when(v.getUUID()).thenReturn(" ");
+ assertTrue(ActiveVehicleSnapshotFactory.fromLive(v).isEmpty());
+ when(v.getUUID()).thenReturn(ID);
+ assertEquals("entity invalid", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason());
+ Entity e = mock(Entity.class); when(v.getEntity()).thenReturn(e); when(e.isDead()).thenReturn(true);
+ assertEquals("entity invalid", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason());
+ when(e.isDead()).thenReturn(false); when(e.getLocation()).thenThrow(new IllegalStateException());
+ assertEquals("could not read location", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason());
+ doReturn(null).when(e).getLocation();
+ assertEquals("no world", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason());
+ when(e.getLocation()).thenReturn(new Location(null, 0, 0, 0));
+ assertEquals("no world", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason());
+ when(e.getLocation()).thenReturn(new Location(mock(World.class), 0, 0, 0));
+ when(v.getSkinHandler()).thenThrow(new IllegalStateException());
+ assertEquals("encode failed", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason());
+ doThrow(new IllegalStateException("missing skin")).when(v).getSkinHandler();
+ assertEquals("encode failed: missing skin", ActiveVehicleSnapshotFactory.tryFromLive(v).failureReason());
+ }
+
+ @Test void liveSnapshotPreservesContainersSeatsWeaponsTrainAndNegativeChunkCoordinates() {
+ ActiveVehicle v = liveVehicle();
+ Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS);
+ when(engine.getType()).thenReturn(Component.ENGINE);
+ when(engine.getThrottle().getCurrent()).thenReturn(40); when(engine.getFuelTank().getCurrent()).thenReturn(12d);
+ GearedEngine geared = mock(GearedEngine.class, RETURNS_DEEP_STUBS);
+ when(geared.getType()).thenReturn(Component.GEARED_ENGINE); when(geared.getCurrentGear()).thenReturn(3);
+ when(geared.getGear().getThrottle().getCurrent()).thenReturn(60); when(geared.getFuelTank().getCurrent()).thenReturn(12d);
+ SinkableHull hull = mock(SinkableHull.class, RETURNS_DEEP_STUBS);
+ when(hull.getType()).thenReturn(Component.HULL); when(hull.isSinking()).thenReturn(true); when(hull.getSinkProgress()).thenReturn(8);
+ when(hull.isOnFire()).thenReturn(true); when(hull.getFire().getProgress()).thenReturn(9);
+ when(v.getComponents()).thenReturn(List.of(engine, geared, hull));
+ Container container = mock(Container.class); when(container.getId()).thenReturn("box");
+ when(container.getAsJson()).thenReturn(JsonParser.parseString("{\"id\":\"box\",\"items\":[]}").getAsJsonObject());
+ when(v.hasContainers()).thenReturn(true); when(v.getContainerHandler().getContainers()).thenReturn(new HashMap<>(Map.of("box", container)));
+ Player player = mock(Player.class); when(player.getName()).thenReturn("Alice");
+ Entity animal = mock(Entity.class); when(animal.getUniqueId()).thenReturn(UUID.fromString(ID));
+ Entity unseated = mock(Entity.class);
+ when(v.getSeatHandler().getPassengers()).thenReturn(List.of(player, animal, unseated));
+ Seat front = mock(Seat.class); when(front.getBone()).thenReturn("front"); when(v.getSeat(player)).thenReturn(front);
+ Seat back = mock(Seat.class); when(back.getBone()).thenReturn("back"); when(v.getSeat(animal)).thenReturn(back); when(v.getSeat(unseated)).thenReturn(null);
+ BoneRotator rotator = mock(BoneRotator.class, RETURNS_DEEP_STUBS); when(rotator.getId()).thenReturn("turret");
+ when(rotator.getAnimator().getRotation()).thenReturn(new Quaternionf(0, 0, 0, 1));
+ when(v.getAccessPanel().getRotators()).thenReturn(List.of(rotator));
+ ActiveWeapon weapon = mock(ActiveWeapon.class, RETURNS_DEEP_STUBS); when(weapon.getId()).thenReturn("gun");
+ when(weapon.getAmmunitionHandler().hasAmmo()).thenReturn(true); when(weapon.getAmmunitionHandler().getAmmo().getId()).thenReturn("shell");
+ when(weapon.getAmmunitionHandler().getCount()).thenReturn(6); when(v.getWeaponHandler().getWeapons()).thenReturn(List.of(weapon));
+ when(v.isTrain()).thenReturn(true); when(v.isLocomotive()).thenReturn(true);
+ when(v.getTrainHandler().toConsistData()).thenReturn(new ConsistData(null, "child", "track", 5d));
+ when(v.getTrainHandler().getOverdrive().toJson()).thenReturn(object("{\"active\":true}"));
+ when(v.getTrainHandler().getInstalledTape()).thenReturn(new ThrottleTape("track", List.of(new ThrottleTape.Sample(0, 1, 60))));
+ when(v.getOwnerData().getTicketId()).thenReturn("ticket"); when(v.getOwnerData().getWhiteList()).thenReturn(List.of("Bob"));
+ VehicleSnapshot snapshot = ActiveVehicleSnapshotFactory.fromLive(v).orElseThrow();
+ assertEquals(-2, snapshot.getChunkX()); assertEquals(-1, snapshot.getChunkZ());
+ assertEquals("world", snapshot.getWorld()); assertEquals("player_Alice", snapshot.getOwner());
+ JsonObject payload = JsonParser.parseString(snapshot.getPayloadJson()).getAsJsonObject();
+ assertEquals(2, payload.getAsJsonObject("passengers").size());
+ assertEquals(8, payload.getAsJsonObject("components").getAsJsonObject("hull").get("sinkprogress").getAsInt());
+ assertEquals(6, payload.getAsJsonObject("weapons").getAsJsonObject("gun").get("count").getAsInt());
+ assertEquals("child", payload.get("child").getAsString()); assertTrue(payload.has("throttleTape"));
+ when(v.hasParent()).thenReturn(true); when(v.isLocomotive()).thenReturn(false);
+ assertFalse(JsonParser.parseString(ActiveVehicleSnapshotFactory.fromLive(v).orElseThrow().getPayloadJson()).getAsJsonObject().has("throttleTape"));
+ }
+
+ static ActiveVehicle liveVehicle() {
+ ActiveVehicle v = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS);
+ World world = mock(World.class); when(world.getName()).thenReturn("world");
+ when(v.getUUID()).thenReturn(ID); when(v.getId()).thenReturn("cart"); when(v.getName()).thenReturn("Cart");
+ Location location = spy(new Location(world, -16.5, 64, -0.5, 45, 0));
+ doReturn(world).when(location).getWorld();
+ when(v.getEntity().getLocation()).thenReturn(location);
+ when(v.getSkinHandler().getCurrentSkin().getId()).thenReturn("blue");
+ when(v.getOwnerData().getOwner()).thenReturn("player_Alice");
+ return v;
+ }
+ static JSONObject object(String json) { return (JSONObject) JSONValue.parse(json); }
+}
diff --git a/src/test/java/net/tfminecraft/vehicleframework/effects/EffectsCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/effects/EffectsCoverageTest.java
new file mode 100644
index 0000000..123343d
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/effects/EffectsCoverageTest.java
@@ -0,0 +1,79 @@
+package net.tfminecraft.vehicleframework.effects;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicReference;
+import org.bukkit.*;
+import org.bukkit.entity.Player;
+import org.junit.jupiter.api.*;
+import org.mockito.MockedStatic;
+import com.ticxo.modelengine.api.ModelEngineAPI;
+import com.ticxo.modelengine.api.animation.BlueprintAnimation;
+import com.ticxo.modelengine.api.animation.property.SimpleProperty;
+import com.ticxo.modelengine.api.model.ActiveModel;
+import com.ticxo.modelengine.api.model.bone.ModelBone;
+import com.ticxo.modelengine.api.model.bone.SimpleManualAnimator;
+import com.ticxo.modelengine.api.utils.config.ConfigCache;
+import net.tfminecraft.vehicleframework.enums.Component;
+import net.tfminecraft.vehicleframework.enums.VehicleDeath;
+import net.tfminecraft.vehicleframework.projectiles.ProjectilesCoverageTest.Rig;
+import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
+
+class EffectsCoverageTest {
+ Rig r; ActiveVehicle vehicle; ActiveModel model; Player viewer;
+ final Map bones=new HashMap<>();
+ final Map animations=new HashMap<>();
+ MockedStatic api;
+
+ @BeforeEach void setup(){
+ r=new Rig();vehicle=r.vehicle();model=mock(ActiveModel.class,RETURNS_DEEP_STUBS);viewer=r.entity(Player.class,new Location(r.world,0,64,0));when(vehicle.getModel()).thenReturn(model);
+ when(model.getBone(anyString())).thenAnswer(c->Optional.ofNullable(bones.get(c.getArgument(0))));when(model.getBlueprint().getAnimations()).thenReturn(animations);
+ api=mockStatic(ModelEngineAPI.class);ConfigCache config=mock(ConfigCache.class);api.when(ModelEngineAPI::getConfigCache).thenReturn(config);bone("base",new Location(r.world,0,64,0));bone("aim",new Location(r.world,0,64,1));
+ }
+ @AfterEach void cleanup(){api.close();r.close();}
+
+ @Test void effectIdentifiersDoNotDependOnTheServerLocale(){
+ when(vehicle.hasComponent(Component.WINGS)).thenReturn(true);Locale saved=Locale.getDefault();
+ try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));CustomEffect effect=new CustomEffect(List.of("particle(crit;base.aim;1;0;0.2)","start_fire(wings)","death(sink)"));assertDoesNotThrow(()->effect.play(List.of(viewer),vehicle));verify(vehicle).kill(VehicleDeath.SINK);verify(vehicle.getComponent(Component.WINGS)).startFire();}finally{Locale.setDefault(saved);}
+ }
+
+ @Test void emptyEffectsFinishImmediatelyAndExposeConfiguredCommands(){
+ CustomEffect empty=new CustomEffect(List.of());assertTrue(empty.isEmpty());assertTrue(empty.isFinished());empty.play(List.of(),vehicle);assertTrue(empty.isFinished());assertEquals(List.of(),empty.getCommands());
+ CustomEffect effect=new CustomEffect(List.of("delay(2)","sound(base;test.horn;2;0.5)"));assertFalse(effect.isEmpty());assertEquals(2,effect.getCommands().size());effect.play(List.of(viewer),vehicle);assertFalse(effect.isFinished());r.tick();verify(r.world,never()).playSound(any(Location.class),anyString(),anyFloat(),anyFloat());r.tick();assertTrue(effect.isFinished());verify(r.world).playSound(new Location(r.world,0,64,0),"test.horn",2f,.5f);
+ }
+
+ @Test void particleEffectsAimAlongBonesAndEmitForEachViewer(){
+ CustomEffect effect=new CustomEffect(List.of("particle(flame;base.aim;3;0;0.4)"));effect.play(List.of(viewer),vehicle);verify(viewer,times(3)).spawnParticle(Particle.FLAME,new Location(r.world,0,64,0),0,0,0,1,.4);assertTrue(effect.isFinished());
+ }
+
+ @Test void conditionsStopFollowingCommandsAndPreserveCompositeValues(){
+ for(String condition:List.of("condition()","condition( ;true)","condition(has_fuel)","condition(has_fuel;true)","condition(AND;has_fuel=true;passengers=true)")){
+ CustomEffect effect=new CustomEffect(List.of(condition,"death(explode)"));effect.play(List.of(),vehicle);assertTrue(effect.isFinished());verify(vehicle,never()).kill(any());
+ }
+ when(vehicle.hasFuel()).thenReturn(true);when(vehicle.getSeatHandler().hasPassengers()).thenReturn(true);CustomEffect passes=new CustomEffect(List.of("condition(AND;has_fuel=true;passengers=true)","death(explode)"));passes.play(List.of(),vehicle);verify(vehicle).kill(VehicleDeath.EXPLODE);
+ }
+
+ @Test void deathAndFireEffectsRespectExistingDestructionAndBurning(){
+ when(vehicle.isDestroyed()).thenReturn(true);new CustomEffect(List.of("death(crash)")).play(List.of(),vehicle);verify(vehicle,never()).kill(any());
+ new CustomEffect(List.of("start_fire(hull)")).play(List.of(),vehicle);verify(vehicle.getComponent(Component.HULL),never()).startFire();
+ when(vehicle.hasComponent(Component.HULL)).thenReturn(true);when(vehicle.getComponent(Component.HULL).isOnFire()).thenReturn(true);new CustomEffect(List.of("start_fire(hull)")).play(List.of(),vehicle);verify(vehicle.getComponent(Component.HULL),never()).startFire();
+ when(vehicle.getComponent(Component.HULL).isOnFire()).thenReturn(false);new CustomEffect(List.of("start_fire(hull)")).play(List.of(),vehicle);verify(vehicle.getComponent(Component.HULL)).startFire();
+ }
+
+ @Test void animationsIgnoreMissingAndAlreadyPlayingEntries(){
+ new CustomEffect(List.of("animation(missing)")).play(List.of(),vehicle);verify(model.getAnimationHandler(),never()).playAnimation(any(SimpleProperty.class),anyBoolean());
+ BlueprintAnimation animation=new BlueprintAnimation(model.getBlueprint(),"fly");animation.setLength(1);animation.setLoopMode(BlueprintAnimation.LoopMode.LOOP);animations.put("fly",animation);when(model.getAnimationHandler().isPlayingAnimation("fly")).thenReturn(true);new CustomEffect(List.of("animation(fly)")).play(List.of(),vehicle);verify(model.getAnimationHandler(),never()).playAnimation(any(SimpleProperty.class),anyBoolean());when(model.getAnimationHandler().isPlayingAnimation("fly")).thenReturn(false);new CustomEffect(List.of("animation(fly)")).play(List.of(),vehicle);verify(model.getAnimationHandler()).playAnimation(any(SimpleProperty.class),eq(true));
+ }
+
+ @Test void boneFreezingCreatesAndReleasesTheAnimatorIdempotently(){
+ ModelBone bone=bones.get("base");new CustomEffect(List.of("freeze_bone(missing;true)","freeze_bone(base;false)","freeze_bone(base;true)","freeze_bone(base;true)")).play(List.of(),vehicle);assertNotNull(bone.getManualAnimator());verify(bone,times(1)).setManualAnimator(any(SimpleManualAnimator.class));new CustomEffect(List.of("freeze_bone(base;false)")).play(List.of(),vehicle);assertNull(bone.getManualAnimator());verify(bone).setManualAnimator(null);
+ }
+
+ private ModelBone bone(String id,Location location){
+ ModelBone bone=mock(ModelBone.class,RETURNS_DEEP_STUBS);when(bone.getBoneId()).thenReturn(id);when(bone.getActiveModel()).thenReturn(model);when(bone.getLocation()).thenAnswer(c->location.clone());when(bone.getLocalTransform().getSafeLeftQuaternion()).thenReturn(new org.joml.Quaternionf());when(bone.getLocalTransform().getSafePosition()).thenReturn(new org.joml.Vector3f());when(bone.getLocalTransform().getSafeScale()).thenReturn(new org.joml.Vector3f(1));
+ AtomicReference animator=new AtomicReference<>();when(bone.getManualAnimator()).thenAnswer(c->animator.get());doAnswer(c->{animator.set(c.getArgument(0));return null;}).when(bone).setManualAnimator(any());bones.put(id,bone);return bone;
+ }
+}
diff --git a/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java
new file mode 100644
index 0000000..c72c1d4
--- /dev/null
+++ b/src/test/java/net/tfminecraft/vehicleframework/loaders/ConfigurationLoadersCoverageTest.java
@@ -0,0 +1,287 @@
+package net.tfminecraft.vehicleframework.loaders;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.io.File;
+import java.nio.file.*;
+import java.util.*;
+import org.bukkit.*;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedStatic;
+import com.ticxo.modelengine.api.ModelEngineAPI;
+import net.tfminecraft.vehicleframework.VFLogger;
+import net.tfminecraft.vehicleframework.cache.Cache;
+import net.tfminecraft.vehicleframework.data.DamageData;
+import net.tfminecraft.vehicleframework.util.ConfigMerger;
+import net.tfminecraft.vehicleframework.vehicles.Vehicle;
+import net.tfminecraft.vehicleframework.vehicles.fuel.Fuel;
+import net.tfminecraft.vehicleframework.weapons.ammunition.*;
+
+class ConfigurationLoadersCoverageTest {
+ @TempDir Path temp;
+ private MockedStatic logger;
+ private MockedStatic models;
+ private final Map fuels = new HashMap<>(FuelLoader.get());
+ private final Map ammunition = new HashMap<>(AmmunitionLoader.get());
+ private final Map vehicles = new HashMap<>(VehicleLoader.get());
+ private final List ignoredExplosions = new ArrayList<>(Cache.ignoreExplode);
+ private final List ignoredLandings = new ArrayList<>(Cache.ignoreLands);
+ private final List ignoredGround = new ArrayList<>(Cache.ignoreGround);
+ private final Map conversions = new HashMap<>(Cache.convertExplode);
+
+ @BeforeEach void setup() {
+ logger = mockStatic(VFLogger.class); models = mockStatic(ModelEngineAPI.class);
+ FuelLoader.get().clear(); AmmunitionLoader.get().clear(); VehicleLoader.get().clear();
+ new WeaponTemplateLoader().clear(); new DeathTemplateLoader().clear(); new ArmorTemplateLoader().clear();
+ Cache.ignoreExplode.clear(); Cache.ignoreLands.clear(); Cache.ignoreGround.clear(); Cache.convertExplode.clear();
+ }
+ @AfterEach void cleanup() throws Exception {
+ File blank = yaml("reset.yml", "{}"); new ConfigLoader().load(blank); new TrainsLoader().load(blank);
+ FuelLoader.get().clear(); FuelLoader.get().putAll(fuels);
+ AmmunitionLoader.get().clear(); AmmunitionLoader.get().putAll(ammunition);
+ VehicleLoader.get().clear(); VehicleLoader.get().putAll(vehicles);
+ Cache.ignoreExplode.clear(); Cache.ignoreExplode.addAll(ignoredExplosions);
+ Cache.ignoreLands.clear(); Cache.ignoreLands.addAll(ignoredLandings);
+ Cache.ignoreGround.clear(); Cache.ignoreGround.addAll(ignoredGround);
+ Cache.convertExplode.clear(); Cache.convertExplode.putAll(conversions);
+ new WeaponTemplateLoader().clear(); new DeathTemplateLoader().clear(); new ArmorTemplateLoader().clear();
+ models.close(); logger.close();
+ }
+
+ @Test void configReloadReplacesRemovedMaterialRules() throws Exception {
+ ConfigLoader loader = new ConfigLoader();
+ loader.load(yaml("first.yml", "ignore-explosion: [stone]\nignore-landing: [dirt]\nignore-ground: [gravel]\nconvert-explosion: [stone.dirt]\n"));
+ loader.load(yaml("second.yml", "ignore-explosion: [sand]\nignore-landing: []\nignore-ground: []\nconvert-explosion: []\n"));
+ assertEquals(List.of(Material.SAND), Cache.ignoreExplode);
+ assertTrue(Cache.ignoreLands.isEmpty()); assertTrue(Cache.ignoreGround.isEmpty()); assertTrue(Cache.convertExplode.isEmpty());
+ }
+
+ @Test void malformedExplosionConversionDoesNotPreventOtherConfigLoading() throws Exception {
+ File file = yaml("conversion.yml", "convert-explosion: [STONE, 'stone.', invalid.dirt, stone.dirt]\nenable-logging: true\n");
+ assertDoesNotThrow(() -> new ConfigLoader().load(file));
+ assertEquals(Map.of(Material.STONE, Material.DIRT), Cache.convertExplode); assertTrue(Cache.enableLogging);
+ }
+
+ @Test void configLoadsValidMaterialsAndAllRuntimeOptions() throws Exception {
+ new ConfigLoader().load(yaml("config.yml", """
+ ignore-explosion: [stone, invalid]
+ ignore-landing: [sand, invalid]
+ ignore-ground: [gravel, invalid]
+ convert-explosion: [stone.dirt, invalid.dirt]
+ enable-logging: true
+ block-damage: false
+ despawn-distance: 17
+ skin-item: custom.skin
+ repair-item: custom.repair
+ destroy-item: custom.destroy
+ ticket-item: custom.ticket
+ mythicmob: testmob
+ allow-whitelist: true
+ whitelisted-by-default: true
+ weapon-degraded-reload-multiplier: 0.5
+ weapon-aim-debug: true
+ terrain-follow-debug: true
+ ground-engine-logging: true
+ persistence-logging: true
+ wipe-log: true
+ """));
+ assertEquals(List.of(Material.STONE), Cache.ignoreExplode); assertEquals(List.of(Material.SAND), Cache.ignoreLands);
+ assertEquals(List.of(Material.GRAVEL), Cache.ignoreGround); assertEquals(Material.DIRT, Cache.convertExplode.get(Material.STONE));
+ assertTrue(Cache.enableLogging); assertFalse(Cache.blockDamage); assertEquals(289, Cache.despawnDistance);
+ assertEquals("custom.skin", Cache.skinItem); assertEquals("custom.repair", Cache.repairItem);
+ assertEquals("custom.destroy", Cache.destroyItem); assertEquals("custom.ticket", Cache.ticketItem); assertEquals("testmob", Cache.mythicMob);
+ assertTrue(Cache.allowWhitelist); assertTrue(Cache.whitelistedByDefault); assertEquals(1, Cache.weaponDegradedReloadMultiplier);
+ assertTrue(Cache.weaponAimDebug); assertTrue(Cache.terrainFollowDebug); assertTrue(Cache.groundEngineLogging);
+ assertTrue(Cache.persistenceLogging); assertTrue(Cache.wipeLog);
+ logger.verify(() -> VFLogger.log("invalid is not a material"), times(3));
+ }
+
+ @Test void trainsUseDefaultsWhenFxAndBuildSectionsAreAbsent() throws Exception {
+ new TrainsLoader().load(yaml("trains.yml", "{}"));
+ assertEquals("ia.tfmc:track_small", Cache.trackItemSmall); assertEquals("ia.tfmc:track_medium", Cache.trackItemMedium);
+ assertEquals("ia.tfmc:track_large", Cache.trackItemLarge); assertEquals("v.iron_shovel", Cache.trackLayerItem);
+ assertEquals(3, Cache.trackSnapDistance); assertEquals(2.5, Cache.trainClearanceHeight);
+ assertEquals(Particle.BLOCK, Cache.trackFxParticle); assertEquals(Material.GRAVEL, Cache.trackFxBlock);
+ assertEquals(3, Cache.trackFxCount); assertEquals(3, Cache.trackFxWidth); assertEquals(2.5, Cache.trackFxSoundInterval);
+ assertEquals(4, Cache.trackBuildIntervalTicks); assertTrue(Cache.trackBuildSwing); assertEquals(6, Cache.trackBuildCount);
+ assertEquals(1, Cache.trackBuildWidth); assertEquals(Particle.BLOCK, Cache.trackBuildParticle); assertEquals(Material.GRAVEL, Cache.trackBuildBlock);
+ }
+
+ @ParameterizedTest @ValueSource(strings = {"BLOCK_CRACK", "BLOCK_DUST", "invalid", "none", " "})
+ void trainsParseLegacyParticlesValidateBlocksAndClampUnsafeRanges(String particle) throws Exception {
+ YamlConfiguration config = new YamlConfiguration();
+ config.set("fx.particle", particle); config.set("build.particle", particle);
+ config.set("fx.particle-block", "not_a_block"); config.set("build.particle-block", "not_a_block");
+ config.set("fx.width", 0); config.set("fx.count", -1); config.set("fx.sound-interval-blocks", 0);
+ config.set("build.interval-ticks", -1); config.set("build.count", -1); config.set("build.width", 0);
+ config.set("clearance.width", 0); config.set("clearance.height", 0); config.set("vehicle-y-offset", 2);
+ config.set("snap-distance", 0); config.set("max-grade-degrees", 3); config.set("desired-grade-degrees", 8);
+ config.set("min-lay-distance", 10); config.set("max-junction-length", 4);
+ config.set("lay-retry-seconds", -1); config.set("remove-cooldown-seconds", -1);
+ config.set("resync-chunks-per-tick", 0); config.set("switch.throw-degrees-per-second", 0);
+ File file = yaml("trains.yml", config.saveToString()); new TrainsLoader().load(file);
+ Particle expected = particle.isBlank() || particle.equals("none") ? null : Particle.BLOCK;
+ assertEquals(expected, Cache.trackFxParticle); assertEquals(expected, Cache.trackBuildParticle);
+ assertEquals(Material.GRAVEL, Cache.trackFxBlock); assertEquals(Material.GRAVEL, Cache.trackBuildBlock);
+ assertEquals(1, Cache.trackFxWidth); assertEquals(0, Cache.trackFxCount); assertEquals(.25, Cache.trackFxSoundInterval);
+ assertEquals(0, Cache.trackBuildIntervalTicks); assertEquals(0, Cache.trackBuildCount); assertEquals(1, Cache.trackBuildWidth);
+ assertEquals(.5, Cache.trainClearanceWidth); assertEquals(2.5, Cache.trainClearanceHeight); assertEquals(.5, Cache.trackSnapDistance);
+ assertEquals(3, Cache.trackDesiredGradeDegrees); assertEquals(10, Cache.trackMaxJunctionLength);
+ assertEquals(0, Cache.trackLayRetryMs); assertEquals(0, Cache.trackRemoveCooldownMs);
+ assertEquals(1, Cache.trackResyncChunksPerTick); assertEquals(1, Cache.trackSwitchThrowDegreesPerSecond);
+ }
+
+ @Test void unreadableOrMalformedConfigDoesNotReplaceTheLastWorkingSettings() throws Exception {
+ File valid = yaml("valid.yml", "skin-item: custom.skin\nitem-small: custom.track\n");
+ new ConfigLoader().load(valid); new TrainsLoader().load(valid);
+ File missing = temp.resolve("missing.yml").toFile(); File malformed = yaml("malformed.yml", "broken: [unterminated");
+ new ConfigLoader().load(missing); new ConfigLoader().load(malformed);
+ new TrainsLoader().load(missing); new TrainsLoader().load(malformed);
+ assertEquals("custom.skin", Cache.skinItem); assertEquals("custom.track", Cache.trackItemSmall);
+ }
+
+ @ParameterizedTest @ValueSource(strings = {"weapon", "death", "armor", "role"})
+ void templateFoldersFilterFilesPreserveFirstDefinitionAndSkipNonSections(String kind) throws Exception {
+ java.util.function.Consumer load = templateLoader(kind, false);
+ java.util.function.Consumer loadFolder = templateLoader(kind, true);
+ Map> target = templates(kind);
+ File first = yaml(kind + "/first.YML", "base:\n damage: 5\n nested:\n radius: 2\n values: [one, two]\ninvalid: scalar\n");
+ load.accept(first); assertEquals(5, target.get("base").get("damage")); assertFalse(target.containsKey("invalid"));
+ load.accept(yaml(kind + "/duplicate.yaml", "base:\n damage: 99\nsecond:\n damage: 7\n"));
+ assertEquals(5, target.get("base").get("damage")); assertEquals(7, target.get("second").get("damage"));
+ yaml(kind + "/ignored.txt", "ignored: {damage: 8}"); yaml(kind + "/nested/ignored.yml", "nested: {damage: 9}");
+ loadFolder.accept(temp.resolve(kind).toFile()); assertEquals(Set.of("base", "second"), target.keySet());
+ loadFolder.accept(null); loadFolder.accept(first); loadFolder.accept(temp.resolve("missing").toFile());
+ Path inaccessible = Files.createDirectory(temp.resolve("unreadable"));
+ var permissions = Files.getPosixFilePermissions(inaccessible);
+ try {
+ Files.setPosixFilePermissions(inaccessible, Set.of());
+ assertNull(inaccessible.toFile().listFiles(), "Fixture must model an unreadable directory");
+ loadFolder.accept(inaccessible.toFile());
+ } finally { Files.setPosixFilePermissions(inaccessible, permissions); }
+ load.accept(temp.resolve("missing.yml").toFile()); load.accept(yaml("bad.yml", "key: [unterminated"));
+ assertEquals(Set.of("base", "second"), target.keySet());
+ }
+
+ @ParameterizedTest @ValueSource(strings = {"weapon", "death"})
+ void templatesResolveDeepInstanceOverridesWithoutMutatingTheTemplate(String kind) throws Exception {
+ templateLoader(kind, false).accept(yaml("template.yml", "base:\n damage: 5\n nested:\n radius: 2\n count: 3\n values: [one, two]\n"));
+ java.util.function.BiFunction resolve = kind.equals("weapon")
+ ? WeaponTemplateLoader::resolve : DeathTemplateLoader::resolve;
+ java.util.function.Function> find = kind.equals("weapon")
+ ? WeaponTemplateLoader::getByString : DeathTemplateLoader::getByString;
+ assertNull(find.apply(null)); assertNull(find.apply("missing")); assertNotNull(find.apply("base"));
+ assertNull(resolve.apply("test", null));
+ YamlConfiguration standalone = configuration("damage: 8\n"); assertSame(standalone, resolve.apply("test", standalone));
+ standalone.set("template", " "); assertSame(standalone, resolve.apply("test", standalone));
+ standalone.set("template", "missing"); assertNull(resolve.apply("test", standalone));
+ ConfigurationSection merged = resolve.apply("test", configuration("template: base\nnested:\n radius: 9\nvalues: [three]\n"));
+ assertEquals(5, merged.getInt("damage")); assertEquals(9, merged.getInt("nested.radius")); assertEquals(3, merged.getInt("nested.count"));
+ assertEquals(List.of("three"), merged.getStringList("values")); assertFalse(merged.contains("template"));
+ assertEquals(2, ((Map, ?>) find.apply("base").get("nested")).get("radius"));
+ assertEquals(List.of("one", "two"), find.apply("base").get("values"));
+ }
+
+ @Test void armorRoleAndInlineDamageLayersResolveInOrderWithoutMutatingTemplates() throws Exception {
+ ArmorTemplateLoader loader = new ArmorTemplateLoader();
+ loader.load(yaml("armor.yml", "plated: {PROJECTILE: 0.5, FIRE: 0.25}\n"), ArmorTemplateLoader.armor, "armor");
+ loader.load(yaml("roles.yml", "carrier: {PROJECTILE: 0.75, FALL: 0.5}\n"), ArmorTemplateLoader.roles, "role");
+ DamageData damage = ArmorTemplateLoader.resolve("hull", configuration("armor: plated\nrole: carrier\ndamage:\n PROJECTILE: 0.9\n"));
+ assertEquals(.9, damage.getModifier("PROJECTILE")); assertEquals(.25, damage.getModifier("FIRE")); assertEquals(.5, damage.getModifier("FALL"));
+ assertEquals(.5, ArmorTemplateLoader.armor.get("plated").get("PROJECTILE"));
+ damage = ArmorTemplateLoader.resolve("hull", configuration("armor: missing\nrole: carrier\ndamage: [FALL(0.8)]\n"));
+ assertEquals(.8, damage.getModifier("FALL")); assertEquals(.75, damage.getModifier("PROJECTILE"));
+ damage = ArmorTemplateLoader.resolve("hull", configuration("role: missing\n")); assertTrue(damage.getModifiers().isEmpty());
+ assertTrue(ArmorTemplateLoader.resolve("hull", null).getModifiers().isEmpty());
+ assertEquals(.7, ArmorTemplateLoader.resolve("hull", configuration("damage: {fall: 0.7}")).getModifier("FALL"));
+ assertEquals(.4, ArmorTemplateLoader.resolve("hull", configuration("damage: [fall(0.4)]")).getModifier("FALL"));
+ assertNull(ArmorTemplateLoader.damageOverlay(null)); assertNull(ArmorTemplateLoader.damageOverlay(new YamlConfiguration()));
+ assertEquals(Map.of(), ArmorTemplateLoader.listToMap(null));
+ assertEquals(Map.of(), ArmorTemplateLoader.mergeLayers(null, null, null));
+ assertEquals(Map.of("FALL", .5), ArmorTemplateLoader.mergeLayers(null, Map.of("FALL", .5), null));
+ }
+
+ @Test void absentTemplateLayersAndNestedValuesAreCopiedWithoutAliasing() {
+ assertEquals(Map.of(), ConfigMerger.fromSection(null)); assertTrue(ConfigMerger.toConfiguration(null).getKeys(false).isEmpty());
+ List