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/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/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] 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); + } +}