From b7bdf68ca156bc8711153faaaaf98771c2a18dc0 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:00:05 +0000 Subject: [PATCH 1/2] Pin TLibs 2.4.1 and drop folia-supported The character session API in TLibs is main-thread only, so VFBuilders no longer claims Folia support. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 2 +- src/main/resources/plugin.yml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 9ab1bec..3946455 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ UTF-8 full 25.0.0 - 2.0.0 + 2.4.1 2.0.1 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index bd3c2c8..4071932 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,7 +3,6 @@ api-version: '1.21.10' name: VFBuilders version: ${project.version} author: Drefvelin -folia-supported: true depend: [TLibs, VehicleFramework] From 86e32816c5cc9e8ed2153dd6016496da37cd0b78 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:00:05 +0000 Subject: [PATCH 2/2] Record the constructor's character beside the account constructorUuid stays the account. Each build now also records constructorCharacterId, the player's current character session when the build starts (null without one, or when TLibs lacks the session API). The station saves it, older saves load with null, and the begin, construct and cancel events expose it as getConstructorCharacterId(). ConstructionFreeze gains a default freezeReason(account, character) that asks the account form; stations call the two-argument form each second. Co-Authored-By: Claude Opus 5.5 --- .../vfbuilders/api/ConstructionFreeze.java | 15 +++- .../vfbuilders/api/ConstructionFreezes.java | 10 ++- .../vfbuilders/core/ActiveStation.java | 33 +++++++- .../vfbuilders/core/ConstructorCharacter.java | 54 +++++++++++++ .../tfminecraft/vfbuilders/data/Database.java | 34 +++++--- .../events/BeginVehicleConstructionEvent.java | 21 +++++ .../events/VehicleConstructEvent.java | 21 +++++ .../VehicleConstructionCancelEvent.java | 17 +++- .../vfbuilders/managers/StationManager.java | 7 +- .../tfminecraft/vfbuilders/DatabaseTest.java | 70 ++++++++++++++++ .../tfminecraft/vfbuilders/DomainTest.java | 80 +++++++++++++++++++ .../tfminecraft/vfbuilders/ManagerTest.java | 55 +++++++++++++ .../tfminecraft/vfbuilders/StationTest.java | 60 +++++++++++++- .../core/ConstructorCharacterTest.java | 72 +++++++++++++++++ 14 files changed, 527 insertions(+), 22 deletions(-) create mode 100644 src/main/java/net/tfminecraft/vfbuilders/core/ConstructorCharacter.java create mode 100644 src/test/java/net/tfminecraft/vfbuilders/core/ConstructorCharacterTest.java diff --git a/src/main/java/net/tfminecraft/vfbuilders/api/ConstructionFreeze.java b/src/main/java/net/tfminecraft/vfbuilders/api/ConstructionFreeze.java index 53a7480..a7f8f48 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/api/ConstructionFreeze.java +++ b/src/main/java/net/tfminecraft/vfbuilders/api/ConstructionFreeze.java @@ -10,8 +10,21 @@ public interface ConstructionFreeze { /** - * @param constructorUuid player who started the project + * @param constructorUuid account that started the project * @return why the project is paused, shown under the station timer, or null to keep counting down */ String freezeReason(UUID constructorUuid); + + /** + * Stations call this form. Override it to freeze by character; the default asks + * {@link #freezeReason(UUID)} with the account. + * + * @param constructorUuid account that started the project + * @param constructorCharacterId character the account was playing when the project started, + * or null when it had no current character session or the project predates the field + * @return why the project is paused, or null to keep counting down + */ + default String freezeReason(UUID constructorUuid, UUID constructorCharacterId) { + return freezeReason(constructorUuid); + } } diff --git a/src/main/java/net/tfminecraft/vfbuilders/api/ConstructionFreezes.java b/src/main/java/net/tfminecraft/vfbuilders/api/ConstructionFreezes.java index abd53e5..39c3d68 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/api/ConstructionFreezes.java +++ b/src/main/java/net/tfminecraft/vfbuilders/api/ConstructionFreezes.java @@ -13,6 +13,14 @@ private ConstructionFreezes() { /** @return the first freeze reason for this constructor, or null when nothing pauses it */ public static String reason(UUID constructorUuid) { + return reason(constructorUuid, null); + } + + /** + * @param constructorCharacterId may be null; providers then decide by account + * @return the first freeze reason for this constructor, or null when nothing pauses it + */ + public static String reason(UUID constructorUuid, UUID constructorCharacterId) { if (constructorUuid == null) { return null; } @@ -20,7 +28,7 @@ public static String reason(UUID constructorUuid) { : Bukkit.getServicesManager().getRegistrations(ConstructionFreeze.class)) { String reason; try { - reason = registration.getProvider().freezeReason(constructorUuid); + reason = registration.getProvider().freezeReason(constructorUuid, constructorCharacterId); } catch (RuntimeException e) { // A broken provider must not stall every station on the server. continue; diff --git a/src/main/java/net/tfminecraft/vfbuilders/core/ActiveStation.java b/src/main/java/net/tfminecraft/vfbuilders/core/ActiveStation.java index 3e92897..c36ef02 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/core/ActiveStation.java +++ b/src/main/java/net/tfminecraft/vfbuilders/core/ActiveStation.java @@ -28,6 +28,7 @@ public class ActiveStation { private Location loc; private Location spawnLoc; private UUID constructorUuid; + private UUID constructorCharacterId; private TextDisplay timerDisplay; private String freezeReason; @@ -51,11 +52,23 @@ public ActiveStation( int timeLeft, Location spawnLoc, UUID constructorUuid) { + this(loc, station, blueprintId, timeLeft, spawnLoc, constructorUuid, null); + } + + public ActiveStation( + Location loc, + Station station, + String blueprintId, + int timeLeft, + Location spawnLoc, + UUID constructorUuid, + UUID constructorCharacterId) { this.id = UUID.randomUUID(); this.loc = loc; this.station = station; this.timeLeft = timeLeft; this.constructorUuid = constructorUuid; + this.constructorCharacterId = constructorCharacterId; if (blueprintId != null) { Blueprint blueprint = BlueprintLoader.getByString(blueprintId); @@ -72,9 +85,14 @@ public ActiveStation( public void selectBlueprint(Blueprint b, UUID constructorUuid) { + selectBlueprint(b, constructorUuid, null); + } + + public void selectBlueprint(Blueprint b, UUID constructorUuid, UUID constructorCharacterId) { blueprint = b; timeLeft = b.getTime(); this.constructorUuid = constructorUuid; + this.constructorCharacterId = constructorCharacterId; freezeReason = null; ensureDisplay(); } @@ -85,7 +103,7 @@ public boolean tick() { return true; } - String reason = ConstructionFreezes.reason(constructorUuid); + String reason = ConstructionFreezes.reason(constructorUuid, constructorCharacterId); if (reason != null) { freezeReason = reason; ensureDisplay(); @@ -125,7 +143,8 @@ public void complete() { Location completedSpawn = spawnLoc.clone(); Bukkit.getPluginManager().callEvent( - new VehicleConstructEvent(constructorUuid, vehicle, blueprint, completedSpawn, this)); + new VehicleConstructEvent( + constructorUuid, constructorCharacterId, vehicle, blueprint, completedSpawn, this)); // Visual effects Location center = completedSpawn.clone().add(0.5, 1, 0.5); @@ -140,18 +159,21 @@ public void complete() { spawnLoc = null; blueprint = null; constructorUuid = null; + constructorCharacterId = null; } public void cancelConstruction() { if (blueprint != null) { blueprint.drop(loc.clone().add(0.5, 1, 0.5)); Bukkit.getPluginManager().callEvent( - new VehicleConstructionCancelEvent(constructorUuid, blueprint, this)); + new VehicleConstructionCancelEvent( + constructorUuid, constructorCharacterId, blueprint, this)); } removeHolograms(); spawnLoc = null; blueprint = null; constructorUuid = null; + constructorCharacterId = null; freezeReason = null; timeLeft = 0; } @@ -200,6 +222,11 @@ public UUID getConstructorUuid() { return constructorUuid; } + /** Null when the constructor had no current character session, or for older saved projects. */ + public UUID getConstructorCharacterId() { + return constructorCharacterId; + } + public void rebindDefinitions() { if (station != null) { Station updatedStation = StationLoader.getByString(station.getId()); diff --git a/src/main/java/net/tfminecraft/vfbuilders/core/ConstructorCharacter.java b/src/main/java/net/tfminecraft/vfbuilders/core/ConstructorCharacter.java new file mode 100644 index 0000000..b0db042 --- /dev/null +++ b/src/main/java/net/tfminecraft/vfbuilders/core/ConstructorCharacter.java @@ -0,0 +1,54 @@ +package net.tfminecraft.vfbuilders.core; + +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.tlibs.character.Characters; + +/** + * The character a player builds as. TLibs' session API is probed on first use, so an older + * TLibs records no character instead of failing every build. Main thread only. + */ +public final class ConstructorCharacter { + + static final String API = "net.tfminecraft.tlibs.character.Characters"; + static final String SESSION = "net.tfminecraft.tlibs.character.CharacterSession"; + + static Boolean available; + + private ConstructorCharacter() { + } + + /** + * @return the player's current character, or null when the player has no current session + * or TLibs lacks the session API + */ + public static UUID of(Player player) { + if (available == null) { + available = probe(API); + } + return available ? current(player) : null; + } + + static boolean probe(String api) { + try { + Class characters = Class.forName(api); + characters.getMethod("currentSession", Player.class); + characters.getMethod("isCurrent", Class.forName(SESSION)); + return true; + } catch (ReflectiveOperationException | LinkageError e) { + Bukkit.getLogger().warning( + "[VFBuilders] TLibs has no character session API; constructions record no character"); + return false; + } + } + + private static UUID current(Player player) { + return Characters.currentSession(player) + .filter(Characters::isCurrent) + .map(session -> session.ref().characterId()) + .orElse(null); + } +} diff --git a/src/main/java/net/tfminecraft/vfbuilders/data/Database.java b/src/main/java/net/tfminecraft/vfbuilders/data/Database.java index b83beba..aa7b7ce 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/data/Database.java +++ b/src/main/java/net/tfminecraft/vfbuilders/data/Database.java @@ -32,8 +32,9 @@ public static void saveStations(Map stations) { data.spawnLocation = station.hasSpawnLocation() ? toSerializableLoc(station.getSpawnLocation()) : null; data.blueprintId = station.hasBlueprint() ? station.getBlueprint().getId() : null; data.timeLeft = station.getTimeLeft(); - if (station.hasBlueprint() && station.getConstructorUuid() != null) { - data.constructorUuid = station.getConstructorUuid().toString(); + if (station.hasBlueprint()) { + data.constructorUuid = toText(station.getConstructorUuid()); + data.constructorCharacterId = toText(station.getConstructorCharacterId()); } dataList.add(data); @@ -60,22 +61,15 @@ public static HashMap loadStations() { Station baseStation = StationLoader.getByString(data.stationId); if (baseStation == null) continue; - UUID constructorUuid = null; - if (data.constructorUuid != null && !data.constructorUuid.isEmpty()) { - try { - constructorUuid = UUID.fromString(data.constructorUuid); - } catch (IllegalArgumentException ignored) { - constructorUuid = null; - } - } - ActiveStation station = new ActiveStation( loc, baseStation, data.blueprintId, data.timeLeft, data.spawnLocation != null ? toBukkitLoc(data.spawnLocation) : null, - constructorUuid + toUuid(data.constructorUuid), + // Stations saved before 2.2.0 have no character; SimpleFactions resolves the heir. + toUuid(data.constructorCharacterId) ); loaded.put(loc, station); @@ -88,6 +82,21 @@ public static HashMap loadStations() { } // === Serializable Helpers === + private static String toText(UUID id) { + return id != null ? id.toString() : null; + } + + private static UUID toUuid(String text) { + if (text == null || text.isEmpty()) { + return null; + } + try { + return UUID.fromString(text); + } catch (IllegalArgumentException ignored) { + return null; + } + } + private static SerializableLoc toSerializableLoc(Location loc) { return new SerializableLoc(loc.getWorld().getName(), loc.getBlockX(), loc.getBlockY(), loc.getBlockZ()); } @@ -115,6 +124,7 @@ private static class StationData { String blueprintId; int timeLeft; String constructorUuid; + String constructorCharacterId; } } diff --git a/src/main/java/net/tfminecraft/vfbuilders/events/BeginVehicleConstructionEvent.java b/src/main/java/net/tfminecraft/vfbuilders/events/BeginVehicleConstructionEvent.java index 13d658a..895b32e 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/events/BeginVehicleConstructionEvent.java +++ b/src/main/java/net/tfminecraft/vfbuilders/events/BeginVehicleConstructionEvent.java @@ -1,5 +1,7 @@ package net.tfminecraft.vfbuilders.events; +import java.util.UUID; + import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.event.Cancellable; @@ -13,6 +15,7 @@ public class BeginVehicleConstructionEvent extends Event implements Cancellable private static final HandlerList HANDLERS = new HandlerList(); private final Player constructor; + private final UUID constructorCharacterId; private final Blueprint blueprint; private final ActiveStation station; private final Location spawnLocation; @@ -24,7 +27,17 @@ public BeginVehicleConstructionEvent( Blueprint blueprint, ActiveStation station, Location spawnLocation) { + this(constructor, null, blueprint, station, spawnLocation); + } + + public BeginVehicleConstructionEvent( + Player constructor, + UUID constructorCharacterId, + Blueprint blueprint, + ActiveStation station, + Location spawnLocation) { this.constructor = constructor; + this.constructorCharacterId = constructorCharacterId; this.blueprint = blueprint; this.station = station; this.spawnLocation = spawnLocation; @@ -35,6 +48,14 @@ public Player getConstructor() { return constructor; } + /** + * The character the constructor is playing, recorded on the station if construction starts. + * Null when the player has no current character session. + */ + public UUID getConstructorCharacterId() { + return constructorCharacterId; + } + public Blueprint getBlueprint() { return blueprint; } diff --git a/src/main/java/net/tfminecraft/vfbuilders/events/VehicleConstructEvent.java b/src/main/java/net/tfminecraft/vfbuilders/events/VehicleConstructEvent.java index b878efd..94cdba9 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/events/VehicleConstructEvent.java +++ b/src/main/java/net/tfminecraft/vfbuilders/events/VehicleConstructEvent.java @@ -16,6 +16,7 @@ public class VehicleConstructEvent extends Event { private static final HandlerList HANDLERS = new HandlerList(); private final UUID constructorUuid; + private final UUID constructorCharacterId; private final Player constructor; private final ActiveVehicle vehicle; private final Blueprint blueprint; @@ -28,7 +29,18 @@ public VehicleConstructEvent( Blueprint blueprint, Location spawnLocation, ActiveStation station) { + this(constructorUuid, null, vehicle, blueprint, spawnLocation, station); + } + + public VehicleConstructEvent( + UUID constructorUuid, + UUID constructorCharacterId, + ActiveVehicle vehicle, + Blueprint blueprint, + Location spawnLocation, + ActiveStation station) { this.constructorUuid = constructorUuid; + this.constructorCharacterId = constructorCharacterId; this.constructor = constructorUuid != null ? Bukkit.getPlayer(constructorUuid) : null; this.vehicle = vehicle; this.blueprint = blueprint; @@ -36,10 +48,19 @@ public VehicleConstructEvent( this.station = station; } + /** The account that started the project. */ public UUID getConstructorUuid() { return constructorUuid; } + /** + * The character the account was playing when the project started. Null when it had no + * current character session, or for projects saved before the character was recorded. + */ + public UUID getConstructorCharacterId() { + return constructorCharacterId; + } + public Player getConstructor() { return constructor; } diff --git a/src/main/java/net/tfminecraft/vfbuilders/events/VehicleConstructionCancelEvent.java b/src/main/java/net/tfminecraft/vfbuilders/events/VehicleConstructionCancelEvent.java index a0ea3e3..c4df9ff 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/events/VehicleConstructionCancelEvent.java +++ b/src/main/java/net/tfminecraft/vfbuilders/events/VehicleConstructionCancelEvent.java @@ -16,20 +16,35 @@ public class VehicleConstructionCancelEvent extends Event { private static final HandlerList HANDLERS = new HandlerList(); private final UUID constructorUuid; + private final UUID constructorCharacterId; private final Blueprint blueprint; private final ActiveStation station; public VehicleConstructionCancelEvent(UUID constructorUuid, Blueprint blueprint, ActiveStation station) { + this(constructorUuid, null, blueprint, station); + } + + public VehicleConstructionCancelEvent( + UUID constructorUuid, UUID constructorCharacterId, Blueprint blueprint, ActiveStation station) { this.constructorUuid = constructorUuid; + this.constructorCharacterId = constructorCharacterId; this.blueprint = blueprint; this.station = station; } - /** Null for constructions saved before the constructor was recorded. */ + /** The account that started the project. Null for constructions saved before it was recorded. */ public UUID getConstructorUuid() { return constructorUuid; } + /** + * The character the account was playing when the project started. Null when it had no + * current character session, or for constructions saved before the character was recorded. + */ + public UUID getConstructorCharacterId() { + return constructorCharacterId; + } + public Blueprint getBlueprint() { return blueprint; } diff --git a/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java b/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java index a15633f..77a7027 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java +++ b/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java @@ -40,6 +40,7 @@ import net.tfminecraft.vfbuilders.core.ActiveStation; import net.tfminecraft.vfbuilders.core.Blueprint; import net.tfminecraft.vfbuilders.core.BlueprintCategory; +import net.tfminecraft.vfbuilders.core.ConstructorCharacter; import net.tfminecraft.vfbuilders.core.Station; import net.tfminecraft.vfbuilders.data.Database; import net.tfminecraft.vfbuilders.display.StationTimerDisplay; @@ -261,8 +262,10 @@ public void onPlayerLeftClick(PlayerInteractEvent e) { } ActiveStation station = placement.getStation(); + // Credit the character playing now; without a current session the build still starts. + UUID characterId = ConstructorCharacter.of(p); BeginVehicleConstructionEvent beginEvent = new BeginVehicleConstructionEvent( - p, blueprint, station, clickLoc); + p, characterId, blueprint, station, clickLoc); Bukkit.getPluginManager().callEvent(beginEvent); if (beginEvent.isCancelled()) { if (!beginEvent.isKeepPlacement()) { @@ -275,7 +278,7 @@ public void onPlayerLeftClick(PlayerInteractEvent e) { blueprint.takeInputs(p); placement.getStation().setSpawnLocation(clickLoc); placement.setFinalSpawnLocation(clickLoc); - station.selectBlueprint(blueprint, p.getUniqueId()); + station.selectBlueprint(blueprint, p.getUniqueId(), characterId); p.sendMessage("§aBuilding site chosen!"); p.sendTitle("", "§eStarted Constructing "+blueprint.getVehicle().getName(), 10, 60, 10); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); diff --git a/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java b/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java index c3a0406..3958cfa 100644 --- a/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java +++ b/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java @@ -75,4 +75,74 @@ void savesRestoresLegacyAndOwnedProjectsAndHandlesIoFailures() throws Exception } } } + + @Test + void constructorCharacterRoundTripsAndOlderSavesLoadWithoutOne() throws Exception { + var server = MockBukkit.mock(); + Path temp = DatabaseFixture.folder(); + try { + VFBuilders.plugin = mock(VFBuilders.class); + when(VFBuilders.plugin.getDataFolder()).thenReturn(temp.toFile()); + Files.createDirectories(temp.resolve("data")); + var file = temp.resolve("data/stations.json"); + var world = server.addSimpleWorld("build-world"); + var loc = new Location(world, 3, 64, 5); + var definition = mock(Station.class); + when(definition.getId()).thenReturn("dock"); + StationLoader.get().clear(); + BlueprintLoader.get().clear(); + StationLoader.get().put("dock", definition); + var blueprint = mock(Blueprint.class); + when(blueprint.getId()).thenReturn("boat"); + when(blueprint.getTime()).thenReturn(40); + BlueprintLoader.get().put("boat", blueprint); + var account = UUID.randomUUID(); + var character = UUID.randomUUID(); + try (var display = mockStatic(net.tfminecraft.vfbuilders.display.StationTimerDisplay.class)) { + world.unloadChunk(0, 0); + var station = new ActiveStation(loc, definition); + station.selectBlueprint(blueprint, account, character); + Database.saveStations(Map.of(loc, station)); + var restored = Database.loadStations().get(loc); + assertEquals(account, restored.getConstructorUuid()); + assertEquals(character, restored.getConstructorCharacterId()); + + station.selectBlueprint(blueprint, account, null); + Database.saveStations(Map.of(loc, station)); + assertFalse(Files.readString(file).contains("constructorCharacterId")); + restored = Database.loadStations().get(loc); + assertEquals(account, restored.getConstructorUuid()); + assertNull(restored.getConstructorCharacterId()); + + // A save written before the character was recorded. + Files.writeString( + file, + """ + [{"location": {"world": "build-world", "x": 3, "y": 64, "z": 5}, + "stationId": "dock", "blueprintId": "boat", "timeLeft": 7, + "constructorUuid": "%s"}] + """ + .formatted(account)); + restored = Database.loadStations().get(loc); + assertTrue(restored.hasBlueprint()); + assertEquals(7, restored.getTimeLeft()); + assertEquals(account, restored.getConstructorUuid()); + assertNull(restored.getConstructorCharacterId()); + + Files.writeString( + file, + Files.readString(file) + .replace("\"timeLeft\"", "\"constructorCharacterId\": \"invalid\", \"timeLeft\"")); + restored = Database.loadStations().get(loc); + assertEquals(account, restored.getConstructorUuid()); + assertNull(restored.getConstructorCharacterId()); + } + } finally { + VFBuilders.plugin = null; + MockBukkit.unmock(); + try (var paths = Files.walk(temp)) { + for (var p : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(p); + } + } + } } diff --git a/src/test/java/net/tfminecraft/vfbuilders/DomainTest.java b/src/test/java/net/tfminecraft/vfbuilders/DomainTest.java index ed5cdb0..690b4fd 100644 --- a/src/test/java/net/tfminecraft/vfbuilders/DomainTest.java +++ b/src/test/java/net/tfminecraft/vfbuilders/DomainTest.java @@ -169,6 +169,7 @@ void eventsExposeConstructionIdentityAndCancellationPolicy() { assertSame(b, e.getBlueprint()); assertSame(station, e.getStation()); assertSame(loc, e.getSpawnLocation()); + assertNull(e.getConstructorCharacterId()); assertFalse(e.isCancelled()); assertFalse(e.isKeepPlacement()); e.setCancelled(true); @@ -184,9 +185,11 @@ void eventsExposeConstructionIdentityAndCancellationPolicy() { assertSame(station, complete.getStation()); assertSame(loc, complete.getSpawnLocation()); assertSame(VehicleConstructEvent.getHandlerList(), complete.getHandlers()); + assertNull(complete.getConstructorCharacterId()); assertNull(new VehicleConstructEvent(null, null, b, loc, station).getConstructor()); var cancel = new VehicleConstructionCancelEvent(player.getUniqueId(), b, station); assertEquals(player.getUniqueId(), cancel.getConstructorUuid()); + assertNull(cancel.getConstructorCharacterId()); assertSame(b, cancel.getBlueprint()); assertSame(station, cancel.getStation()); assertSame(VehicleConstructionCancelEvent.getHandlerList(), cancel.getHandlers()); @@ -204,6 +207,83 @@ void eventsExposeConstructionIdentityAndCancellationPolicy() { new Cache(); } + @Test + void eventsCarryTheConstructorCharacterBesideTheAccount() { + var player = server.addPlayer(); + var station = new ActiveStation(player.getLocation(), null); + var b = mock(Blueprint.class); + var loc = player.getLocation(); + var character = UUID.randomUUID(); + var begin = new BeginVehicleConstructionEvent(player, character, b, station, loc); + assertSame(player, begin.getConstructor()); + assertEquals(character, begin.getConstructorCharacterId()); + assertSame(b, begin.getBlueprint()); + var complete = + new VehicleConstructEvent(player.getUniqueId(), character, null, b, loc, station); + assertSame(player, complete.getConstructor()); + assertEquals(player.getUniqueId(), complete.getConstructorUuid()); + assertEquals(character, complete.getConstructorCharacterId()); + assertSame(station, complete.getStation()); + var cancel = new VehicleConstructionCancelEvent(player.getUniqueId(), character, b, station); + assertEquals(player.getUniqueId(), cancel.getConstructorUuid()); + assertEquals(character, cancel.getConstructorCharacterId()); + assertSame(b, cancel.getBlueprint()); + } + + @Test + void accessorsCanBeProbedReflectively() throws Exception { + for (var type : + List.of( + BeginVehicleConstructionEvent.class, + VehicleConstructEvent.class, + VehicleConstructionCancelEvent.class)) { + assertEquals(UUID.class, type.getMethod("getConstructorCharacterId").getReturnType()); + } + var freeze = ConstructionFreeze.class.getMethod("freezeReason", UUID.class, UUID.class); + assertTrue(freeze.isDefault()); + assertEquals(String.class, freeze.getReturnType()); + } + + @Test + void accountOnlyFreezesStillDecideWhenAskedWithACharacter() { + var account = UUID.randomUUID(); + var character = UUID.randomUUID(); + var asked = new ArrayList(); + ConstructionFreeze byAccount = + id -> { + asked.add(id); + return account.equals(id) ? "account frozen" : null; + }; + assertEquals("account frozen", byAccount.freezeReason(account, character)); + assertNull(byAccount.freezeReason(UUID.randomUUID(), character)); + assertEquals(account, asked.get(0)); + + var plugin = MockBukkit.createMockPlugin(); + var seen = new ArrayList>(); + ConstructionFreeze byCharacter = + new ConstructionFreeze() { + @Override + public String freezeReason(UUID constructorUuid) { + return "account only"; + } + + @Override + public String freezeReason(UUID constructorUuid, UUID constructorCharacterId) { + seen.add(Arrays.asList(constructorUuid, constructorCharacterId)); + return character.equals(constructorCharacterId) ? "character frozen" : null; + } + }; + server + .getServicesManager() + .register(ConstructionFreeze.class, byCharacter, plugin, ServicePriority.Normal); + assertEquals("character frozen", ConstructionFreezes.reason(account, character)); + assertNull(ConstructionFreezes.reason(account)); + assertEquals(List.of(List.of(account, character)), seen.subList(0, 1)); + assertEquals(Arrays.asList(account, null), seen.get(1)); + assertNull(ConstructionFreezes.reason(null, character)); + assertEquals(2, seen.size()); + } + @Test void freezeProvidersFailIndependentlyAndFirstReasonWins() { assertNull(ConstructionFreezes.reason(null)); diff --git a/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java b/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java index a66828d..e9a3312 100644 --- a/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java +++ b/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java @@ -220,9 +220,64 @@ void placementDistanceMaterialsAndSuccessfulCommit() throws Exception { assertTrue(placements().isEmpty()); assertSame(b, s.getBlueprint()); assertEquals(player.getUniqueId(), s.getConstructorUuid()); + // No character provider: the build starts without a character. + assertNull(s.getConstructorCharacterId()); assertEquals(player.getLocation(), s.getSpawnLocation()); } + @Test + void buildStartRecordsTheCurrentCharacterOnTheEventAndStation() throws Exception { + var character = UUID.randomUUID(); + var session = + new net.tfminecraft.tlibs.character.CharacterSession( + new net.tfminecraft.tlibs.character.CharacterRef(player.getUniqueId(), character), 1L); + var identity = mock(net.tfminecraft.tlibs.character.CharacterIdentity.class); + when(identity.currentSession(player)).thenReturn(Optional.of(session)); + when(identity.isCurrent(session)).thenReturn(true); + var plugin = MockBukkit.createMockPlugin(); + server + .getServicesManager() + .register( + net.tfminecraft.tlibs.character.CharacterIdentity.class, + identity, + plugin, + org.bukkit.plugin.ServicePriority.Normal); + var begun = new ArrayList(); + server + .getPluginManager() + .registerEvent( + net.tfminecraft.vfbuilders.events.BeginVehicleConstructionEvent.class, + new org.bukkit.event.Listener() {}, + org.bukkit.event.EventPriority.MONITOR, + (listener, event) -> + begun.add( + ((net.tfminecraft.vfbuilders.events.BeginVehicleConstructionEvent) event) + .getConstructorCharacterId()), + plugin); + var s = manager.getStation(block); + var b = mock(Blueprint.class); + var vehicle = mock(net.tfminecraft.vehicleframework.vehicles.Vehicle.class); + when(vehicle.getName()).thenReturn("Boat"); + when(b.getVehicle()).thenReturn(vehicle); + when(b.hasInputs(player)).thenReturn(true); + player.teleport(block.getLocation()); + placements().put(player.getUniqueId(), new ActivePlacement(player, s, b)); + manager.onPlayerLeftClick(left()); + assertEquals(List.of(character), begun); + assertEquals(player.getUniqueId(), s.getConstructorUuid()); + assertEquals(character, s.getConstructorCharacterId()); + + // A session that is no longer current is not credited; the build still starts. + s.cancelConstruction(); + when(identity.isCurrent(session)).thenReturn(false); + placements().put(player.getUniqueId(), new ActivePlacement(player, s, b)); + manager.onPlayerLeftClick(left()); + assertEquals(Arrays.asList(character, null), begun); + assertSame(b, s.getBlueprint()); + assertEquals(player.getUniqueId(), s.getConstructorUuid()); + assertNull(s.getConstructorCharacterId()); + } + @Test void cancelledBeginCanKeepPlacementForConfirmation() throws Exception { var s = manager.getStation(block); diff --git a/src/test/java/net/tfminecraft/vfbuilders/StationTest.java b/src/test/java/net/tfminecraft/vfbuilders/StationTest.java index bcf0184..d7a24c3 100644 --- a/src/test/java/net/tfminecraft/vfbuilders/StationTest.java +++ b/src/test/java/net/tfminecraft/vfbuilders/StationTest.java @@ -70,10 +70,10 @@ void selectionCountdownFreezeAndCancellation() { assertTrue(s.hasBlueprint()); assertEquals(2, s.getTimeLeft()); assertEquals(id, s.getConstructorUuid()); - freezes.when(() -> ConstructionFreezes.reason(id)).thenReturn("pause"); + freezes.when(() -> ConstructionFreezes.reason(id, null)).thenReturn("pause"); assertFalse(s.tick()); assertEquals(2, s.getTimeLeft()); - freezes.when(() -> ConstructionFreezes.reason(id)).thenReturn(null); + freezes.when(() -> ConstructionFreezes.reason(id, null)).thenReturn(null); assertFalse(s.tick()); assertTrue(s.tick()); assertEquals(0, s.getTimeLeft()); @@ -91,6 +91,56 @@ void selectionCountdownFreezeAndCancellation() { assertEquals(id, s.getConstructorUuid()); } + @Test + void characterIsAskedForFreezesAndCarriedToCompletionAndCancellation() { + var account = UUID.randomUUID(); + var character = UUID.randomUUID(); + var s = new ActiveStation(loc, definition); + s.selectBlueprint(b, account, character); + assertEquals(account, s.getConstructorUuid()); + assertEquals(character, s.getConstructorCharacterId()); + freezes.when(() -> ConstructionFreezes.reason(account, character)).thenReturn("siege"); + assertFalse(s.tick()); + assertEquals(2, s.getTimeLeft()); + + var fired = new ArrayList(); + var plugin = MockBukkit.createMockPlugin(); + for (var type : + List.of( + net.tfminecraft.vfbuilders.events.VehicleConstructEvent.class, + net.tfminecraft.vfbuilders.events.VehicleConstructionCancelEvent.class)) { + server + .getPluginManager() + .registerEvent( + type, + new org.bukkit.event.Listener() {}, + org.bukkit.event.EventPriority.MONITOR, + (listener, event) -> fired.add(event), + plugin); + } + s.setSpawnLocation(loc); + var p = mock(Player.class); + when(p.getLocation()).thenReturn(loc); + when(world.getPlayers()).thenReturn(List.of(p)); + try (var vf = mockStatic(VehicleFramework.class)) { + var manager = mock(VehicleManager.class); + vf.when(VehicleFramework::getVehicleManager).thenReturn(manager); + when(manager.spawn(loc, (Vehicle) null)).thenReturn(mock(ActiveVehicle.class)); + s.complete(); + } + var built = (net.tfminecraft.vfbuilders.events.VehicleConstructEvent) fired.get(0); + assertEquals(account, built.getConstructorUuid()); + assertEquals(character, built.getConstructorCharacterId()); + assertNull(s.getConstructorCharacterId()); + + s.selectBlueprint(b, account, character); + s.cancelConstruction(); + var cancelled = (net.tfminecraft.vfbuilders.events.VehicleConstructionCancelEvent) fired.get(1); + assertEquals(account, cancelled.getConstructorUuid()); + assertEquals(character, cancelled.getConstructorCharacterId()); + assertNull(s.getConstructorCharacterId()); + } + @Test void restorationAndReloadRebindOrRefund() { var old = new ActiveStation(loc, definition, null, 3, null); @@ -104,6 +154,12 @@ void restorationAndReloadRebindOrRefund() { BlueprintLoader.get().put("boat", b); var s = new ActiveStation(loc, definition, "boat", 5, loc, UUID.randomUUID()); assertSame(b, s.getBlueprint()); + assertNull(s.getConstructorCharacterId()); + var character = UUID.randomUUID(); + assertEquals( + character, + new ActiveStation(loc, definition, "boat", 5, loc, UUID.randomUUID(), character) + .getConstructorCharacterId()); s.rebindDefinitions(); assertSame(definition, s.getStation()); var replacement = mock(Station.class); diff --git a/src/test/java/net/tfminecraft/vfbuilders/core/ConstructorCharacterTest.java b/src/test/java/net/tfminecraft/vfbuilders/core/ConstructorCharacterTest.java new file mode 100644 index 0000000..f4d4976 --- /dev/null +++ b/src/test/java/net/tfminecraft/vfbuilders/core/ConstructorCharacterTest.java @@ -0,0 +1,72 @@ +package net.tfminecraft.vfbuilders.core; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.tlibs.character.*; +import org.bukkit.plugin.ServicePriority; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class ConstructorCharacterTest { + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + ConstructorCharacter.available = null; + } + + @AfterEach + void teardown() { + ConstructorCharacter.available = null; + MockBukkit.unmock(); + } + + @Test + void currentSessionNamesTheCharacterAndStaleOrMissingSessionsNameNone() { + var player = server.addPlayer(); + assertNull(ConstructorCharacter.of(player)); + assertTrue(ConstructorCharacter.available); + + var character = UUID.randomUUID(); + var session = new CharacterSession(new CharacterRef(player.getUniqueId(), character), 4L); + var identity = mock(CharacterIdentity.class); + when(identity.currentSession(player)).thenReturn(Optional.of(session)); + when(identity.isCurrent(session)).thenReturn(true); + server + .getServicesManager() + .register( + CharacterIdentity.class, + identity, + MockBukkit.createMockPlugin(), + ServicePriority.Normal); + assertEquals(character, ConstructorCharacter.of(player)); + when(identity.isCurrent(session)).thenReturn(false); + assertNull(ConstructorCharacter.of(player)); + when(identity.currentSession(player)).thenReturn(Optional.empty()); + assertNull(ConstructorCharacter.of(player)); + } + + @Test + void olderTlibsWithoutTheSessionApiRecordsNoCharacter() { + assertTrue(ConstructorCharacter.probe(ConstructorCharacter.API)); + assertFalse(ConstructorCharacter.probe("net.tfminecraft.tlibs.character.Missing")); + // An older Characters class without the session methods. + assertFalse(ConstructorCharacter.probe(String.class.getName())); + + var player = server.addPlayer(); + var identity = mock(CharacterIdentity.class); + server + .getServicesManager() + .register( + CharacterIdentity.class, + identity, + MockBukkit.createMockPlugin(), + ServicePriority.Normal); + ConstructorCharacter.available = false; + assertNull(ConstructorCharacter.of(player)); + verifyNoInteractions(identity); + } +}