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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ public class Cache {

// Keep the same defaults as an empty config if the first YAML load fails.
public static int despawnDistance = 64 * 64;
/** cleanup.worlds (lower case): idle vehicles there are removed; empty means no cleanup. */
public static Set<String> cleanupWorlds = new HashSet<>();
/** cleanup.keep-owners: stored owner values whose vehicles the cleanup never removes. */
public static Set<String> cleanupKeepOwners = new HashSet<>();
/** cleanup.idle-minutes, in milliseconds: how long a vehicle may stand empty. */
public static long cleanupIdleMillis = 30 * 60_000L;

public static boolean blockDamage = true;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.io.File;
import java.io.IOException;
import java.util.HashSet;
import java.util.Locale;

import org.bukkit.Material;
Expand Down Expand Up @@ -90,6 +91,13 @@ public void load(File configFile) {

Cache.despawnDistance = (int) Math.round(Math.pow(config.getInt("despawn-distance", 64), 2));

Cache.cleanupWorlds = new HashSet<>();
for (String world : config.getStringList("cleanup.worlds")) {
Cache.cleanupWorlds.add(world.toLowerCase(Locale.ROOT));
}
Cache.cleanupKeepOwners = new HashSet<>(config.getStringList("cleanup.keep-owners"));
Cache.cleanupIdleMillis = Math.max(1, config.getInt("cleanup.idle-minutes", 30)) * 60_000L;

Cache.skinItem = config.getString("skin-item", "v.bucket");
Cache.repairItem = config.getString("repair-item", "v.iron_shovel");
Cache.destroyItem = config.getString("destroy-item", "v.stone_axe");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package net.tfminecraft.vehicleframework.managers;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;

import net.tfminecraft.vehicleframework.cache.Cache;
import net.tfminecraft.vehicleframework.data.OwnerData;
import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;

/**
* Finds idle vehicles to remove on the worlds in {@code cleanup.worlds} (config.yml), such as a
* tutorial server's world. A vehicle is due once it has had no passenger and has not moved for
* {@code cleanup.idle-minutes}, counted from when the cleanup first saw it loaded or it was last in
* use. Use is a passenger at a check, a mount or dismount at any time ({@link #markUsed}), or moving
* more than a block between checks, so a train car behind an occupied locomotive, a horse-drawn cart
* or a towed vehicle is not idle. A vehicle that unloads and loads again starts a fresh clock.
* Owners in {@code cleanup.keep-owners} (stored owner values, e.g. {@code char_<uuid>}) are never
* removed. With no worlds configured nothing is ever due.
*/
public final class VehicleCleanup {

/** One loaded vehicle: the instance, when it was last in use and where the last check saw it. */
private static final class Track {
final ActiveVehicle vehicle;
long since;
double[] position;

Track(ActiveVehicle vehicle, long since) {
this.vehicle = vehicle;
this.since = since;
}
}

private final Map<String, Track> tracks = new HashMap<>();

/** Someone got in or out: the vehicle's idle clock starts again now. */
public void markUsed(ActiveVehicle v, long now) {
Track track = v == null || v.getUUID() == null ? null : tracks.get(v.getUUID());
if (track != null && track.vehicle == v) {
track.since = now;
}
}

/** The loaded vehicles to remove now; also forgets vehicles that are no longer loaded. */
public List<ActiveVehicle> due(Collection<ActiveVehicle> vehicles, long now) {
List<ActiveVehicle> due = new ArrayList<>();
if (Cache.cleanupWorlds.isEmpty()) {
tracks.clear();
return due;
}
Set<String> seen = new HashSet<>();
for (ActiveVehicle v : vehicles) {
String id = v.getUUID();
// One vehicle can be registered under several entities: judge it once.
if (id == null || v.isDestroyed() || !seen.add(id) || !onCleanupWorld(v) || isKept(v)) {
continue;
}
Track track = tracks.get(id);
if (track == null || track.vehicle != v) {
// first sight, or loaded again since the last check: a fresh clock
track = new Track(v, now);
tracks.put(id, track);
moved(track, v.getEntity().getLocation());
continue;
}
if (moved(track, v.getEntity().getLocation()) || !v.getSeatHandler().getPassengers().isEmpty()) {
track.since = now;
} else if (now - track.since >= Cache.cleanupIdleMillis) {
due.add(v);
}
}
tracks.keySet().retainAll(seen);
return due;
}

/** Whether the vehicle is more than a block from where the last check saw it; remembers where it is. */
private static boolean moved(Track track, Location at) {
if (at == null) {
return false;
}
double[] now = {at.getX(), at.getY(), at.getZ()};
double[] before = track.position;
track.position = now;
return before != null
&& Math.abs(now[0] - before[0]) + Math.abs(now[1] - before[1]) + Math.abs(now[2] - before[2]) > 1.0;
}

private static boolean onCleanupWorld(ActiveVehicle v) {
Entity entity = v.getEntity();
World world = entity == null ? null : entity.getWorld();
return world != null && Cache.cleanupWorlds.contains(world.getName().toLowerCase(Locale.ROOT));
}

private static boolean isKept(ActiveVehicle v) {
OwnerData owner = v.getOwnerData();
return owner != null && owner.getOwner() != null && Cache.cleanupKeepOwners.contains(owner.getOwner());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ public class VehicleManager implements Listener{
private HashMap<Player, HashMap<String, Long>> ejectCooldown = new HashMap<>();

private final OwnershipManager ownership = new OwnershipManager(this);
private final VehicleCleanup cleanup = new VehicleCleanup();

private HashMap<Player, ActiveVehicle> tow = new HashMap<>();

Expand Down Expand Up @@ -296,6 +297,33 @@ public void start() {
vehicleFastTickCycle();
vehicleSlowTickCycle();
snapshotCycle();
cleanupCycle();
}

/** Every minute: remove the idle vehicles on the cleanup worlds (cleanup: in config.yml). */
private void cleanupCycle() {
new BukkitRunnable() {
@Override
public void run() {
runCleanup(System.currentTimeMillis());
}
}.runTaskTimer(VehicleFramework.plugin, 1200L, 1200L);
}

/** Removes the vehicles the cleanup finds idle, like an admin kill; returns how many. */
int runCleanup(long now) {
int removed = 0;
for (ActiveVehicle v : cleanup.due(new ArrayList<>(vehicles.values()), now)) {
String described = describeVehicle(v) + " at " + describeLocation(v);
v.remove(VehicleRemoveReason.ADMIN_KILL);
if (!persistDestroy(v)) {
VFLogger.log("Failed to tombstone " + described + " after cleanup");
}
VFLogger.info("Cleanup removed " + described + " (empty for "
+ Cache.cleanupIdleMillis / 60_000L + " minutes)");
removed++;
}
return removed;
}

public void reload() {
Expand Down Expand Up @@ -409,7 +437,7 @@ public boolean isPassenger(Entity e) {
}

public void dismount(Player p) {
if(activeVehicle.containsKey(p)) activeVehicle.remove(p);
cleanup.markUsed(activeVehicle.remove(p), System.currentTimeMillis());
if (p != null) {
packetSneak.remove(p.getUniqueId());
mountedLeftClickAt.remove(p.getUniqueId());
Expand Down Expand Up @@ -999,6 +1027,7 @@ private boolean takeSeat(Player p, ActiveVehicle v, Seat seat) {
}
if (tempVehicle.containsKey(p)) tempVehicle.remove(p);
if (!activeVehicle.containsKey(p)) activeVehicle.put(p, v);
cleanup.markUsed(v, System.currentTimeMillis());
return true;
}

Expand Down
9 changes: 9 additions & 0 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ enable-logging: false
##How far away does the player need to be for the vehicle to despawn?
despawn-distance: 160

##Idle vehicle cleanup, for a world where vehicles are only for trying out (e.g. a tutorial server).
##Every minute, a vehicle on one of these worlds that has had no passenger for idle-minutes is removed
##like an admin kill. keep-owners lists stored owner values (e.g. char_<uuid>) that are never removed.
##No worlds (the default) means no cleanup.
cleanup:
worlds: []
keep-owners: []
idle-minutes: 30

##Explosion Damage
block-damage: true

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ void configLoadsValidMaterialsAndAllRuntimeOptions() throws Exception {
enable-logging: true
block-damage: false
despawn-distance: 17
cleanup:
worlds: [TFMC_S2]
keep-owners: [char_demo]
idle-minutes: 0
skin-item: custom.skin
repair-item: custom.repair
destroy-item: custom.destroy
Expand All @@ -153,6 +157,9 @@ void configLoadsValidMaterialsAndAllRuntimeOptions() throws Exception {
assertTrue(Cache.enableLogging);
assertFalse(Cache.blockDamage);
assertEquals(289, Cache.despawnDistance);
assertEquals(Set.of("tfmc_s2"), Cache.cleanupWorlds);
assertEquals(Set.of("char_demo"), Cache.cleanupKeepOwners);
assertEquals(60_000L, Cache.cleanupIdleMillis); // at least a minute
assertEquals("custom.skin", Cache.skinItem);
assertEquals("custom.repair", Cache.repairItem);
assertEquals("custom.destroy", Cache.destroyItem);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package net.tfminecraft.vehicleframework.managers;

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.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import net.tfminecraft.vehicleframework.cache.Cache;
import net.tfminecraft.vehicleframework.data.OwnerData;
import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;

class VehicleCleanupTest {
private static final long MIN = 60_000L;
private Set<String> worlds, keep;
private long idle;
private final World tutorial = mock(World.class), main = mock(World.class);
private final VehicleCleanup cleanup = new VehicleCleanup();

@BeforeEach
void configure() {
worlds = Cache.cleanupWorlds; keep = Cache.cleanupKeepOwners; idle = Cache.cleanupIdleMillis;
when(tutorial.getName()).thenReturn("TFMC_S2");
when(main.getName()).thenReturn("TFMC_Map");
Cache.cleanupWorlds = new HashSet<>(Set.of("tfmc_s2"));
Cache.cleanupKeepOwners = new HashSet<>(Set.of("char_demo"));
Cache.cleanupIdleMillis = 30 * MIN;
}

@AfterEach
void restore() {
Cache.cleanupWorlds = worlds; Cache.cleanupKeepOwners = keep; Cache.cleanupIdleMillis = idle;
}

private ActiveVehicle vehicle(String id, World world, String owner) {
ActiveVehicle v = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS);
when(v.getUUID()).thenReturn(id);
when(v.getEntity().getWorld()).thenReturn(world);
OwnerData data = new OwnerData();
data.setOwner(owner);
when(v.getOwnerData()).thenReturn(data);
when(v.getSeatHandler().getPassengers()).thenReturn(new ArrayList<>());
when(v.getEntity().getLocation()).thenReturn(new Location(world, 0, 64, 0));
return v;
}

@Test
void idleVehiclesOnCleanupWorldsAreDueAfterTheIdleTime() {
ActiveVehicle built = vehicle("built", tutorial, "player_Saint");
ActiveVehicle unowned = vehicle("unowned", tutorial, "none");
ActiveVehicle demo = vehicle("demo", tutorial, "char_demo");
ActiveVehicle elsewhere = vehicle("elsewhere", main, "player_Saint");
List<ActiveVehicle> all = List.of(built, unowned, demo, elsewhere, built); // built twice: two entities

// First seen: the idle clock starts, nothing is due yet.
assertTrue(cleanup.due(all, 0).isEmpty());
assertTrue(cleanup.due(all, 29 * MIN).isEmpty());
// Kept owners and other worlds never come due; a vehicle registered twice is listed once.
assertEquals(List.of(built, unowned), cleanup.due(all, 30 * MIN));
}

@Test
void aPassengerRestartsTheIdleClock() {
ActiveVehicle car = vehicle("car", tutorial, "player_Saint");
List<ActiveVehicle> all = List.of(car);
cleanup.due(all, 0);
when(car.getSeatHandler().getPassengers()).thenReturn(new ArrayList<>(List.<Entity>of(mock(Player.class))));
assertTrue(cleanup.due(all, 25 * MIN).isEmpty()); // occupied: clock now at 25
when(car.getSeatHandler().getPassengers()).thenReturn(new ArrayList<>());
assertTrue(cleanup.due(all, 50 * MIN).isEmpty());
assertEquals(List.of(car), cleanup.due(all, 55 * MIN));
}

@Test
void aMovingVehicleWithoutPassengersIsNotIdle() {
// a train car behind an occupied locomotive, or a horse-drawn cart: no seat taken, but moving
ActiveVehicle car = vehicle("car", tutorial, "player_Saint");
List<ActiveVehicle> all = List.of(car);
cleanup.due(all, 0);
when(car.getEntity().getLocation()).thenReturn(new Location(tutorial, 40, 64, 0));
assertTrue(cleanup.due(all, 20 * MIN).isEmpty()); // moved: clock now at 20
when(car.getEntity().getLocation()).thenReturn(new Location(tutorial, 40.5, 64, 0.4));
assertTrue(cleanup.due(all, 45 * MIN).isEmpty()); // drifted under a block: not use
assertEquals(List.of(car), cleanup.due(all, 50 * MIN));
when(car.getEntity().getLocation()).thenReturn(null);
assertEquals(List.of(car), cleanup.due(all, 51 * MIN)); // no location: judged by passengers only
}

@Test
void aReloadedVehicleStartsAFreshClockAndGettingInOrOutCountsAsUse() {
ActiveVehicle car = vehicle("car", tutorial, "player_Saint");
cleanup.due(List.of(car), 0);
// unloaded and loaded again between two checks: a new instance with the same id
ActiveVehicle reloaded = vehicle("car", tutorial, "player_Saint");
assertTrue(cleanup.due(List.of(reloaded), 29 * MIN).isEmpty());
assertTrue(cleanup.due(List.of(reloaded), 58 * MIN).isEmpty());
cleanup.markUsed(car, 58 * MIN); // the old instance: ignored
// someone got in and out between checks, at minute 58
cleanup.markUsed(reloaded, 58 * MIN);
assertTrue(cleanup.due(List.of(reloaded), 60 * MIN).isEmpty());
assertEquals(List.of(reloaded), cleanup.due(List.of(reloaded), 88 * MIN));
cleanup.markUsed(null, 0); // a player who was in no vehicle
cleanup.markUsed(vehicle(null, tutorial, "x"), 0);
cleanup.markUsed(vehicle("never-seen", tutorial, "x"), 0);
}

@Test
void unloadedVehiclesAreForgottenAndNoWorldsMeansNoCleanup() {
ActiveVehicle car = vehicle("car", tutorial, "player_Saint");
cleanup.due(List.of(car), 0);
cleanup.due(List.of(), 10 * MIN); // unloaded: forgotten
assertTrue(cleanup.due(List.of(car), 40 * MIN).isEmpty()); // seen again: a fresh clock
assertEquals(List.of(car), cleanup.due(List.of(car), 70 * MIN));

Cache.cleanupWorlds = new HashSet<>();
assertTrue(cleanup.due(List.of(car), 200 * MIN).isEmpty());
Cache.cleanupWorlds = new HashSet<>(Set.of("tfmc_s2"));
assertTrue(cleanup.due(List.of(car), 201 * MIN).isEmpty()); // the clock was cleared too
}

@Test
void vehiclesWithoutAnIdWorldOrOwnerAreHandledSafely() {
ActiveVehicle noId = vehicle(null, tutorial, "player_A");
ActiveVehicle destroyed = vehicle("destroyed", tutorial, "player_A");
when(destroyed.isDestroyed()).thenReturn(true);
ActiveVehicle noEntity = vehicle("noEntity", tutorial, "player_A");
when(noEntity.getEntity()).thenReturn(null);
ActiveVehicle noWorld = vehicle("noWorld", null, "player_A");
ActiveVehicle noOwnerData = vehicle("noOwnerData", tutorial, "x");
when(noOwnerData.getOwnerData()).thenReturn(null);
ActiveVehicle nullOwner = vehicle("nullOwner", tutorial, null);
List<ActiveVehicle> all = List.of(noId, destroyed, noEntity, noWorld, noOwnerData, nullOwner);
cleanup.due(all, 0);
assertEquals(List.of(noOwnerData, nullOwner), cleanup.due(all, 30 * MIN));
}
}
Loading
Loading