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/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/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/src/main/java/com/github/igotyou/FactoryMod/ConfigParser.java b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/ConfigParser.java index db647f3c8d..d94c8efc96 100644 --- a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/ConfigParser.java +++ b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/ConfigParser.java @@ -5,7 +5,6 @@ import com.github.igotyou.FactoryMod.eggs.PipeEgg; import com.github.igotyou.FactoryMod.eggs.SorterEgg; import com.github.igotyou.FactoryMod.listeners.NetherPortalListener; -import com.github.igotyou.FactoryMod.recipes.AOERepairRecipe; import com.github.igotyou.FactoryMod.recipes.CompactingRecipe; import com.github.igotyou.FactoryMod.recipes.DecompactingRecipe; import com.github.igotyou.FactoryMod.recipes.DeterministicEnchantingRecipe; @@ -649,21 +648,6 @@ private IRecipe parseRecipe(ConfigurationSection config) { result = new Upgraderecipe(identifier, name, productionTime, input, (FurnCraftChestEgg) egg); } break; - case "AOEREPAIR": - // This is untested and should not be used for now - plugin.warning( - "This recipe is not tested or even completly developed, use it with great care and don't expect it to work"); - ItemMap tessence = ConfigHelper.parseItemMap(config.getConfigurationSection("essence")); - if (tessence.getTotalUniqueItemAmount() > 0) { - ItemStack essence = tessence.getItemStackRepresentation().get(0); - int repPerEssence = config.getInt("repair_per_essence"); - int range = config.getInt("range"); - result = new AOERepairRecipe(identifier, name, productionTime, essence, range, repPerEssence); - } else { - plugin.severe("No essence specified for AOEREPAIR " + config.getCurrentPath()); - result = null; - } - break; case "PYLON": ConfigurationSection outputSec = config.getConfigurationSection("output"); ItemMap outputMap; 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..b0ecfe1e9e 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()) { diff --git a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/AOERepairRecipe.java b/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/AOERepairRecipe.java deleted file mode 100644 index bdfbc8d36f..0000000000 --- a/plugins/factorymod-paper/src/main/java/com/github/igotyou/FactoryMod/recipes/AOERepairRecipe.java +++ /dev/null @@ -1,188 +0,0 @@ -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.repairManager.PercentageHealthRepairManager; -import java.util.Arrays; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; - -import org.bukkit.ChatColor; -import org.bukkit.Location; -import org.bukkit.Material; -import org.bukkit.block.Chest; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.InventoryHolder; -import org.bukkit.inventory.ItemStack; -import vg.civcraft.mc.civmodcore.inventory.items.ItemMap; -import vg.civcraft.mc.civmodcore.inventory.items.ItemUtils; - -public class AOERepairRecipe extends InputRecipe { - - private ItemStack essence; - private int repairPerEssence; - private int range; - - public AOERepairRecipe(String identifier, String name, int productionTime, ItemStack essence, - int range, int repairPerEssence) { - super(identifier, name, productionTime, new ItemMap(essence)); - this.essence = essence; - this.range = range; - this.repairPerEssence = repairPerEssence; - } - - @Override - public Material getRecipeRepresentationMaterial() { - return essence.getType(); - } - - @Override - public List getInputRepresentation(Inventory i, FurnCraftChestFactory fccf) { - Chest c = (Chest) i.getHolder(); - Location loc = c.getLocation(); - List facs = getNearbyFactoriesSortedByDistance(loc); - int facCounter = 0; - int essenceCount = new ItemMap(i).getAmount(essence); - for (FurnCraftChestFactory fac : facs) { - PercentageHealthRepairManager rm = (PercentageHealthRepairManager) fac - .getRepairManager(); - int diff = 100 - rm.getRawHealth(); - if (diff >= repairPerEssence) { - essenceCount -= Math.min(essenceCount, diff / repairPerEssence); - facCounter++; - } - if (essenceCount <= 0) { - break; - } - } - ItemMap imp = new ItemMap(); - imp.addItemAmount(essence, new ItemMap(i).getAmount(essence) - - essenceCount); - List bla = imp.getItemStackRepresentation(); - for (ItemStack item : bla) { - item.setAmount(new ItemMap(i).getAmount(essence) - essenceCount); - ItemUtils.addLore(item, ChatColor.YELLOW + "Will repair " - + facCounter + " nearby factories total"); - } - return bla; - } - - private List getNearbyFactoriesSortedByDistance( - Location loc) { - LinkedList list = new LinkedList<>(); - Map distances = new HashMap<>(); - for (Factory f : FactoryMod.getInstance().getManager().getNearbyFactories(loc, range)) { - if (f instanceof FurnCraftChestFactory) { - double dist = f.getMultiBlockStructure().getCenter() - .distance(loc); - distances.put((FurnCraftChestFactory) f, dist); - if (list.isEmpty()) { - list.add((FurnCraftChestFactory) f); - } else { - for (int j = 0; j < list.size(); j++) { - if (distances.get(list.get(j)) > dist) { - list.add(j, (FurnCraftChestFactory) f); - break; - } - if (j == list.size() - 1) { - list.add(j, (FurnCraftChestFactory) f); - break; - } - } - } - } - } - return list; - } - - @Override - public List getOutputRepresentation(Inventory i, FurnCraftChestFactory fccf) { - Chest c = (Chest) i.getHolder(); - Location loc = c.getLocation(); - List facs = getNearbyFactoriesSortedByDistance(loc); - ItemStack is = new ItemStack(Material.CRAFTING_TABLE); - int essenceCount = new ItemMap(i).getAmount(essence); - for (FurnCraftChestFactory fac : facs) { - PercentageHealthRepairManager rm = (PercentageHealthRepairManager) fac - .getRepairManager(); - int diff = 100 - rm.getRawHealth(); - if (diff >= repairPerEssence) { - ItemUtils.addLore( - is, - ChatColor.LIGHT_PURPLE - + "Will repair " - + fac.getName() - + " to " - + Math.min( - 100, - rm.getRawHealth() - + (repairPerEssence * Math - .min(essenceCount, - diff - / repairPerEssence)))); - essenceCount -= Math.min(essenceCount, diff / repairPerEssence); - } - if (essenceCount <= 0) { - break; - } - } - List bla = new LinkedList<>(); - bla.add(is); - return bla; - } - - @Override - public boolean applyEffect(Inventory inputInv, Inventory outputInv, FurnCraftChestFactory fccf) { - Location loc = fccf.getChest().getLocation(); - List facs = getNearbyFactoriesSortedByDistance(loc); - int essenceCount = new ItemMap(inputInv).getAmount(essence); - for (FurnCraftChestFactory fac : facs) { - PercentageHealthRepairManager rm = (PercentageHealthRepairManager) fac - .getRepairManager(); - int diff = 100 - rm.getRawHealth(); - fac.getMultiBlockStructure().recheckComplete(); - if (diff >= repairPerEssence - && fac.getMultiBlockStructure().isComplete() - && !fac.isActive() - && fac.getPowerManager().powerAvailable(1)) { - int rem = Math.min(essenceCount, diff / repairPerEssence); - ItemStack remStack = essence.clone(); - remStack.setAmount(rem); - ItemMap remMap = new ItemMap(remStack); - Inventory targetInv = ((InventoryHolder) (fac.getChest() - .getState())).getInventory(); - if (remMap.fitsIn(targetInv)) { - if (remMap.removeSafelyFrom(inputInv)) { - targetInv.addItem(remStack); - for (IRecipe rec : fac.getRecipes()) { - if (rec instanceof RepairRecipe) { - fac.setRecipe(rec); - break; - } - } - fac.attemptToActivate(null, false); - break; - } - } - } - if (essenceCount <= 0) { - break; - } - } - return true; - } - - @Override - public String getTypeIdentifier() { - return "AOEREPAIR"; - } - - @Override - public List getTextualOutputRepresentation(Inventory i, FurnCraftChestFactory fccf) { - return Arrays.asList("TODO"); - } - -} 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/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; }