diff --git a/libraries/name-api/build.gradle.kts b/libraries/name-api/build.gradle.kts index b7af74bf37..385df49acf 100644 --- a/libraries/name-api/build.gradle.kts +++ b/libraries/name-api/build.gradle.kts @@ -10,4 +10,11 @@ dependencies { api(libs.configurate.yaml) api("org.mariadb.jdbc:mariadb-java-client:3.5.6") implementation(libs.slf4j.api) + + testImplementation(libs.bundles.junit) + testImplementation("org.mockito:mockito-core:5.11.0") +} + +tasks.withType { + useJUnitPlatform() } diff --git a/libraries/name-api/src/main/java/net/civmc/nameapi/Migrator.java b/libraries/name-api/src/main/java/net/civmc/nameapi/Migrator.java index 94a2f5daff..1c7922f389 100644 --- a/libraries/name-api/src/main/java/net/civmc/nameapi/Migrator.java +++ b/libraries/name-api/src/main/java/net/civmc/nameapi/Migrator.java @@ -5,6 +5,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Statement; import java.util.HashMap; import java.util.Map; import java.util.NavigableMap; @@ -32,33 +33,40 @@ public void registerMigration(String namespace, int id, @Language("mariadb") Str public void migrate(Connection connection) throws SQLException { connection.setAutoCommit(false); - connection.createStatement().executeUpdate("CREATE TABLE IF NOT EXISTS migrations (" + - "namespace VARCHAR(64) PRIMARY KEY," + - "id INT NOT NULL)"); + try (Statement createTable = connection.createStatement()) { + createTable.executeUpdate("CREATE TABLE IF NOT EXISTS migrations (" + + "namespace VARCHAR(64) PRIMARY KEY," + + "id INT NOT NULL)"); + } for (Map.Entry> entry : migrations.entrySet()) { - PreparedStatement getMigrationId = connection.prepareStatement("SELECT id FROM migrations WHERE namespace = ? FOR UPDATE"); - getMigrationId.setString(1, entry.getKey()); - ResultSet resultSet = getMigrationId.executeQuery(); int minId; - if (resultSet.next()) { - minId = resultSet.getInt("id"); - } else { - minId = -1; + try (PreparedStatement getMigrationId = connection.prepareStatement("SELECT id FROM migrations WHERE namespace = ? FOR UPDATE")) { + getMigrationId.setString(1, entry.getKey()); + ResultSet resultSet = getMigrationId.executeQuery(); + if (resultSet.next()) { + minId = resultSet.getInt("id"); + } else { + minId = -1; + } } NavigableMap value = entry.getValue().tailMap(minId, false); int maxId = entry.getValue().lastKey(); for (String[] migration : value.sequencedValues()) { for (String sql : migration) { - connection.createStatement().executeUpdate(sql); + try (Statement statement = connection.createStatement()) { + statement.executeUpdate(sql); + } } } if (maxId != minId) { - PreparedStatement setMigrationId = connection.prepareStatement("REPLACE INTO migrations (namespace, id) VALUES (?, ?)"); - setMigrationId.setString(1, entry.getKey()); - setMigrationId.setInt(2, maxId); + try (PreparedStatement setMigrationId = connection.prepareStatement("REPLACE INTO migrations (namespace, id) VALUES (?, ?)")) { + setMigrationId.setString(1, entry.getKey()); + setMigrationId.setInt(2, maxId); + setMigrationId.executeUpdate(); + } } } connection.setAutoCommit(true); diff --git a/libraries/name-api/src/test/java/net/civmc/nameapi/MigratorTest.java b/libraries/name-api/src/test/java/net/civmc/nameapi/MigratorTest.java new file mode 100644 index 0000000000..ce4f587607 --- /dev/null +++ b/libraries/name-api/src/test/java/net/civmc/nameapi/MigratorTest.java @@ -0,0 +1,41 @@ +package net.civmc.nameapi; + +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MigratorTest { + + @Test + void persistsAppliedMigrationId() throws SQLException { + Connection connection = mock(Connection.class); + when(connection.createStatement()).thenReturn(mock(Statement.class)); + + ResultSet emptyResult = mock(ResultSet.class); + when(emptyResult.next()).thenReturn(false); + + PreparedStatement selectStatement = mock(PreparedStatement.class); + when(selectStatement.executeQuery()).thenReturn(emptyResult); + when(connection.prepareStatement("SELECT id FROM migrations WHERE namespace = ? FOR UPDATE")) + .thenReturn(selectStatement); + + PreparedStatement replaceStatement = mock(PreparedStatement.class); + when(connection.prepareStatement("REPLACE INTO migrations (namespace, id) VALUES (?, ?)")) + .thenReturn(replaceStatement); + + Migrator migrator = new Migrator(); + migrator.registerMigration("test", 0, "SELECT 1"); + migrator.migrate(connection); + + verify(replaceStatement).setInt(2, 0); + verify(replaceStatement).executeUpdate(); + } +} diff --git a/plugins/bastion-paper/src/main/java/isaac/bastion/BastionBlock.java b/plugins/bastion-paper/src/main/java/isaac/bastion/BastionBlock.java index 4a219b9a0c..3902204fe7 100644 --- a/plugins/bastion-paper/src/main/java/isaac/bastion/BastionBlock.java +++ b/plugins/bastion-paper/src/main/java/isaac/bastion/BastionBlock.java @@ -463,7 +463,6 @@ public String getStrengthText() { return formatter.format(rein.getHealth()) + "/" + formatter.format(rein.getType().getHealth()); } - // TODO: Test world-aware comparison @Override public int compareTo(BastionBlock other) { UUID thisWorld = location.getWorld().getUID(); @@ -471,7 +470,7 @@ public int compareTo(BastionBlock other) { int thisY = location.getBlockY(); int thisZ = location.getBlockZ(); - UUID otherWorld = location.getWorld().getUID(); + UUID otherWorld = other.location.getWorld().getUID(); int otherX = other.location.getBlockX(); int otherY = other.location.getBlockY(); int otherZ = other.location.getBlockZ(); diff --git a/plugins/bastion-paper/src/main/java/isaac/bastion/listeners/BastionDamageListener.java b/plugins/bastion-paper/src/main/java/isaac/bastion/listeners/BastionDamageListener.java index d1827bae01..bed0eea392 100644 --- a/plugins/bastion-paper/src/main/java/isaac/bastion/listeners/BastionDamageListener.java +++ b/plugins/bastion-paper/src/main/java/isaac/bastion/listeners/BastionDamageListener.java @@ -19,10 +19,12 @@ import org.bukkit.block.BlockState; import org.bukkit.block.data.type.Dispenser; import org.bukkit.entity.EnderPearl; +import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.block.BlockDispenseEvent; +import org.bukkit.event.block.BlockFertilizeEvent; import org.bukkit.event.block.BlockFromToEvent; import org.bukkit.event.block.BlockPistonEvent; import org.bukkit.event.block.BlockPistonExtendEvent; @@ -114,6 +116,28 @@ private void handlePistonEvent(BlockPistonEvent event, List blocks) { } } + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onFertilize(BlockFertilizeEvent event) { + Player player = event.getPlayer(); + if (player == null) { + Set blocks = new HashSet<>(); + for (BlockState state : event.getBlocks()) { + blocks.add(state.getLocation()); + } + if (stopBlockEvent(event.getBlock().getLocation(), blocks)) { + event.setCancelled(true); + } + return; + } + PermissionType permission = PermissionType.getPermission(Permissions.BASTION_PLACE); + boolean removed = event.getBlocks().removeIf(state -> blockManager + .getBlockingBastionsWithoutPermission(state.getLocation(), player.getUniqueId(), permission) + .stream().anyMatch(bastion -> !bastion.getType().isOnlyDirectDestruction())); + if (removed && !Bastion.getSettingManager().getIgnorePlacementMessages(player.getUniqueId())) { + player.sendMessage(ChatColor.RED + "Bastion prevented fertilizing"); + } + } + @EventHandler(ignoreCancelled = true) public void onBucketEmpty(PlayerBucketEmptyEvent event) { Set blocking = blockManager.getBlockingBastionsWithoutPermission(event.getBlock().getLocation(), diff --git a/plugins/bastion-paper/src/test/java/isaac/bastion/BastionBlockCompareToTest.java b/plugins/bastion-paper/src/test/java/isaac/bastion/BastionBlockCompareToTest.java new file mode 100644 index 0000000000..ab70510181 --- /dev/null +++ b/plugins/bastion-paper/src/test/java/isaac/bastion/BastionBlockCompareToTest.java @@ -0,0 +1,116 @@ +package isaac.bastion; + +import isaac.bastion.storage.BastionBlockStorage; +import java.util.UUID; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import vg.civcraft.mc.citadel.Citadel; +import vg.civcraft.mc.citadel.ReinforcementManager; +import vg.civcraft.mc.citadel.model.Reinforcement; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BastionBlockCompareToTest { + + private MockedStatic citadelStatic; + private MockedStatic bastionStatic; + private BastionType type; + + // Locations hold their World via WeakReference; keep strong refs so getWorld() stays valid. + private final UUID worldUidLow = new UUID(0L, 1L); + private final UUID worldUidHigh = new UUID(0L, 2L); + private World worldLow; + private World worldHigh; + + @BeforeEach + void setUp() { + worldLow = mockWorld(worldUidLow); + worldHigh = mockWorld(worldUidHigh); + + type = mock(BastionType.class); + when(type.getWarmupTime()).thenReturn(0L); + + Citadel citadel = mock(Citadel.class); + ReinforcementManager reinforcementManager = mock(ReinforcementManager.class); + Reinforcement reinforcement = mock(Reinforcement.class); + when(reinforcement.getGroupId()).thenReturn(1); + when(reinforcementManager.getReinforcement(any(Location.class))).thenReturn(reinforcement); + when(citadel.getReinforcementManager()).thenReturn(reinforcementManager); + + citadelStatic = Mockito.mockStatic(Citadel.class); + citadelStatic.when(Citadel::getInstance).thenReturn(citadel); + + Bastion plugin = mock(Bastion.class); + BastionBlockStorage storage = mock(BastionBlockStorage.class); + bastionStatic = Mockito.mockStatic(Bastion.class); + bastionStatic.when(Bastion::getPlugin).thenReturn(plugin); + bastionStatic.when(Bastion::getBastionStorage).thenReturn(storage); + } + + @AfterEach + void tearDown() { + try { + citadelStatic.close(); + } finally { + bastionStatic.close(); + } + } + + private World mockWorld(UUID uid) { + World world = mock(World.class); + Chunk chunk = mock(Chunk.class); + when(world.getChunkAt(any(Location.class))).thenReturn(chunk); + when(world.getUID()).thenReturn(uid); + return world; + } + + private BastionBlock block(World world, int x, int y, int z) { + return new BastionBlock(new Location(world, x, y, z), 0L, 1, type); + } + + @Test + void ordersBlocksByWorldUidWhenCoordsAreIdentical() { + BastionBlock low = block(worldLow, 10, 64, 20); + BastionBlock high = block(worldHigh, 10, 64, 20); + + Assertions.assertTrue(low.compareTo(high) < 0, + "block in lower world UID must sort before identical coords in higher world UID"); + Assertions.assertTrue(high.compareTo(low) > 0, + "block in higher world UID must sort after identical coords in lower world UID"); + Assertions.assertNotEquals(0, low.compareTo(high), + "blocks in different worlds with identical coords must not compare equal"); + Assertions.assertNotEquals(low, high, + "blocks in different worlds are distinct instances and not equal"); + } + + @Test + void compareToIsAntisymmetricAcrossWorldsAndCoords() { + BastionBlock[] blocks = { + block(worldLow, 0, 0, 0), + block(worldLow, 0, 0, 5), + block(worldLow, 0, 7, 0), + block(worldLow, 3, 0, 0), + block(worldHigh, 0, 0, 0), + block(worldHigh, 3, 7, 5), + block(worldHigh, -4, -2, -8), + }; + + for (BastionBlock a : blocks) { + for (BastionBlock b : blocks) { + Assertions.assertEquals( + Integer.signum(a.compareTo(b)), + -Integer.signum(b.compareTo(a)), + "compareTo must be antisymmetric for every pair"); + } + } + } +} diff --git a/plugins/bastion-paper/src/test/java/isaac/bastion/listeners/BastionDamageListenerFertilizeTest.java b/plugins/bastion-paper/src/test/java/isaac/bastion/listeners/BastionDamageListenerFertilizeTest.java new file mode 100644 index 0000000000..0373f18aa4 --- /dev/null +++ b/plugins/bastion-paper/src/test/java/isaac/bastion/listeners/BastionDamageListenerFertilizeTest.java @@ -0,0 +1,144 @@ +package isaac.bastion.listeners; + +import isaac.bastion.Bastion; +import isaac.bastion.BastionBlock; +import isaac.bastion.BastionType; +import isaac.bastion.manager.BastionBlockManager; +import isaac.bastion.utils.BastionSettingManager; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.entity.Player; +import org.bukkit.event.block.BlockFertilizeEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import vg.civcraft.mc.namelayer.group.Group; +import vg.civcraft.mc.namelayer.permission.PermissionType; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BastionDamageListenerFertilizeTest { + + private MockedStatic bastionStatic; + private MockedStatic permissionStatic; + private BastionBlockManager blockManager; + private BastionDamageListener listener; + private World world; + private Player player; + private UUID playerId; + private PermissionType placePermission; + + @BeforeEach + void setUp() { + blockManager = mock(BastionBlockManager.class); + BastionSettingManager settings = mock(BastionSettingManager.class); + + bastionStatic = Mockito.mockStatic(Bastion.class); + bastionStatic.when(Bastion::getBastionManager).thenReturn(blockManager); + bastionStatic.when(Bastion::getSettingManager).thenReturn(settings); + + permissionStatic = Mockito.mockStatic(PermissionType.class); + placePermission = mock(PermissionType.class); + permissionStatic.when(() -> PermissionType.getPermission(anyString())).thenReturn(placePermission); + + world = mock(World.class); + playerId = UUID.randomUUID(); + player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + + listener = new BastionDamageListener(); + } + + @AfterEach + void tearDown() { + bastionStatic.close(); + permissionStatic.close(); + } + + private BlockState stateAt(Location location) { + BlockState state = mock(BlockState.class); + when(state.getLocation()).thenReturn(location); + return state; + } + + private BastionBlock bastion(boolean onlyDirectDestruction) { + BastionType type = mock(BastionType.class); + when(type.isOnlyDirectDestruction()).thenReturn(onlyDirectDestruction); + BastionBlock bastion = mock(BastionBlock.class); + when(bastion.getType()).thenReturn(type); + return bastion; + } + + @Test + void removesBlocksInsideBlockingBastionField() { + Location inField = new Location(world, 10, 64, 10); + Location outside = new Location(world, 100, 64, 100); + BlockState inFieldState = stateAt(inField); + BlockState outsideState = stateAt(outside); + + Set blocking = Set.of(bastion(false)); + when(blockManager.getBlockingBastionsWithoutPermission(eq(inField), eq(playerId), eq(placePermission))) + .thenReturn(blocking); + when(blockManager.getBlockingBastionsWithoutPermission(eq(outside), eq(playerId), eq(placePermission))) + .thenReturn(Set.of()); + + Block clicked = mock(Block.class); + BlockFertilizeEvent event = new BlockFertilizeEvent(clicked, player, + new ArrayList<>(List.of(inFieldState, outsideState))); + + listener.onFertilize(event); + + Assertions.assertEquals(List.of(outsideState), event.getBlocks()); + Assertions.assertFalse(event.isCancelled()); + } + + @Test + void keepsBlocksWhenBastionIsOnlyDirectDestruction() { + Location inField = new Location(world, 10, 64, 10); + BlockState state = stateAt(inField); + + Set blocking = Set.of(bastion(true)); + when(blockManager.getBlockingBastionsWithoutPermission(eq(inField), eq(playerId), eq(placePermission))) + .thenReturn(blocking); + + Block clicked = mock(Block.class); + BlockFertilizeEvent event = new BlockFertilizeEvent(clicked, player, new ArrayList<>(List.of(state))); + + listener.onFertilize(event); + + Assertions.assertEquals(List.of(state), event.getBlocks()); + Assertions.assertFalse(event.isCancelled()); + } + + @Test + void cancelsDispenserFertilizeEnteringForeignField() { + Location source = new Location(world, 0, 64, 0); + Location target = new Location(world, 1, 64, 0); + BlockState state = stateAt(target); + + Block dispenser = mock(Block.class); + when(dispenser.getLocation()).thenReturn(source); + + when(blockManager.getEnteredGroupFields(eq(source), any())) + .thenReturn(Set.of(mock(Group.class))); + + BlockFertilizeEvent event = new BlockFertilizeEvent(dispenser, null, new ArrayList<>(List.of(state))); + + listener.onFertilize(event); + + Assertions.assertTrue(event.isCancelled()); + } +} diff --git a/plugins/citadel-paper/src/main/java/vg/civcraft/mc/citadel/listener/EntityListener.java b/plugins/citadel-paper/src/main/java/vg/civcraft/mc/citadel/listener/EntityListener.java index facdb5cbc0..afb657fa5d 100644 --- a/plugins/citadel-paper/src/main/java/vg/civcraft/mc/citadel/listener/EntityListener.java +++ b/plugins/citadel-paper/src/main/java/vg/civcraft/mc/citadel/listener/EntityListener.java @@ -49,8 +49,6 @@ import vg.civcraft.mc.civmodcore.players.settings.impl.BooleanSetting; import vg.civcraft.mc.namelayer.GroupManager; import vg.civcraft.mc.namelayer.NameLayerAPI; -import vg.civcraft.mc.namelayer.NameLayerPlugin; -import vg.civcraft.mc.namelayer.database.GroupManagerDao; public class EntityListener implements Listener { @@ -183,8 +181,7 @@ public void playerJoinEvent(PlayerJoinEvent event) { new BukkitRunnable() { @Override public void run() { - GroupManagerDao db = NameLayerPlugin.getGroupManagerDao(); - for (String groupName : db.getGroupNames(uuid)) { + for (String groupName : gm.getAllGroupNames(uuid)) { if (NameLayerAPI.getGroupManager().hasAccess(groupName, uuid, CitadelPermissionHandler.getBypass())) { GroupManager.getGroup(groupName).updateActivityTimeStamp(); diff --git a/plugins/civspy-api/build.gradle.kts b/plugins/civspy-api/build.gradle.kts index b93459304f..0b86ab71a0 100644 --- a/plugins/civspy-api/build.gradle.kts +++ b/plugins/civspy-api/build.gradle.kts @@ -3,4 +3,7 @@ version = "2.0.1" dependencies { implementation("com.zaxxer:HikariCP:3.4.2") implementation("org.postgresql:postgresql:42.3.5") + + testImplementation(libs.bundles.junit) + testImplementation("org.mockito:mockito-core:5.11.0") } diff --git a/plugins/civspy-api/src/main/java/com/programmerdan/minecraft/civspy/database/Database.java b/plugins/civspy-api/src/main/java/com/programmerdan/minecraft/civspy/database/Database.java index d71f4755e0..d3ef56ff1c 100644 --- a/plugins/civspy-api/src/main/java/com/programmerdan/minecraft/civspy/database/Database.java +++ b/plugins/civspy-api/src/main/java/com/programmerdan/minecraft/civspy/database/Database.java @@ -228,40 +228,40 @@ public int insertData(String key, UUID uuid, String sValue, Number nValue, Long } public int insertData(String key, String server, String world, Integer chunk_x, Integer chunk_z) { - return insertData(key, world, server, chunk_x, chunk_z, null, null, null, null, null); + return insertData(key, server, world, chunk_x, chunk_z, null, null, null, null, null); } public int insertData(String key, String server, String world, Integer chunk_x, Integer chunk_z, String value) { - return insertData(key, world, server, chunk_x, chunk_z, null, value, null, null, null); + return insertData(key, server, world, chunk_x, chunk_z, null, value, null, null, null); } public int insertData(String key, String server, String world, Integer chunk_x, Integer chunk_z, Number value) { - return insertData(key, world, server, chunk_x, chunk_z, null, null, value, null, null); + return insertData(key, server, world, chunk_x, chunk_z, null, null, value, null, null); } public int insertData(String key, String server, String world, Integer chunk_x, Integer chunk_z, String sValue, Number nValue) { - return insertData(key, world, server, chunk_x, chunk_z, null, sValue, nValue, null, null); + return insertData(key, server, world, chunk_x, chunk_z, null, sValue, nValue, null, null); } public int insertData(String key, String server, String world, Integer chunk_x, Integer chunk_z, String sValue, Number nValue, Long time, Connection connection) { - return insertData(key, world, server, chunk_x, chunk_z, null, sValue, nValue, time, connection); + return insertData(key, server, world, chunk_x, chunk_z, null, sValue, nValue, time, connection); } public int insertData(String key, String server, String world, Integer chunk_x, Integer chunk_z, UUID uuid) { - return insertData(key, world, server, chunk_x, chunk_z, uuid, null, null, null, null); + return insertData(key, server, world, chunk_x, chunk_z, uuid, null, null, null, null); } public int insertData(String key, String server, String world, Integer chunk_x, Integer chunk_z, UUID uuid, String value) { - return insertData(key, world, server, chunk_x, chunk_z, uuid, value, null, null, null); + return insertData(key, server, world, chunk_x, chunk_z, uuid, value, null, null, null); } public int insertData(String key, String server, String world, Integer chunk_x, Integer chunk_z, UUID uuid, Number value) { - return insertData(key, world, server, chunk_x, chunk_z, uuid, null, value, null, null); + return insertData(key, server, world, chunk_x, chunk_z, uuid, null, value, null, null); } public int insertData(String key, String server, String world, Integer chunk_x, Integer chunk_z, UUID uuid, String sValue, Number nValue) { - return insertData(key, world, server, chunk_x, chunk_z, uuid, sValue, nValue, null, null); + return insertData(key, server, world, chunk_x, chunk_z, uuid, sValue, nValue, null, null); } /** diff --git a/plugins/civspy-api/src/test/java/com/programmerdan/minecraft/civspy/database/DatabaseInsertDataTest.java b/plugins/civspy-api/src/test/java/com/programmerdan/minecraft/civspy/database/DatabaseInsertDataTest.java new file mode 100644 index 0000000000..c7e33b7689 --- /dev/null +++ b/plugins/civspy-api/src/test/java/com/programmerdan/minecraft/civspy/database/DatabaseInsertDataTest.java @@ -0,0 +1,53 @@ +package com.programmerdan.minecraft.civspy.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +public class DatabaseInsertDataTest { + + /** + * INSERT_STRING binds (stat_time, stat_key, string_value, server, world, ...), so with a string value + * present the server column is parameter 4 and the world column is parameter 5. + */ + private static final int SERVER_INDEX = 4; + private static final int WORLD_INDEX = 5; + + @Test + public void convenienceOverloadBindsServerAndWorldToCorrectColumns() throws Exception { + Connection connection = Mockito.mock(Connection.class); + PreparedStatement statement = Mockito.mock(PreparedStatement.class); + when(connection.prepareStatement(Database.INSERT_STRING)).thenReturn(statement); + when(statement.executeUpdate()).thenReturn(1); + + Database db = new Database(Logger.getLogger("test"), null, null, null, 0, null, 0, 0L, 0L, 0L); + + db.insertData("test.key", "the-server", "the-world", 7, 9, "string-value", null, 123L, connection); + + ArgumentCaptor indexCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(String.class); + verify(statement, atLeastOnce()).setString(indexCaptor.capture(), valueCaptor.capture()); + + Map boundStrings = new HashMap<>(); + List indices = indexCaptor.getAllValues(); + List values = valueCaptor.getAllValues(); + for (int i = 0; i < indices.size(); i++) { + boundStrings.put(indices.get(i), values.get(i)); + } + + assertEquals("the-server", boundStrings.get(SERVER_INDEX), "server must bind to the server column"); + assertEquals("the-world", boundStrings.get(WORLD_INDEX), "world must bind to the world column"); + } +} diff --git a/plugins/combattagplus-paper/build.gradle.kts b/plugins/combattagplus-paper/build.gradle.kts index 7847d5a942..5d753b145e 100644 --- a/plugins/combattagplus-paper/build.gradle.kts +++ b/plugins/combattagplus-paper/build.gradle.kts @@ -11,4 +11,5 @@ dependencies { } compileOnly(libs.barapi) + compileOnly(files("./libs/GSit-3.3.1.jar")) } diff --git a/plugins/combattagplus-paper/libs/GSit-3.3.1.jar b/plugins/combattagplus-paper/libs/GSit-3.3.1.jar new file mode 100644 index 0000000000..8705a8f40d Binary files /dev/null and b/plugins/combattagplus-paper/libs/GSit-3.3.1.jar differ diff --git a/plugins/combattagplus-paper/src/main/java/net/minelink/ctplus/CombatTagPlus.java b/plugins/combattagplus-paper/src/main/java/net/minelink/ctplus/CombatTagPlus.java index 81eb3d1e34..fed1231809 100644 --- a/plugins/combattagplus-paper/src/main/java/net/minelink/ctplus/CombatTagPlus.java +++ b/plugins/combattagplus-paper/src/main/java/net/minelink/ctplus/CombatTagPlus.java @@ -10,6 +10,7 @@ import net.minelink.ctplus.hook.Hook; import net.minelink.ctplus.hook.HookManager; import net.minelink.ctplus.listener.ForceFieldListener; +import net.minelink.ctplus.listener.GSitListener; import net.minelink.ctplus.listener.InstakillListener; import net.minelink.ctplus.listener.NpcListener; import net.minelink.ctplus.listener.PlayerListener; @@ -112,6 +113,8 @@ public void onEnable() { Bukkit.getPluginManager().registerEvents(new PlayerListener(this), this); Bukkit.getPluginManager().registerEvents(new TagListener(this), this); + Bukkit.getPluginManager().registerEvents(new GSitListener(this), this); + // Anti-SafeZone task ForceFieldTask.run(this); diff --git a/plugins/combattagplus-paper/src/main/java/net/minelink/ctplus/listener/GSitListener.java b/plugins/combattagplus-paper/src/main/java/net/minelink/ctplus/listener/GSitListener.java new file mode 100644 index 0000000000..5aa2cbc3c1 --- /dev/null +++ b/plugins/combattagplus-paper/src/main/java/net/minelink/ctplus/listener/GSitListener.java @@ -0,0 +1,23 @@ +package net.minelink.ctplus.listener; + +import dev.geco.gsit.api.event.PrePlayerCrawlEvent; +import net.minelink.ctplus.CombatTagPlus; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; + +public class GSitListener implements Listener { + + private final CombatTagPlus plugin; + + public GSitListener(CombatTagPlus plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onCrawlStart(PrePlayerCrawlEvent event) { + if(plugin.getTagManager().isTagged(event.getPlayer().getUniqueId())) { + event.setCancelled(true); + } + } + +} diff --git a/plugins/exilepearl-paper/build.gradle.kts b/plugins/exilepearl-paper/build.gradle.kts index 09dbd03380..462f473723 100644 --- a/plugins/exilepearl-paper/build.gradle.kts +++ b/plugins/exilepearl-paper/build.gradle.kts @@ -19,4 +19,13 @@ dependencies { compileOnly(project(":plugins:randomspawn-paper")) compileOnly(files("../../ansible/src/paper-plugins/BreweryX-3.6.0.jar")) + + testImplementation(libs.bundles.junit) + testImplementation(project(":plugins:civmodcore-paper")) + testImplementation(project(":plugins:combattagplus-paper")) + testImplementation("org.mockito:mockito-core:5.11.0") +} + +tasks.test { + useJUnitPlatform() } diff --git a/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/CoreLoreGenerator.java b/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/CoreLoreGenerator.java index 41b5b6fbfa..ab9b2e4c99 100644 --- a/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/CoreLoreGenerator.java +++ b/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/CoreLoreGenerator.java @@ -84,6 +84,15 @@ private List generateLoreInternal(ExilePearl pearl, int health, boolean lore.add(parse("")); lore.add(parse("Health: %s/%s", health, config.getPearlHealthMaxValue())); + String unit = config.getPearlHealthDecayHumanInterval(); + int decayPerHumanInterval = PearlDecayMath.decayPerHumanInterval( + config.getPearlHealthDecayHumanIntervalMin(), + config.getPearlHealthDecayIntervalMin(), + config.getPearlHealthDecayAmount()); + int intervalsRemaining = PearlDecayMath.intervalsRemaining(health, decayPerHumanInterval); + if (intervalsRemaining > 0 && pearl.isActive()) { + lore.add(parse("Time remaining: %d %s", intervalsRemaining, unit)); + } Set repair = config.getRepairMaterials(pearl.getPearlType()); if (repair != null) { for (RepairMaterial rep : repair) { @@ -92,9 +101,8 @@ private List generateLoreInternal(ExilePearl pearl, int health, boolean if (rep.getStack().hasItemMeta() && rep.getStack().getItemMeta().hasDisplayName()) { item = rep.getStack().getItemMeta().getDisplayName(); } - int damagesPerHumanInterval = (config.getPearlHealthDecayHumanIntervalMin() / config.getPearlHealthDecayIntervalMin()) * config.getPearlHealthDecayAmount(); // intervals in a human interval * damage per - int repairsPerHumanInterval = (int) Math.ceil(damagesPerHumanInterval / amountPerItem); - lore.add(parse("Cost per %s using %s: %s", config.getPearlHealthDecayHumanInterval(), item, Integer.toString(repairsPerHumanInterval))); + int repairsPerHumanInterval = (int) Math.ceil(decayPerHumanInterval / amountPerItem); + lore.add(parse("Cost per %s using %s: %s", unit, item, Integer.toString(repairsPerHumanInterval))); } } diff --git a/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/PearlDecayMath.java b/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/PearlDecayMath.java new file mode 100644 index 0000000000..738df054fc --- /dev/null +++ b/plugins/exilepearl-paper/src/main/java/com/devotedmc/ExilePearl/core/PearlDecayMath.java @@ -0,0 +1,20 @@ +package com.devotedmc.ExilePearl.core; + +final class PearlDecayMath { + + private PearlDecayMath() {} + + static int decayPerHumanInterval(int humanIntervalMin, int decayIntervalMin, int decayAmount) { + if (decayIntervalMin <= 0) { + return 0; + } + return (humanIntervalMin / decayIntervalMin) * decayAmount; + } + + static int intervalsRemaining(int health, int decayPerHumanInterval) { + if (decayPerHumanInterval <= 0 || health <= 0) { + return 0; + } + return (int) Math.ceil((double) health / decayPerHumanInterval); + } +} diff --git a/plugins/exilepearl-paper/src/test/java/com/devotedmc/ExilePearl/core/CoreLoreGeneratorTest.java b/plugins/exilepearl-paper/src/test/java/com/devotedmc/ExilePearl/core/CoreLoreGeneratorTest.java new file mode 100644 index 0000000000..bf7615c7f6 --- /dev/null +++ b/plugins/exilepearl-paper/src/test/java/com/devotedmc/ExilePearl/core/CoreLoreGeneratorTest.java @@ -0,0 +1,150 @@ +package com.devotedmc.ExilePearl.core; + +import com.devotedmc.ExilePearl.ExilePearl; +import com.devotedmc.ExilePearl.ExilePearlApi; +import com.devotedmc.ExilePearl.ExilePearlPlugin; +import com.devotedmc.ExilePearl.PearlType; +import com.devotedmc.ExilePearl.RepairMaterial; +import com.devotedmc.ExilePearl.config.PearlConfig; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +class CoreLoreGeneratorTest { + + private PearlConfig config; + private ExilePearl pearl; + private CoreLoreGenerator generator; + private MockedStatic pluginStatic; + + @BeforeEach + void setUp() { + config = Mockito.mock(PearlConfig.class); + Mockito.when(config.getPearlHealthMaxValue()).thenReturn(1000); + Mockito.when(config.getPearlHealthDecayHumanIntervalMin()).thenReturn(1440); // 1 day + Mockito.when(config.getPearlHealthDecayIntervalMin()).thenReturn(60); // hourly tick + Mockito.when(config.getPearlHealthDecayAmount()).thenReturn(1); // -> 24/day + Mockito.when(config.getPearlHealthDecayHumanInterval()).thenReturn("day"); + Mockito.when(config.getRepairMaterials(Mockito.any())).thenReturn(null); + Mockito.when(config.getDefaultPearlType()).thenReturn(PearlType.EXILE); + Mockito.when(config.getUpgradeMaterials()).thenReturn(null); + + pearl = Mockito.mock(ExilePearl.class); + Mockito.when(pearl.getItemName()).thenReturn("Exile Pearl"); + Mockito.when(pearl.getPlayerName()).thenReturn("TestPlayer"); + Mockito.when(pearl.getPearlId()).thenReturn(12345); + Mockito.when(pearl.getPearledOn()).thenReturn(new Date(0)); + Mockito.when(pearl.getKillerName()).thenReturn("KillerPlayer"); + Mockito.when(pearl.getPlayerId()).thenReturn(UUID.randomUUID()); + Mockito.when(pearl.getPearlType()).thenReturn(PearlType.EXILE); + Mockito.when(pearl.getHealth()).thenReturn(240); // exactly 10 days at 24/day + Mockito.when(pearl.isActive()).thenReturn(true); + Mockito.when(pearl.getLongTimeMultiplier()).thenReturn(1.0); + + ExilePearlApi api = Mockito.mock(ExilePearlApi.class); + Mockito.when(api.isBanStickEnabled()).thenReturn(false); + pluginStatic = Mockito.mockStatic(ExilePearlPlugin.class); + pluginStatic.when(ExilePearlPlugin::getApi).thenReturn(api); + + generator = new CoreLoreGenerator(config, null); + } + + @AfterEach + void tearDown() { + pluginStatic.close(); + } + + @Test + void generateLore_includesTimeRemainingForActivePearl() { + List lore = generator.generateLore(pearl); + String timeRemaining = findLine(lore, "Time remaining:"); + Assertions.assertNotNull(timeRemaining, "Expected a 'Time remaining' line, got: " + lore); + Assertions.assertTrue(timeRemaining.contains("10"), "Expected 10 days for health=240 / 24-per-day, got: " + timeRemaining); + Assertions.assertTrue(timeRemaining.contains("day"), "Expected unit 'day' in: " + timeRemaining); + } + + @Test + void generateLore_omitsTimeRemainingForInactivePearl() { + Mockito.when(pearl.isActive()).thenReturn(false); + List lore = generator.generateLore(pearl); + Assertions.assertNull(findLine(lore, "Time remaining:"), + "Inactive pearl should not show time remaining (already shows 'suspended due to Inactivity'); got: " + lore); + } + + @Test + void generateLore_omitsTimeRemainingWhenHealthIsZero() { + Mockito.when(pearl.getHealth()).thenReturn(0); + List lore = generator.generateLore(pearl); + Assertions.assertNull(findLine(lore, "Time remaining:"), "Zero health should hide time remaining; got: " + lore); + } + + @Test + void generateLore_omitsTimeRemainingWhenDecayDisabled() { + Mockito.when(config.getPearlHealthDecayAmount()).thenReturn(0); + List lore = generator.generateLore(pearl); + Assertions.assertNull(findLine(lore, "Time remaining:"), "Decay disabled should hide time remaining; got: " + lore); + } + + @Test + void generateLore_timeRemainingRoundsUpForPartialInterval() { + Mockito.when(pearl.getHealth()).thenReturn(241); // 24*10 + 1 + List lore = generator.generateLore(pearl); + String timeRemaining = findLine(lore, "Time remaining:"); + Assertions.assertNotNull(timeRemaining); + Assertions.assertTrue(timeRemaining.contains("11"), "241 health at 24/day should round up to 11 days, got: " + timeRemaining); + } + + @Test + void generateLore_timeRemainingUsesConfiguredUnit() { + Mockito.when(config.getPearlHealthDecayHumanInterval()).thenReturn("week"); + List lore = generator.generateLore(pearl); + String timeRemaining = findLine(lore, "Time remaining:"); + Assertions.assertNotNull(timeRemaining); + Assertions.assertTrue(timeRemaining.contains("week"), "Configured unit 'week' should be used, got: " + timeRemaining); + } + + @Test + void generateLore_timeRemainingAppearsRightAfterHealth() { + List lore = generator.generateLore(pearl); + int healthIdx = indexOfContaining(lore, "Health:"); + int timeIdx = indexOfContaining(lore, "Time remaining:"); + Assertions.assertTrue(healthIdx >= 0 && timeIdx == healthIdx + 1, + "Time remaining should be immediately after Health; got lore: " + lore); + } + + @Test + void generateLore_doesNotMutateConfiguredUnitForRepairLine() { + Mockito.when(config.getPearlHealthDecayHumanInterval()).thenReturn("day"); + Mockito.when(pearl.getHealth()).thenReturn(24); // 1 interval + List lore = generator.generateLore(pearl); + String timeRemaining = findLine(lore, "Time remaining:"); + Assertions.assertNotNull(timeRemaining); + // Regression guard: no English-plural "s" injected, matches existing repair-line style + Assertions.assertFalse(timeRemaining.contains("days"), + "Lore must not append plural 's' to configured unit; got: " + timeRemaining); + } + + private static String findLine(List lore, String marker) { + for (String line : lore) { + if (line.contains(marker)) { + return line; + } + } + return null; + } + + private static int indexOfContaining(List lore, String marker) { + for (int i = 0; i < lore.size(); i++) { + if (lore.get(i).contains(marker)) { + return i; + } + } + return -1; + } +} diff --git a/plugins/exilepearl-paper/src/test/java/com/devotedmc/ExilePearl/core/PearlDecayMathTest.java b/plugins/exilepearl-paper/src/test/java/com/devotedmc/ExilePearl/core/PearlDecayMathTest.java new file mode 100644 index 0000000000..c571ffe05b --- /dev/null +++ b/plugins/exilepearl-paper/src/test/java/com/devotedmc/ExilePearl/core/PearlDecayMathTest.java @@ -0,0 +1,53 @@ +package com.devotedmc.ExilePearl.core; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class PearlDecayMathTest { + + @Test + void decayPerHumanInterval_typicalDayConfig() { + // 1440 min/day, decay every 60 min, 1 health per tick -> 24 health/day + Assertions.assertEquals(24, PearlDecayMath.decayPerHumanInterval(1440, 60, 1)); + } + + @Test + void decayPerHumanInterval_zeroDecayInterval_returnsZero() { + Assertions.assertEquals(0, PearlDecayMath.decayPerHumanInterval(1440, 0, 1)); + } + + @Test + void decayPerHumanInterval_zeroDecayAmount_returnsZero() { + Assertions.assertEquals(0, PearlDecayMath.decayPerHumanInterval(1440, 60, 0)); + } + + @Test + void intervalsRemaining_exactlyDivisible() { + Assertions.assertEquals(10, PearlDecayMath.intervalsRemaining(240, 24)); + } + + @Test + void intervalsRemaining_roundsUp() { + Assertions.assertEquals(11, PearlDecayMath.intervalsRemaining(241, 24)); + } + + @Test + void intervalsRemaining_partialIntervalRoundsUpToOne() { + Assertions.assertEquals(1, PearlDecayMath.intervalsRemaining(1, 24)); + } + + @Test + void intervalsRemaining_zeroHealth_returnsZero() { + Assertions.assertEquals(0, PearlDecayMath.intervalsRemaining(0, 24)); + } + + @Test + void intervalsRemaining_negativeHealth_returnsZero() { + Assertions.assertEquals(0, PearlDecayMath.intervalsRemaining(-5, 24)); + } + + @Test + void intervalsRemaining_decayDisabled_returnsZero() { + Assertions.assertEquals(0, PearlDecayMath.intervalsRemaining(100, 0)); + } +} diff --git a/plugins/factorymod-paper/build.gradle.kts b/plugins/factorymod-paper/build.gradle.kts index a0af4c9aef..2bbcedf494 100644 --- a/plugins/factorymod-paper/build.gradle.kts +++ b/plugins/factorymod-paper/build.gradle.kts @@ -13,4 +13,7 @@ dependencies { compileOnly(project(":plugins:namelayer-paper")) compileOnly(project(":plugins:citadel-paper")) compileOnly(project(":plugins:heliodor-paper")) + + testImplementation(libs.bundles.junit) + testImplementation("org.mockito:mockito-core:5.11.0") } diff --git a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/factories/FurnCraftChestFactory.java b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/factories/FurnCraftChestFactory.java index 008366211d..14e6a9779b 100644 --- a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/factories/FurnCraftChestFactory.java +++ b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/factories/FurnCraftChestFactory.java @@ -26,6 +26,7 @@ import java.util.Map; import java.util.UUID; import java.util.function.Function; +import java.util.function.Predicate; import com.github.igotyou.FactoryMod.utility.MultiInventoryWrapper; import org.bukkit.Bukkit; @@ -57,6 +58,7 @@ public class FurnCraftChestFactory extends Factory implements IIOFInventoryProvi protected int currentProductionTimer = 0; protected List recipes; protected IRecipe currentRecipe; + private IRecipe preferredRecipe; protected Map runCount; protected Map recipeLevel; private UUID activator; @@ -295,8 +297,10 @@ public void attemptToActivate(Player p, boolean onStartUp) { } } - // If we run out of input materials, try to auto-select a new recipe - if (!hasInputMaterials()) { + // Re-select when out of materials, or when holding a repair recipe on a + // factory that isn't broken (auto mode must never repair a healthy factory) + boolean healthyButRepairing = currentRecipe instanceof RepairRecipe && !rm.inDisrepair(); + if (!hasInputMaterials() || healthyButRepairing) { IRecipe autoSelected = getAutoSelectRecipe(); if (autoSelected == null) { if (p != null) { @@ -669,6 +673,26 @@ public void setRecipe(IRecipe pr) { } } + /** + * Sets the recipe in response to an explicit player selection, recording it as + * the preferred recipe so auto mode returns to it after any automatic detour + * (e.g. a repair). One-off manual repairs don't become the preference. + */ + public void setRecipeManually(IRecipe pr) { + setRecipe(pr); + if (currentRecipe == pr && !(pr instanceof RepairRecipe)) { + preferredRecipe = pr; + } + } + + public IRecipe getPreferredRecipe() { + return preferredRecipe; + } + + public void setPreferredRecipe(IRecipe pr) { + preferredRecipe = pr; + } + public void setRecipeForce(IRecipe pr) { currentRecipe = pr; } @@ -736,18 +760,9 @@ public boolean hasInputMaterials() { * @return null if no suitable recipe was found */ public IRecipe getAutoSelectRecipe() { - var selectedRecipe = recipes.stream() - .filter(it -> { - // We want to select a repair recipe if and only if the factory is in disrepair - if (rm.inDisrepair()) { - return it instanceof RepairRecipe; - } else { - return !(it instanceof RepairRecipe); - } - }) - .filter(it -> it.enoughMaterialAvailable(getInputInventory())) - .findFirst() - .orElse(null); + IRecipe selectedRecipe = chooseAutoRecipe(recipes, rm.inDisrepair(), preferredRecipe, + it -> it instanceof RepairRecipe, + it -> it.enoughMaterialAvailable(getInputInventory())); if (selectedRecipe != null) { LoggingUtils.log("Auto-selected recipe " + selectedRecipe.getName()); @@ -756,6 +771,27 @@ public IRecipe getAutoSelectRecipe() { return selectedRecipe; } + /** + * Picks the recipe auto mode should run. A recipe is eligible only when its + * repair-ness matches the factory state: repair recipes when in disrepair, + * production recipes otherwise. Among eligible recipes the player's preferred + * one wins when it's still runnable, otherwise the first eligible recipe with + * enough materials is used. Pure so it can be unit tested without a live factory. + */ + static IRecipe chooseAutoRecipe(List recipes, boolean inDisrepair, IRecipe preferred, + Predicate isRepair, Predicate hasMaterials) { + Predicate eligible = it -> inDisrepair == isRepair.test(it); + if (!inDisrepair && preferred != null && eligible.test(preferred) + && recipes.contains(preferred) && hasMaterials.test(preferred)) { + return preferred; + } + return recipes.stream() + .filter(eligible) + .filter(hasMaterials) + .findFirst() + .orElse(null); + } + /** * @return yields a repair type recipe for repairing the factory if any exists, returns null if none exists */ diff --git a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/interactionManager/FurnCraftChestInteractionManager.java b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/interactionManager/FurnCraftChestInteractionManager.java index 4bd6b46f8c..a37987f7db 100644 --- a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/interactionManager/FurnCraftChestInteractionManager.java +++ b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/interactionManager/FurnCraftChestInteractionManager.java @@ -273,7 +273,7 @@ private Scrollbar buildRecipeScrollbar(int rows) { List recipeClickList = new ArrayList<>(recipeList.size()); for (IRecipe rec : fccf.getRecipes()) { InputRecipe recipe = (InputRecipe) (rec); - ItemStack recStack = recipe.getRecipeRepresentation(); + ItemStack recStack = recipe.getRecipeRepresentation(fccf.getInputInventory()); int runcount = fccf.getRunCount(recipe); ItemUtils.addLore(recStack, "", ChatColor.AQUA + "Ran " + String.valueOf(runcount) + " times"); if (rec == fccf.getCurrentRecipe()) { @@ -296,7 +296,7 @@ public void clicked(Player p) { if (fccf.isActive()) { p.sendMessage(ChatColor.RED + "You can't switch recipes while the factory is running"); } else { - fccf.setRecipe(recipe); + fccf.setRecipeManually(recipe); p.sendMessage(ChatColor.GREEN + "Switched recipe to " + recipe.getName()); ComponableInventory compInv = buildRecipeInventory(p); compInv.update(); diff --git a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/CompactingRecipe.java b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/CompactingRecipe.java index f62df27f92..283f380b62 100644 --- a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/CompactingRecipe.java +++ b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/CompactingRecipe.java @@ -181,6 +181,9 @@ private boolean compactable(ItemStack is, ItemMap im) { is.getItemMeta().getLore().contains(compactedLore))) { return false; } + if (is.getItemMeta() instanceof org.bukkit.inventory.meta.BundleMeta) { + return false; + } return im.getAmount(is) >= getCompactStackSize(is.getType()); } diff --git a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/DecompactingRecipe.java b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/DecompactingRecipe.java index e3624713ca..39d815dcc9 100644 --- a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/DecompactingRecipe.java +++ b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/DecompactingRecipe.java @@ -1,15 +1,16 @@ package com.github.igotyou.FactoryMod.recipes; import com.github.igotyou.FactoryMod.factories.FurnCraftChestFactory; +import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedList; import java.util.List; -import java.util.Objects; import com.github.igotyou.FactoryMod.utility.MultiInventoryWrapper; import org.bukkit.Material; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BundleMeta; import org.bukkit.inventory.meta.ItemMeta; import vg.civcraft.mc.civmodcore.inventory.items.ItemMap; import vg.civcraft.mc.civmodcore.inventory.items.ItemUtils; @@ -46,52 +47,58 @@ public boolean enoughMaterialAvailable(Inventory inputInv) { @Override public EffectFeasibility evaluateEffectFeasibility(Inventory inputInv, Inventory outputInv) { - boolean isFeasible = Arrays.stream(inputInv.getContents()) - .filter(Objects::nonNull) - .filter(this::isDecompactable) - .map(it -> { - ItemStack removeClone = it.clone(); + for (ItemStack is : inputInv.getContents()) { + if (is != null && isDecompactable(is)) { + ItemStack removeClone = is.clone(); removeClone.setAmount(1); removeCompactLore(removeClone); ItemMap toAdd = new ItemMap(removeClone); toAdd.addItemAmount(removeClone, CompactingRecipe.getCompactStackSize(removeClone.getType())); - return toAdd; - }) - .allMatch(it -> it.fitsIn(outputInv)); - return new EffectFeasibility( - isFeasible, - isFeasible ? null : "it ran out of storage space" - ); + if (canFitInOutput(toAdd, outputInv)) { + return new EffectFeasibility(true, null); + } else { + return new EffectFeasibility(false, "it ran out of storage space"); + } + } + } + return new EffectFeasibility(true, null); } @Override public boolean applyEffect(Inventory inputInv, Inventory outputInv, FurnCraftChestFactory fccf) { MultiInventoryWrapper combo = new MultiInventoryWrapper(inputInv, outputInv); logBeforeRecipeRun(combo, fccf); - if (input.isContainedIn(inputInv)) { - for (ItemStack is : inputInv.getContents()) { - if (is != null) { - if (isDecompactable(is)) { - ItemStack removeClone = is.clone(); - removeClone.setAmount(1); - ItemMap toRemove = new ItemMap(removeClone); - ItemMap toAdd = new ItemMap(); - removeCompactLore(removeClone); - toAdd.addItemAmount(removeClone, CompactingRecipe.getCompactStackSize(removeClone.getType())); - if (toAdd.fitsIn(outputInv)) { //fits in chest - if (input.removeSafelyFrom(inputInv)) { //remove extra input - if (toRemove.removeSafelyFrom(inputInv)) { //remove one compacted item - for (ItemStack add : toAdd.getItemStackRepresentation()) { - outputInv.addItem(add); - } - } - } - } else { // does not fit in chest - return false; - } - break; - } + if (!input.isContainedIn(inputInv)) { + logAfterRecipeRun(combo, fccf); + return true; + } + for (ItemStack is : inputInv.getContents()) { + if (is != null && isDecompactable(is)) { + ItemStack removeClone = is.clone(); + removeClone.setAmount(1); + ItemMap toRemove = new ItemMap(removeClone); + ItemMap toAdd = new ItemMap(); + removeCompactLore(removeClone); + toAdd.addItemAmount(removeClone, CompactingRecipe.getCompactStackSize(removeClone.getType())); + if (!canFitInOutput(toAdd, outputInv)) { + return false; + } + if (!input.removeSafelyFrom(inputInv)) { + return false; } + if (!toRemove.removeSafelyFrom(inputInv)) { + restoreInput(input, inputInv, fccf); + return false; + } + List insertedOutput = new ArrayList<>(); + if (!addOutputToInventorySafely(toAdd, outputInv, insertedOutput)) { + rollbackOutput(outputInv, insertedOutput); + restoreInput(toRemove, inputInv, fccf); + restoreInput(input, inputInv, fccf); + return false; + } + logAfterRecipeRun(combo, fccf); + return true; } } logAfterRecipeRun(combo, fccf); @@ -187,4 +194,5 @@ public List getTextualOutputRepresentation(Inventory i, FurnCraftChestFa public List getTextualInputRepresentation(Inventory i, FurnCraftChestFactory fccf) { return Arrays.asList("A single compacted item"); } + } diff --git a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/InputRecipe.java b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/InputRecipe.java index 6a31f669c7..ebc37f0eca 100644 --- a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/InputRecipe.java +++ b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/InputRecipe.java @@ -1,5 +1,6 @@ package com.github.igotyou.FactoryMod.recipes; +import com.github.igotyou.FactoryMod.FactoryMod; import com.github.igotyou.FactoryMod.factories.Factory; import com.github.igotyou.FactoryMod.factories.FurnCraftChestFactory; import com.github.igotyou.FactoryMod.utility.LoggingUtils; @@ -7,6 +8,7 @@ import java.util.ArrayList; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Map.Entry; import java.util.concurrent.TimeUnit; import org.apache.commons.lang3.StringUtils; @@ -149,13 +151,33 @@ public String getIdentifier() { * whole in an item gui */ public ItemStack getRecipeRepresentation() { + return getRecipeRepresentation(null); + } + + public ItemStack getRecipeRepresentation(Inventory inputInv) { ItemStack res = new ItemStack(getRecipeRepresentationMaterial()); ItemMeta im = res.getItemMeta(); im.setDisplayName(ChatColor.DARK_GREEN + getName()); List lore = new ArrayList<>(); lore.add(ChatColor.GOLD + "Input:"); - for (String s : getTextualInputRepresentation(null, null)) { - lore.add(ChatColor.GRAY + " - " + ChatColor.AQUA + s); + List textualInputs = getTextualInputRepresentation(null, null); + List> baseItems = new ArrayList<>(); + for (Entry entry : input.getAllItems().entrySet()) { + if (entry.getValue() > 0) { + baseItems.add(entry); + } + } + ItemMap inventoryMap = inputInv != null ? new ItemMap(inputInv) : null; + for (int i = 0; i < textualInputs.size(); i++) { + if (i < baseItems.size() && inputInv != null) { + Entry entry = baseItems.get(i); + String name = formatIngredientName(entry.getKey()); + int have = inventoryMap.getAmount(entry.getKey()); + ChatColor color = have >= entry.getValue() ? ChatColor.GREEN : ChatColor.RED; + lore.add(ChatColor.GRAY + " - " + color + have + "/" + entry.getValue() + " " + name); + } else { + lore.add(ChatColor.GRAY + " - " + ChatColor.AQUA + textualInputs.get(i)); + } } lore.add(""); lore.add(ChatColor.GOLD + "Output:"); @@ -214,41 +236,159 @@ public int hashCode() { return identifier.hashCode(); } + protected String formatIngredientName(ItemStack item) { + if (item == null || item.isEmpty()) { + return "Unknown"; + } + if (CustomItem.isCustomItem(item)) { + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + if (meta.hasDisplayName()) { + return StringUtils.abbreviate(meta.getDisplayName(), 35); + } else if (meta.hasItemName()) { + return StringUtils.abbreviate(meta.getItemName(), 35); + } + } + return ChatColor.ITALIC + ItemUtils.getItemName(item); + } + if (!item.hasItemMeta()) { + return ItemUtils.getItemName(item); + } + ItemMeta meta = item.getItemMeta(); + String name = ChatColor.ITALIC + ItemUtils.getItemName(item); + if (meta.hasDisplayName()) { + name += String.format("%s [%s%1$s]", ChatColor.DARK_AQUA, StringUtils.abbreviate(meta.getDisplayName(), 20)); + } + return name; + } + protected List formatLore(ItemMap ingredients) { List result = new ArrayList<>(); for (Entry entry : ingredients.getItems().entrySet()) { if (entry.getValue() > 0) { - if (!entry.getKey().hasItemMeta()) { - result.add(entry.getValue() + " " + ItemUtils.getItemName(entry.getKey())); - } else { - String lore = String.format("%s %s%s", entry.getValue(), ChatColor.ITALIC, ItemUtils.getItemName(entry.getKey())); - if (entry.getKey().getItemMeta().hasDisplayName()) { - lore += String.format("%s [%s%1$s]", ChatColor.DARK_AQUA, StringUtils.abbreviate(entry.getKey().getItemMeta().getDisplayName(), 20)); - } - result.add(lore); - } + result.add(entry.getValue() + " " + formatIngredientName(entry.getKey())); } } // Custom items should have their custom name displayed more prominently, their actual item type is irrelevant for (Entry entry : ingredients.getCustomItems().entrySet()) { if (entry.getValue() > 0) { ItemStack item = CustomItem.getCustomItem(entry.getKey()); - if (!item.hasItemMeta()) { - result.add(entry.getValue() + " " + ItemUtils.getItemName(item)); - } else { - String lore; - if (item.getItemMeta().hasDisplayName()) { - lore = String.format("%s %s", entry.getValue(), StringUtils.abbreviate(item.getItemMeta().getDisplayName(), 35)); - } else if (item.getItemMeta().hasItemName()) { - lore = String.format("%s %s", entry.getValue(), StringUtils.abbreviate(item.getItemMeta().getItemName(), 35)); - } else { - lore = String.format("%s %s%s", entry.getValue(), ChatColor.ITALIC, ItemUtils.getItemName(item)); - } - result.add(lore); - } + result.add(entry.getValue() + " " + formatIngredientName(item)); } } return result; } + protected boolean canFitInOutput(ItemMap outputMap, Inventory outputInv) { + ItemStack[] currentContent = outputInv.getStorageContents(); + ItemStack[] simulatedOutput = new ItemStack[currentContent.length]; + for (int i = 0; i < currentContent.length; i++) { + ItemStack slot = currentContent[i]; + simulatedOutput[i] = slot == null ? null : slot.clone(); + } + + for (Entry outputEntry : outputMap.getAllItems().entrySet()) { + ItemStack outputTemplate = outputEntry.getKey(); + int remainingAmount = outputEntry.getValue(); + if (outputTemplate == null || outputTemplate.isEmpty() || remainingAmount <= 0) { + continue; + } + + int maxStackSize = Math.max(1, outputTemplate.getMaxStackSize()); + for (int i = 0; i < simulatedOutput.length && remainingAmount > 0; i++) { + ItemStack existingStack = simulatedOutput[i]; + if (existingStack == null || existingStack.isEmpty() || !existingStack.isSimilar(outputTemplate)) { + continue; + } + int existingMaxStackSize = Math.max(1, existingStack.getMaxStackSize()); + int freeSpace = Math.max(0, existingMaxStackSize - existingStack.getAmount()); + if (freeSpace <= 0) { + continue; + } + int movedAmount = Math.min(remainingAmount, freeSpace); + existingStack.setAmount(existingStack.getAmount() + movedAmount); + remainingAmount -= movedAmount; + } + + for (int i = 0; i < simulatedOutput.length && remainingAmount > 0; i++) { + ItemStack existingStack = simulatedOutput[i]; + if (existingStack != null && !existingStack.isEmpty()) { + continue; + } + int movedAmount = Math.min(remainingAmount, maxStackSize); + ItemStack toInsert = outputTemplate.clone(); + toInsert.setAmount(movedAmount); + simulatedOutput[i] = toInsert; + remainingAmount -= movedAmount; + } + + if (remainingAmount > 0) { + return false; + } + } + return true; + } + + protected boolean addOutputToInventorySafely(ItemMap outputMap, Inventory outputInv, List insertedOutput) { + for (Entry outputEntry : outputMap.getAllItems().entrySet()) { + ItemStack outputTemplate = outputEntry.getKey(); + int remainingAmount = outputEntry.getValue(); + if (outputTemplate == null || outputTemplate.isEmpty() || remainingAmount <= 0) { + continue; + } + + int maxStackSize = Math.max(1, outputTemplate.getMaxStackSize()); + while (remainingAmount > 0) { + int movedAmount = Math.min(remainingAmount, maxStackSize); + ItemStack toInsert = outputTemplate.clone(); + toInsert.setAmount(movedAmount); + Map overflow = outputInv.addItem(toInsert); + int overflowAmount = 0; + for (ItemStack overflowStack : overflow.values()) { + overflowAmount += overflowStack.getAmount(); + } + int insertedAmount = movedAmount - overflowAmount; + if (insertedAmount > 0) { + ItemStack insertedStack = outputTemplate.clone(); + insertedStack.setAmount(insertedAmount); + insertedOutput.add(insertedStack); + } + if (!overflow.isEmpty()) { + return false; + } + remainingAmount -= movedAmount; + } + } + return true; + } + + protected void rollbackOutput(Inventory outputInv, List insertedOutput) { + for (ItemStack outputStack : insertedOutput) { + outputInv.removeItem(outputStack); + } + } + + protected void restoreInput(ItemMap removedInput, Inventory inputInv, FurnCraftChestFactory fccf) { + for (Entry removedEntry : removedInput.getAllItems().entrySet()) { + ItemStack removedTemplate = removedEntry.getKey(); + int remainingAmount = removedEntry.getValue(); + if (removedTemplate == null || removedTemplate.isEmpty() || remainingAmount <= 0) { + continue; + } + + int maxStackSize = Math.max(1, removedTemplate.getMaxStackSize()); + while (remainingAmount > 0) { + int movedAmount = Math.min(remainingAmount, maxStackSize); + ItemStack removedStack = removedTemplate.clone(); + removedStack.setAmount(movedAmount); + Map overflow = inputInv.addItem(removedStack); + if (!overflow.isEmpty()) { + FactoryMod.getInstance().warning("Failed to fully restore input after recipe rollback :(," + fccf.getLogData()); + return; + } + remainingAmount -= movedAmount; + } + } + } + } diff --git a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/PrintBookRecipe.java b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/PrintBookRecipe.java index 9e6307c3b9..c3582d5fed 100644 --- a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/PrintBookRecipe.java +++ b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/PrintBookRecipe.java @@ -151,6 +151,47 @@ public List getOutputRepresentation(Inventory i, FurnCraftChestFactor return stacks; } + @Override + public List getTextualInputRepresentation(Inventory i, FurnCraftChestFactory fccf) { + List result = super.getTextualInputRepresentation(i, fccf); + result.add("1 Printing Plate"); + return result; + } + + @Override + public ItemStack getRecipeRepresentation(Inventory inputInv) { + ItemStack res = super.getRecipeRepresentation(inputInv); + if (inputInv == null) { + return res; + } + int have = getPrintingPlateItemStack(inputInv, this.printingPlate) != null ? 1 : 0; + int needed = 1; + ChatColor color = have >= needed ? ChatColor.GREEN : ChatColor.RED; + String plain = ChatColor.GRAY + " - " + ChatColor.AQUA + "1 Printing Plate"; + String repl = ChatColor.GRAY + " - " + color + have + "/" + needed + " " + PrintingPlateRecipe.itemName; + return replaceLoreLine(res, plain, repl); + } + + private static ItemStack replaceLoreLine(ItemStack item, String target, String replacement) { + ItemMeta im = item.getItemMeta(); + if (im == null) { + return item; + } + List lore = im.getLore(); + if (lore == null) { + return item; + } + for (int i = 0; i < lore.size(); i++) { + if (lore.get(i).equals(target)) { + lore.set(i, replacement); + break; + } + } + im.setLore(lore); + item.setItemMeta(im); + return item; + } + @Override public Material getRecipeRepresentationMaterial() { return Material.WRITTEN_BOOK; diff --git a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/PrintNoteRecipe.java b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/PrintNoteRecipe.java index db4fd9dac0..6078009d84 100644 --- a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/PrintNoteRecipe.java +++ b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/PrintNoteRecipe.java @@ -158,11 +158,7 @@ public Material getRecipeRepresentationMaterial() { @Override public List getTextualInputRepresentation(Inventory i, FurnCraftChestFactory fccf) { - List result = super.getTextualInputRepresentation(i, fccf); - - result.add("1 Printing Plate"); - - return result; + return super.getTextualInputRepresentation(i, fccf); } @Override diff --git a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/PrintingPlateRecipe.java b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/PrintingPlateRecipe.java index c50ae75158..f8aa66516f 100644 --- a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/PrintingPlateRecipe.java +++ b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/PrintingPlateRecipe.java @@ -23,6 +23,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.BookMeta; import org.bukkit.inventory.meta.BookMeta.Generation; +import org.bukkit.inventory.meta.ItemMeta; import vg.civcraft.mc.civmodcore.inventory.items.ItemMap; import vg.civcraft.mc.civmodcore.inventory.items.ItemUtils; @@ -152,6 +153,44 @@ public List getOutputRepresentation(Inventory i, FurnCraftChestFactor return stacks; } + @Override + public ItemStack getRecipeRepresentation(Inventory inputInv) { + ItemStack res = super.getRecipeRepresentation(inputInv); + if (inputInv == null) { + return res; + } + int have = 0; + for (ItemStack is : inputInv.getContents()) { + if (is != null && is.getType() == Material.WRITTEN_BOOK) { + BookMeta meta = (BookMeta) is.getItemMeta(); + if (meta.getGeneration() != Generation.TATTERED) { + have++; + } + } + } + int needed = 1; + ChatColor color = have >= needed ? ChatColor.GREEN : ChatColor.RED; + String name = ItemUtils.getItemName(new ItemStack(Material.WRITTEN_BOOK)); + String plain = ChatColor.GRAY + " - " + ChatColor.AQUA + "1 Written Book"; + String repl = ChatColor.GRAY + " - " + color + have + "/" + needed + " " + name; + + ItemMeta im = res.getItemMeta(); + if (im != null) { + List lore = im.getLore(); + if (lore != null) { + for (int i = 0; i < lore.size(); i++) { + if (lore.get(i).equals(plain)) { + lore.set(i, repl); + break; + } + } + im.setLore(lore); + res.setItemMeta(im); + } + } + return res; + } + @Override public Material getRecipeRepresentationMaterial() { return getPrintingPlateRepresentation(this.output, getName()).getType(); diff --git a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/RandomOutputRecipe.java b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/RandomOutputRecipe.java index 73b76e30ed..9cb422180b 100644 --- a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/RandomOutputRecipe.java +++ b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/RandomOutputRecipe.java @@ -165,118 +165,6 @@ public EffectFeasibility evaluateEffectFeasibility(Inventory inputInv, Inventory return new EffectFeasibility(false, "it ran out of storage space"); } - private boolean canFitInOutput(ItemMap outputMap, Inventory outputInv) { - ItemStack[] currentContent = outputInv.getStorageContents(); - ItemStack[] simulatedOutput = new ItemStack[currentContent.length]; - for (int i = 0; i < currentContent.length; i++) { - ItemStack slot = currentContent[i]; - simulatedOutput[i] = slot == null ? null : slot.clone(); - } - - for (Entry outputEntry : outputMap.getAllItems().entrySet()) { - ItemStack outputTemplate = outputEntry.getKey(); - int remainingAmount = outputEntry.getValue(); - if (outputTemplate == null || outputTemplate.isEmpty() || remainingAmount <= 0) { - continue; - } - - int maxStackSize = Math.max(1, outputTemplate.getMaxStackSize()); - for (int i = 0; i < simulatedOutput.length && remainingAmount > 0; i++) { - ItemStack existingStack = simulatedOutput[i]; - if (existingStack == null || existingStack.isEmpty() || !existingStack.isSimilar(outputTemplate)) { - continue; - } - int existingMaxStackSize = Math.max(1, existingStack.getMaxStackSize()); - int freeSpace = Math.max(0, existingMaxStackSize - existingStack.getAmount()); - if (freeSpace <= 0) { - continue; - } - int movedAmount = Math.min(remainingAmount, freeSpace); - existingStack.setAmount(existingStack.getAmount() + movedAmount); - remainingAmount -= movedAmount; - } - - for (int i = 0; i < simulatedOutput.length && remainingAmount > 0; i++) { - ItemStack existingStack = simulatedOutput[i]; - if (existingStack != null && !existingStack.isEmpty()) { - continue; - } - int movedAmount = Math.min(remainingAmount, maxStackSize); - ItemStack toInsert = outputTemplate.clone(); - toInsert.setAmount(movedAmount); - simulatedOutput[i] = toInsert; - remainingAmount -= movedAmount; - } - - if (remainingAmount > 0) { - return false; - } - } - return true; - } - - private boolean addOutputToInventorySafely(ItemMap outputMap, Inventory outputInv, List insertedOutput) { - for (Entry outputEntry : outputMap.getAllItems().entrySet()) { - ItemStack outputTemplate = outputEntry.getKey(); - int remainingAmount = outputEntry.getValue(); - if (outputTemplate == null || outputTemplate.isEmpty() || remainingAmount <= 0) { - continue; - } - - int maxStackSize = Math.max(1, outputTemplate.getMaxStackSize()); - while (remainingAmount > 0) { - int movedAmount = Math.min(remainingAmount, maxStackSize); - ItemStack toInsert = outputTemplate.clone(); - toInsert.setAmount(movedAmount); - Map overflow = outputInv.addItem(toInsert); - int overflowAmount = 0; - for (ItemStack overflowStack : overflow.values()) { - overflowAmount += overflowStack.getAmount(); - } - int insertedAmount = movedAmount - overflowAmount; - if (insertedAmount > 0) { - ItemStack insertedStack = outputTemplate.clone(); - insertedStack.setAmount(insertedAmount); - insertedOutput.add(insertedStack); - } - if (!overflow.isEmpty()) { - return false; - } - remainingAmount -= movedAmount; - } - } - return true; - } - - private void rollbackOutput(Inventory outputInv, List insertedOutput) { - for (ItemStack outputStack : insertedOutput) { - outputInv.removeItem(outputStack); - } - } - - private void restoreInput(ItemMap removedInput, Inventory inputInv, FurnCraftChestFactory fccf) { - for (Entry removedEntry : removedInput.getAllItems().entrySet()) { - ItemStack removedTemplate = removedEntry.getKey(); - int remainingAmount = removedEntry.getValue(); - if (removedTemplate == null || removedTemplate.isEmpty() || remainingAmount <= 0) { - continue; - } - - int maxStackSize = Math.max(1, removedTemplate.getMaxStackSize()); - while (remainingAmount > 0) { - int movedAmount = Math.min(remainingAmount, maxStackSize); - ItemStack removedStack = removedTemplate.clone(); - removedStack.setAmount(movedAmount); - Map overflow = inputInv.addItem(removedStack); - if (!overflow.isEmpty()) { - FactoryMod.getInstance().warning("Failed to fully restore input after random output rollback :(," + fccf.getLogData()); - return; - } - remainingAmount -= movedAmount; - } - } - } - @Override public String getTypeIdentifier() { return "RANDOM"; diff --git a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/utility/FileHandler.java b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/utility/FileHandler.java index 76cc4cfddc..b0b3178824 100644 --- a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/utility/FileHandler.java +++ b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/utility/FileHandler.java @@ -84,6 +84,8 @@ public void save(Collection factories) { config.set(current + ".runtime", fccf.getRunningTime()); config.set(current + ".selectedRecipe", fccf .getCurrentRecipe().getName()); + config.set(current + ".preferredRecipe", fccf.getPreferredRecipe() == null + ? null : fccf.getPreferredRecipe().getName()); config.set(current + ".autoSelect", fccf.isAutoSelect()); List recipeList = new LinkedList(); for (IRecipe rec : fccf.getRecipes()) { @@ -299,6 +301,15 @@ private void loadFromFile(File f, Map eggs) { } } fac.setAutoSelect(autoSelect); + String preferredRecipe = current.getString("preferredRecipe"); + if (preferredRecipe != null) { + for (IRecipe r : fac.getRecipes()) { + if (r.getName().equals(preferredRecipe)) { + fac.setPreferredRecipe(r); + break; + } + } + } { ConfigurationSection iosec = current.getConfigurationSection("furnace-io"); if (iosec != null) { diff --git a/plugins/factorymod-paper/src/test/java/com/github/igotyou/FactoryMod/factories/ChooseAutoRecipeTest.java b/plugins/factorymod-paper/src/test/java/com/github/igotyou/FactoryMod/factories/ChooseAutoRecipeTest.java new file mode 100644 index 0000000000..4175c1811a --- /dev/null +++ b/plugins/factorymod-paper/src/test/java/com/github/igotyou/FactoryMod/factories/ChooseAutoRecipeTest.java @@ -0,0 +1,73 @@ +package com.github.igotyou.FactoryMod.factories; + +import com.github.igotyou.FactoryMod.recipes.IRecipe; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; + +class ChooseAutoRecipeTest { + + private final IRecipe production1 = mock(IRecipe.class); + private final IRecipe production2 = mock(IRecipe.class); + private final IRecipe repair = mock(IRecipe.class); + + private final Predicate isRepair = r -> r == repair; + + private static Predicate hasMaterials(IRecipe... withMaterials) { + Set set = Set.of(withMaterials); + return set::contains; + } + + @Test + void healthyPrefersPlayerChoiceOverFirstInList() { + IRecipe chosen = FurnCraftChestFactory.chooseAutoRecipe( + List.of(production1, production2, repair), false, production2, + isRepair, hasMaterials(production1, production2)); + assertSame(production2, chosen); + } + + @Test + void healthyFallsBackToFirstAvailableWhenPreferredLacksMaterials() { + IRecipe chosen = FurnCraftChestFactory.chooseAutoRecipe( + List.of(production1, production2, repair), false, production2, + isRepair, hasMaterials(production1)); + assertSame(production1, chosen); + } + + @Test + void healthyNeverRunsRepairEvenWhenCurrentlySelected() { + IRecipe chosen = FurnCraftChestFactory.chooseAutoRecipe( + List.of(production1, repair), false, production1, + isRepair, hasMaterials(production1, repair)); + assertSame(production1, chosen); + } + + @Test + void healthyStaysIdleWhenNoProductionHasMaterials() { + IRecipe chosen = FurnCraftChestFactory.chooseAutoRecipe( + List.of(production1, repair), false, production1, + isRepair, hasMaterials(repair)); + assertNull(chosen); + } + + @Test + void brokenRunsRepairRegardlessOfPreferred() { + IRecipe chosen = FurnCraftChestFactory.chooseAutoRecipe( + List.of(production1, repair), true, production1, + isRepair, hasMaterials(production1, repair)); + assertSame(repair, chosen); + } + + @Test + void brokenWithNoRepairRecipeReturnsNull() { + IRecipe chosen = FurnCraftChestFactory.chooseAutoRecipe( + List.of(production1, production2), true, null, + isRepair, hasMaterials(production1, production2)); + assertNull(chosen); + } +} diff --git a/plugins/namelayer-paper/build.gradle.kts b/plugins/namelayer-paper/build.gradle.kts index ef484f2ec1..44ce9508eb 100644 --- a/plugins/namelayer-paper/build.gradle.kts +++ b/plugins/namelayer-paper/build.gradle.kts @@ -12,4 +12,16 @@ dependencies { compileOnly(project(":plugins:civmodcore-paper")) api(project(":libraries:name-api")) + + testImplementation(libs.bundles.junit) + testImplementation(libs.mockbukkit) + testImplementation("io.papermc.paper:paper-api:${libs.versions.paper.get()}") + testImplementation("org.mockito:mockito-core:5.14.2") + // GroupManagerDao references civmodcore's ManagedDatasource, which Mockito must load to mock it. + testImplementation(project(":plugins:civmodcore-paper")) +} + +// https://docs.mockbukkit.org/docs/en/user_guide/advanced/paperweight +paperweight { + addServerDependencyTo = configurations.named(JavaPlugin.COMPILE_ONLY_CONFIGURATION_NAME).map { setOf(it) } } diff --git a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/GroupManager.java b/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/GroupManager.java index 68773b4d0a..ef57a8fdb7 100644 --- a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/GroupManager.java +++ b/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/GroupManager.java @@ -5,6 +5,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; @@ -29,6 +30,12 @@ public class GroupManager { private static Map groupsByName = new ConcurrentHashMap<>(); private static Map groupsById = new ConcurrentHashMap<>(); + // Group ids that resolved to nothing in the database. Citadel reinforcements and JukeAlert + // snitches keep referencing deleted group ids forever, and getGroup(int) runs its query on the + // calling (main) thread — without negative caching every hit on such a block is a blocking + // round-trip, which has caused multi-second tick stalls during fights. Cleared wholesale in + // invalidateCache so a manually restored group is picked up without a restart. + private static Set missingGroupIds = ConcurrentHashMap.newKeySet(); private static boolean mergingInProgress = false; @@ -105,6 +112,7 @@ private void doCreateGroupAsync(final Group group, final RunnableOnGroup postCre NameLayerPlugin.log(Level.INFO, "Group create was cancelled for group: " + group.getName()); postCreate.setGroup(new Group(group.getName(), group.getOwner(), true, group.getPassword(), -1, System.currentTimeMillis(), group.getGroupColor().toString())); Bukkit.getScheduler().runTask(NameLayerPlugin.getInstance(), postCreate); + return; } final String name = event.getGroupName(); final UUID owner = event.getOwner(); @@ -348,20 +356,24 @@ public static Group getGroup(String name) { } public static Group getGroup(int groupId) { - if (groupsById.containsKey(groupId)) { - return groupsById.get(groupId); - } else { - Group group = groupManagerDao.getGroup(groupId); - if (group != null) { - groupsByName.put(group.getName().toLowerCase(), group); - for (int j : group.getGroupIds()) { - groupsById.put(j, group); - } - } else { - NameLayerPlugin.getInstance().getLogger().log(Level.INFO, "getGroup by ID failed, unable to find the group " + groupId); + Group cached = groupsById.get(groupId); + if (cached != null) { + return cached; + } + if (missingGroupIds.contains(groupId)) { + return null; + } + Group group = groupManagerDao.getGroup(groupId); + if (group != null) { + groupsByName.put(group.getName().toLowerCase(), group); + for (int j : group.getGroupIds()) { + groupsById.put(j, group); } - return group; + } else { + missingGroupIds.add(groupId); + NameLayerPlugin.getInstance().getLogger().log(Level.INFO, "getGroup by ID failed, unable to find the group " + groupId); } + return group; } public static boolean hasGroup(String groupName) { @@ -434,8 +446,14 @@ public boolean hasAccess(Group group, UUID player, PermissionType perm) { if (p != null && (p.isOp() || p.hasPermission("namelayer.admin"))) { return true; } - if (group == null || perm == null) { - NameLayerPlugin.getInstance().getLogger().log(Level.INFO, "hasAccess failed, caller passed in null", new Exception()); + // A null perm means the caller passed a bad permission constant: a real bug worth a trace. + // A null group is a normal "deny" (an unloaded or deleted group, routinely produced by + // movement handlers like Bastion's overlay) and must not spam a stack trace every tick. + if (perm == null) { + NameLayerPlugin.getInstance().getLogger().log(Level.INFO, "hasAccess failed, caller passed in null perm", new Exception()); + return false; + } + if (group == null) { return false; } if (!group.isValid()) { @@ -522,28 +540,17 @@ public static void invalidateCache(String group) { return; } + // Any group change may turn a previously-missing id valid (merges, manual repair), so the + // negative cache is cleared wholesale; missing ids re-cache on their next lookup. + missingGroupIds.clear(); + Group g = groupsByName.get(group.toLowerCase()); if (g != null) { g.setValid(false); - List k = g.getGroupIds(); groupsByName.remove(group.toLowerCase()); NameLayerPlugin.getBlackList().removeFromCache(g.getName()); - - boolean fail = true; - // You have a freaking hashmap, use it. - for (int j : k) { - if (groupsById.remove(j) != null) { - fail = false; - } - } - - // FALLBACK is hardloop - if (fail) { // can't find ID or cache is wrong. - for (Group x : groupsById.values()) { - if (x.getName().equals(g.getName())) { - groupsById.remove(x.getGroupId()); - } - } + for (int j : g.getGroupIds()) { + groupsById.remove(j); } } else { NameLayerPlugin.getInstance().getLogger().log(Level.INFO, "Invalidate cache by name failed, unable to find the group " + group); diff --git a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/command/commands/LinkGroups.java b/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/command/commands/LinkGroups.java deleted file mode 100644 index 20279f1edb..0000000000 --- a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/command/commands/LinkGroups.java +++ /dev/null @@ -1,82 +0,0 @@ -package vg.civcraft.mc.namelayer.command.commands; - -import co.aikar.commands.annotation.CommandAlias; -import co.aikar.commands.annotation.CommandCompletion; -import co.aikar.commands.annotation.Description; -import co.aikar.commands.annotation.Syntax; -import java.util.UUID; -import org.bukkit.ChatColor; -import org.bukkit.entity.Player; -import vg.civcraft.mc.namelayer.GroupManager; -import vg.civcraft.mc.namelayer.NameLayerAPI; -import vg.civcraft.mc.namelayer.command.BaseCommandMiddle; -import vg.civcraft.mc.namelayer.group.Group; -import vg.civcraft.mc.namelayer.permission.PermissionType; - -public class LinkGroups extends BaseCommandMiddle { - - @CommandAlias("nllink|linkgroups") - @Syntax(" ") - @Description("Links two groups to each other as nested groups.") - @CommandCompletion("@NL_Groups @NL_Groups") - public void execute(Player sender, String parentGroup, String childGroup) { - Player p = (Player) sender; - - String supername = parentGroup, subname = childGroup; - - Group supergroup = GroupManager.getGroup(supername); - if (groupIsNull(sender, supername, supergroup)) { - return; - } - - Group subgroup = GroupManager.getGroup(subname); - if (groupIsNull(sender, subname, subgroup)) { - return; - } - - if (subgroup.getName().equalsIgnoreCase(supergroup.getName())) { - p.sendMessage(ChatColor.RED + "Not today"); - return; - } - - // check if groups are accessible - - UUID uuid = NameLayerAPI.getUUID(p.getName()); - - if (!supergroup.isMember(uuid) || !subgroup.isMember(uuid)) { - p.sendMessage(ChatColor.RED + "You're not on one of the groups."); - return; - } - - if (supergroup.isDisciplined() || subgroup.isDisciplined()) { - p.sendMessage(ChatColor.RED + "One of the groups is disciplined."); - return; - } - - if (!gm.hasAccess(subgroup, uuid, PermissionType.getPermission("LINKING"))) { - p.sendMessage(ChatColor.RED - + "You don't have permission to do that on the sub group."); - return; - } - if (!gm.hasAccess(supergroup, uuid, PermissionType.getPermission("LINKING"))) { - p.sendMessage(ChatColor.RED - + "You don't have permission to do that on the super group."); - return; - } - - if (Group.areLinked(supergroup, subgroup)) { - p.sendMessage(ChatColor.RED + "These groups are already linked."); - return; - } - - boolean success = Group.link(supergroup, subgroup, true); - - String message; - if (success) { - message = ChatColor.GREEN + "The groups have been successfully linked."; - } else { - message = ChatColor.RED + "Failed to link the groups."; - } - p.sendMessage(message); - } -} diff --git a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/command/commands/UnlinkGroups.java b/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/command/commands/UnlinkGroups.java deleted file mode 100644 index e94757795b..0000000000 --- a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/command/commands/UnlinkGroups.java +++ /dev/null @@ -1,79 +0,0 @@ -package vg.civcraft.mc.namelayer.command.commands; - -import co.aikar.commands.annotation.CommandAlias; -import co.aikar.commands.annotation.CommandCompletion; -import co.aikar.commands.annotation.Description; -import co.aikar.commands.annotation.Syntax; -import java.util.UUID; -import org.bukkit.ChatColor; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import vg.civcraft.mc.namelayer.GroupManager; -import vg.civcraft.mc.namelayer.NameLayerAPI; -import vg.civcraft.mc.namelayer.command.BaseCommandMiddle; -import vg.civcraft.mc.namelayer.group.Group; -import vg.civcraft.mc.namelayer.permission.PermissionType; - -public class UnlinkGroups extends BaseCommandMiddle { - - @CommandAlias("nlunlink|unlink|unlinkgroups") - @Syntax(" ") - @Description("Unlinks two groups from each other.") - @CommandCompletion("@NL_Groups @NL_Groups") - public void execute(CommandSender sender, String parentGroup, String childGroup) { - if (!(sender instanceof Player)) { - sender.sendMessage(ChatColor.LIGHT_PURPLE + "Sorry bruh, no can do."); - return; - } - Player p = (Player) sender; - - // check if groups exist - - String supername = parentGroup, subname = childGroup; - - Group supergroup = GroupManager.getGroup(supername); - if (groupIsNull(sender, supername, supergroup)) { - return; - } - - Group subgroup = GroupManager.getGroup(subname); - if (groupIsNull(sender, subname, subgroup)) { - return; - } - - // check if groups are accessible - - UUID uuid = NameLayerAPI.getUUID(p.getName()); - - if (!supergroup.isMember(uuid) || !subgroup.isMember(uuid)) { - p.sendMessage(ChatColor.RED + "You're not on one of the groups."); - return; - } - - if (supergroup.isDisciplined() || subgroup.isDisciplined()) { - p.sendMessage(ChatColor.RED + "One of the groups is disciplined."); - return; - } - - if (!gm.hasAccess(supergroup, uuid, PermissionType.getPermission("LINKING"))) { - p.sendMessage(ChatColor.RED - + "You don't have permission to do that on the super group."); - return; - } - - if (!Group.areLinked(supergroup, subgroup)) { - p.sendMessage(ChatColor.RED + "These groups are not linked."); - return; - } - - boolean success = Group.unlink(supergroup, subgroup); - - String message; - if (success) { - message = ChatColor.GREEN + "The groups have been successfully unlinked."; - } else { - message = ChatColor.RED + "Failed to unlink the groups."; - } - p.sendMessage(message); - } -} diff --git a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/database/GroupManagerDao.java b/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/database/GroupManagerDao.java index 8b07fe3057..ffb70a880a 100644 --- a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/database/GroupManagerDao.java +++ b/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/database/GroupManagerDao.java @@ -61,6 +61,14 @@ public class GroupManagerDao { private static final String getMembers = "select fm.member_name from faction_member fm " + "inner join faction_id id on id.group_name = ? " + "where fm.group_id = id.group_id and fm.role = ?"; + // Ordered by PlayerType declaration precedence so a member listed under multiple roles resolves + // to the same winning role the per-type ctor loop produced (last put in values() order wins). + private static final String getAllMembers = "select fm.member_name, fm.role from faction_member fm " + + "inner join faction_id id on id.group_name = ? " + + "where fm.group_id = id.group_id " + + "order by case fm.role " + + "when 'MEMBERS' then 0 when 'MODS' then 1 when 'ADMINS' then 2 when 'OWNER' then 3 " + + "when 'NOT_BLACKLISTED' then 4 else 5 end"; private static final String removeMember = "delete fm.* from faction_member fm " + "inner join faction_id fi on fi.group_id = fm.group_id " + "where fm.member_name = ? and fi.group_name =?"; @@ -760,6 +768,33 @@ public List getAllMembers(String groupName, PlayerType role) { return members; } + public Map getAllMembers(String groupName) { + Map members = new HashMap<>(); + try (Connection connection = db.getConnection(); + PreparedStatement getMembers = connection.prepareStatement(GroupManagerDao.getAllMembers)) { + getMembers.setString(1, groupName); + try (ResultSet set = getMembers.executeQuery()) { + while (set.next()) { + String uuid = set.getString(1); + if (uuid == null) { + continue; + } + String roleName = set.getString(2); + PlayerType role = roleName == null ? null : PlayerType.getPlayerType(roleName); + if (role == null) { + continue; + } + members.put(UUID.fromString(uuid), role); + } + } catch (SQLException e) { + logger.log(Level.WARNING, "Problem getting all members for group " + groupName, e); + } + } catch (SQLException e) { + logger.log(Level.WARNING, "Problem preparing to get all members for group " + groupName, e); + } + return members; + } + public void removeMemberAsync(final UUID member, final String group) { plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() { @@ -1303,7 +1338,9 @@ public String getDefaultGroup(UUID uuid) { PreparedStatement getDefaultGroup = connection.prepareStatement(GroupManagerDao.getDefaultGroup);) { getDefaultGroup.setString(1, uuid.toString()); try (ResultSet set = getDefaultGroup.executeQuery();) { - group = set.getString(1); + if (set.next()) { + group = set.getString(1); + } } catch (SQLException e) { logger.log(Level.WARNING, "Problem getting default group for " + uuid, e); } diff --git a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/group/Group.java b/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/group/Group.java index f1051369ba..0a4023f460 100644 --- a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/group/Group.java +++ b/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/group/Group.java @@ -58,11 +58,9 @@ public Group(String name, UUID owner, boolean disciplined, return; } - for (PlayerType permission : PlayerType.values()) { - List list = db.getAllMembers(name, permission); - for (UUID uuid : list) { - players.put(uuid, permission); - } + Map loadedMembers = db.getAllMembers(name); + if (loadedMembers != null) { + players.putAll(loadedMembers); } // This returns list of ids w/ id holding largest # of players at top. @@ -563,6 +561,9 @@ public String getPassword() { * @return Returns true if they equal, otherwise false. */ public boolean isPassword(String password) { + if (this.password == null) { + return password == null; + } return this.password.equals(password); } diff --git a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/gui/AdminFunctionsGUI.java b/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/gui/AdminFunctionsGUI.java index 728402491e..3045b0b389 100644 --- a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/gui/AdminFunctionsGUI.java +++ b/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/gui/AdminFunctionsGUI.java @@ -134,11 +134,6 @@ public void clicked(Player arg0) { ci.showInventory(p); } -// private void showLinkingMenu() { -// LinkingGUI lgui = new LinkingGUI(g, p, this); -// lgui.showScreen(); -// } - private void showMergingMenu() { MergeGUI mGui = new MergeGUI(g, p, this); mGui.showScreen(); diff --git a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/gui/LinkingGUI.java b/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/gui/LinkingGUI.java deleted file mode 100644 index 330cdea723..0000000000 --- a/plugins/namelayer-paper/src/main/java/vg/civcraft/mc/namelayer/gui/LinkingGUI.java +++ /dev/null @@ -1,414 +0,0 @@ -package vg.civcraft.mc.namelayer.gui; - -import java.util.ArrayList; -import java.util.List; -import java.util.logging.Level; -import org.bukkit.ChatColor; -import org.bukkit.Material; -import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; -import vg.civcraft.mc.civmodcore.inventory.gui.Clickable; -import vg.civcraft.mc.civmodcore.inventory.gui.ClickableInventory; -import vg.civcraft.mc.civmodcore.inventory.gui.DecorationStack; -import vg.civcraft.mc.civmodcore.inventory.items.ItemUtils; -import vg.civcraft.mc.namelayer.NameLayerPlugin; -import vg.civcraft.mc.namelayer.group.Group; -import vg.civcraft.mc.namelayer.permission.PermissionType; - -public class LinkingGUI extends AbstractGroupGUI { - - private AdminFunctionsGUI parent; - private boolean makingSubGroup; - private int linkSelectingPage; - private int subGroupSelectingPage; - - public LinkingGUI(Group g, Player p, AdminFunctionsGUI parent) { - super(g, p); - this.parent = parent; - subGroupSelectingPage = 0; - } - - public void showScreen() { - ClickableInventory ci = new ClickableInventory(54, g.getName()); - ci.setSlot(getInfoClickable(), 4); - if (g.hasSuperGroup()) { - ci.setSlot(getRemoveSuperClickable(), 3); - } else { - ci.setSlot(getAddSuperClickable(), 3); - } - ci.setSlot(getAddSubClickable(), 5); - final List clicks = getSubClickables(); - for (int i = (45 * subGroupSelectingPage) + 9; i < (45 * (subGroupSelectingPage + 1)) + 9 - && i < clicks.size(); i++) { - ci.setSlot(clicks.get(i), i - (45 * subGroupSelectingPage)); - } - - // previous button - if (subGroupSelectingPage > 0) { - ItemStack back = new ItemStack(Material.ARROW); - ItemUtils.setDisplayName(back, ChatColor.GOLD + "Go to previous page"); - Clickable baCl = new Clickable(back) { - - @Override - public void clicked(Player arg0) { - if (subGroupSelectingPage > 0) { - subGroupSelectingPage--; - } - showScreen(); - } - }; - ci.setSlot(baCl, 0); - } - // next button - if ((45 * (subGroupSelectingPage + 1)) <= clicks.size()) { - ItemStack forward = new ItemStack(Material.ARROW); - ItemUtils.setDisplayName(forward, ChatColor.GOLD + "Go to next page"); - Clickable forCl = new Clickable(forward) { - - @Override - public void clicked(Player arg0) { - if ((45 * (subGroupSelectingPage + 1)) <= clicks.size()) { - subGroupSelectingPage++; - } - showScreen(); - } - }; - ci.setSlot(forCl, 8); - } - - // back button - ItemStack backToOverview = goBackStack(); - ItemUtils.setDisplayName(backToOverview, ChatColor.GOLD + "Back to overview"); - ci.setSlot(new Clickable(backToOverview) { - - @Override - public void clicked(Player arg0) { - parent.showScreen(); - } - }, 7); - ci.showInventory(p); - } - - private List getSubClickables() { - List clicks = new ArrayList(); - for (final Group sub : g.getSubgroups()) { - ItemStack is = new ItemStack(Material.MAGMA_CREAM); - ItemUtils.setDisplayName(is, ChatColor.GOLD + sub.getName()); - ItemUtils.addLore(is, ChatColor.AQUA + "This group has " - + sub.getSubgroups().size() + "sub groups itself"); - ItemUtils.addLore(is, ChatColor.DARK_AQUA - + "Click to remove this sub group"); - Clickable c = new Clickable(is) { - - @Override - public void clicked(Player arg0) { - if (!gm.hasAccess(g, p.getUniqueId(), - PermissionType.getPermission("LINKING"))) { - p.sendMessage(ChatColor.RED - + "You dont have permission to unlink " - + g.getName()); - showScreen(); - return; - } - if (!gm.hasAccess(sub, p.getUniqueId(), - PermissionType.getPermission("LINKING"))) { - p.sendMessage(ChatColor.RED - + "You dont have permission to unlink " - + sub.getName()); - showScreen(); - return; - } - boolean success = Group.unlink(g, sub); - String message; - if (success) { - message = ChatColor.GREEN + sub.getName() - + " is no longer a sub group of " + g.getName(); - } else { - message = ChatColor.RED - + "Failed to unlink the groups, you should complain to an admin about this"; - } - p.sendMessage(message); - showScreen(); - - } - }; - clicks.add(c); - } - return clicks; - } - - private Clickable getAddSubClickable() { - ItemStack makeSuper = new ItemStack(Material.LEATHER); - ItemUtils.setDisplayName(makeSuper, ChatColor.GOLD + "Add a new subgroup"); - ItemUtils.addLore( - makeSuper, - ChatColor.AQUA - + "This option means that the additional group you chose will inherit all members of " - + g.getName() + " with their ranks"); - Clickable superClick = new Clickable(makeSuper) { - - @Override - public void clicked(Player arg0) { - makingSubGroup = false; - linkSelectingPage = 0; - showGroupSelector(); - } - }; - return superClick; - } - - private Clickable getInfoClickable() { - ItemStack is = new ItemStack(Material.PAPER); - ItemUtils.setDisplayName(is, ChatColor.GOLD + "Linking stats for " + g.getName()); - if (g.hasSuperGroup()) { - ItemUtils.addLore(is, ChatColor.AQUA + "Current super group: " - + g.getSuperGroup().getName()); - } else { - ItemUtils.addLore(is, ChatColor.AQUA + "No current super group"); - } - ItemUtils.addLore(is, ChatColor.DARK_AQUA + "Currently " - + g.getSubgroups().size() + " sub group" - + ((g.getSubgroups().size() == 1) ? "" : "s") - + ", which are listed below"); - return new DecorationStack(is); - } - - private Clickable getRemoveSuperClickable() { - ItemStack is = new ItemStack(Material.DIAMOND); - ItemUtils.setDisplayName(is, ChatColor.GOLD + "Remove current super group"); - ItemUtils.addLore(is, ChatColor.AQUA + g.getSuperGroup().getName() - + " is the super group of " + g.getName()); - ItemUtils.addLore(is, ChatColor.DARK_AQUA + "Click to remove this link"); - Clickable c = new Clickable(is) { - - @Override - public void clicked(Player arg0) { - if (!gm.hasAccess(g, p.getUniqueId(), - PermissionType.getPermission("LINKING"))) { - p.sendMessage(ChatColor.RED - + "You dont have permission to unlink " - + g.getName()); - showScreen(); - return; - } - if (!gm.hasAccess(g.getSuperGroup(), p.getUniqueId(), - PermissionType.getPermission("LINKING"))) { - p.sendMessage(ChatColor.RED - + "You dont have permission to unlink " - + g.getSuperGroup().getName()); - showScreen(); - return; - } - Group superGroup = g.getSuperGroup(); - boolean success = Group.unlink(superGroup, g); - String message; - if (success) { - message = ChatColor.GREEN + g.getName() - + " is no longer a sub group of " - + superGroup.getName(); - } else { - message = ChatColor.RED - + "Failed to unlink the groups, you should complain to an admin about this"; - } - p.sendMessage(message); - showScreen(); - } - }; - return c; - } - - private Clickable getAddSuperClickable() { - ItemStack makeSub = new ItemStack(Material.BEACON); - ItemUtils.setDisplayName(makeSub, ChatColor.GOLD + "Add super group"); - ItemUtils.addLore( - makeSub, - ChatColor.AQUA - + "This option means that " - + g.getName() - + " will inherit all members with their respective ranks from the second group you chose"); - Clickable subClick = new Clickable(makeSub) { - - @Override - public void clicked(Player arg0) { - makingSubGroup = true; - linkSelectingPage = 0; - showGroupSelector(); - } - }; - return subClick; - } - - private void showGroupSelector() { - final List clicks = new ArrayList(); - for (final String groupName : gm.getAllGroupNames(p.getUniqueId())) { - Group g = gm.getGroup(groupName); - if (g == null) { - // ???? - continue; - } - ItemStack is = new ItemStack(Material.MAGMA_CREAM); - ItemUtils.setDisplayName(is, g.getName()); - Clickable c; - if (!gm.hasAccess(g, p.getUniqueId(), - PermissionType.getPermission("LINKING"))) { - if (!makingSubGroup && g.hasSuperGroup()) { - // making a supergroup, but this one already has one - ItemUtils.addLore(is, ChatColor.RED - + "This group already has a super group"); - } else { - ItemUtils.addLore(is, ChatColor.RED - + "You don't have permission to link this group"); - } - c = new DecorationStack(is); - } else { - c = new Clickable(is) { - - @Override - public void clicked(Player arg0) { - requestLink(groupName); - showScreen(); - } - }; - } - clicks.add(c); - } - ClickableInventory ci = new ClickableInventory(54, this.g.getName()); - if (clicks.size() < 45 * linkSelectingPage) { - // would show an empty page, so go to previous - linkSelectingPage--; - } - - for (int i = 45 * linkSelectingPage; i < 45 * (linkSelectingPage + 1) - && i < clicks.size(); i++) { - ci.setSlot(clicks.get(i), i - (45 * linkSelectingPage)); - } - // previous button - if (linkSelectingPage > 0) { - ItemStack back = new ItemStack(Material.ARROW); - ItemUtils.setDisplayName(back, ChatColor.GOLD + "Go to previous page"); - Clickable baCl = new Clickable(back) { - - @Override - public void clicked(Player arg0) { - if (linkSelectingPage > 0) { - linkSelectingPage--; - } - showGroupSelector(); - } - }; - ci.setSlot(baCl, 45); - } - // next button - if ((45 * (linkSelectingPage + 1)) <= clicks.size()) { - ItemStack forward = new ItemStack(Material.ARROW); - ItemUtils.setDisplayName(forward, ChatColor.GOLD + "Go to next page"); - Clickable forCl = new Clickable(forward) { - - @Override - public void clicked(Player arg0) { - if ((45 * (linkSelectingPage + 1)) <= clicks.size()) { - linkSelectingPage++; - } - showGroupSelector(); - } - }; - ci.setSlot(forCl, 53); - } - - // close button - ItemStack backToOverview = goBackStack(); - ItemUtils.setDisplayName(backToOverview, ChatColor.GOLD + "Back to overview"); - ci.setSlot(new Clickable(backToOverview) { - - @Override - public void clicked(Player arg0) { - showScreen(); - } - }, 49); - ci.showInventory(p); - } - - private void requestLink(String groupName) { - Group linkGroup = gm.getGroup(groupName); - if (linkGroup == null) { - p.sendMessage(ChatColor.RED - + "This group no longer exists? Something went wrong"); - showScreen(); - return; - } - if (!gm.hasAccess(g, p.getUniqueId(), - PermissionType.getPermission("LINKING"))) { - p.sendMessage(ChatColor.RED + "You dont have permission to link " - + g.getName()); - showScreen(); - return; - } - if (!gm.hasAccess(linkGroup, p.getUniqueId(), - PermissionType.getPermission("LINKING"))) { - p.sendMessage(ChatColor.RED + "You dont have permission to link " - + linkGroup.getName()); - showScreen(); - return; - } - if (makingSubGroup && g.hasSuperGroup()) { - p.sendMessage(ChatColor.RED + g.getName() - + " already has a super group"); - showScreen(); - return; - } - if (!makingSubGroup && linkGroup.hasSuperGroup()) { - p.sendMessage(ChatColor.RED + linkGroup.getName() - + " already has a super group"); - showScreen(); - return; - } - boolean linkCheck; - if (makingSubGroup) { - linkCheck = Group.areLinked(linkGroup, g); - } else { - linkCheck = Group.areLinked(g, linkGroup); - } - if (!linkCheck) { - p.sendMessage(ChatColor.RED - + "Those groups are already linked directly or indirectly, you can't link them"); - showScreen(); - return; - } - if (g.isDisciplined() || linkGroup.isDisciplined()) { - p.sendMessage(ChatColor.RED + "One of the groups is disciplined."); - showScreen(); - return; - } - boolean success; - if (makingSubGroup) { - success = Group.link(linkGroup, g, true); - } else { - success = Group.link(g, linkGroup, true); - } - NameLayerPlugin.log( - Level.INFO, - p.getName() - + " linked " - + linkGroup.getName() - + " and " - + g.getName() - + " via the gui, " - + (makingSubGroup ? linkGroup.getName() : g.getName() - + " was the super group")); - String message; - if (success) { - if (makingSubGroup) { - message = ChatColor.GREEN + "Successfully made " + g.getName() - + " a subgroup of " + linkGroup.getName(); - } else { - message = ChatColor.GREEN + "Successfully made " - + linkGroup.getName() + " a subgroup of " + g.getName(); - } - } else { - message = ChatColor.RED - + "Failed to link the groups, you should complain to an admin about this"; - } - p.sendMessage(message); - } - -} diff --git a/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/GroupCreateCancelTest.java b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/GroupCreateCancelTest.java new file mode 100644 index 0000000000..136b3ff8a3 --- /dev/null +++ b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/GroupCreateCancelTest.java @@ -0,0 +1,123 @@ +package vg.civcraft.mc.namelayer; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.plugin.PluginMock; +import org.slf4j.helpers.NOPLogger; +import vg.civcraft.mc.namelayer.database.GroupManagerDao; +import vg.civcraft.mc.namelayer.group.BlackList; +import vg.civcraft.mc.namelayer.group.Group; + +/** + * Regression test for the cancelled GroupCreateEvent in GroupManager.doCreateGroupAsync. A + * cancelled create must not insert into the DAO; previously the cancel branch fell through to the + * async insert. + */ +public class GroupCreateCancelTest { + + private ServerMock server; + private PluginMock plugin; + private GroupManagerDao dao; + private GroupManager groupManager; + + private final UUID owner = UUID.fromString("00000000-0000-0000-0000-0000000000dd"); + + @BeforeEach + public void setUp() throws Exception { + server = MockBukkit.mock(); + plugin = MockBukkit.createMockPlugin(); + + dao = mock(GroupManagerDao.class); + when(dao.getAllMembers(anyString())).thenReturn(Collections.emptyMap()); + when(dao.getAllIDs(anyString())).thenReturn(List.of(1)); + when(dao.getSubGroups(anyString())).thenReturn(Collections.emptyList()); + when(dao.getGroup(anyInt())).thenReturn(null); + TestDaoInjector.inject(dao); + + NameLayerPlugin instance = mock(NameLayerPlugin.class); + // JavaPlugin.getName()/equals() are final and read the private pluginMeta field. The subclass + // mock leaves it null, so plugin equality NPEs at MockBukkit unmock time. Borrow the real + // PluginMock's meta so identity works. + copyPluginMeta(plugin, instance); + when(instance.getSLF4JLogger()).thenReturn(NOPLogger.NOP_LOGGER); + when(instance.getLogger()).thenReturn(java.util.logging.Logger.getLogger("test")); + setStatic("instance", instance); + setStatic("blackList", new BlackList()); + + groupManager = new GroupManager(); + } + + @AfterEach + public void tearDown() { + MockBukkit.unmock(); + } + + private static void setStatic(String field, Object value) throws Exception { + Field f = NameLayerPlugin.class.getDeclaredField(field); + f.setAccessible(true); + f.set(null, value); + } + + private static void copyPluginMeta(Object from, Object to) throws Exception { + Field meta = org.bukkit.plugin.java.JavaPlugin.class.getDeclaredField("pluginMeta"); + meta.setAccessible(true); + meta.set(to, meta.get(from)); + } + + private Group placeholderGroup() { + return new Group("cancelgroup", owner, false, null, -1, 0L, "red"); + } + + private RunnableOnGroup noopPostCreate() { + return new RunnableOnGroup() { + @Override + public void run() { + } + }; + } + + @Test + public void cancelledCreateDoesNotInsert() { + server.getPluginManager().registerEvents(new Listener() { + @EventHandler + public void onCreate(vg.civcraft.mc.namelayer.events.GroupCreateEvent event) { + event.setCancelled(true); + } + }, plugin); + + groupManager.createGroupAsync(placeholderGroup(), noopPostCreate(), false); + server.getScheduler().waitAsyncTasksFinished(); + server.getScheduler().performTicks(2); + + verify(dao, never()).createGroup(anyString(), any(UUID.class), any()); + } + + @Test + public void uncancelledCreateDoesInsert() { + when(dao.createGroup(anyString(), any(UUID.class), any())).thenReturn(-1); + + groupManager.createGroupAsync(placeholderGroup(), noopPostCreate(), false); + server.getScheduler().waitAsyncTasksFinished(); + server.getScheduler().performTicks(2); + + verify(dao, times(1)).createGroup(anyString(), any(UUID.class), any()); + } +} diff --git a/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/GroupIdNegativeCacheTest.java b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/GroupIdNegativeCacheTest.java new file mode 100644 index 0000000000..f4e2528a54 --- /dev/null +++ b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/GroupIdNegativeCacheTest.java @@ -0,0 +1,129 @@ +package vg.civcraft.mc.namelayer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.plugin.PluginMock; +import org.slf4j.helpers.NOPLogger; +import vg.civcraft.mc.namelayer.database.GroupManagerDao; +import vg.civcraft.mc.namelayer.group.BlackList; +import vg.civcraft.mc.namelayer.group.Group; + +/** + * Regression test for the missing-group query storm: Citadel reinforcements and JukeAlert snitches + * reference deleted group ids forever, and GroupManager.getGroup(int) queries on the calling (main) + * thread. Without negative caching every lookup of a missing id was a blocking DB round-trip, + * which stalled the main thread for seconds during fights. + */ +public class GroupIdNegativeCacheTest { + + private static final int MISSING_ID = 55; + + private PluginMock plugin; + private GroupManagerDao dao; + + private final UUID owner = UUID.fromString("00000000-0000-0000-0000-0000000000ee"); + + @BeforeEach + public void setUp() throws Exception { + MockBukkit.mock(); + plugin = MockBukkit.createMockPlugin(); + + dao = mock(GroupManagerDao.class); + when(dao.getAllMembers(anyString())).thenReturn(Collections.emptyMap()); + when(dao.getAllIDs(anyString())).thenReturn(List.of(MISSING_ID)); + when(dao.getSubGroups(anyString())).thenReturn(Collections.emptyList()); + when(dao.getGroup(anyInt())).thenReturn(null); + TestDaoInjector.inject(dao); + + NameLayerPlugin instance = mock(NameLayerPlugin.class); + copyPluginMeta(plugin, instance); + when(instance.getSLF4JLogger()).thenReturn(NOPLogger.NOP_LOGGER); + when(instance.getLogger()).thenReturn(java.util.logging.Logger.getLogger("test")); + setStatic("instance", instance); + setStatic("blackList", new BlackList()); + + clearGroupManagerStatics(); + } + + @AfterEach + public void tearDown() throws Exception { + clearGroupManagerStatics(); + MockBukkit.unmock(); + } + + private static void setStatic(String field, Object value) throws Exception { + Field f = NameLayerPlugin.class.getDeclaredField(field); + f.setAccessible(true); + f.set(null, value); + } + + private static void copyPluginMeta(Object from, Object to) throws Exception { + Field meta = org.bukkit.plugin.java.JavaPlugin.class.getDeclaredField("pluginMeta"); + meta.setAccessible(true); + meta.set(to, meta.get(from)); + } + + /** GroupManager's caches are static and leak across tests; reset them for isolation. */ + private static void clearGroupManagerStatics() throws Exception { + for (String field : new String[] {"groupsByName", "groupsById", "missingGroupIds"}) { + Field f = GroupManager.class.getDeclaredField(field); + f.setAccessible(true); + Object value = f.get(null); + if (value instanceof Map map) { + map.clear(); + } else if (value instanceof Collection collection) { + collection.clear(); + } + } + } + + @Test + public void missingIdIsQueriedOnlyOnce() { + assertNull(GroupManager.getGroup(MISSING_ID)); + assertNull(GroupManager.getGroup(MISSING_ID)); + assertNull(GroupManager.getGroup(MISSING_ID)); + + verify(dao, times(1)).getGroup(MISSING_ID); + } + + @Test + public void invalidateCacheRetriesMissingId() { + assertNull(GroupManager.getGroup(MISSING_ID)); + + GroupManager.invalidateCache("somegroup"); + + Group restored = new Group("restored", owner, false, null, MISSING_ID, 0L, "gray"); + when(dao.getGroup(MISSING_ID)).thenReturn(restored); + + assertEquals(restored, GroupManager.getGroup(MISSING_ID)); + verify(dao, times(2)).getGroup(MISSING_ID); + } + + @Test + public void foundGroupIsCachedPositively() { + Group group = new Group("realgroup", owner, false, null, MISSING_ID, 0L, "gray"); + when(dao.getGroup(MISSING_ID)).thenReturn(group); + + assertEquals(group, GroupManager.getGroup(MISSING_ID)); + assertEquals(group, GroupManager.getGroup(MISSING_ID)); + + verify(dao, times(1)).getGroup(MISSING_ID); + } +} diff --git a/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/HasAccessNullArgsTest.java b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/HasAccessNullArgsTest.java new file mode 100644 index 0000000000..2dd02e319e --- /dev/null +++ b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/HasAccessNullArgsTest.java @@ -0,0 +1,113 @@ +package vg.civcraft.mc.namelayer; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.plugin.PluginMock; +import org.slf4j.helpers.NOPLogger; +import vg.civcraft.mc.namelayer.database.GroupManagerDao; +import vg.civcraft.mc.namelayer.group.Group; +import vg.civcraft.mc.namelayer.permission.PermissionType; + +/** + * Regression test for hasAccess null-argument handling. A null group is a normal "deny" (an + * unloaded or deleted group, routinely produced by movement handlers such as Bastion's overlay) + * and must resolve quietly; previously it logged a stack trace every tick. A null perm is a real + * caller bug and is still logged. + */ +public class HasAccessNullArgsTest { + + private ServerMock server; + private PluginMock plugin; + private GroupManagerDao dao; + private GroupManager groupManager; + private final List records = new ArrayList<>(); + + @BeforeEach + public void setUp() throws Exception { + server = MockBukkit.mock(); + plugin = MockBukkit.createMockPlugin(); + + dao = mock(GroupManagerDao.class); + when(dao.getPermissionMapping()).thenReturn(Collections.emptyMap()); + TestDaoInjector.inject(dao); + + Logger logger = Logger.getLogger("hasaccess-null-test"); + logger.setUseParentHandlers(false); + for (Handler old : logger.getHandlers()) { + logger.removeHandler(old); + } + logger.addHandler(new Handler() { + @Override + public void publish(LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }); + + NameLayerPlugin instance = mock(NameLayerPlugin.class); + copyPluginMeta(plugin, instance); + when(instance.getSLF4JLogger()).thenReturn(NOPLogger.NOP_LOGGER); + when(instance.getLogger()).thenReturn(logger); + setStatic("instance", instance); + + PermissionType.initialize(); + + groupManager = new GroupManager(); + } + + @AfterEach + public void tearDown() { + MockBukkit.unmock(); + } + + @Test + public void nullGroupDeniesWithoutLogging() { + PermissionType perm = PermissionType.getPermission("MEMBERS"); + + assertFalse(groupManager.hasAccess((Group) null, UUID.randomUUID(), perm)); + assertTrue(records.isEmpty(), "a null group is a normal deny and must not log"); + } + + @Test + public void nullPermStillLogs() { + Group group = mock(Group.class); + + assertFalse(groupManager.hasAccess(group, UUID.randomUUID(), null)); + assertFalse(records.isEmpty(), "a null perm is a caller bug and must still be logged"); + } + + private static void setStatic(String field, Object value) throws Exception { + Field f = NameLayerPlugin.class.getDeclaredField(field); + f.setAccessible(true); + f.set(null, value); + } + + private static void copyPluginMeta(Object from, Object to) throws Exception { + Field meta = org.bukkit.plugin.java.JavaPlugin.class.getDeclaredField("pluginMeta"); + meta.setAccessible(true); + meta.set(to, meta.get(from)); + } +} diff --git a/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/PlayerTypeTest.java b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/PlayerTypeTest.java new file mode 100644 index 0000000000..95d9794507 --- /dev/null +++ b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/PlayerTypeTest.java @@ -0,0 +1,50 @@ +package vg.civcraft.mc.namelayer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; +import vg.civcraft.mc.namelayer.GroupManager.PlayerType; + +/** Characterization tests for the pure PlayerType id/name mappings. */ +public class PlayerTypeTest { + + @Test + public void idRoundTrips() { + for (PlayerType type : PlayerType.values()) { + assertEquals(type, PlayerType.getByID(PlayerType.getID(type))); + } + } + + @Test + public void knownIdMapping() { + assertEquals(0, PlayerType.getID(PlayerType.NOT_BLACKLISTED)); + assertEquals(1, PlayerType.getID(PlayerType.MEMBERS)); + assertEquals(2, PlayerType.getID(PlayerType.MODS)); + assertEquals(3, PlayerType.getID(PlayerType.ADMINS)); + assertEquals(4, PlayerType.getID(PlayerType.OWNER)); + assertEquals(-1, PlayerType.getID(null)); + } + + @Test + public void unknownIdIsNull() { + assertNull(PlayerType.getByID(99)); + assertNull(PlayerType.getByID(-1)); + } + + @Test + public void byNameIsCaseInsensitive() { + assertEquals(PlayerType.OWNER, PlayerType.getPlayerType("owner")); + assertEquals(PlayerType.OWNER, PlayerType.getPlayerType("OWNER")); + assertNull(PlayerType.getPlayerType("nope")); + } + + @Test + public void niceRankNames() { + assertEquals("Member", PlayerType.getNiceRankName(PlayerType.MEMBERS)); + assertEquals("Mod", PlayerType.getNiceRankName(PlayerType.MODS)); + assertEquals("Admin", PlayerType.getNiceRankName(PlayerType.ADMINS)); + assertEquals("Owner", PlayerType.getNiceRankName(PlayerType.OWNER)); + assertEquals("RANK_ERROR", PlayerType.getNiceRankName(null)); + } +} diff --git a/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/TestDaoInjector.java b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/TestDaoInjector.java new file mode 100644 index 0000000000..a83ba3cdd8 --- /dev/null +++ b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/TestDaoInjector.java @@ -0,0 +1,31 @@ +package vg.civcraft.mc.namelayer; + +import java.lang.reflect.Field; +import vg.civcraft.mc.namelayer.database.GroupManagerDao; +import vg.civcraft.mc.namelayer.group.Group; + +/** + * namelayer holds its DAO in private static fields populated from NameLayerPlugin during plugin + * enable. Tests never run a real plugin, so we set those fields directly with a mock. + */ +public final class TestDaoInjector { + + private TestDaoInjector() { + } + + public static void inject(GroupManagerDao dao) { + setStatic(Group.class, "db", dao); + setStatic(GroupManager.class, "groupManagerDao", dao); + setStatic(NameLayerPlugin.class, "groupManagerDao", dao); + } + + private static void setStatic(Class owner, String field, Object value) { + try { + Field f = owner.getDeclaredField(field); + f.setAccessible(true); + f.set(null, value); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException("Failed to inject test DAO into " + owner.getSimpleName() + "." + field, e); + } + } +} diff --git a/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/database/GetDefaultGroupTest.java b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/database/GetDefaultGroupTest.java new file mode 100644 index 0000000000..92bd77eb4c --- /dev/null +++ b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/database/GetDefaultGroupTest.java @@ -0,0 +1,57 @@ +package vg.civcraft.mc.namelayer.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.UUID; +import java.util.logging.Logger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import vg.civcraft.mc.civmodcore.dao.ManagedDatasource; + +/** + * Regression tests for GroupManagerDao.getDefaultGroup. The empty-result case used to read the + * ResultSet without calling next(); it must guard with next() and return null when there is no row. + */ +public class GetDefaultGroupTest { + + private final UUID uuid = UUID.fromString("00000000-0000-0000-0000-0000000000cc"); + + private ManagedDatasource db; + private ResultSet resultSet; + private GroupManagerDao dao; + + @BeforeEach + public void setUp() throws Exception { + db = mock(ManagedDatasource.class); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + resultSet = mock(ResultSet.class); + + when(db.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeQuery()).thenReturn(resultSet); + + dao = new GroupManagerDao(Logger.getLogger("test"), db); + } + + @Test + public void emptyResultReturnsNull() throws Exception { + when(resultSet.next()).thenReturn(false); + assertNull(dao.getDefaultGroup(uuid)); + } + + @Test + public void presentRowReturnsGroupName() throws Exception { + when(resultSet.next()).thenReturn(true); + when(resultSet.getString(anyInt())).thenReturn("somegroup"); + assertEquals("somegroup", dao.getDefaultGroup(uuid)); + } +} diff --git a/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/group/GroupColorTest.java b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/group/GroupColorTest.java new file mode 100644 index 0000000000..7262afeebe --- /dev/null +++ b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/group/GroupColorTest.java @@ -0,0 +1,63 @@ +package vg.civcraft.mc.namelayer.group; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import vg.civcraft.mc.namelayer.TestDaoInjector; +import vg.civcraft.mc.namelayer.database.GroupManagerDao; + +/** + * Characterization tests for the group color parsing in the Group constructor (Group.java ~82-86): + * try the NamedTextColor registry first (lowercase names), then fall back to a hex string parse, + * else null. + */ +public class GroupColorTest { + + private final UUID owner = UUID.fromString("00000000-0000-0000-0000-0000000000aa"); + + @BeforeEach + public void setUp() { + GroupManagerDao dao = mock(GroupManagerDao.class); + when(dao.getAllMembers(anyString())).thenReturn(Collections.emptyMap()); + when(dao.getAllIDs(anyString())).thenReturn(List.of(1)); + when(dao.getSubGroups(anyString())).thenReturn(Collections.emptyList()); + TestDaoInjector.inject(dao); + } + + private TextColor colorOf(String input) { + return new Group("colorgroup", owner, false, null, 1, 0L, input).getGroupColor(); + } + + @Test + public void namedColorLowercaseResolves() { + assertEquals(NamedTextColor.RED, colorOf("red")); + assertEquals(NamedTextColor.DARK_PURPLE, colorOf("dark_purple")); + } + + @Test + public void hexStringResolves() { + assertEquals(TextColor.fromHexString("#ABCDEF"), colorOf("#ABCDEF")); + } + + @Test + public void uppercaseNamedColorIsNotFoundInRegistry() { + // NamedTextColor.NAMES uses lowercase keys, so "RED" misses the registry and is not a hex + // string either, yielding null. Pin this current (arguably surprising) behavior. + assertNull(colorOf("RED")); + } + + @Test + public void unknownColorIsNull() { + assertNull(colorOf("not_a_color")); + } +} diff --git a/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/group/GroupMembersTest.java b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/group/GroupMembersTest.java new file mode 100644 index 0000000000..16b6b43100 --- /dev/null +++ b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/group/GroupMembersTest.java @@ -0,0 +1,111 @@ +package vg.civcraft.mc.namelayer.group; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import vg.civcraft.mc.namelayer.GroupManager.PlayerType; +import vg.civcraft.mc.namelayer.TestDaoInjector; +import vg.civcraft.mc.namelayer.database.GroupManagerDao; + +/** + * Characterization tests pinning how the Group constructor turns per-PlayerType member lists from + * the DAO into the players map. This is the safety net for a later refactor of that loop. + */ +public class GroupMembersTest { + + private GroupManagerDao dao; + + private final UUID ownerA = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private final UUID ownerB = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private final UUID admin = UUID.fromString("00000000-0000-0000-0000-000000000003"); + private final UUID mod = UUID.fromString("00000000-0000-0000-0000-000000000004"); + private final UUID member1 = UUID.fromString("00000000-0000-0000-0000-000000000005"); + private final UUID member2 = UUID.fromString("00000000-0000-0000-0000-000000000006"); + + @BeforeEach + public void setUp() { + dao = mock(GroupManagerDao.class); + when(dao.getAllMembers(anyString())).thenReturn(Collections.emptyMap()); + when(dao.getAllIDs(anyString())).thenReturn(List.of(1)); + when(dao.getSubGroups(anyString())).thenReturn(Collections.emptyList()); + TestDaoInjector.inject(dao); + } + + private Group newGroup(String color) { + return new Group("testgroup", ownerA, false, null, 1, 0L, color); + } + + @Test + public void playersMapMatchesDaoMembersPerType() { + Map daoMembers = new HashMap<>(); + daoMembers.put(ownerA, PlayerType.OWNER); + daoMembers.put(ownerB, PlayerType.OWNER); + daoMembers.put(admin, PlayerType.ADMINS); + daoMembers.put(mod, PlayerType.MODS); + daoMembers.put(member1, PlayerType.MEMBERS); + daoMembers.put(member2, PlayerType.MEMBERS); + when(dao.getAllMembers("testgroup")).thenReturn(daoMembers); + + Group group = newGroup("RED"); + + Map expected = new HashMap<>(); + expected.put(ownerA, PlayerType.OWNER); + expected.put(ownerB, PlayerType.OWNER); + expected.put(admin, PlayerType.ADMINS); + expected.put(mod, PlayerType.MODS); + expected.put(member1, PlayerType.MEMBERS); + expected.put(member2, PlayerType.MEMBERS); + + Map actual = new HashMap<>(); + for (UUID uuid : group.getAllMembers()) { + actual.put(uuid, group.getPlayerType(uuid)); + } + assertEquals(expected, actual); + } + + @Test + public void getAllMembersByTypeReturnsOnlyThatType() { + Map daoMembers = new HashMap<>(); + daoMembers.put(member1, PlayerType.MEMBERS); + daoMembers.put(member2, PlayerType.MEMBERS); + daoMembers.put(mod, PlayerType.MODS); + when(dao.getAllMembers("testgroup")).thenReturn(daoMembers); + + Group group = newGroup("BLUE"); + + assertEquals(Set.of(member1, member2), new HashSet<>(group.getAllMembers(PlayerType.MEMBERS))); + assertEquals(List.of(mod), group.getAllMembers(PlayerType.MODS)); + assertEquals(List.of(), group.getAllMembers(PlayerType.OWNER)); + } + + @Test + public void laterTypeOverwritesEarlierTypeForSameUuid() { + // A uuid listed under multiple roles resolves to the highest-precedence role (the same + // winning role the old per-type ctor loop produced via last put in values() order). The DAO + // collapses multi-role rows into one entry per uuid, so we pin the post-collapse result. + Map daoMembers = new HashMap<>(); + daoMembers.put(member1, PlayerType.OWNER); + when(dao.getAllMembers("testgroup")).thenReturn(daoMembers); + + Group group = newGroup("GREEN"); + + assertEquals(PlayerType.OWNER, group.getPlayerType(member1)); + } + + @Test + public void emptyMembersGivesEmptyPlayersMap() { + Group group = newGroup("WHITE"); + assertEquals(List.of(), group.getAllMembers()); + } +} diff --git a/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/group/GroupPasswordTest.java b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/group/GroupPasswordTest.java new file mode 100644 index 0000000000..92815709b3 --- /dev/null +++ b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/group/GroupPasswordTest.java @@ -0,0 +1,67 @@ +package vg.civcraft.mc.namelayer.group; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import vg.civcraft.mc.namelayer.TestDaoInjector; +import vg.civcraft.mc.namelayer.database.GroupManagerDao; + +/** + * Regression tests for Group.isPassword. A group with no password used to NPE because it called + * equals on a null field; it must return false instead. + */ +public class GroupPasswordTest { + + private final UUID owner = UUID.fromString("00000000-0000-0000-0000-0000000000bb"); + + @BeforeEach + public void setUp() { + GroupManagerDao dao = mock(GroupManagerDao.class); + when(dao.getAllMembers(anyString())).thenReturn(Collections.emptyMap()); + when(dao.getAllIDs(anyString())).thenReturn(List.of(1)); + when(dao.getSubGroups(anyString())).thenReturn(Collections.emptyList()); + TestDaoInjector.inject(dao); + } + + private Group groupWithPassword(String password) { + return new Group("pwgroup", owner, false, password, 1, 0L, "red"); + } + + @Test + public void noPasswordAnyGuessIsFalse() { + Group group = groupWithPassword(null); + assertFalse(group.isPassword("anything")); + } + + @Test + public void noPasswordNullGuessIsTrue() { + Group group = groupWithPassword(null); + assertTrue(group.isPassword(null)); + } + + @Test + public void matchingPasswordIsTrue() { + Group group = groupWithPassword("hunter2"); + assertTrue(group.isPassword("hunter2")); + } + + @Test + public void wrongPasswordIsFalse() { + Group group = groupWithPassword("hunter2"); + assertFalse(group.isPassword("nope")); + } + + @Test + public void passwordSetNullGuessIsFalse() { + Group group = groupWithPassword("hunter2"); + assertFalse(group.isPassword(null)); + } +} diff --git a/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/permission/PermissionLogicTest.java b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/permission/PermissionLogicTest.java new file mode 100644 index 0000000000..3c9911d966 --- /dev/null +++ b/plugins/namelayer-paper/src/test/java/vg/civcraft/mc/namelayer/permission/PermissionLogicTest.java @@ -0,0 +1,113 @@ +package vg.civcraft.mc.namelayer.permission; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import vg.civcraft.mc.namelayer.GroupManager.PlayerType; +import vg.civcraft.mc.namelayer.TestDaoInjector; +import vg.civcraft.mc.namelayer.database.GroupManagerDao; +import vg.civcraft.mc.namelayer.group.Group; + +/** + * Characterization tests for the pure permission logic: the registered NameLayer PermissionTypes, + * PermissionType.isOwnerPermission, and the GroupPermission add/remove/has/getFirst logic. + * No real database; the DAO is mocked, which is enough for PermissionType.initialize and + * GroupPermission to load. + */ +public class PermissionLogicTest { + + private static GroupManagerDao dao; + + @BeforeAll + public static void initPermissions() { + dao = mock(GroupManagerDao.class); + when(dao.getPermissionMapping()).thenReturn(Collections.emptyMap()); + TestDaoInjector.inject(dao); + PermissionType.initialize(); + } + + @BeforeEach + public void reinject() { + TestDaoInjector.inject(dao); + } + + @Test + public void nameLayerPermissionsAreRegistered() { + assertEquals("MEMBERS", PermissionType.getPermission("MEMBERS").getName()); + assertEquals("OWNER", PermissionType.getPermission("OWNER").getName()); + assertEquals("DELETE", PermissionType.getPermission("DELETE").getName()); + assertNull(PermissionType.getPermission("NOT_A_REAL_PERM")); + } + + @Test + public void isOwnerPermissionMatchesDefaultLevels() { + // These perms default to {OWNER} only, so isOwnerPermission is true. + assertTrue(PermissionType.getPermission("OWNER").isOwnerPermission()); + assertTrue(PermissionType.getPermission("DELETE").isOwnerPermission()); + assertTrue(PermissionType.getPermission("PERMS").isOwnerPermission()); + assertTrue(PermissionType.getPermission("ADMINS").isOwnerPermission()); + + // MEMBERS defaults to {MODS, ADMINS, OWNER}; OPEN_GUI defaults to all four types. + assertFalse(PermissionType.getPermission("MEMBERS").isOwnerPermission()); + assertFalse(PermissionType.getPermission("OPEN_GUI").isOwnerPermission()); + } + + private GroupPermission groupPermissionWith(Map> stored) { + Group group = mock(Group.class); + when(group.getName()).thenReturn("permgroup"); + when(dao.getPermissions("permgroup")).thenReturn(new HashMap<>(stored)); + return new GroupPermission(group); + } + + @Test + public void hasPermissionReflectsStoredPerms() { + PermissionType delete = PermissionType.getPermission("DELETE"); + Map> stored = new HashMap<>(); + stored.put(PlayerType.OWNER, List.of(delete)); + GroupPermission gp = groupPermissionWith(stored); + + assertTrue(gp.hasPermission(PlayerType.OWNER, delete)); + assertFalse(gp.hasPermission(PlayerType.MEMBERS, delete)); + assertFalse(gp.hasPermission(null, delete)); + assertFalse(gp.hasPermission(PlayerType.OWNER, null)); + } + + @Test + public void addAndRemovePermissionInMemory() { + PermissionType perms = PermissionType.getPermission("PERMS"); + GroupPermission gp = groupPermissionWith(new HashMap<>()); + + assertFalse(gp.hasPermission(PlayerType.OWNER, perms)); + assertTrue(gp.addPermission(PlayerType.OWNER, perms, false)); + assertTrue(gp.hasPermission(PlayerType.OWNER, perms)); + // Adding again is a no-op returning false. + assertFalse(gp.addPermission(PlayerType.OWNER, perms, false)); + + assertTrue(gp.removePermission(PlayerType.OWNER, perms, false)); + assertFalse(gp.hasPermission(PlayerType.OWNER, perms)); + // Removing something not present returns false. + assertFalse(gp.removePermission(PlayerType.OWNER, perms, false)); + } + + @Test + public void getFirstWithPermFindsAnyPlayerTypeHolding() { + PermissionType members = PermissionType.getPermission("MEMBERS"); + Map> stored = new HashMap<>(); + stored.put(PlayerType.MODS, List.of(members)); + GroupPermission gp = groupPermissionWith(stored); + + assertEquals(PlayerType.MODS, gp.getFirstWithPerm(members)); + assertNull(gp.getFirstWithPerm(PermissionType.getPermission("DELETE"))); + } +} diff --git a/plugins/namelayer-paper/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/plugins/namelayer-paper/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..fdbd0b1579 --- /dev/null +++ b/plugins/namelayer-paper/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass diff --git a/plugins/railswitch-paper/build.gradle.kts b/plugins/railswitch-paper/build.gradle.kts index 699fbacf87..2e5753ad94 100644 --- a/plugins/railswitch-paper/build.gradle.kts +++ b/plugins/railswitch-paper/build.gradle.kts @@ -12,4 +12,7 @@ dependencies { compileOnly(project(":plugins:civmodcore-paper")) compileOnly(project(":plugins:namelayer-paper")) compileOnly(project(":plugins:citadel-paper")) + + testImplementation(libs.bundles.junit) + testImplementation(project(":plugins:civmodcore-paper")) } diff --git a/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/RailSwitchPlugin.java b/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/RailSwitchPlugin.java index d3028dcf08..47c704fe92 100644 --- a/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/RailSwitchPlugin.java +++ b/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/RailSwitchPlugin.java @@ -3,6 +3,7 @@ import org.bukkit.event.Listener; import sh.okx.railswitch.commands.DestinationCommand; import sh.okx.railswitch.glue.CitadelGlue; +import sh.okx.railswitch.settings.DestinationDisplayListener; import sh.okx.railswitch.settings.SettingsManager; import sh.okx.railswitch.switches.SwitchListener; import vg.civcraft.mc.civmodcore.ACivMod; @@ -23,6 +24,7 @@ public void onEnable() { registerListener(new CitadelGlue(this)); registerListener(new SwitchListener()); registerListener(new DestSignListener()); + registerListener(new DestinationDisplayListener()); commandManager = new CommandManager(this); commandManager.init(); commandManager.registerCommand(new DestinationCommand()); diff --git a/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/settings/DestinationDisplayListener.java b/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/settings/DestinationDisplayListener.java new file mode 100644 index 0000000000..5d99ae49ad --- /dev/null +++ b/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/settings/DestinationDisplayListener.java @@ -0,0 +1,18 @@ +package sh.okx.railswitch.settings; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +/** + * Restores a player's destination sidebar line when they log in. + */ +public final class DestinationDisplayListener implements Listener { + + // NORMAL runs after civmodcore's ScoreBoardListener resets the scoreboard at LOWEST on join. + @EventHandler(priority = EventPriority.NORMAL) + public void onJoin(PlayerJoinEvent event) { + SettingsManager.restoreDestinationDisplay(event.getPlayer()); + } +} diff --git a/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/settings/DestinationScoreboard.java b/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/settings/DestinationScoreboard.java new file mode 100644 index 0000000000..4df1511989 --- /dev/null +++ b/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/settings/DestinationScoreboard.java @@ -0,0 +1,54 @@ +package sh.okx.railswitch.settings; + +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; +import vg.civcraft.mc.civmodcore.players.scoreboard.side.CivScoreBoard; +import vg.civcraft.mc.civmodcore.players.scoreboard.side.ScoreBoardAPI; + +/** + * Shows a player's rail destination on the civmodcore sidebar scoreboard. + */ +public final class DestinationScoreboard { + + private static final String BOARD_KEY = "railSwitchDest"; + + // CivScoreBoard truncates the displayed line at 40 chars but caches the full string, + // so an over-long line leaves a stale entry that never clears. The "/dest: " label plus + // colour codes is 11 chars, leaving 29 for the value. + private static final int MAX_DESTINATION_LENGTH = 29; + + private final CivScoreBoard board; + + public DestinationScoreboard() { + this.board = ScoreBoardAPI.createBoard(BOARD_KEY); + } + + /** + * Shows the destination line for the player, or hides it when there is no destination. + */ + public void update(Player player, String destination) { + if (player == null) { + return; + } + String line = render(destination); + if (line == null) { + board.hide(player); + } else { + board.set(player, line); + } + } + + public void delete() { + ScoreBoardAPI.deleteBoard(board); + } + + static String render(String destination) { + if (destination == null || destination.isBlank()) { + return null; + } + String value = destination.length() > MAX_DESTINATION_LENGTH + ? destination.substring(0, MAX_DESTINATION_LENGTH) + : destination; + return ChatColor.GOLD + "/dest: " + ChatColor.LIGHT_PURPLE + value; + } +} diff --git a/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/settings/SettingsManager.java b/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/settings/SettingsManager.java index cd841c67bf..9fa6865583 100644 --- a/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/settings/SettingsManager.java +++ b/plugins/railswitch-paper/src/main/java/sh/okx/railswitch/settings/SettingsManager.java @@ -2,6 +2,7 @@ import com.google.common.base.Preconditions; import com.google.common.base.Strings; +import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.entity.Player; import sh.okx.railswitch.RailSwitchPlugin; @@ -17,6 +18,8 @@ public final class SettingsManager { private static ResetSetting resetSetting; + private static DestinationScoreboard scoreboard; + /** * Initialise the settings manager. This should only be called within RailSwitch onEnable(). * @@ -31,6 +34,15 @@ public static void init(RailSwitchPlugin plugin) { menu.registerToParentMenu(); menu.registerSetting(destSetting); menu.registerSetting(resetSetting); + + // Mirror the destination onto the sidebar scoreboard whenever it changes. + // setValue() fires listeners BEFORE storing the new value, so use newValue here, not getDestination(). + scoreboard = new DestinationScoreboard(); + destSetting.registerListener((uuid, setting, oldValue, newValue) -> { + if (scoreboard != null) { + scoreboard.update(Bukkit.getPlayer(uuid), newValue); + } + }); } /** @@ -38,6 +50,10 @@ public static void init(RailSwitchPlugin plugin) { */ public static void reset() { // TODO: Deregister and unload all the menu elements once PlayerSettingAPI becomes reload safe + if (scoreboard != null) { + scoreboard.delete(); + scoreboard = null; + } menu = null; destSetting = null; resetSetting = null; @@ -82,4 +98,16 @@ public static String getDestination(Player player) { return value; } + /** + * Restores the player's destination line on the sidebar, e.g. when they log in. + * Reads the stored value directly because player settings are already loaded at join time. + * + * @param player The player whose destination line should be refreshed. + */ + public static void restoreDestinationDisplay(Player player) { + if (scoreboard != null) { + scoreboard.update(player, getDestination(player)); + } + } + } diff --git a/plugins/railswitch-paper/src/test/java/sh/okx/railswitch/settings/DestinationScoreboardTest.java b/plugins/railswitch-paper/src/test/java/sh/okx/railswitch/settings/DestinationScoreboardTest.java new file mode 100644 index 0000000000..0183b03d25 --- /dev/null +++ b/plugins/railswitch-paper/src/test/java/sh/okx/railswitch/settings/DestinationScoreboardTest.java @@ -0,0 +1,37 @@ +package sh.okx.railswitch.settings; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.bukkit.ChatColor; +import org.junit.jupiter.api.Test; + +class DestinationScoreboardTest { + + @Test + void render_nullDestination_returnsNull() { + assertNull(DestinationScoreboard.render(null)); + } + + @Test + void render_emptyDestination_returnsNull() { + assertNull(DestinationScoreboard.render("")); + } + + @Test + void render_blankDestination_returnsNull() { + assertNull(DestinationScoreboard.render(" ")); + } + + @Test + void render_value_returnsLabelledColouredLine() { + String expected = ChatColor.GOLD + "/dest: " + ChatColor.LIGHT_PURPLE + "Spawn"; + assertEquals(expected, DestinationScoreboard.render("Spawn")); + } + + @Test + void render_longValue_isCappedToFitScoreboardLine() { + String line = DestinationScoreboard.render("A".repeat(60)); + assertEquals(ChatColor.GOLD + "/dest: " + ChatColor.LIGHT_PURPLE + "A".repeat(29), line); + } +} diff --git a/plugins/realisticbiomes-paper/src/main/java/com/untamedears/realisticbiomes/growth/TreeGrower.java b/plugins/realisticbiomes-paper/src/main/java/com/untamedears/realisticbiomes/growth/TreeGrower.java index 85ab8cbaf3..a71f08cbef 100644 --- a/plugins/realisticbiomes-paper/src/main/java/com/untamedears/realisticbiomes/growth/TreeGrower.java +++ b/plugins/realisticbiomes-paper/src/main/java/com/untamedears/realisticbiomes/growth/TreeGrower.java @@ -67,8 +67,8 @@ private static boolean canGrowBig(Block block, Material mat) { */ private static Block findNWSapling(Block block, Material mat) { Block northwest = null; - for (Block nwCandidate : new Block[]{block, block.getRelative(1, 0, 1), block.getRelative(0, 0, 1), - block.getRelative(1, 0, 0)}) { + for (Block nwCandidate : new Block[]{block.getRelative(-1, 0, -1), block.getRelative(0, 0, -1), + block.getRelative(-1, 0, 0), block}) { if (adjacentSaplingCheck(mat, nwCandidate)) { northwest = nwCandidate; break; @@ -83,30 +83,22 @@ private static Block findNWSapling(Block block, Material mat) { private static void removeSapling(Block block) { PlantManager manager = RealisticBiomes.getInstance().getPlantManager(); Plant plant = manager.getPlant(block); - if (plant == null) { - return; + if (plant != null) { + manager.deletePlant(plant); } - manager.deletePlant(plant); block.setType(Material.AIR); } /** - * Remove a 2x2 saplings grid if the block is part of one + * Remove a 2x2 saplings grid given its north west block * - * @param block to check for - * @param mat Sapling material + * @param northwest the north west block of the 2x2 grid + * @param mat Sapling material */ - private static void clearBigTreeSaplings(Block block, Material mat) { - Block northwest = null; - Block northeast, southwest, southeast; - northwest = findNWSapling(block, mat); - if (northwest == null) { - return; - } - - northeast = northwest.getRelative(BlockFace.EAST); - southwest = northwest.getRelative(BlockFace.SOUTH); - southeast = northeast.getRelative(BlockFace.SOUTH); + private static void clearBigTreeSaplings(Block northwest, Material mat) { + Block northeast = northwest.getRelative(BlockFace.EAST); + Block southwest = northwest.getRelative(BlockFace.SOUTH); + Block southeast = northeast.getRelative(BlockFace.SOUTH); removeSapling(northwest); removeSapling(northeast); @@ -169,21 +161,35 @@ public boolean setStage(Plant plant, int stage) { } Material mat = block.getType(); boolean canBeBig = canBeBig(mat); + Block northwest = null; if (canBeBig) { canBeBig = canGrowBig(block, mat); + if (canBeBig) { + northwest = findNWSapling(block, mat); + } } TreeType type = remapSaplingToTree(mat, canBeBig); if (type == null) { return true; } - if (canBeBig) { - clearBigTreeSaplings(block, mat); + if (canBeBig && northwest != null) { + clearBigTreeSaplings(northwest, mat); + if (!northwest.getLocation().getWorld().generateTree(northwest.getLocation(), type)) { + //failed, so restore all 4 saplings + Block northeast = northwest.getRelative(BlockFace.EAST); + Block southwest = northwest.getRelative(BlockFace.SOUTH); + Block southeast = northeast.getRelative(BlockFace.SOUTH); + northwest.setType(mat); + northeast.setType(mat); + southwest.setType(mat); + southeast.setType(mat); + } } else { block.setType(Material.AIR); - } - if (!block.getLocation().getWorld().generateTree(block.getLocation(), type)) { - //failed, so restore sapling, TODO restore 2x2 - block.setType(mat); + if (!block.getLocation().getWorld().generateTree(block.getLocation(), type)) { + //failed, so restore sapling + block.setType(mat); + } } return true; }