diff --git a/common/src/main/java/net/theevilreaper/bounce/common/adapter/PushDataAdapter.java b/common/src/main/java/net/theevilreaper/bounce/common/adapter/PushDataAdapter.java index ac9e224b..b0f74038 100644 --- a/common/src/main/java/net/theevilreaper/bounce/common/adapter/PushDataAdapter.java +++ b/common/src/main/java/net/theevilreaper/bounce/common/adapter/PushDataAdapter.java @@ -18,11 +18,14 @@ * Serializer and Deserializer implementation for {@link PushData} object. * * @author theEvilReaper - * @version 1.0.0 + * @version 1.1.0 * @since 1.0.0 */ public class PushDataAdapter implements JsonDeserializer, JsonSerializer { + private static final double DEFAULT_GROUND_WEIGHT = 1.0; + private static final double DEFAULT_PUSH_WEIGHT = 0.05; + @Override public PushData deserialize(JsonElement element, Type type, JsonDeserializationContext context) { JsonArray jsonArray = element.getAsJsonArray(); @@ -33,15 +36,19 @@ public PushData deserialize(JsonElement element, Type type, JsonDeserializationC } for (JsonElement jsonElement : jsonArray.asList()) { - Key blockKey = context.deserialize(jsonElement.getAsJsonObject().get("block"), Key.class); - int value = jsonElement.getAsJsonObject().get("value").getAsInt(); - boolean ground = jsonElement.getAsJsonObject().get("ground").getAsBoolean(); + JsonObject jsonObject = jsonElement.getAsJsonObject(); + Key blockKey = context.deserialize(jsonObject.get("block"), Key.class); + int value = jsonObject.get("value").getAsInt(); + boolean ground = jsonObject.get("ground").getAsBoolean(); + double weight = jsonObject.has("weight") + ? jsonObject.get("weight").getAsDouble() + : (ground ? DEFAULT_GROUND_WEIGHT : DEFAULT_PUSH_WEIGHT); Block block = Block.fromKey(blockKey); if (ground) { - builder.add(0, PushEntry.groundEntry(block, value)); + builder.add(0, PushEntry.groundEntry(block, value, weight)); } else { - builder.add(PushEntry.pushEntry(block, value)); + builder.add(PushEntry.pushEntry(block, value, weight)); } } @@ -58,6 +65,7 @@ public JsonElement serialize(PushData data, Type type, JsonSerializationContext jsonObject.add("block", context.serialize(blockKey, Key.class)); jsonObject.addProperty("value", pushEntry.getValue()); jsonObject.addProperty("ground", pushEntry.isGround()); + jsonObject.addProperty("weight", pushEntry.getWeight()); jsonArray.add(jsonObject); }); diff --git a/common/src/main/java/net/theevilreaper/bounce/common/config/GameConfig.java b/common/src/main/java/net/theevilreaper/bounce/common/config/GameConfig.java index 16c7e1da..c7e97024 100644 --- a/common/src/main/java/net/theevilreaper/bounce/common/config/GameConfig.java +++ b/common/src/main/java/net/theevilreaper/bounce/common/config/GameConfig.java @@ -1,7 +1,6 @@ package net.theevilreaper.bounce.common.config; import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.NotNull; /** * The {@link GameConfig} interface represents the structure for a configuration which is used by the game. diff --git a/common/src/main/java/net/theevilreaper/bounce/common/ground/Area.java b/common/src/main/java/net/theevilreaper/bounce/common/ground/Area.java index bab02a4c..26535e93 100644 --- a/common/src/main/java/net/theevilreaper/bounce/common/ground/Area.java +++ b/common/src/main/java/net/theevilreaper/bounce/common/ground/Area.java @@ -1,19 +1,28 @@ package net.theevilreaper.bounce.common.ground; import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.Instance; import net.minestom.server.instance.block.Block; import net.theevilreaper.bounce.common.push.PushData; +import java.util.List; + /** * The {@link Area} interface represents an area in the game. * * @author theEvilReaper - * @version 1.0.0 + * @version 1.1.0 * @since 0.1.0 */ public interface Area { - void calculatePositions(); + /** + * Scans the volume between {@link #min()} and {@link #max()} in the given instance and records every position + * whose block matches {@link #groundBlock()}. A no-op if positions were already calculated, see {@link #hasPositions()}. + * + * @param instance the instance to scan + */ + void calculatePositions(Instance instance); /** * Returns a boolean indicator if the are includes an amount of positions. @@ -22,6 +31,14 @@ public interface Area { */ boolean hasPositions(); + /** + * Returns the positions calculated by {@link #calculatePositions(Instance)}, or an empty list if it hasn't + * been called yet. + * + * @return an unmodifiable view of the calculated positions + */ + List positions(); + /** * Returns the minimum point of the area. * diff --git a/common/src/main/java/net/theevilreaper/bounce/common/ground/AreaFiller.java b/common/src/main/java/net/theevilreaper/bounce/common/ground/AreaFiller.java new file mode 100644 index 00000000..bc154ab2 --- /dev/null +++ b/common/src/main/java/net/theevilreaper/bounce/common/ground/AreaFiller.java @@ -0,0 +1,95 @@ +package net.theevilreaper.bounce.common.ground; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import net.theevilreaper.bounce.common.push.PushEntry; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Fills an {@link Area}'s scanned positions with a weighted-random mix of its {@link PushEntry} blocks, and + * partially reshuffles that fill at runtime. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 1.0.0 + */ +public final class AreaFiller { + + private AreaFiller() { + // Prevent instantiation + } + + /** + * Scans the area (if not already scanned) and fills every found position with a weighted-random block. + * + * @param instance the instance to place blocks in + * @param area the area to fill + */ + public static void fill(Instance instance, Area area) { + area.calculatePositions(instance); + + List entries = area.data().push(); + for (Vec position : area.positions()) { + instance.setBlock(position, pickWeightedBlock(entries, area.groundBlock())); + } + } + + /** + * Re-rolls {@code percentage} of the area's already scanned positions, skipping the position directly under + * any of the given players so nobody's ground changes under their feet. + * + * @param instance the instance to place blocks in + * @param area the area to reshuffle, must already have positions calculated (see {@link #fill}) + * @param percentage the fraction (0.0-1.0) of positions to re-roll + * @param players players whose current standing position must not be touched + */ + public static void reshuffle(Instance instance, Area area, double percentage, Collection players) { + List positions = area.positions(); + if (positions.isEmpty()) return; + + Set excluded = new HashSet<>(); + for (Player player : players) { + Pos playerPosition = player.getPosition(); + excluded.add(new Vec(Math.floor(playerPosition.x()), Math.floor(playerPosition.y() - 1), Math.floor(playerPosition.z()))); + } + + List candidates = new ArrayList<>(); + for (Vec position : positions) { + if (!excluded.contains(position)) candidates.add(position); + } + if (candidates.isEmpty()) return; + + Collections.shuffle(candidates, ThreadLocalRandom.current()); + int amount = Math.min(candidates.size(), (int) Math.round(positions.size() * percentage)); + + List entries = area.data().push(); + for (int i = 0; i < amount; i++) { + Vec position = candidates.get(i); + instance.setBlock(position, pickWeightedBlock(entries, area.groundBlock())); + } + } + + private static Block pickWeightedBlock(List entries, Block fallback) { + double roll = ThreadLocalRandom.current().nextDouble(); // 0.0 to 1.0 + double cumulative = 0.0; + for (PushEntry entry : entries) { + if (entry.isGround()) continue; + double p = Math.clamp(entry.getWeight(), 0.0, 1.0); + cumulative += p; + if (roll < cumulative) { + return entry.getBlock(); + } + } + return fallback; + } +} diff --git a/common/src/main/java/net/theevilreaper/bounce/common/ground/GroundArea.java b/common/src/main/java/net/theevilreaper/bounce/common/ground/GroundArea.java index bb3fdf98..31b993b3 100644 --- a/common/src/main/java/net/theevilreaper/bounce/common/ground/GroundArea.java +++ b/common/src/main/java/net/theevilreaper/bounce/common/ground/GroundArea.java @@ -1,14 +1,17 @@ package net.theevilreaper.bounce.common.ground; import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.Chunk; +import net.minestom.server.instance.Instance; import net.minestom.server.instance.block.Block; import net.theevilreaper.bounce.common.push.PushData; -import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.concurrent.CompletableFuture; public final class GroundArea implements Area { @@ -32,7 +35,7 @@ public GroundArea(Vec min, Vec max, Block groundBlock, PushData pushData) { * {@inheritDoc} */ @Override - public void calculatePositions() { + public void calculatePositions(Instance instance) { // Avoid double calculations if (!this.positions.isEmpty()) return; @@ -40,16 +43,60 @@ public void calculatePositions() { int maxX = (int) Math.floor(Math.max(min.x(), max.x())); int minZ = (int) Math.floor(Math.min(min.z(), max.z())); int maxZ = (int) Math.floor(Math.max(min.z(), max.z())); + int targetY = (int) Math.floor(min.y()); + int minChunkX = minX >> 4; + int maxChunkX = maxX >> 4; + int minChunkZ = minZ >> 4; + int maxChunkZ = maxZ >> 4; + + List> chunkFutures = new ArrayList<>(); + for (int cx = minChunkX; cx <= maxChunkX; cx++) { + for (int cz = minChunkZ; cz <= maxChunkZ; cz++) { + chunkFutures.add(instance.loadChunk(cx, cz)); + } + } + CompletableFuture.allOf(chunkFutures.toArray(new CompletableFuture[0])).join(); + + // Scan the single 2D plane at targetY for (int x = minX; x <= maxX; x++) { for (int z = minZ; z <= maxZ; z++) { - positions.add(new Vec(x, min.y(), z)); + if (isAreaBlock(instance.getBlock(x, targetY, z))) { + positions.add(new Vec(x, targetY, z)); + } + } + } + + // If no positions were found at targetY, try scanning targetY - 1 + // in case coordinates were captured while standing on top of the ground platform + if (positions.isEmpty()) { + int scanY = targetY - 1; + for (int x = minX; x <= maxX; x++) { + for (int z = minZ; z <= maxZ; z++) { + if (isAreaBlock(instance.getBlock(x, scanY, z))) { + positions.add(new Vec(x, scanY, z)); + } + } } } LOGGER.info("Calculated positions for area: {} to {} with {} positions", min, max, positions.size()); } + private boolean isAreaBlock(Block block) { + if (block.compare(groundBlock) || block.compare(Block.REDSTONE_BLOCK)) { + return true; + } + if (data != null && data.push() != null) { + for (var entry : data.push()) { + if (block.compare(entry.getBlock())) { + return true; + } + } + } + return false; + } + /** * {@inheritDoc} */ @@ -58,6 +105,14 @@ public boolean hasPositions() { return !this.positions.isEmpty(); } + /** + * {@inheritDoc} + */ + @Override + public List positions() { + return Collections.unmodifiableList(this.positions); + } + /** * {@inheritDoc} */ diff --git a/common/src/main/java/net/theevilreaper/bounce/common/map/GameMap.java b/common/src/main/java/net/theevilreaper/bounce/common/map/GameMap.java index c29c6127..146d3ef4 100644 --- a/common/src/main/java/net/theevilreaper/bounce/common/map/GameMap.java +++ b/common/src/main/java/net/theevilreaper/bounce/common/map/GameMap.java @@ -2,7 +2,9 @@ import net.minestom.server.coordinate.Pos; import net.theevilreaper.aves.map.BaseMap; +import net.theevilreaper.bounce.common.ground.Area; import net.theevilreaper.bounce.common.push.PushData; +import org.jetbrains.annotations.Nullable; import java.util.List; @@ -11,26 +13,36 @@ * It holds data about the used positions and other things. * * @author theEvilReaper - * @version 1.1.0 + * @version 1.2.0 * @since 0.1.0 */ public final class GameMap extends BaseMap { private final Pos gameSpawn; private final PushData pushData; + private final @Nullable Area area; + private final int shuffleIntervalTicks; + private final double reshufflePercentage; /** * Creates a new reference from the map class. * - * @param name the name of the map - * @param spawn the spawn position - * @param gameSpawn the spawn position during the game - * @param pushData the {@link PushData} which includes information about push values + * @param name the name of the map + * @param spawn the spawn position + * @param gameSpawn the spawn position during the game + * @param pushData the {@link PushData} which includes information about push values + * @param builders the list of builders who worked on the map + * @param area the ground area which gets dynamically filled, or {@code null} for a fully manual map + * @param shuffleIntervalTicks the amount of ticks between two runtime reshuffles of the area + * @param reshufflePercentage the fraction (0.0-1.0) of the area's positions to re-roll on each reshuffle */ - public GameMap(String name, Pos spawn, Pos gameSpawn, PushData pushData, List builders) { + public GameMap(String name, Pos spawn, Pos gameSpawn, PushData pushData, List builders, @Nullable Area area, int shuffleIntervalTicks, double reshufflePercentage) { super(name, spawn, builders); this.gameSpawn = gameSpawn; this.pushData = pushData; + this.area = area; + this.shuffleIntervalTicks = shuffleIntervalTicks; + this.reshufflePercentage = reshufflePercentage; } /** @@ -50,4 +62,31 @@ public PushData getPushData() { public Pos getGameSpawn() { return gameSpawn; } -} \ No newline at end of file + + /** + * Returns the dynamically filled ground area of this map, or {@code null} for a fully manual map. + * + * @return the area, or {@code null} + */ + public @Nullable Area getArea() { + return area; + } + + /** + * Returns the amount of ticks between two runtime reshuffles of the area. + * + * @return the interval in ticks + */ + public int getShuffleIntervalTicks() { + return shuffleIntervalTicks; + } + + /** + * Returns the fraction of the area's positions which get re-rolled on each runtime reshuffle. + * + * @return the percentage as a fraction between 0.0 and 1.0 + */ + public double getReshufflePercentage() { + return reshufflePercentage; + } +} diff --git a/common/src/main/java/net/theevilreaper/bounce/common/push/PushDataBuilder.java b/common/src/main/java/net/theevilreaper/bounce/common/push/PushDataBuilder.java index d0c4356e..31a54ec3 100644 --- a/common/src/main/java/net/theevilreaper/bounce/common/push/PushDataBuilder.java +++ b/common/src/main/java/net/theevilreaper/bounce/common/push/PushDataBuilder.java @@ -34,9 +34,9 @@ public PushDataBuilder(PushData pushData) { for (int i = 0; i < pushData.push().size(); i++) { PushEntry entry = pushData.push().get(i); if (entry.isGround()) { - this.blocks.add(PushEntry.groundEntry(entry.getBlock(), entry.getValue())); + this.blocks.add(PushEntry.groundEntry(entry.getBlock(), entry.getValue(), entry.getWeight())); } else { - this.blocks.add(PushEntry.pushEntry(entry.getBlock(), entry.getValue())); + this.blocks.add(PushEntry.pushEntry(entry.getBlock(), entry.getValue(), entry.getWeight())); } } } diff --git a/common/src/main/java/net/theevilreaper/bounce/common/push/PushEntry.java b/common/src/main/java/net/theevilreaper/bounce/common/push/PushEntry.java index 796bd17c..67978849 100644 --- a/common/src/main/java/net/theevilreaper/bounce/common/push/PushEntry.java +++ b/common/src/main/java/net/theevilreaper/bounce/common/push/PushEntry.java @@ -10,7 +10,7 @@ * This class is used to manage the push values in the game. * * @author Joltra - * @version 1.0.0 + * @version 1.1.0 * @since 0.1.0 */ public final class PushEntry { @@ -18,39 +18,77 @@ public final class PushEntry { private final boolean ground; private Block block; private int value; + private double weight; /** - * Constructs a new PushEntry with the specified block and value. + * Constructs a new PushEntry with the specified block and value. Uses default weight (1.0 for ground, 0.05 for push). * * @param block the block associated with this PushEntry * @param value the initial value for this PushEntry * @return a new PushEntry instance representing a ground block entry */ public static PushEntry groundEntry(Block block, int value) { - return new PushEntry(block, value, true); + return new PushEntry(block, value, 1.0, true); } /** - * Constructs a new PushEntry with the specified block and value. + * Constructs a new PushEntry with the specified block, value and weight. + * + * @param block the block associated with this PushEntry + * @param value the initial value for this PushEntry + * @param weight the probability (0.0 to 1.0) used when this entry is picked during area filling + * @return a new PushEntry instance representing a ground block entry + */ + public static PushEntry groundEntry(Block block, int value, double weight) { + return new PushEntry(block, value, weight, true); + } + + /** + * Constructs a new PushEntry with the specified block and value. Uses a default weight of {@code 0.05} (5%). * * @param block the block associated with this PushEntry * @param value the initial value for this PushEntry * @return a new PushEntry instance */ public static PushEntry pushEntry(Block block, int value) { - return new PushEntry(block, value, false); + return new PushEntry(block, value, 0.05, false); } /** - * Constructs a new PushEntry with the specified block and value. + * Constructs a new PushEntry with the specified block, value and weight. * - * @param block the block associated with this PushEntry - * @param value the initial value for this PushEntry + * @param block the block associated with this PushEntry + * @param value the initial value for this PushEntry + * @param weight the probability (0.0 to 1.0) used when this entry is picked during area filling + * @return a new PushEntry instance + */ + public static PushEntry pushEntry(Block block, int value, double weight) { + return new PushEntry(block, value, weight, false); + } + + /** + * Constructs a new PushEntry with a default weight. + * + * @param block the block associated with this PushEntry + * @param value the initial value for this PushEntry * @param ground indicates whether this entry is a ground block entry */ public PushEntry(Block block, int value, boolean ground) { + this(block, value, ground ? 1.0 : 0.05, ground); + } + + /** + * Constructs a new PushEntry with the specified block, value, weight and ground flag. + * + * @param block the block associated with this PushEntry + * @param value the initial value for this PushEntry + * @param weight the probability (0.0 to 1.0) used when this entry is picked during area filling + * @param ground indicates whether this entry is a ground block entry + */ + public PushEntry(Block block, int value, double weight, boolean ground) { this.block = block; this.value = value; + this.weight = clampWeight(weight); this.ground = ground; } @@ -74,6 +112,15 @@ public void setValue(int value) { this.value = value; } + /** + * Sets the weight (probability 0.0 to 1.0) for this PushEntry. + * + * @param weight the new weight to set + */ + public void setWeight(double weight) { + this.weight = clampWeight(weight); + } + /** * Increments the value of this PushEntry. * If the value is already at Integer.MAX_VALUE, it does nothing. @@ -92,6 +139,24 @@ public void decrementValue() { this.value--; } + /** + * Increments the weight by 0.01 (1%), clamped at 1.0. + */ + public void incrementWeight() { + this.weight = clampWeight(this.weight + 0.01); + } + + /** + * Decrements the weight by 0.01 (1%), clamped at 0.0. + */ + public void decrementWeight() { + this.weight = clampWeight(this.weight - 0.01); + } + + private static double clampWeight(double w) { + return Math.clamp(Math.round(w * 100.0) / 100.0, 0.0, 1.0); + } + /** * Gets the current value of this PushEntry. * This method should be used to retrieve the value for display or processing. @@ -102,6 +167,15 @@ public int getValue() { return value; } + /** + * Gets the current weight of this PushEntry as a probability between 0.0 and 1.0. + * + * @return the current weight of this PushEntry + */ + public double getWeight() { + return weight; + } + /** * Gets the block associated with this PushEntry. * This method should be used to retrieve the block for display or processing. diff --git a/common/src/test/java/net/theevilreaper/bounce/common/adapter/PushDataAdapterTest.java b/common/src/test/java/net/theevilreaper/bounce/common/adapter/PushDataAdapterTest.java index c91e6262..d36701dd 100644 --- a/common/src/test/java/net/theevilreaper/bounce/common/adapter/PushDataAdapterTest.java +++ b/common/src/test/java/net/theevilreaper/bounce/common/adapter/PushDataAdapterTest.java @@ -31,6 +31,20 @@ class PushDataAdapterTest { ] """; + private static final String TEST_JSON_WITH_WEIGHT = """ + [ + { + "block": { + "namespace": "minecraft", + "value": "slime_block" + }, + "ground": false, + "value": 1, + "weight": 0.15 + } + ] + """; + @Test void testPushDataWrite() { PushData pushData = PushData.builder() @@ -53,4 +67,25 @@ void testPushDataRead() { assertEquals(1, pushData.getPush(Block.SLIME_BLOCK)); assertEquals(2, pushData.getPush(Block.AMETHYST_BLOCK)); } + + @Test + void testPushDataReadDefaultsMissingWeightToOne() { + PushData pushData = GsonUtil.GSON.fromJson(TEST_JSON, PushData.class); + assertEquals(1.0, pushData.push().getFirst().getWeight(), "Old maps without a weight field must default to 1.0 for ground"); + } + + @Test + void testPushDataReadKeepsExplicitWeight() { + PushData pushData = GsonUtil.GSON.fromJson(TEST_JSON_WITH_WEIGHT, PushData.class); + assertEquals(0.15, pushData.push().getFirst().getWeight()); + } + + @Test + void testPushDataWriteIncludesWeight() { + PushData pushData = PushData.builder() + .add(PushEntry.pushEntry(Block.SLIME_BLOCK, 1, 0.25)) + .build(); + String json = GsonUtil.GSON.toJson(pushData); + assertTrue(json.contains("\"weight\": 0.25")); + } } diff --git a/common/src/test/java/net/theevilreaper/bounce/common/ground/AreaFillerIntegrationTest.java b/common/src/test/java/net/theevilreaper/bounce/common/ground/AreaFillerIntegrationTest.java new file mode 100644 index 00000000..913f6949 --- /dev/null +++ b/common/src/test/java/net/theevilreaper/bounce/common/ground/AreaFillerIntegrationTest.java @@ -0,0 +1,134 @@ +package net.theevilreaper.bounce.common.ground; + +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.theevilreaper.bounce.common.push.PushData; +import net.theevilreaper.bounce.common.push.PushEntry; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MicrotusExtension.class) +class AreaFillerIntegrationTest { + + @Test + void testFillFallsBackToGroundBlockWhenPushDataEmpty(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + for (int x = 0; x < 5; x++) { + instance.setBlock(x, 0, 0, Block.GLASS); + } + + Area area = new GroundArea(new Vec(0, 0, 0), new Vec(4, 0, 0), Block.GLASS, new PushData(List.of())); + + AreaFiller.fill(instance, area); + + for (Vec position : area.positions()) { + assertTrue(instance.getBlock(position).compare(Block.GLASS)); + } + + env.destroyInstance(instance, true); + } + + @Test + void testFillNeverPicksAZeroWeightEntry(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + for (int x = 0; x < 30; x++) { + instance.setBlock(x, 0, 0, Block.GLASS); + } + + PushData pushData = new PushData(List.of( + PushEntry.groundEntry(Block.GLASS, 1, 1.0), + PushEntry.pushEntry(Block.DIAMOND_BLOCK, 5, 0.0) + )); + Area area = new GroundArea(new Vec(0, 0, 0), new Vec(29, 0, 0), Block.GLASS, pushData); + + AreaFiller.fill(instance, area); + + for (Vec position : area.positions()) { + assertFalse(instance.getBlock(position).compare(Block.DIAMOND_BLOCK), "A weight of 0 must never be picked"); + } + + env.destroyInstance(instance, true); + } + + @Test + void testFillDistributionRoughlyFollowsWeights(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + for (int x = 0; x < 100; x++) { + instance.setBlock(x, 0, 0, Block.GLASS); + } + + PushData pushData = new PushData(List.of( + PushEntry.groundEntry(Block.GLASS, 1, 1.0), + PushEntry.pushEntry(Block.GOLD_BLOCK, 1, 0.8) // 80% gold, 20% glass + )); + Area area = new GroundArea(new Vec(0, 0, 0), new Vec(99, 0, 0), Block.GLASS, pushData); + + AreaFiller.fill(instance, area); + + long goldCount = area.positions().stream().filter(pos -> instance.getBlock(pos).compare(Block.GOLD_BLOCK)).count(); + long glassCount = area.positions().stream().filter(pos -> instance.getBlock(pos).compare(Block.GLASS)).count(); + + assertEquals(100, goldCount + glassCount); + assertTrue(goldCount > glassCount, "Gold has 80% probability and should dominate the distribution"); + + env.destroyInstance(instance, true); + } + + @Test + void testReshuffleSkipsThePositionUnderAPlayer(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + for (int x = 0; x < 10; x++) { + instance.setBlock(x, 0, 0, Block.GLASS); + } + // A block no fill/reshuffle would ever place, so if it's still here afterwards we know the position was skipped + instance.setBlock(0, 0, 0, Block.WATER); + + Player player = env.createPlayer(instance); + player.teleport(new net.minestom.server.coordinate.Pos(0.5, 1, 0.5)).join(); + + PushData pushData = new PushData(List.of(PushEntry.groundEntry(Block.GLASS, 1, 1.0))); + Area area = new GroundArea(new Vec(0, 0, 0), new Vec(9, 0, 0), Block.GLASS, pushData); + area.calculatePositions(instance); + + AreaFiller.reshuffle(instance, area, 1.0, List.of(player)); + + assertTrue(instance.getBlock(0, 0, 0).compare(Block.WATER), "The position under the player must be left untouched"); + + env.destroyInstance(instance, true); + } + + @Test + void testReshuffleExcludesPlayerPositionEvenWhenItIsAValidCandidate(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + for (int x = 0; x < 10; x++) { + instance.setBlock(x, 0, 0, Block.GLASS); + } + + Player player = env.createPlayer(instance); + player.teleport(new net.minestom.server.coordinate.Pos(0.5, 1, 0.5)).join(); + + // Only a non-ground entry with probability 1.0 is configured, so every reshuffled position is guaranteed to + // become DIAMOND_BLOCK unless it was excluded because a player stands on it. + PushData pushData = new PushData(List.of(PushEntry.pushEntry(Block.DIAMOND_BLOCK, 1, 1.0))); + Area area = new GroundArea(new Vec(0, 0, 0), new Vec(9, 0, 0), Block.GLASS, pushData); + area.calculatePositions(instance); + + AreaFiller.reshuffle(instance, area, 1.0, List.of(player)); + + assertTrue(instance.getBlock(0, 0, 0).compare(Block.GLASS), "The position under the player was a valid candidate but must remain untouched"); + for (int x = 1; x < 10; x++) { + assertTrue(instance.getBlock(x, 0, 0).compare(Block.DIAMOND_BLOCK), "Every other position should have been reshuffled"); + } + + env.destroyInstance(instance, true); + } +} diff --git a/common/src/test/java/net/theevilreaper/bounce/common/ground/AreaIntegrationTest.java b/common/src/test/java/net/theevilreaper/bounce/common/ground/AreaIntegrationTest.java new file mode 100644 index 00000000..dac2a097 --- /dev/null +++ b/common/src/test/java/net/theevilreaper/bounce/common/ground/AreaIntegrationTest.java @@ -0,0 +1,128 @@ +package net.theevilreaper.bounce.common.ground; + +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.theevilreaper.bounce.common.push.PushData; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MicrotusExtension.class) +class AreaIntegrationTest { + + @Test + void testCalculatePositionsOnlyIncludesMatchingGroundBlock(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + + instance.setBlock(0, 0, 0, Block.AMETHYST_BLOCK); + instance.setBlock(1, 0, 0, Block.AMETHYST_BLOCK); + instance.setBlock(2, 0, 0, Block.STONE); // not the configured ground block + + Area area = new GroundArea(new Vec(0, 0, 0), new Vec(2, 0, 0), Block.AMETHYST_BLOCK, new PushData(List.of())); + assertFalse(area.hasPositions()); + + area.calculatePositions(instance); + + assertTrue(area.hasPositions()); + assertEquals(2, area.positions().size()); + assertTrue(area.positions().contains(new Vec(0, 0, 0))); + assertTrue(area.positions().contains(new Vec(1, 0, 0))); + assertFalse(area.positions().contains(new Vec(2, 0, 0))); + + env.destroyInstance(instance, true); + assertTrue(instance.getPlayers().isEmpty()); + } + + @Test + void testCalculatePositionsIsIdempotent(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + instance.setBlock(0, 0, 0, Block.GLASS); + + Area area = new GroundArea(new Vec(0, 0, 0), new Vec(0, 0, 0), Block.GLASS, new PushData(List.of())); + area.calculatePositions(instance); + instance.setBlock(0, 0, 0, Block.STONE); // world changes after the first scan + + area.calculatePositions(instance); // second call must be a no-op + + assertEquals(1, area.positions().size(), "A second call must not re-scan or clear the already computed positions"); + + env.destroyInstance(instance, true); + } + + @Test + void testPositionsIsUnmodifiable(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + instance.setBlock(0, 0, 0, Block.GLASS); + + Area area = new GroundArea(new Vec(0, 0, 0), new Vec(0, 0, 0), Block.GLASS, new PushData(List.of())); + area.calculatePositions(instance); + + Vec vec = new Vec(9, 9, 9); + assertThrows(UnsupportedOperationException.class, () -> area.positions().add(vec)); + + env.destroyInstance(instance, true); + } + + @Test + void testCalculatePositionsLoadsChunksInArea(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + + // Coordinates spanning multiple chunks (e.g. chunk -2, 3 to chunk -1, 4) + Vec min = new Vec(-25, 60, -25); + Vec max = new Vec(25, 60, 25); + + Area area = new GroundArea(min, max, Block.STONE, new PushData(List.of())); + assertDoesNotThrow(() -> area.calculatePositions(instance)); + + env.destroyInstance(instance, true); + } + + @Test + void testCalculatePositionsIncludesConfiguredPushBlocks(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + + instance.setBlock(0, 0, 0, Block.AMETHYST_BLOCK); // ground block + instance.setBlock(1, 0, 0, Block.GOLD_BLOCK); // existing push block + instance.setBlock(2, 0, 0, Block.STONE); // unrelated block + + PushData pushData = PushData.builder() + .add(net.theevilreaper.bounce.common.push.PushEntry.groundEntry(Block.AMETHYST_BLOCK, 1)) + .add(net.theevilreaper.bounce.common.push.PushEntry.pushEntry(Block.GOLD_BLOCK, 2)) + .build(); + + Area area = new GroundArea(new Vec(0, 0, 0), new Vec(2, 0, 0), Block.AMETHYST_BLOCK, pushData); + area.calculatePositions(instance); + + assertEquals(2, area.positions().size()); + assertTrue(area.positions().contains(new Vec(0, 0, 0))); + assertTrue(area.positions().contains(new Vec(1, 0, 0))); + assertFalse(area.positions().contains(new Vec(2, 0, 0))); + + env.destroyInstance(instance, true); + } + + @Test + void testCalculatePositionsScansLayerBelowWhenStandingOnPlatform(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + + // Platform block is placed at Y=63, air at Y=64 + instance.setBlock(0, 63, 0, Block.GLASS); + instance.setBlock(0, 64, 0, Block.AIR); + + // Area captured at player feet level (Y=64) + Area area = new GroundArea(new Vec(0, 64, 0), new Vec(0, 64, 0), Block.GLASS, new PushData(List.of())); + area.calculatePositions(instance); + + assertEquals(1, area.positions().size(), "Should fallback to Y=63 when Y=64 has no ground blocks"); + assertEquals(new Vec(0, 63, 0), area.positions().getFirst()); + + env.destroyInstance(instance, true); + } +} diff --git a/common/src/test/java/net/theevilreaper/bounce/common/ground/AreaTest.java b/common/src/test/java/net/theevilreaper/bounce/common/ground/AreaTest.java deleted file mode 100644 index c6f6aa73..00000000 --- a/common/src/test/java/net/theevilreaper/bounce/common/ground/AreaTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package net.theevilreaper.bounce.common.ground; - -import net.minestom.server.coordinate.Vec; -import net.minestom.server.instance.block.Block; -import net.theevilreaper.bounce.common.push.PushData; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -class AreaTest { - - @Test - void testArea() { - Area area = new GroundArea(Vec.ZERO, Vec.ONE, Block.AMETHYST_BLOCK, new PushData(List.of())); - - assertNotNull(area); - assertEquals(Vec.ZERO, area.min()); - assertEquals(Vec.ONE, area.max()); - assertEquals(Block.AMETHYST_BLOCK, area.groundBlock()); - assertNotNull(area.data()); - assertNotNull(area.data().push()); - assertTrue(area.data().push().isEmpty()); - assertEquals(0.0, area.data().getPush(area.groundBlock())); - - assertFalse(area.hasPositions()); - - area.calculatePositions(); - - assertTrue(area.hasPositions()); - } -} diff --git a/common/src/test/java/net/theevilreaper/bounce/common/map/GameMapTest.java b/common/src/test/java/net/theevilreaper/bounce/common/map/GameMapTest.java index 8a34c14e..be5284f9 100644 --- a/common/src/test/java/net/theevilreaper/bounce/common/map/GameMapTest.java +++ b/common/src/test/java/net/theevilreaper/bounce/common/map/GameMapTest.java @@ -13,7 +13,7 @@ class GameMapTest { @Test void testGameMap() { - GameMap gameMap = new GameMap("Test-Map", Pos.ZERO, new Pos(10, 0, 10), PushData.builder().build(), List.of()); + GameMap gameMap = new GameMap("Test-Map", Pos.ZERO, new Pos(10, 0, 10), PushData.builder().build(), List.of(), null, 100, 0.25); assertNotNull(gameMap); assertInstanceOf(BaseMap.class, gameMap, "The GameMap should be rely on the BaseMap"); assertEquals("Test-Map", gameMap.name()); @@ -22,5 +22,8 @@ void testGameMap() { assertNotEquals(gameMap.getGameSpawn(), gameMap.spawn()); assertNotNull(gameMap.getPushData()); assertTrue(gameMap.getPushData().push().isEmpty()); + assertNull(gameMap.getArea(), "A map without an area should report null"); + assertEquals(100, gameMap.getShuffleIntervalTicks()); + assertEquals(0.25, gameMap.getReshufflePercentage()); } } diff --git a/common/src/test/java/net/theevilreaper/bounce/common/push/PushDataBuilderTest.java b/common/src/test/java/net/theevilreaper/bounce/common/push/PushDataBuilderTest.java new file mode 100644 index 00000000..bc431af2 --- /dev/null +++ b/common/src/test/java/net/theevilreaper/bounce/common/push/PushDataBuilderTest.java @@ -0,0 +1,22 @@ +package net.theevilreaper.bounce.common.push; + +import net.minestom.server.instance.block.Block; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PushDataBuilderTest { + + @Test + void testCopyConstructorPreservesWeight() { + PushData original = PushData.builder() + .add(PushEntry.groundEntry(Block.GLASS, 1, 0.8)) + .add(PushEntry.pushEntry(Block.GOLD_BLOCK, 3, 0.2)) + .build(); + + PushData copy = PushData.builder(original).build(); + + assertEquals(0.8, copy.push().get(0).getWeight()); + assertEquals(0.2, copy.push().get(1).getWeight()); + } +} diff --git a/common/src/test/java/net/theevilreaper/bounce/common/push/PushEntryTest.java b/common/src/test/java/net/theevilreaper/bounce/common/push/PushEntryTest.java index 9d08f689..d24b4f27 100644 --- a/common/src/test/java/net/theevilreaper/bounce/common/push/PushEntryTest.java +++ b/common/src/test/java/net/theevilreaper/bounce/common/push/PushEntryTest.java @@ -67,4 +67,54 @@ void testEquality() { entry2.setBlock(Block.STONE); assertNotEquals(entry1, entry2, "Entries with different blocks should not be equal"); } + + @Test + void testWeightDefaults() { + PushEntry ground = PushEntry.groundEntry(Block.SAND, 5); + assertEquals(1.0, ground.getWeight(), "Ground weight should default to 1.0"); + + PushEntry push = PushEntry.pushEntry(Block.SAND, 5); + assertEquals(0.05, push.getWeight(), "Push weight should default to 0.05"); + } + + @Test + void testWeightConstructorOverload() { + PushEntry pushEntry = PushEntry.pushEntry(Block.SAND, 5, 0.25); + assertEquals(0.25, pushEntry.getWeight()); + } + + @Test + void testIncrementWeight() { + PushEntry pushEntry = PushEntry.pushEntry(Block.SAND, 5, 0.05); + pushEntry.incrementWeight(); + assertEquals(0.06, pushEntry.getWeight()); + } + + @Test + void testIncrementWeightMaxValue() { + PushEntry pushEntry = PushEntry.pushEntry(Block.SAND, 5, 1.0); + pushEntry.incrementWeight(); + assertEquals(1.0, pushEntry.getWeight(), "Weight must not exceed 1.0"); + } + + @Test + void testDecrementWeight() { + PushEntry pushEntry = PushEntry.pushEntry(Block.SAND, 5, 0.05); + pushEntry.decrementWeight(); + assertEquals(0.04, pushEntry.getWeight()); + } + + @Test + void testDecrementWeightNeverGoesNegative() { + PushEntry pushEntry = PushEntry.pushEntry(Block.SAND, 5, 0.0); + pushEntry.decrementWeight(); + assertEquals(0.0, pushEntry.getWeight(), "Weight of 0 must stay 0, it means the entry is never picked"); + } + + @Test + void testSetWeight() { + PushEntry pushEntry = PushEntry.pushEntry(Block.SAND, 5); + pushEntry.setWeight(0.75); + assertEquals(0.75, pushEntry.getWeight()); + } } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/BounceSetup.java b/setup/src/main/java/net/theevilreaper/bounce/setup/BounceSetup.java index b953ea89..2d53f03a 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/BounceSetup.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/BounceSetup.java @@ -20,6 +20,8 @@ import net.theevilreaper.bounce.setup.event.ground.PlayerGroundBlockSelectEvent; import net.theevilreaper.bounce.setup.event.SetupInventorySwitchEvent; import net.theevilreaper.bounce.setup.event.map.PlayerDeletePromptEvent; +import net.theevilreaper.bounce.setup.event.map.SaveValidationPromptEvent; +import net.theevilreaper.bounce.setup.event.map.SetupDiscardEvent; import net.theevilreaper.bounce.setup.event.push.PlayerPushBlockSelectEvent; import net.theevilreaper.bounce.setup.event.push.PlayerPushIndexChangeEvent; import net.theevilreaper.bounce.setup.inventory.InventoryService; @@ -30,10 +32,12 @@ import net.theevilreaper.bounce.setup.listener.dialog.PlayerCustomClickEventListener; import net.theevilreaper.bounce.setup.listener.dialog.PlayerDeletePromptListener; import net.theevilreaper.bounce.setup.listener.dialog.PlayerDialogRequestListener; +import net.theevilreaper.bounce.setup.listener.dialog.SaveValidationPromptListener; import net.theevilreaper.bounce.setup.listener.entity.EntityAddToInstanceListener; import net.theevilreaper.bounce.setup.listener.ground.PlayerBlockSelectListener; import net.theevilreaper.bounce.setup.listener.inventory.SetupInventorySwitchListener; import net.theevilreaper.bounce.setup.listener.map.MapSetupSelectListener; +import net.theevilreaper.bounce.setup.listener.map.SetupDiscardListener; import net.theevilreaper.bounce.setup.listener.map.SetupFinishListener; import net.theevilreaper.bounce.setup.listener.push.PlayerPushBlockSelectListener; import net.theevilreaper.bounce.setup.listener.push.PlayerPushIndexChangeListener; @@ -92,7 +96,9 @@ private void registerListener(@NotNull EventNode node) { SetupItems.setOverViewItem(player); }; - node.addListener(SetupFinishEvent.class, new SetupFinishListener(instanceSwitcher)); + node.addListener(SetupFinishEvent.class, new SetupFinishListener(instanceSwitcher, this.setupDataService::remove)); + node.addListener(SetupDiscardEvent.class, new SetupDiscardListener(instanceSwitcher, this.setupDataService::remove)); + node.addListener(SaveValidationPromptEvent.class, new SaveValidationPromptListener(dialogRegistry)); node.addListener(PlayerGroundBlockSelectEvent.class, new PlayerBlockSelectListener(this.setupDataService::get)); node.addListener(SetupInventorySwitchEvent.class, new SetupInventorySwitchListener(this.inventoryService, this.setupDataService::get)); node.addListener(GameMapBuilderStateNotifyEvent.class, new GameMapBuilderStateNotifyListener()); diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/builder/GameMapBuilder.java b/setup/src/main/java/net/theevilreaper/bounce/setup/builder/GameMapBuilder.java index 60d2e4e7..190c1118 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/builder/GameMapBuilder.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/builder/GameMapBuilder.java @@ -1,40 +1,67 @@ package net.theevilreaper.bounce.setup.builder; import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; import net.minestom.server.instance.block.Block; import net.theevilreaper.aves.map.BaseMapBuilder; +import net.theevilreaper.bounce.common.ground.Area; import net.theevilreaper.bounce.common.map.GameMap; import net.theevilreaper.bounce.common.push.PushData; import net.theevilreaper.bounce.common.push.PushEntry; -import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; public final class GameMapBuilder extends BaseMapBuilder { + private static final int DEFAULT_SHUFFLE_INTERVAL_TICKS = 100; + private static final double DEFAULT_RESHUFFLE_PERCENTAGE = 0.1; + private final PushData.Builder pushDataBuilder; - private Pos gameSpawn; + private @Nullable Pos gameSpawn; + private @Nullable Area area; + private int shuffleIntervalTicks; + private double reshufflePercentage; + private @Nullable Vec pos1; + private @Nullable Vec pos2; public GameMapBuilder() { super(); + this.shuffleIntervalTicks = DEFAULT_SHUFFLE_INTERVAL_TICKS; + this.reshufflePercentage = DEFAULT_RESHUFFLE_PERCENTAGE; this.pushDataBuilder = PushData.builder(); this.pushDataBuilder - .add(PushEntry.groundEntry(Block.GLASS, 1)) - .add(PushEntry.pushEntry(Block.GOLD_BLOCK, 1)) - .add(PushEntry.pushEntry(Block.DIAMOND_BLOCK, 1)) - .add(PushEntry.pushEntry(Block.EMERALD_BLOCK, 1)); + .add(PushEntry.groundEntry(Block.GLASS, 1, 1.0)) + .add(PushEntry.pushEntry(Block.GOLD_BLOCK, 1, 0.05)) + .add(PushEntry.pushEntry(Block.DIAMOND_BLOCK, 1, 0.03)) + .add(PushEntry.pushEntry(Block.EMERALD_BLOCK, 1, 0.02)); } - public GameMapBuilder(@NotNull GameMap gameMap) { + public GameMapBuilder(GameMap gameMap) { super(gameMap); this.gameSpawn = gameMap.getGameSpawn(); + this.area = gameMap.getArea(); + this.shuffleIntervalTicks = gameMap.getShuffleIntervalTicks() > 0 + ? gameMap.getShuffleIntervalTicks() + : DEFAULT_SHUFFLE_INTERVAL_TICKS; + this.reshufflePercentage = gameMap.getReshufflePercentage() > 0 + ? gameMap.getReshufflePercentage() + : DEFAULT_RESHUFFLE_PERCENTAGE; + + if (this.area != null) { + this.pos1 = this.area.min(); + this.pos2 = this.area.max(); + } if (gameMap.getPushData() == null) { this.pushDataBuilder = PushData.builder(); this.pushDataBuilder - .add(PushEntry.groundEntry(Block.GLASS, 1)) - .add(PushEntry.pushEntry(Block.GOLD_BLOCK, 1)) - .add(PushEntry.pushEntry(Block.DIAMOND_BLOCK, 1)) - .add(PushEntry.pushEntry(Block.EMERALD_BLOCK, 1)); - } else{ + .add(PushEntry.groundEntry(Block.GLASS, 1, 1.0)) + .add(PushEntry.pushEntry(Block.GOLD_BLOCK, 1, 0.05)) + .add(PushEntry.pushEntry(Block.DIAMOND_BLOCK, 1, 0.03)) + .add(PushEntry.pushEntry(Block.EMERALD_BLOCK, 1, 0.02)); + } else { this.pushDataBuilder = PushData.builder(gameMap.getPushData()); } } @@ -45,7 +72,7 @@ public GameMapBuilder(@NotNull GameMap gameMap) { * @param groundBlock the block to set as the ground block * @return this builder instance for chaining */ - public @NotNull GameMapBuilder groundBlock(Block groundBlock) { + public GameMapBuilder groundBlock(Block groundBlock) { PushEntry pushEntry = this.pushDataBuilder.getPushValues().getFirst(); pushEntry.setBlock(groundBlock); return this; @@ -57,19 +84,74 @@ public GameMapBuilder(@NotNull GameMap gameMap) { * @param gameSpawn the spawn position * @return this builder instance for chaining */ - public @NotNull GameMapBuilder gameSpawn(Pos gameSpawn) { + public GameMapBuilder gameSpawn(Pos gameSpawn) { this.gameSpawn = gameSpawn; return this; } + /** + * Sets the ground area which gets dynamically filled. + * + * @param area the area, or {@code null} to disable dynamic filling + * @return this builder instance for chaining + */ + public GameMapBuilder area(@Nullable Area area) { + this.area = area; + return this; + } + + /** + * Sets the amount of ticks between two runtime reshuffles of the area. + * + * @param shuffleIntervalTicks the interval in ticks + * @return this builder instance for chaining + */ + public GameMapBuilder shuffleIntervalTicks(int shuffleIntervalTicks) { + this.shuffleIntervalTicks = shuffleIntervalTicks; + return this; + } + + /** + * Sets the fraction of the area's positions to re-roll on each runtime reshuffle. + * + * @param reshufflePercentage the percentage as a fraction between 0.0 and 1.0 + * @return this builder instance for chaining + */ + public GameMapBuilder reshufflePercentage(double reshufflePercentage) { + this.reshufflePercentage = reshufflePercentage; + return this; + } + + /** + * Sets the first captured corner of the ground area. + * + * @param pos1 the corner position, or {@code null} to clear it + * @return this builder instance for chaining + */ + public GameMapBuilder pos1(@Nullable Vec pos1) { + this.pos1 = pos1; + return this; + } + + /** + * Sets the second captured corner of the ground area. + * + * @param pos2 the corner position, or {@code null} to clear it + * @return this builder instance for chaining + */ + public GameMapBuilder pos2(@Nullable Vec pos2) { + this.pos2 = pos2; + return this; + } + /** * Builds a new {@link GameMap} instance with the current properties. * * @return a new GameMap instance */ @Override - public @NotNull GameMap build() { - return new GameMap(this.name, this.spawn, this.gameSpawn, pushDataBuilder.build(), this.builders); + public GameMap build() { + return new GameMap(this.name, this.spawn, this.gameSpawn, pushDataBuilder.build(), this.builders, this.area, this.shuffleIntervalTicks, this.reshufflePercentage); } /** @@ -77,10 +159,55 @@ public GameMapBuilder(@NotNull GameMap gameMap) { * * @return the game spawn position */ - public Pos getGameSpawn() { + public @Nullable Pos getGameSpawn() { return gameSpawn; } + /** + * Returns the ground area which gets dynamically filled. + * + * @return the area, or {@code null} + */ + public @Nullable Area getArea() { + return area; + } + + /** + * Returns the amount of ticks between two runtime reshuffles of the area. + * + * @return the interval in ticks + */ + public int getShuffleIntervalTicks() { + return shuffleIntervalTicks; + } + + /** + * Returns the fraction of the area's positions to re-roll on each runtime reshuffle. + * + * @return the percentage as a fraction between 0.0 and 1.0 + */ + public double getReshufflePercentage() { + return reshufflePercentage; + } + + /** + * Returns the first captured corner of the ground area. + * + * @return the corner position, or {@code null} if not set + */ + public @Nullable Vec getPos1() { + return pos1; + } + + /** + * Returns the second captured corner of the ground area. + * + * @return the corner position, or {@code null} if not set + */ + public @Nullable Vec getPos2() { + return pos2; + } + /** * Returns the {@link PushData.Builder} instance used to build push data. * @@ -95,11 +222,56 @@ public PushData.Builder getPushDataBuilder() { * * @return the ground block entry */ - public @NotNull PushEntry getGroundBlockEntry() { + public PushEntry getGroundBlockEntry() { return this.pushDataBuilder.getPushValues().getFirst(); } - public @NotNull Pos getSpawnOrDefault(@NotNull Pos defaultSpawn) { + /*** + * Returns the spawn position of the map or a default. + * + * @param defaultSpawn as fallback + * @return the spawn position + */ + public Pos getSpawnOrDefault(Pos defaultSpawn) { return this.spawn != null ? this.spawn : defaultSpawn; } + + /** + * Returns whether every field required to save this map is present. + * + * @return {@code true} if {@link #getMissingFieldNames()} is empty + */ + public boolean isReadyToSave() { + return getMissingFieldNames().isEmpty(); + } + + /** + * Returns the human-readable names of the required fields which are not yet set. + * + * @return an empty list if the map is ready to save + */ + public List getMissingFieldNames() { + List missing = new ArrayList<>(); + if (isDefaultName()) missing.add("Name"); + if (getSpawn() == null) missing.add("Spawn"); + if (getGameSpawn() == null) missing.add("Game Spawn"); + if (getArea() == null) missing.add("Area"); + if (!hasValidPushData()) missing.add("Push Data"); + return missing; + } + + /** + * Checks whether the push data is usable: every entry needs a positive value, and at least one non-ground + * entry needs a positive weight so something can actually be placed besides the ground block. + * + * @return {@code true} if the push data satisfies both conditions + */ + private boolean hasValidPushData() { + boolean hasWeightedPushEntry = false; + for (PushEntry entry : pushDataBuilder.getPushValues()) { + if (entry.getValue() <= 0) return false; + if (!entry.isGround() && entry.getWeight() > 0) hasWeightedPushEntry = true; + } + return hasWeightedPushEntry; + } } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/builder/package-info.java b/setup/src/main/java/net/theevilreaper/bounce/setup/builder/package-info.java new file mode 100644 index 00000000..9a861877 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/builder/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.theevilreaper.bounce.setup.builder; + +import org.jetbrains.annotations.NotNullByDefault; \ No newline at end of file diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/data/BounceData.java b/setup/src/main/java/net/theevilreaper/bounce/setup/data/BounceData.java index c59cc714..ba8e812f 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/data/BounceData.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/data/BounceData.java @@ -5,7 +5,6 @@ import net.minestom.server.entity.Player; import net.minestom.server.event.EventDispatcher; import net.minestom.server.instance.InstanceContainer; -import net.minestom.server.instance.anvil.AnvilLoader; import net.minestom.server.world.DimensionType; import net.onelitefeather.falco.anvil.FalcoAnvilLoader; import net.onelitefeather.guira.data.SetupData; @@ -14,11 +13,11 @@ import net.theevilreaper.bounce.common.map.GameMap; import net.theevilreaper.bounce.common.util.GsonUtil; import net.theevilreaper.bounce.setup.builder.GameMapBuilder; +import net.theevilreaper.bounce.setup.inventory.area.AreaViewInventory; import net.theevilreaper.bounce.setup.inventory.ground.GroundViewInventory; import net.theevilreaper.bounce.setup.inventory.overview.MapOverviewInventory; import net.theevilreaper.bounce.setup.inventory.push.PushValueInventory; import net.theevilreaper.bounce.setup.util.SetupTags; -import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -38,6 +37,7 @@ public final class BounceData implements SetupData { private MapOverviewInventory overviewInventory; private GroundViewInventory groundViewInventory; private PushValueInventory pushValueInventory; + private AreaViewInventory areaViewInventory; public BounceData(UUID owner, MapEntry mapEntry) { this.owner = owner; @@ -72,8 +72,10 @@ public void reset() { player.removeTag(SetupTags.SETUP_TAG); player.removeTag(SetupTags.PUSH_SLOT_INDEX); this.overviewInventory.unregister(); + this.groundViewInventory.unregisterGroundValueInventory(); this.groundViewInventory.unregister(); this.pushValueInventory.unregister(); + this.areaViewInventory.unregister(); MinecraftServer.getSchedulerManager().scheduleNextTick(() -> { MinecraftServer.getInstanceManager().unregisterInstance(this.instance); @@ -107,6 +109,9 @@ public void loadData() { this.pushValueInventory = new PushValueInventory(this.player, this.gameMapBuilder); this.pushValueInventory.register(); + this.areaViewInventory = new AreaViewInventory(this.player, this.gameMapBuilder); + this.areaViewInventory.register(); + this.instance = MinecraftServer.getInstanceManager().createInstanceContainer(); this.loader = new FalcoAnvilLoader(this.mapEntry.getDirectoryRoot(), DimensionType.OVERWORLD.key()); this.instance.setChunkLoader(this.loader); @@ -141,6 +146,10 @@ public void triggerUpdate() { this.overviewInventory.invalidateDataLayout(); } + public void triggerAreaViewUpdate() { + this.areaViewInventory.invalidateDataLayout(); + } + public void triggerGroundViewUpdate() { this.groundViewInventory.invalidateDataLayout(); this.groundViewInventory.invalidateGroundValueInventory(); @@ -169,6 +178,13 @@ public void openGroundBlockView() { this.groundViewInventory.openGroundBlockValueInventory(); } + /** + * Opens the {@link AreaViewInventory} for the player which owns the data. + */ + public void openAreaView() { + this.areaViewInventory.open(); + } + /** * {@inheritDoc} */ diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/SetupDialogRegistry.java b/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/SetupDialogRegistry.java index bad56987..92615d03 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/SetupDialogRegistry.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/SetupDialogRegistry.java @@ -39,6 +39,10 @@ public SetupDialogRegistry() { )); this.registerDialog(new DeleteDialog()); this.registerDialog(new ValueInputDialog()); + this.registerDialog(new WeightInputDialog()); + this.registerDialog(new ShuffleIntervalInputDialog()); + this.registerDialog(new ReshufflePercentageInputDialog()); + this.registerDialog(new SaveValidationDialog()); } private void registerDialog(@NotNull DialogTemplate dialog) { diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/event/PlayerDialogRequestEvent.java b/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/event/PlayerDialogRequestEvent.java index 3cc3ef0d..63ea1b4c 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/event/PlayerDialogRequestEvent.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/event/PlayerDialogRequestEvent.java @@ -58,7 +58,19 @@ public enum Target { /** * The target for the dialog request is to set up the block bounce. */ - SETUP_BLOCK_BOUNCE + SETUP_BLOCK_BOUNCE, + /** + * The target for the dialog request is to set up the block weight/chance. + */ + SETUP_BLOCK_WEIGHT, + /** + * The target for the dialog request is to set up the reshuffle interval. + */ + SETUP_SHUFFLE_INTERVAL, + /** + * The target for the dialog request is to set up the reshuffle percentage. + */ + SETUP_RESHUFFLE_PERCENTAGE ; } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/type/ReshufflePercentageInputDialog.java b/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/type/ReshufflePercentageInputDialog.java new file mode 100644 index 00000000..fc3f945e --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/type/ReshufflePercentageInputDialog.java @@ -0,0 +1,70 @@ +package net.theevilreaper.bounce.setup.dialog.type; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.dialog.*; +import net.minestom.server.entity.Player; +import net.minestom.server.network.packet.server.common.ShowDialogPacket; +import net.theevilreaper.bounce.setup.dialog.AbstractDialogTemplate; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public final class ReshufflePercentageInputDialog extends AbstractDialogTemplate { + + public static final Key DIALOG_KEY = Key.key("bounce", "reshuffle_percentage_setup_dialog"); + + public ReshufflePercentageInputDialog() { + super( + Component.text("Change reshuffle percentage"), + Component.text("Click to confirm"), + Component.text("Click to cancel") + ); + } + + @Override + public void open(@NotNull Player player) { + this.open(player, 10.0f); + } + + @Override + public void open(@NotNull Player player, @Nullable Float data) { + float initial = data != null ? data : 10.0f; + ShowDialogPacket packet = new ShowDialogPacket(new Dialog.Confirmation( + new DialogMetadata( + header, + null, + false, + false, + DialogAfterAction.CLOSE, + List.of( + new DialogBody.PlainMessage(Component.text("Percentage of the area re-rolled\non each reshuffle (0 - 100%):"), 320) + ), + List.of( + new DialogInput.NumberRange("reshuffle_percentage", 320, Component.text("Percentage"), "options.percent_value", 0f, 100f, initial, 0.1f) + ) + ), + new DialogActionButton( + submitComponent, + Component.text("Click to confirm", NamedTextColor.GREEN), + 155, + new DialogAction.DynamicCustom(DIALOG_KEY, CompoundBinaryTag.builder().build()) + ), + new DialogActionButton( + cancelComponent, + Component.text("Click to cancel", NamedTextColor.RED), + 155, + null + ) + )); + player.sendPacket(packet); + } + + @Override + public @NotNull Key key() { + return DIALOG_KEY; + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/type/SaveValidationDialog.java b/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/type/SaveValidationDialog.java new file mode 100644 index 00000000..ffca4b27 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/type/SaveValidationDialog.java @@ -0,0 +1,80 @@ +package net.theevilreaper.bounce.setup.dialog.type; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.dialog.*; +import net.minestom.server.entity.Player; +import net.minestom.server.network.packet.server.common.ShowDialogPacket; +import net.theevilreaper.bounce.setup.dialog.AbstractDialogTemplate; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shown when a player tries to save a map which is still missing required data. Offers to either go back and + * finish it, or to discard the whole setup process. + */ +public final class SaveValidationDialog extends AbstractDialogTemplate> { + + public static final Key DIALOG_KEY = Key.key("bounce", "save_validation_dialog"); + + public SaveValidationDialog() { + super( + Component.text("This map isn't ready yet"), + Component.text("Delete data"), + Component.text("Back") + ); + } + + @Override + public void open(@NotNull Player player, @Nullable List missingFields) { + List body = new ArrayList<>(); + body.add(new DialogBody.PlainMessage(Component.text("The following data is still missing:"), 200)); + body.add(new DialogBody.PlainMessage(Component.empty(), 1)); + + if (missingFields == null || missingFields.isEmpty()) { + body.add(new DialogBody.PlainMessage(Component.text("Unknown", NamedTextColor.RED), 200)); + } else { + for (String missingField : missingFields) { + body.add(new DialogBody.PlainMessage(Component.text("- " + missingField, NamedTextColor.RED), 200)); + } + } + + body.add(new DialogBody.PlainMessage(Component.empty(), 1)); + body.add(new DialogBody.PlainMessage(Component.text("Go back and fill them in, or delete this setup."), 200)); + + ShowDialogPacket packet = new ShowDialogPacket(new Dialog.Confirmation( + new DialogMetadata( + header, + null, + false, + false, + DialogAfterAction.CLOSE, + body, + List.of() + ), + new DialogActionButton( + submitComponent, + Component.text("Click to confirm", NamedTextColor.GREEN), + 155, + new DialogAction.DynamicCustom(DIALOG_KEY, CompoundBinaryTag.builder().build()) + ), + new DialogActionButton( + cancelComponent, + Component.text("Click to cancel", NamedTextColor.RED), + 155, + null + ) + )); + player.sendPacket(packet); + } + + @Override + public @NotNull Key key() { + return DIALOG_KEY; + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/type/ShuffleIntervalInputDialog.java b/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/type/ShuffleIntervalInputDialog.java new file mode 100644 index 00000000..7ef88528 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/type/ShuffleIntervalInputDialog.java @@ -0,0 +1,70 @@ +package net.theevilreaper.bounce.setup.dialog.type; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.dialog.*; +import net.minestom.server.entity.Player; +import net.minestom.server.network.packet.server.common.ShowDialogPacket; +import net.theevilreaper.bounce.setup.dialog.AbstractDialogTemplate; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public final class ShuffleIntervalInputDialog extends AbstractDialogTemplate { + + public static final Key DIALOG_KEY = Key.key("bounce", "shuffle_interval_setup_dialog"); + + public ShuffleIntervalInputDialog() { + super( + Component.text("Change shuffle interval"), + Component.text("Click to confirm"), + Component.text("Click to cancel") + ); + } + + @Override + public void open(@NotNull Player player) { + this.open(player, 100.0f); + } + + @Override + public void open(@NotNull Player player, @Nullable Float data) { + float initial = data != null ? data : 100.0f; + ShowDialogPacket packet = new ShowDialogPacket(new Dialog.Confirmation( + new DialogMetadata( + header, + null, + false, + false, + DialogAfterAction.CLOSE, + List.of( + new DialogBody.PlainMessage(Component.text("Reshuffle interval in ticks (20 ticks = 1 second):"), 320) + ), + List.of( + new DialogInput.NumberRange("interval_ticks", 320, Component.text("Interval"), "options.generic_value", 20f, 600f, initial, 10f) + ) + ), + new DialogActionButton( + submitComponent, + Component.text("Click to confirm", NamedTextColor.GREEN), + 155, + new DialogAction.DynamicCustom(DIALOG_KEY, CompoundBinaryTag.builder().build()) + ), + new DialogActionButton( + cancelComponent, + Component.text("Click to cancel", NamedTextColor.RED), + 155, + null + ) + )); + player.sendPacket(packet); + } + + @Override + public @NotNull Key key() { + return DIALOG_KEY; + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/type/WeightInputDialog.java b/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/type/WeightInputDialog.java new file mode 100644 index 00000000..a71c499c --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/dialog/type/WeightInputDialog.java @@ -0,0 +1,70 @@ +package net.theevilreaper.bounce.setup.dialog.type; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.dialog.*; +import net.minestom.server.entity.Player; +import net.minestom.server.network.packet.server.common.ShowDialogPacket; +import net.theevilreaper.bounce.setup.dialog.AbstractDialogTemplate; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public final class WeightInputDialog extends AbstractDialogTemplate { + + public static final Key DIALOG_KEY = Key.key("bounce", "weight_setup_dialog"); + + public WeightInputDialog() { + super( + Component.text("Change spawn chance"), + Component.text("Click to confirm"), + Component.text("Click to cancel") + ); + } + + @Override + public void open(@NotNull Player player) { + this.open(player, 5.0f); + } + + @Override + public void open(@NotNull Player player, @Nullable Float data) { + float initial = data != null ? data : 5.0f; + ShowDialogPacket packet = new ShowDialogPacket(new Dialog.Confirmation( + new DialogMetadata( + header, + null, + false, + false, + DialogAfterAction.CLOSE, + List.of( + new DialogBody.PlainMessage(Component.text("Spawn probability in percent (0 - 100%):"), 320) + ), + List.of( + new DialogInput.NumberRange("weight_percentage", 320, Component.text("Chance"), "options.percent_value", 0f, 100f, initial, 0.1f) + ) + ), + new DialogActionButton( + submitComponent, + Component.text("Click to confirm", NamedTextColor.GREEN), + 155, + new DialogAction.DynamicCustom(DIALOG_KEY, CompoundBinaryTag.builder().build()) + ), + new DialogActionButton( + cancelComponent, + Component.text("Click to cancel", NamedTextColor.RED), + 155, + null + ) + )); + player.sendPacket(packet); + } + + @Override + public @NotNull Key key() { + return DIALOG_KEY; + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/event/SetupInventorySwitchEvent.java b/setup/src/main/java/net/theevilreaper/bounce/setup/event/SetupInventorySwitchEvent.java index e235264d..d25adde7 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/event/SetupInventorySwitchEvent.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/event/SetupInventorySwitchEvent.java @@ -78,5 +78,9 @@ public enum SwitchTarget { * Switch to the overview of maps. */ MAP_OVERVIEW, + /** + * Switch to the area selection view of the map. + */ + AREA_VIEW, } } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/event/map/SaveValidationPromptEvent.java b/setup/src/main/java/net/theevilreaper/bounce/setup/event/map/SaveValidationPromptEvent.java new file mode 100644 index 00000000..092a7907 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/event/map/SaveValidationPromptEvent.java @@ -0,0 +1,40 @@ +package net.theevilreaper.bounce.setup.event.map; + +import net.minestom.server.entity.Player; +import net.minestom.server.event.trait.PlayerEvent; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Fired when a player tries to save a map which is still missing required data. + */ +public class SaveValidationPromptEvent implements PlayerEvent { + + private final Player player; + private final List missingFields; + + public SaveValidationPromptEvent(@NotNull Player player, @NotNull List missingFields) { + this.player = player; + this.missingFields = missingFields; + } + + /** + * Gets the names of the required fields which are still missing. + * + * @return the missing field names + */ + public @NotNull List getMissingFields() { + return missingFields; + } + + /** + * Gets the player who triggered the save validation prompt event. + * + * @return the player who triggered the event + */ + @Override + public @NotNull Player getPlayer() { + return this.player; + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/event/map/SetupDiscardEvent.java b/setup/src/main/java/net/theevilreaper/bounce/setup/event/map/SetupDiscardEvent.java new file mode 100644 index 00000000..b8cef5f3 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/event/map/SetupDiscardEvent.java @@ -0,0 +1,27 @@ +package net.theevilreaper.bounce.setup.event.map; + +import net.minestom.server.event.Event; +import net.onelitefeather.guira.data.SetupData; +import org.jetbrains.annotations.NotNull; + +/** + * Fired when a player discards an in-progress setup instead of saving it, e.g. after confirming a + * {@link net.theevilreaper.bounce.setup.dialog.type.SaveValidationDialog}. + */ +public class SetupDiscardEvent implements Event { + + private final SetupData setupData; + + public SetupDiscardEvent(@NotNull SetupData setupData) { + this.setupData = setupData; + } + + /** + * Returns the setup data of the discarded setup process. + * + * @return the setup data + */ + public @NotNull SetupData getData() { + return setupData; + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/DataType.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/DataType.java new file mode 100644 index 00000000..4d4dd819 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/DataType.java @@ -0,0 +1,36 @@ +package net.theevilreaper.bounce.setup.inventory; + +import net.kyori.adventure.text.format.TextColor; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; + +public interface DataType { + + /** + * Gets the name of this overview type. + * + * @return the name of + */ + String getName(); + + /** + * Gets the material associated with this overview type. + * + * @return the material + */ + Material getMaterial(); + + /** + * Gets the text color associated with this overview type. + * + * @return the text color + */ + TextColor getColor(); + + /** + * Gets the ItemStack representation of this data type. + * + * @return the ItemStack for this data type + */ + ItemStack getItem(); +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/area/AreaViewInventory.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/area/AreaViewInventory.java new file mode 100644 index 00000000..684324f2 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/area/AreaViewInventory.java @@ -0,0 +1,98 @@ +package net.theevilreaper.bounce.setup.inventory.area; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Player; +import net.minestom.server.inventory.InventoryType; +import net.kyori.adventure.text.Component; +import net.theevilreaper.aves.inventory.PersonalInventoryBuilder; +import net.theevilreaper.aves.inventory.layout.InventoryLayout; +import net.theevilreaper.aves.inventory.util.LayoutCalculator; +import net.theevilreaper.bounce.common.ground.Area; +import net.theevilreaper.bounce.common.ground.GroundArea; +import net.theevilreaper.bounce.common.push.PushEntry; +import net.theevilreaper.bounce.setup.builder.GameMapBuilder; +import net.theevilreaper.bounce.setup.event.SetupInventorySwitchEvent.SwitchTarget; +import net.theevilreaper.bounce.setup.inventory.slot.SwitchTargetSlot; +import net.theevilreaper.bounce.setup.inventory.slot.area.AreaCornerSlot; +import net.theevilreaper.bounce.setup.inventory.slot.area.ReshufflePercentageSlot; +import net.theevilreaper.bounce.setup.inventory.slot.area.ShuffleIntervalSlot; + +import static net.theevilreaper.bounce.setup.util.SetupItems.DECORATION; + +public final class AreaViewInventory extends PersonalInventoryBuilder { + + private static final Component TITLE = Component.text("Setup area"); + + private static final int SHUFFLE_INTERVAL_SLOT = 10; + private static final int POS1_SLOT = 11; + private static final int POS2_SLOT = 13; + private static final int RESHUFFLE_PERCENTAGE_SLOT = 16; + + private final GameMapBuilder gameMapBuilder; + + public AreaViewInventory(Player player, GameMapBuilder gameMapBuilder) { + super(TITLE, InventoryType.CHEST_3_ROW, player); + this.gameMapBuilder = gameMapBuilder; + + InventoryLayout layout = InventoryLayout.fromType(getType()); + layout.setItems(LayoutCalculator.quad(0, getType().getSize() - 1), DECORATION); + layout.setItem(getType().getSize() - 1, new SwitchTargetSlot(SwitchTarget.MAP_OVERVIEW)); + this.setLayout(layout); + + this.setDataLayoutFunction(dataLayoutFunction -> { + InventoryLayout dataLayout = dataLayoutFunction == null ? InventoryLayout.fromType(getType()) : dataLayoutFunction; + dataLayout.blank(LayoutCalculator.from(SHUFFLE_INTERVAL_SLOT, POS1_SLOT, POS2_SLOT, RESHUFFLE_PERCENTAGE_SLOT)); + + dataLayout.setItem(SHUFFLE_INTERVAL_SLOT, new ShuffleIntervalSlot(AreaViewType.SHUFFLE_INTERVAL, gameMapBuilder.getShuffleIntervalTicks())); + dataLayout.setItem(POS1_SLOT, new AreaCornerSlot(AreaViewType.LEFT_AREA_CORNER, gameMapBuilder.getPos1(), this::setPos1ToCurrentPosition)); + dataLayout.setItem(POS2_SLOT, new AreaCornerSlot(AreaViewType.RIGHT_AREA_CORNER, gameMapBuilder.getPos2(), this::setPos2ToCurrentPosition)); + dataLayout.setItem(RESHUFFLE_PERCENTAGE_SLOT, new ReshufflePercentageSlot(AreaViewType.RESHUFFLE_PERCENTAGE, gameMapBuilder.getReshufflePercentage())); + + return dataLayout; + }); + } + + /** + * Sets Pos1 on the {@link GameMapBuilder} to the player's current position, rebuilds the area once both + * corners are known, and refreshes the layout. + * + * @param player the player whose position is captured + */ + public void setPos1ToCurrentPosition(Player player) { + gameMapBuilder.pos1(toVec(player.getPosition())); + rebuildAreaIfBothCornersSet(); + this.invalidateDataLayout(); + } + + /** + * Sets Pos2 on the {@link GameMapBuilder} to the player's current position, rebuilds the area once both + * corners are known, and refreshes the layout. + * + * @param player the player whose position is captured + */ + public void setPos2ToCurrentPosition(Player player) { + gameMapBuilder.pos2(toVec(player.getPosition())); + rebuildAreaIfBothCornersSet(); + this.invalidateDataLayout(); + } + + /** + * Builds a {@link GroundArea} from the captured corners and the current ground block/push data, and stores + * it on the {@link GameMapBuilder}. A no-op if either corner is still unset. Final validation that an area + * is actually configured happens when the map itself is saved. + */ + private void rebuildAreaIfBothCornersSet() { + Vec pos1 = gameMapBuilder.getPos1(); + Vec pos2 = gameMapBuilder.getPos2(); + if (pos1 == null || pos2 == null) return; + + PushEntry groundEntry = gameMapBuilder.getGroundBlockEntry(); + Area area = new GroundArea(pos1, pos2, groundEntry.getBlock(), gameMapBuilder.getPushDataBuilder().build()); + gameMapBuilder.area(area); + } + + private Vec toVec(Pos pos) { + return new Vec(pos.x(), pos.y(), pos.z()); + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/area/AreaViewType.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/area/AreaViewType.java new file mode 100644 index 00000000..3eeab2af --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/area/AreaViewType.java @@ -0,0 +1,87 @@ +package net.theevilreaper.bounce.setup.inventory.area; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.theevilreaper.bounce.setup.inventory.DataType; + +import java.util.EnumMap; +import java.util.Map; + +/** + * The {@link AreaViewType} enum represents different types of items that can be displayed in the + * {@link AreaViewInventory}. + */ +public enum AreaViewType implements DataType { + + LEFT_AREA_CORNER("Left Corner", Material.GREEN_WOOL, NamedTextColor.AQUA), + RIGHT_AREA_CORNER("Right Corner", Material.RED_WOOL, NamedTextColor.AQUA), + SHUFFLE_INTERVAL("Reshuffle Interval", Material.CLOCK, NamedTextColor.LIGHT_PURPLE), + RESHUFFLE_PERCENTAGE("Reshuffle Percentage", Material.TARGET, NamedTextColor.LIGHT_PURPLE) + + ; + + private final String name; + private final Material material; + private final TextColor color; + + private static final Map itemCache = new EnumMap<>(AreaViewType.class); + private static final AreaViewType[] VALUES = values(); + + /** + * Constructs a new AreaViewType with the specified name, material, and color. + * + * @param name the name of the area view type + * @param material the material associated with this area view type + * @param color the text color for this area view type + */ + AreaViewType(String name, Material material, TextColor color) { + this.name = name; + this.material = material; + this.color = color; + } + + /** + * Gets the name of this area view type. + * + * @return the name of + */ + @Override + public String getName() { + return name; + } + + /** + * Gets the material associated with this area view type. + * + * @return the material + */ + @Override + public Material getMaterial() { + return material; + } + + /** + * Gets the text color associated with this area view type. + * + * @return the text color + */ + @Override + public TextColor getColor() { + return color; + } + + /** + * Gets the ItemStack representation of this area view type. + * + * @return the ItemStack for this area view type + */ + @Override + public ItemStack getItem() { + return itemCache.computeIfAbsent(this, type -> ItemStack.builder(type.getMaterial()) + .customName(Component.text(type.getName(), type.getColor())) + .build()); + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/area/package-info.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/area/package-info.java new file mode 100644 index 00000000..7f00e85e --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/area/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.theevilreaper.bounce.setup.inventory.area; + +import org.jetbrains.annotations.NotNullByDefault; \ No newline at end of file diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/GroundBlockOverviewInventory.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/GroundBlockOverviewInventory.java index 4bcc4784..390986e3 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/GroundBlockOverviewInventory.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/GroundBlockOverviewInventory.java @@ -15,7 +15,6 @@ import net.theevilreaper.bounce.setup.event.ground.PlayerGroundBlockSelectEvent; import net.theevilreaper.bounce.setup.inventory.SetupBlocks; import net.theevilreaper.bounce.setup.inventory.slot.SwitchTargetSlot; -import org.jetbrains.annotations.NotNull; import java.util.Iterator; import java.util.function.Consumer; @@ -56,7 +55,7 @@ public GroundBlockOverviewInventory() { * @param stack the item stack that was clicked * @param result the consumer to handle the click result */ - private void handleClick(@NotNull Player player, int slot, @NotNull Click clickType, @NotNull ItemStack stack, @NotNull Consumer result) { + private void handleClick(Player player, int slot, Click clickType, ItemStack stack, Consumer result) { result.accept(ClickHolder.cancelClick()); EventDispatcher.call(new PlayerGroundBlockSelectEvent(player, stack.material())); } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/GroundValueInventory.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/GroundValueInventory.java index 7f54a760..e56ce83e 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/GroundValueInventory.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/GroundValueInventory.java @@ -13,11 +13,11 @@ import net.theevilreaper.aves.inventory.util.LayoutCalculator; import net.theevilreaper.bounce.common.push.PushEntry; import net.theevilreaper.bounce.setup.builder.GameMapBuilder; +import net.theevilreaper.bounce.setup.dialog.event.PlayerDialogRequestEvent; import net.theevilreaper.bounce.setup.event.SetupInventorySwitchEvent; import net.theevilreaper.bounce.setup.event.SetupInventorySwitchEvent.SwitchTarget; import net.theevilreaper.bounce.setup.inventory.slot.SwitchTargetSlot; import net.theevilreaper.bounce.setup.util.LoreHelper; -import org.jetbrains.annotations.NotNull; import java.util.function.Consumer; @@ -28,11 +28,12 @@ public class GroundValueInventory extends PersonalInventoryBuilder { private static final Component TITLE = Component.text("Ground block"); private static final int BLOCK_SLOT = 11; + private static final int WEIGHT_SLOT = 13; private static final int VALUE_SLOT = 15; private final PushEntry pushEntry; - public GroundValueInventory(@NotNull Player player, @NotNull GameMapBuilder gameMapBuilder) { + public GroundValueInventory(Player player, GameMapBuilder gameMapBuilder) { super(TITLE, InventoryType.CHEST_3_ROW, player); InventoryLayout layout = InventoryLayout.fromType(getType()); layout.setItems(LayoutCalculator.quad(0, getType().getSize() - 1), DECORATION); @@ -47,18 +48,19 @@ public GroundValueInventory(@NotNull Player player, @NotNull GameMapBuilder game dataLayout.blank(LayoutCalculator.from(BLOCK_SLOT, VALUE_SLOT)); Material material = pushEntry.getBlock().material(); dataLayout.setItem(BLOCK_SLOT, ItemStack.builder(material).build(), this::handleBlockClick); + dataLayout.setItem(WEIGHT_SLOT, LoreHelper.getWeight(pushEntry), this::handleWeightButtonClick); dataLayout.setItem(VALUE_SLOT, LoreHelper.getPushValue(pushEntry), this::handlePushButtonClick); return dataLayout; }); } - private void handleBlockClick(@NotNull Player player, int slot, @NotNull Click clickType, @NotNull ItemStack stack, @NotNull Consumer result) { + private void handleBlockClick(Player player, int slot, Click clickType, ItemStack stack, Consumer result) { result.accept(ClickHolder.cancelClick()); player.closeInventory(); EventDispatcher.call(new SetupInventorySwitchEvent(player, SwitchTarget.GROUND_BLOCKS_OVERVIEW)); } - private void handlePushButtonClick(@NotNull Player player, int slot, @NotNull Click click, @NotNull ItemStack stack, @NotNull Consumer result) { + private void handlePushButtonClick(Player player, int slot, Click click, ItemStack stack, Consumer result) { result.accept(ClickHolder.cancelClick()); if ((!(click instanceof Click.Left || click instanceof Click.Right))) return; @@ -73,4 +75,12 @@ private void handlePushButtonClick(@NotNull Player player, int slot, @NotNull Cl this.invalidateDataLayout(); } } + + private void handleWeightButtonClick(Player player, int slot, Click click, ItemStack stack, Consumer result) { + result.accept(ClickHolder.cancelClick()); + if ((!(click instanceof Click.Left || click instanceof Click.Right))) return; + + player.setTag(net.theevilreaper.bounce.setup.util.SetupTags.PUSH_SLOT_INDEX, 0); + EventDispatcher.call(new PlayerDialogRequestEvent(player, PlayerDialogRequestEvent.Target.SETUP_BLOCK_WEIGHT)); + } } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/GroundViewInventory.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/GroundViewInventory.java index 9bca65c7..6da6f874 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/GroundViewInventory.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/GroundViewInventory.java @@ -20,7 +20,7 @@ import net.theevilreaper.bounce.setup.inventory.slot.SwitchTargetSlot; import net.theevilreaper.bounce.setup.util.SetupItems; import net.theevilreaper.bounce.setup.util.SetupMessages; -import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.List; import java.util.function.Consumer; @@ -36,10 +36,10 @@ public class GroundViewInventory extends PersonalInventoryBuilder { Component.empty() ); - private GroundValueInventory groundValueInventory; + private @Nullable GroundValueInventory groundValueInventory; private final GameMapBuilder gameMapBuilder; - public GroundViewInventory(@NotNull Player player, @NotNull GameMapBuilder gameMapBuilder) { + public GroundViewInventory(Player player, GameMapBuilder gameMapBuilder) { super(Component.text("Setup playing area"), InventoryType.CHEST_3_ROW, player); this.gameMapBuilder = gameMapBuilder; InventoryLayout layout = InventoryLayout.fromType(getType()); @@ -81,7 +81,7 @@ public GroundViewInventory(@NotNull Player player, @NotNull GameMapBuilder gameM * @param stack the item stack that was clicked * @param result the consumer to handle the click result */ - private void handlePushButton(@NotNull Player player, int slot, @NotNull Click clickType, @NotNull ItemStack stack, @NotNull Consumer result) { + private void handlePushButton(Player player, int slot, Click clickType, ItemStack stack, Consumer result) { result.accept(ClickHolder.cancelClick()); if (!stack.hasTag(PUSH_SLOT_INDEX)) return; @@ -100,7 +100,7 @@ private void handlePushButton(@NotNull Player player, int slot, @NotNull Click c * @param stack the item stack that was clicked * @param result the consumer to handle the click result */ - private void handleGroundButton(@NotNull Player player, int slot, @NotNull Click clickType, @NotNull ItemStack stack, @NotNull Consumer result) { + private void handleGroundButton(Player player, int slot, Click clickType, ItemStack stack, Consumer result) { result.accept(ClickHolder.cancelClick()); player.closeInventory(); @@ -111,7 +111,7 @@ private void handleGroundButton(@NotNull Player player, int slot, @NotNull Click groundValueInventory.open(); } - private @NotNull ItemStack getSlotItem(@NotNull Material material, int slotId) { + private ItemStack getSlotItem(Material material, int slotId) { return ItemStack.builder(material) .customName(Component.translatable(material.translationKey(), NamedTextColor.AQUA)) .lore(PUSH_LORE) @@ -125,6 +125,15 @@ public void invalidateGroundValueInventory() { } } + /** + * Unregisters the lazily-created ground value inventory, if one was ever opened for this setup session. + */ + public void unregisterGroundValueInventory() { + if (groundValueInventory != null) { + groundValueInventory.unregister(); + } + } + /** * Opens the ground value inventory for the player. * This method checks if the ground value inventory is initialized before attempting to open it. diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/package-info.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/package-info.java new file mode 100644 index 00000000..04a5fd07 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/ground/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.theevilreaper.bounce.setup.inventory.ground; + +import org.jetbrains.annotations.NotNullByDefault; \ No newline at end of file diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/overview/MapOverviewInventory.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/overview/MapOverviewInventory.java index 73fbcdb5..8eb9424c 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/overview/MapOverviewInventory.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/overview/MapOverviewInventory.java @@ -8,11 +8,11 @@ import net.theevilreaper.aves.inventory.slot.ISlot; import net.theevilreaper.aves.inventory.util.LayoutCalculator; import net.theevilreaper.bounce.setup.builder.GameMapBuilder; +import net.theevilreaper.bounce.setup.inventory.slot.area.AreaOverviewSlot; import net.theevilreaper.bounce.setup.inventory.slot.MultiStringSlot; import net.theevilreaper.bounce.setup.inventory.slot.PositionSlot; import net.theevilreaper.bounce.setup.inventory.slot.StringSlot; import net.theevilreaper.bounce.setup.util.SetupItems; -import org.jetbrains.annotations.NotNull; /** * The {@link MapOverviewInventory} is a custom inventory implementation of the {@link PersonalInventoryBuilder} class. @@ -24,7 +24,7 @@ */ public final class MapOverviewInventory extends PersonalInventoryBuilder { - private static final int[] DATA_SLOT = LayoutCalculator.from(10, 12, 14, 16); + private static final int[] DATA_SLOT = LayoutCalculator.from(10, 11, 12, 13, 14); private final GameMapBuilder builder; @@ -34,7 +34,7 @@ public final class MapOverviewInventory extends PersonalInventoryBuilder { * @param player the {@link Player} who is involved * @param builder the {@link GameMapBuilder} which contains the map data */ - public MapOverviewInventory(@NotNull Player player, @NotNull GameMapBuilder builder) { + public MapOverviewInventory(Player player, GameMapBuilder builder) { super(Component.text("Data view"), InventoryType.CHEST_3_ROW, player); this.builder = builder; InventoryLayout layout = InventoryLayout.fromType(getType()); @@ -62,12 +62,13 @@ public MapOverviewInventory(@NotNull Player player, @NotNull GameMapBuilder buil * @param type the {@link OverviewType} to map * @return the corresponding {@link ISlot} for the given type */ - private @NotNull ISlot getOverViewItem(@NotNull OverviewType type) { + private ISlot getOverViewItem(OverviewType type) { return switch (type) { - case SPAWN -> new PositionSlot(type, this.builder.getSpawn()); - case GAME_SPAWN -> new PositionSlot(type, this.builder.getGameSpawn()); + case SPAWN -> new PositionSlot<>(type, this.builder.getSpawn()); + case GAME_SPAWN -> new PositionSlot<>(type, this.builder.getGameSpawn()); case NAME -> new StringSlot(type, builder.getName()); case BUILDER -> new MultiStringSlot(type, builder.getBuilders()); + case AREA -> new AreaOverviewSlot(type, builder.getArea()); }; } } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/overview/OverviewType.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/overview/OverviewType.java index 2fc6e090..c08dd510 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/overview/OverviewType.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/overview/OverviewType.java @@ -5,7 +5,7 @@ import net.kyori.adventure.text.format.TextColor; import net.minestom.server.item.ItemStack; import net.minestom.server.item.Material; -import org.jetbrains.annotations.NotNull; +import net.theevilreaper.bounce.setup.inventory.DataType; import java.util.EnumMap; import java.util.Map; @@ -18,12 +18,13 @@ * @version 1.0.0 * @since 0.1.0 */ -public enum OverviewType { +public enum OverviewType implements DataType { NAME("Map Name", Material.OAK_SIGN, NamedTextColor.YELLOW), BUILDER("Builder", Material.OAK_HANGING_SIGN, NamedTextColor.AQUA), SPAWN("Spawn Point", Material.COMPASS, NamedTextColor.RED), - GAME_SPAWN("Game Spawn Point", Material.RECOVERY_COMPASS, NamedTextColor.RED) + GAME_SPAWN("Game Spawn Point", Material.RECOVERY_COMPASS, NamedTextColor.RED), + AREA("Playing Area", Material.FILLED_MAP, NamedTextColor.GOLD) ; @@ -41,7 +42,7 @@ public enum OverviewType { * @param material the material associated with this overview type * @param color the text color for this overview type */ - OverviewType(@NotNull String name, @NotNull Material material, @NotNull TextColor color) { + OverviewType(String name, Material material, TextColor color) { this.name = name; this.material = material; this.color = color; @@ -52,7 +53,8 @@ public enum OverviewType { * * @return the name of */ - public @NotNull String getName() { + @Override + public String getName() { return name; } @@ -61,7 +63,8 @@ public enum OverviewType { * * @return the material */ - public @NotNull Material getMaterial() { + @Override + public Material getMaterial() { return material; } @@ -70,7 +73,8 @@ public enum OverviewType { * * @return the text color */ - public @NotNull TextColor getColor() { + @Override + public TextColor getColor() { return color; } @@ -79,7 +83,8 @@ public enum OverviewType { * * @return the ItemStack for this overview type */ - public @NotNull ItemStack getItem() { + @Override + public ItemStack getItem() { return itemCache.computeIfAbsent(this, type -> ItemStack.builder(type.getMaterial()) .customName(Component.text(type.getName(), type.getColor())) .build()); @@ -90,11 +95,11 @@ public enum OverviewType { * * @return an array of all OverviewType values */ - public static @NotNull OverviewType[] getValues() { + public static OverviewType[] getValues() { return VALUES; } - public static @NotNull OverviewType fromOrdinal(int ordinal) { + public static OverviewType fromOrdinal(int ordinal) { if (ordinal < 0 || ordinal >= VALUES.length) { throw new IndexOutOfBoundsException("Invalid ordinal for OverviewType: " + ordinal); } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/overview/package-info.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/overview/package-info.java new file mode 100644 index 00000000..92161dd9 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/overview/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.theevilreaper.bounce.setup.inventory.overview; + +import org.jetbrains.annotations.NotNullByDefault; \ No newline at end of file diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/push/PushValueInventory.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/push/PushValueInventory.java index 43bc65b6..dade6189 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/push/PushValueInventory.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/push/PushValueInventory.java @@ -29,6 +29,7 @@ public final class PushValueInventory extends PersonalInventoryBuilder { private static final Component TITLE = Component.text("Push Value"); private static final int BLOCK_SLOT = 11; + private static final int WEIGHT_SLOT = 13; private static final int VALUE_SLOT = 15; private final GameMapBuilder gameMapBuilder; @@ -51,13 +52,14 @@ public void updateLayout(int index) { this.setDataLayoutFunction(dataLayoutFunction -> { InventoryLayout dataLayout = dataLayoutFunction == null ? InventoryLayout.fromType(getType()) : dataLayoutFunction; - dataLayout.blank(LayoutCalculator.from(BLOCK_SLOT, VALUE_SLOT)); + dataLayout.blank(LayoutCalculator.from(BLOCK_SLOT, WEIGHT_SLOT, VALUE_SLOT)); PushEntry pushEntry = this.gameMapBuilder.getPushDataBuilder().getPushValues().get(index); ItemStack stack = ItemStack.builder(pushEntry.getBlock().material()) .build(); dataLayout.setItem(BLOCK_SLOT, stack, this::handleBlockClick); + dataLayout.setItem(WEIGHT_SLOT, LoreHelper.getWeight(pushEntry), this::handleWeightButtonClick); dataLayout.setItem(VALUE_SLOT, LoreHelper.getPushValue(pushEntry), this::handlePushButtonClick); return dataLayout; @@ -81,4 +83,13 @@ private void handlePushButtonClick(@NotNull Player player, int slot, @NotNull Cl player.setTag(PUSH_SLOT_INDEX, index); EventDispatcher.call(new PlayerDialogRequestEvent(player, PlayerDialogRequestEvent.Target.SETUP_BLOCK_BOUNCE)); } + + private void handleWeightButtonClick(@NotNull Player player, int slot, @NotNull Click click, @NotNull ItemStack stack, @NotNull Consumer result) { + result.accept(ClickHolder.cancelClick()); + if ((!(click instanceof Click.Left || click instanceof Click.Right))) return; + + int index = player.getTag(PUSH_SLOT_INDEX); + player.setTag(PUSH_SLOT_INDEX, index); + EventDispatcher.call(new PlayerDialogRequestEvent(player, PlayerDialogRequestEvent.Target.SETUP_BLOCK_WEIGHT)); + } } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/AbstractDataSlot.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/AbstractDataSlot.java index a70d00ea..f14ece68 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/AbstractDataSlot.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/AbstractDataSlot.java @@ -6,26 +6,34 @@ import net.minestom.server.item.ItemStack; import net.theevilreaper.aves.inventory.click.ClickHolder; import net.theevilreaper.aves.inventory.slot.Slot; -import net.theevilreaper.bounce.setup.inventory.overview.OverviewType; -import org.jetbrains.annotations.NotNull; +import net.theevilreaper.bounce.setup.inventory.DataType; import java.util.function.Consumer; -public abstract class AbstractDataSlot extends Slot { +public abstract class AbstractDataSlot extends Slot { - protected final OverviewType type; + protected final T type; - protected AbstractDataSlot(@NotNull OverviewType type) { + protected AbstractDataSlot(T type) { this.type = type; this.setClick(this::click); } + /** + * Handles what happen when a player clicks + * + * @param player who clicked + * @param slot was clicked + * @param clickType was involved + * @param stack was involved + * @param result of the click + */ protected abstract void click( - @NotNull Player player, + Player player, int slot, - @NotNull Click clickType, - @NotNull ItemStack stack, - @NotNull Consumer result + Click clickType, + ItemStack stack, + Consumer result ); /** @@ -35,7 +43,7 @@ protected abstract void click( * @param stack the ItemStack to convert * @return a new ItemStack.Builder with the same material and custom name */ - protected @NotNull ItemStack.Builder asBuilder(@NotNull ItemStack stack) { + protected ItemStack.Builder asBuilder(ItemStack stack) { ItemStack.Builder builder = ItemStack.builder(stack.material()); if (stack.has(DataComponents.CUSTOM_NAME)) { builder.customName(stack.get(DataComponents.CUSTOM_NAME)); diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/EmptyPushSlot.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/EmptyPushSlot.java index 025d329b..79eeb532 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/EmptyPushSlot.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/EmptyPushSlot.java @@ -3,7 +3,6 @@ import net.minestom.server.item.ItemStack; import net.theevilreaper.aves.inventory.slot.Slot; import net.theevilreaper.aves.inventory.util.InventoryConstants; -import org.jetbrains.annotations.NotNull; /** * The {@link EmptyPushSlot} represents a {@link Slot} implementation that is used to indicate that a given push slot is not set up with any data. @@ -19,11 +18,14 @@ public final class EmptyPushSlot extends Slot { * * @param itemStack the {@link ItemStack} to be used for this slot, typically representing an empty or default state. */ - public EmptyPushSlot(@NotNull ItemStack itemStack) { + public EmptyPushSlot(ItemStack itemStack) { setItemStack(itemStack); setClick(InventoryConstants.CANCEL_CLICK); } + /** + * {@inheritDoc} + */ @Override public ItemStack getItem() { return this.itemStack; diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/MaterialSlot.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/MaterialSlot.java index d999e90a..4a8b16ca 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/MaterialSlot.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/MaterialSlot.java @@ -6,7 +6,6 @@ import net.minestom.server.item.Material; import net.theevilreaper.aves.inventory.slot.Slot; import net.theevilreaper.bounce.setup.util.SetupMessages; -import org.jetbrains.annotations.NotNull; public class MaterialSlot extends Slot { @@ -17,7 +16,7 @@ public class MaterialSlot extends Slot { * * @param material the {@link Material} to be used for this slot, typically representing a block or item. */ - public MaterialSlot(@NotNull Material material) { + public MaterialSlot(Material material) { this.stack = ItemStack.builder(material) .lore(Component.empty(), SetupMessages.NO_SPACE_SEPARATOR.append(Component.space()).append(Component.text("Ground block", NamedTextColor.GRAY))) .build(); @@ -28,10 +27,13 @@ public MaterialSlot(@NotNull Material material) { * * @param stack the {@link ItemStack} to be used for this slot, typically representing a material. */ - public MaterialSlot(@NotNull ItemStack stack) { + public MaterialSlot(ItemStack stack) { this.stack = stack; } + /** + * {@inheritDoc} + */ @Override public ItemStack getItem() { return stack; diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/MultiStringSlot.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/MultiStringSlot.java index 84f78360..95f0f863 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/MultiStringSlot.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/MultiStringSlot.java @@ -9,7 +9,6 @@ import net.theevilreaper.bounce.setup.dialog.event.PlayerDialogRequestEvent; import net.theevilreaper.bounce.setup.event.map.PlayerDeletePromptEvent; import net.theevilreaper.bounce.setup.inventory.overview.OverviewType; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.List; @@ -18,20 +17,23 @@ import static net.theevilreaper.bounce.setup.util.SetupMessages.DELETE_CLICK; import static net.theevilreaper.bounce.setup.util.SetupMessages.NO_SPACE_SEPARATOR; -public class MultiStringSlot extends AbstractDataSlot { +public class MultiStringSlot extends AbstractDataSlot { private final List data; - public MultiStringSlot(@NotNull OverviewType overviewType, @Nullable List data) { + public MultiStringSlot(OverviewType overviewType, List data) { super(overviewType); this.data = data; } + /** + * {@inheritDoc} + */ @Override public ItemStack getItem() { ItemStack overviewItem = this.type.getItem(); - if (data == null || data.isEmpty()) { + if (data.isEmpty()) { return overviewItem; } return asBuilder(overviewItem).lore( @@ -44,11 +46,14 @@ public ItemStack getItem() { .build(); } + /** + * {@inheritDoc} + */ @Override - protected void click(@NotNull Player player, int slot, @NotNull Click click, @NotNull ItemStack stack, @NotNull Consumer result) { + protected void click(Player player, int slot, Click click, ItemStack stack, Consumer result) { result.accept(ClickHolder.cancelClick()); - if (data == null || data.isEmpty()) { + if (data.isEmpty()) { EventDispatcher.call(new PlayerDialogRequestEvent(player, PlayerDialogRequestEvent.Target.SETUP_REQUEST_AUTHOR)); return; } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/PositionSlot.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/PositionSlot.java index da9ede1e..e26c9308 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/PositionSlot.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/PositionSlot.java @@ -10,8 +10,8 @@ import net.theevilreaper.aves.inventory.click.ClickHolder; import net.theevilreaper.aves.util.Components; import net.theevilreaper.bounce.setup.event.map.PlayerDeletePromptEvent; +import net.theevilreaper.bounce.setup.inventory.DataType; import net.theevilreaper.bounce.setup.inventory.overview.OverviewType; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.math.RoundingMode; @@ -25,7 +25,7 @@ import static net.theevilreaper.bounce.setup.util.SetupMessages.DELETE_CLICK; import static net.theevilreaper.bounce.setup.util.SetupMessages.TELEPORT_CLICK; -public class PositionSlot extends AbstractDataSlot { +public class PositionSlot extends AbstractDataSlot { private static final DecimalFormat DECIMAL_FORMAT; @@ -35,10 +35,10 @@ public class PositionSlot extends AbstractDataSlot { DECIMAL_FORMAT.setDecimalFormatSymbols(DecimalFormatSymbols.getInstance(Locale.ROOT)); } - private final Pos position; + private final @Nullable Pos position; - public PositionSlot(@NotNull OverviewType overviewType, @Nullable Pos position) { - super(overviewType); + public PositionSlot(T type, @Nullable Pos position) { + super(type); this.position = position; } @@ -46,9 +46,8 @@ public PositionSlot(@NotNull OverviewType overviewType, @Nullable Pos position) public ItemStack getItem() { ItemStack overviewItem = this.type.getItem(); - if (position == null) { - return overviewItem; - } + if (position == null) return overviewItem; + List lore = new ArrayList<>(); lore.add(Component.empty()); lore.addAll(Components.pointToLore(MiniMessage.miniMessage(), position, DECIMAL_FORMAT)); @@ -61,7 +60,7 @@ public ItemStack getItem() { } @Override - protected void click(@NotNull Player player, int slot, @NotNull Click click, @NotNull ItemStack stack, @NotNull Consumer result) { + protected void click(Player player, int slot, Click click, ItemStack stack, Consumer result) { result.accept(ClickHolder.cancelClick()); if ((!(click instanceof Click.Left || click instanceof Click.Right)) || position == null) return; if (click instanceof Click.Left) { @@ -70,6 +69,8 @@ protected void click(@NotNull Player player, int slot, @NotNull Click click, @No return; } - EventDispatcher.call(new PlayerDeletePromptEvent(player, this.type)); + if (this.type instanceof OverviewType overviewType) { + EventDispatcher.call(new PlayerDeletePromptEvent(player, overviewType)); + } } } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/StringSlot.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/StringSlot.java index 354c2eba..90254f53 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/StringSlot.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/StringSlot.java @@ -9,7 +9,6 @@ import net.theevilreaper.bounce.setup.dialog.event.PlayerDialogRequestEvent; import net.theevilreaper.bounce.setup.event.map.PlayerDeletePromptEvent; import net.theevilreaper.bounce.setup.inventory.overview.OverviewType; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.function.Consumer; @@ -17,15 +16,18 @@ import static net.theevilreaper.bounce.setup.util.SetupMessages.DELETE_CLICK; import static net.theevilreaper.bounce.setup.util.SetupMessages.NO_SPACE_SEPARATOR; -public class StringSlot extends AbstractDataSlot { +public class StringSlot extends AbstractDataSlot { private final String data; - public StringSlot(@NotNull OverviewType overviewType, @Nullable String data) { + public StringSlot(OverviewType overviewType, @Nullable String data) { super(overviewType); this.data = data; } + /** + * {@inheritDoc} + */ @Override public ItemStack getItem() { ItemStack overviewItem = this.type.getItem(); @@ -43,8 +45,11 @@ public ItemStack getItem() { .build(); } + /** + * {@inheritDoc} + */ @Override - protected void click(@NotNull Player player, int slot, @NotNull Click click, @NotNull ItemStack stack, @NotNull Consumer result) { + protected void click(Player player, int slot, Click click, ItemStack stack, Consumer result) { result.accept(ClickHolder.cancelClick()); if (data == null || data.isEmpty()) { diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/SwitchTargetSlot.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/SwitchTargetSlot.java index e8f4e974..b6e6019f 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/SwitchTargetSlot.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/SwitchTargetSlot.java @@ -11,7 +11,6 @@ import net.theevilreaper.aves.inventory.slot.Slot; import net.theevilreaper.bounce.setup.event.SetupInventorySwitchEvent; import net.theevilreaper.bounce.setup.event.SetupInventorySwitchEvent.SwitchTarget; -import org.jetbrains.annotations.NotNull; import java.util.function.Consumer; @@ -20,7 +19,7 @@ public final class SwitchTargetSlot extends Slot { private final ItemStack stack; private final SwitchTarget switchTarget; - public SwitchTargetSlot(@NotNull SwitchTarget target) { + public SwitchTargetSlot(SwitchTarget target) { this.stack = ItemStack.builder(Material.BARRIER) .customName(Component.text("Back", NamedTextColor.RED)) .build(); @@ -29,19 +28,17 @@ public SwitchTargetSlot(@NotNull SwitchTarget target) { } /** - * Handles the click event for the back slot. - * - * @param player the player who clicked - * @param slot the slot that was clicked - * @param clickType the type of click - * @param result the result of the click condition + * {@inheritDoc} */ - private void handleClick(@NotNull Player player, int slot, @NotNull Click clickType, @NotNull ItemStack stack, @NotNull Consumer result) { + private void handleClick(Player player, int slot, Click clickType, ItemStack stack, Consumer result) { result.accept(ClickHolder.cancelClick()); player.closeInventory(); EventDispatcher.call(new SetupInventorySwitchEvent(player, this.switchTarget)); } + /** + * {@inheritDoc} + */ @Override public ItemStack getItem() { return this.stack; diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/AreaCornerSlot.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/AreaCornerSlot.java new file mode 100644 index 00000000..77501f42 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/AreaCornerSlot.java @@ -0,0 +1,61 @@ +package net.theevilreaper.bounce.setup.inventory.slot.area; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Player; +import net.minestom.server.inventory.click.Click; +import net.minestom.server.item.ItemStack; +import net.theevilreaper.aves.inventory.click.ClickHolder; +import net.theevilreaper.bounce.setup.inventory.area.AreaViewType; +import net.theevilreaper.bounce.setup.inventory.slot.AbstractDataSlot; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Consumer; + +/** + * Displays one corner of the ground area (see {@link AreaViewType#LEFT_AREA_CORNER}/{@link AreaViewType#RIGHT_AREA_CORNER}) + * and captures the player's current position for it on left-click. + */ +public final class AreaCornerSlot extends AbstractDataSlot { + + private final @Nullable Vec position; + private final Consumer onSet; + + public AreaCornerSlot(AreaViewType type, @Nullable Vec position, Consumer onSet) { + super(type); + this.position = position; + this.onSet = onSet; + } + + /** + * {@inheritDoc} + */ + @Override + public ItemStack getItem() { + ItemStack baseItem = this.type.getItem(); + if (position == null) { + return asBuilder(baseItem).lore( + Component.empty(), + Component.text("Not set", NamedTextColor.RED), + Component.empty(), + Component.text("Left-click: set to your position", NamedTextColor.GRAY) + ).build(); + } + return asBuilder(baseItem).lore( + Component.empty(), + Component.text("X: " + position.x() + " Y: " + position.y() + " Z: " + position.z(), NamedTextColor.YELLOW), + Component.empty(), + Component.text("Left-click: set to your position", NamedTextColor.GRAY) + ).build(); + } + + /** + * {@inheritDoc} + */ + @Override + protected void click(Player player, int slot, Click click, ItemStack stack, Consumer result) { + result.accept(ClickHolder.cancelClick()); + if (click instanceof Click.Left) onSet.accept(player); + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/AreaOverviewSlot.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/AreaOverviewSlot.java new file mode 100644 index 00000000..f521e29c --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/AreaOverviewSlot.java @@ -0,0 +1,56 @@ +package net.theevilreaper.bounce.setup.inventory.slot.area; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.inventory.click.Click; +import net.minestom.server.item.ItemStack; +import net.theevilreaper.aves.inventory.click.ClickHolder; +import net.theevilreaper.bounce.common.ground.Area; +import net.theevilreaper.bounce.setup.event.SetupInventorySwitchEvent; +import net.theevilreaper.bounce.setup.event.SetupInventorySwitchEvent.SwitchTarget; +import net.theevilreaper.bounce.setup.inventory.overview.OverviewType; +import net.theevilreaper.bounce.setup.inventory.slot.AbstractDataSlot; +import net.theevilreaper.bounce.setup.util.SetupMessages; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Consumer; + +public final class AreaOverviewSlot extends AbstractDataSlot { + + private final @Nullable Area area; + + public AreaOverviewSlot(OverviewType overviewType, @Nullable Area area) { + super(overviewType); + this.area = area; + } + + @Override + public ItemStack getItem() { + ItemStack overviewItem = this.type.getItem(); + if (area == null) { + return asBuilder(overviewItem).lore( + Component.empty(), + Component.text("Not set", NamedTextColor.RED), + Component.empty(), + Component.text("Click to configure", NamedTextColor.GRAY), + Component.empty() + ).build(); + } + return asBuilder(overviewItem).lore( + Component.empty(), + Component.text("Configured", type.getColor()), + Component.empty(), + SetupMessages.CLICK_TO_EDIT, + Component.empty() + ).build(); + } + + @Override + protected void click(Player player, int slot, Click click, ItemStack stack, Consumer result) { + result.accept(ClickHolder.cancelClick()); + EventDispatcher.call(new SetupInventorySwitchEvent(player, SwitchTarget.AREA_VIEW)); + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/ReshufflePercentageSlot.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/ReshufflePercentageSlot.java new file mode 100644 index 00000000..8f47a1dc --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/ReshufflePercentageSlot.java @@ -0,0 +1,52 @@ +package net.theevilreaper.bounce.setup.inventory.slot.area; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.inventory.click.Click; +import net.minestom.server.item.ItemStack; +import net.theevilreaper.aves.inventory.click.ClickHolder; +import net.theevilreaper.bounce.setup.dialog.event.PlayerDialogRequestEvent; +import net.theevilreaper.bounce.setup.inventory.area.AreaViewType; +import net.theevilreaper.bounce.setup.inventory.slot.AbstractDataSlot; +import net.theevilreaper.bounce.setup.util.SetupMessages; +import org.jetbrains.annotations.NotNull; + +import java.util.Locale; +import java.util.function.Consumer; + +public final class ReshufflePercentageSlot extends AbstractDataSlot { + + private final double reshufflePercentage; + + public ReshufflePercentageSlot(AreaViewType type, double reshufflePercentage) { + super(type); + this.reshufflePercentage = reshufflePercentage; + } + + /** + * {@inheritDoc} + */ + @Override + public ItemStack getItem() { + ItemStack baseItem = this.type.getItem(); + double percentage = reshufflePercentage * 100.0; + return asBuilder(baseItem).lore( + Component.empty(), + Component.text(String.format(Locale.ROOT, "%.1f%%", percentage), NamedTextColor.YELLOW), + Component.empty(), + SetupMessages.CLICK_TO_EDIT, + Component.empty() + ).build(); + } + + /** + * {@inheritDoc} + */ + @Override + protected void click(Player player, int slot, Click click, ItemStack stack, Consumer result) { + result.accept(ClickHolder.cancelClick()); + EventDispatcher.call(new PlayerDialogRequestEvent(player, PlayerDialogRequestEvent.Target.SETUP_RESHUFFLE_PERCENTAGE)); + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/ShuffleIntervalSlot.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/ShuffleIntervalSlot.java new file mode 100644 index 00000000..4376dd4f --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/ShuffleIntervalSlot.java @@ -0,0 +1,52 @@ +package net.theevilreaper.bounce.setup.inventory.slot.area; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.inventory.click.Click; +import net.minestom.server.item.ItemStack; +import net.theevilreaper.aves.inventory.click.ClickHolder; +import net.theevilreaper.bounce.setup.dialog.event.PlayerDialogRequestEvent; +import net.theevilreaper.bounce.setup.inventory.area.AreaViewType; +import net.theevilreaper.bounce.setup.inventory.slot.AbstractDataSlot; +import net.theevilreaper.bounce.setup.util.SetupMessages; +import org.jetbrains.annotations.NotNull; + +import java.util.Locale; +import java.util.function.Consumer; + +public final class ShuffleIntervalSlot extends AbstractDataSlot { + + private final int shuffleIntervalTicks; + + public ShuffleIntervalSlot(AreaViewType type, int shuffleIntervalTicks) { + super(type); + this.shuffleIntervalTicks = shuffleIntervalTicks; + } + + /** + * {@inheritDoc} + */ + @Override + public ItemStack getItem() { + ItemStack baseItem = this.type.getItem(); + double seconds = shuffleIntervalTicks / 20.0; + return asBuilder(baseItem).lore( + Component.empty(), + Component.text(String.format(Locale.ROOT, "%d ticks (%.1fs)", shuffleIntervalTicks, seconds), NamedTextColor.YELLOW), + Component.empty(), + SetupMessages.CLICK_TO_EDIT, + Component.empty() + ).build(); + } + + /** + * {@inheritDoc} + */ + @Override + protected void click(Player player, int slot, Click click, ItemStack stack, Consumer result) { + result.accept(ClickHolder.cancelClick()); + EventDispatcher.call(new PlayerDialogRequestEvent(player, PlayerDialogRequestEvent.Target.SETUP_SHUFFLE_INTERVAL)); + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/package-info.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/package-info.java new file mode 100644 index 00000000..de65f7d0 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/area/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.theevilreaper.bounce.setup.inventory.slot.area; + +import org.jetbrains.annotations.NotNullByDefault; \ No newline at end of file diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/package-info.java b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/package-info.java new file mode 100644 index 00000000..fed92122 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/inventory/slot/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.theevilreaper.bounce.setup.inventory.slot; + +import org.jetbrains.annotations.NotNullByDefault; \ No newline at end of file diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/listener/PlayerItemListener.java b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/PlayerItemListener.java index 0c12fac1..7098a2ca 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/listener/PlayerItemListener.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/PlayerItemListener.java @@ -4,11 +4,14 @@ import net.onelitefeather.guira.functional.OptionalSetupDataGetter; import net.theevilreaper.aves.util.functional.PlayerConsumer; import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; import net.minestom.server.event.player.PlayerUseItemEvent; import net.minestom.server.item.ItemStack; import net.theevilreaper.bounce.setup.data.BounceData; +import net.theevilreaper.bounce.setup.event.map.SaveValidationPromptEvent; import org.jetbrains.annotations.NotNull; +import java.util.List; import java.util.Optional; import java.util.function.Consumer; @@ -60,6 +63,12 @@ public void accept(@NotNull PlayerUseItemEvent event) { return; } + List missingFields = setupData.getMapBuilder().getMissingFieldNames(); + if (!missingFields.isEmpty()) { + EventDispatcher.call(new SaveValidationPromptEvent(player, missingFields)); + return; + } + setupData.save(); player.getInventory().setItemStack(0x0, ItemStack.AIR); } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/listener/dialog/PlayerCustomClickEventListener.java b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/dialog/PlayerCustomClickEventListener.java index d35db99f..e47146cc 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/listener/dialog/PlayerCustomClickEventListener.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/dialog/PlayerCustomClickEventListener.java @@ -4,11 +4,13 @@ import net.kyori.adventure.nbt.BinaryTag; import net.kyori.adventure.nbt.CompoundBinaryTag; import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; import net.minestom.server.event.player.PlayerCustomClickEvent; import net.onelitefeather.guira.functional.OptionalSetupDataGetter; import net.theevilreaper.bounce.setup.data.BounceData; import net.theevilreaper.bounce.setup.dialog.*; import net.theevilreaper.bounce.setup.dialog.type.*; +import net.theevilreaper.bounce.setup.event.map.SetupDiscardEvent; import net.theevilreaper.bounce.setup.inventory.overview.OverviewType; import net.theevilreaper.bounce.setup.util.SetupTags; import org.jetbrains.annotations.NotNull; @@ -60,12 +62,59 @@ public void accept(@NotNull PlayerCustomClickEvent event) { case AuthorInputDialog ignored -> handleAuthorSet(data, dialogData); case DeleteDialog ignored -> this.handleDataDelete(data, dialogData); case ValueInputDialog ignored -> this.handleValueUpdate(player, data, dialogData); + case WeightInputDialog ignored -> this.handleWeightUpdate(player, data, dialogData); + case ShuffleIntervalInputDialog ignored -> this.handleShuffleIntervalSet(data, dialogData); + case ReshufflePercentageInputDialog ignored -> this.handleReshufflePercentageSet(data, dialogData); + case SaveValidationDialog ignored -> EventDispatcher.call(new SetupDiscardEvent(data)); default -> throw new IllegalStateException("Unexpected dialog type: " + dialogTemplate.getClass().getCanonicalName()); } }); } + /** + * Handles setting the reshuffle interval based on the dialog data provided. + * @param data the BounceData instance containing the map builder + * @param dialogData the dialog data containing the interval ticks to set + */ + private void handleShuffleIntervalSet(@NotNull BounceData data, @NotNull CompoundBinaryTag dialogData) { + int ticks = (int) dialogData.getFloat("interval_ticks", 100f); + if (ticks < 20) ticks = 20; + data.getMapBuilder().shuffleIntervalTicks(ticks); + data.triggerAreaViewUpdate(); + } + + /** + * Handles setting the reshuffle percentage based on the dialog data provided. + * @param data the BounceData instance containing the map builder + * @param dialogData the dialog data containing the reshuffle percentage to set + */ + private void handleReshufflePercentageSet(@NotNull BounceData data, @NotNull CompoundBinaryTag dialogData) { + float percentage = dialogData.getFloat("reshuffle_percentage", 10.0f); + double reshufflePercentage = Math.max(0.0, Math.min(1.0, percentage / 100.0)); + data.getMapBuilder().reshufflePercentage(reshufflePercentage); + data.triggerAreaViewUpdate(); + } + + /** + * Handles the update of a weight/chance based on the dialog data provided. + * @param player the player who triggered the dialog + * @param data the BounceData instance containing the map builder + * @param dialogData the dialog data containing the weight percentage to update + */ + private void handleWeightUpdate(@NotNull Player player, @NotNull BounceData data, @NotNull CompoundBinaryTag dialogData) { + float percentage = dialogData.getFloat("weight_percentage", 5.0f); + double weight = Math.max(0.0, Math.min(1.0, Math.round((percentage / 100.0) * 1000.0) / 1000.0)); + int valueIndex = player.hasTag(SetupTags.PUSH_SLOT_INDEX) ? player.getTag(SetupTags.PUSH_SLOT_INDEX) : 0; + player.removeTag(SetupTags.PUSH_SLOT_INDEX); + data.getMapBuilder().getPushDataBuilder().getPushValues().get(valueIndex).setWeight(weight); + if (valueIndex == 0) { + data.triggerGroundViewUpdate(); + } else { + data.triggerPushValueUpdate(valueIndex); + } + } + /** * Handles the update of a value based on the dialog data provided. * @param player the player who triggered the dialog diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/listener/dialog/PlayerDialogRequestListener.java b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/dialog/PlayerDialogRequestListener.java index 4853bc0f..97f9767a 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/listener/dialog/PlayerDialogRequestListener.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/dialog/PlayerDialogRequestListener.java @@ -33,6 +33,9 @@ public void accept(@NotNull PlayerDialogRequestEvent event) { case Target.SETUP_AUTHOR -> dialogTemplate = dialogRegistry.get(AuthorInputDialog.DIALOG_KEY); case Target.SETUP_REQUEST_AUTHOR -> dialogTemplate = dialogRegistry.get(AuthorRequestDialog.DIALOG_KEY); case Target.SETUP_BLOCK_BOUNCE -> dialogTemplate = dialogRegistry.get(ValueInputDialog.DIALOG_KEY); + case Target.SETUP_BLOCK_WEIGHT -> dialogTemplate = dialogRegistry.get(net.theevilreaper.bounce.setup.dialog.type.WeightInputDialog.DIALOG_KEY); + case Target.SETUP_SHUFFLE_INTERVAL -> dialogTemplate = dialogRegistry.get(net.theevilreaper.bounce.setup.dialog.type.ShuffleIntervalInputDialog.DIALOG_KEY); + case Target.SETUP_RESHUFFLE_PERCENTAGE -> dialogTemplate = dialogRegistry.get(net.theevilreaper.bounce.setup.dialog.type.ReshufflePercentageInputDialog.DIALOG_KEY); default -> throw new IllegalArgumentException("Unknown target: " + target); } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/listener/dialog/SaveValidationPromptListener.java b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/dialog/SaveValidationPromptListener.java new file mode 100644 index 00000000..6332a8d7 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/dialog/SaveValidationPromptListener.java @@ -0,0 +1,32 @@ +package net.theevilreaper.bounce.setup.listener.dialog; + +import net.theevilreaper.bounce.setup.dialog.DialogRegistry; +import net.theevilreaper.bounce.setup.dialog.DialogTemplate; +import net.theevilreaper.bounce.setup.dialog.type.SaveValidationDialog; +import net.theevilreaper.bounce.setup.event.map.SaveValidationPromptEvent; +import org.jetbrains.annotations.NotNull; + +import java.util.function.Consumer; + +public class SaveValidationPromptListener implements Consumer { + + private final DialogRegistry dialogRegistry; + + public SaveValidationPromptListener(@NotNull DialogRegistry dialogRegistry) { + this.dialogRegistry = dialogRegistry; + } + + @Override + public void accept(@NotNull SaveValidationPromptEvent event) { + DialogTemplate dialog = dialogRegistry.get(SaveValidationDialog.DIALOG_KEY); + + if (dialog == null) { + throw new IllegalStateException("Dialog with key " + SaveValidationDialog.DIALOG_KEY + " not found in registry."); + } + + switch (dialog) { + case SaveValidationDialog saveValidationDialog -> saveValidationDialog.open(event.getPlayer(), event.getMissingFields()); + default -> throw new IllegalStateException("Unexpected dialog type: " + dialog.getClass().getCanonicalName()); + } + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/listener/inventory/SetupInventorySwitchListener.java b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/inventory/SetupInventorySwitchListener.java index 9fbec825..60ec6496 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/listener/inventory/SetupInventorySwitchListener.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/inventory/SetupInventorySwitchListener.java @@ -1,9 +1,11 @@ package net.theevilreaper.bounce.setup.listener.inventory; import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; import net.onelitefeather.guira.data.SetupData; import net.onelitefeather.guira.functional.OptionalSetupDataGetter; import net.theevilreaper.bounce.setup.data.BounceData; +import net.theevilreaper.bounce.setup.dialog.event.PlayerDialogRequestEvent; import net.theevilreaper.bounce.setup.event.SetupInventorySwitchEvent; import net.theevilreaper.bounce.setup.inventory.InventoryService; import net.theevilreaper.bounce.setup.util.SetupMessages; @@ -68,5 +70,9 @@ public void accept(@NotNull SetupInventorySwitchEvent event) { if (event.getTarget() == SwitchTarget.GROUND_BLOCK_VIEW) { data.openGroundBlockView(); } + + if (event.getTarget() == SwitchTarget.AREA_VIEW) { + data.openAreaView(); + } } } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/listener/map/SetupDiscardListener.java b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/map/SetupDiscardListener.java new file mode 100644 index 00000000..a50806a1 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/map/SetupDiscardListener.java @@ -0,0 +1,32 @@ +package net.theevilreaper.bounce.setup.listener.map; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.entity.Player; +import net.onelitefeather.guira.data.SetupData; +import net.onelitefeather.guira.functional.OptionalSetupDataGetter; +import net.theevilreaper.aves.util.functional.PlayerConsumer; +import net.theevilreaper.bounce.setup.event.map.SetupDiscardEvent; +import org.jetbrains.annotations.NotNull; + +import java.util.function.Consumer; + +public class SetupDiscardListener implements Consumer { + + private final PlayerConsumer instanceSwitcher; + private final OptionalSetupDataGetter setupDataRemover; + + public SetupDiscardListener(@NotNull PlayerConsumer instanceSwitcher, @NotNull OptionalSetupDataGetter setupDataRemover) { + this.instanceSwitcher = instanceSwitcher; + this.setupDataRemover = setupDataRemover; + } + + @Override + public void accept(@NotNull SetupDiscardEvent event) { + SetupData setupData = event.getData(); + + Player player = MinecraftServer.getConnectionManager().getOnlinePlayerByUuid(setupData.getId()); + this.instanceSwitcher.accept(player); + setupData.reset(); + this.setupDataRemover.get(setupData.getId()); + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/listener/map/SetupFinishListener.java b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/map/SetupFinishListener.java index 165fd3d8..651f8fda 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/listener/map/SetupFinishListener.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/listener/map/SetupFinishListener.java @@ -4,6 +4,7 @@ import net.minestom.server.entity.Player; import net.onelitefeather.guira.data.SetupData; import net.onelitefeather.guira.event.SetupFinishEvent; +import net.onelitefeather.guira.functional.OptionalSetupDataGetter; import net.theevilreaper.aves.util.functional.PlayerConsumer; import org.jetbrains.annotations.NotNull; @@ -12,9 +13,11 @@ public class SetupFinishListener implements Consumer { private final PlayerConsumer instanceSwitcher; + private final OptionalSetupDataGetter setupDataRemover; - public SetupFinishListener(@NotNull PlayerConsumer instanceSwitcher) { + public SetupFinishListener(@NotNull PlayerConsumer instanceSwitcher, @NotNull OptionalSetupDataGetter setupDataRemover) { this.instanceSwitcher = instanceSwitcher; + this.setupDataRemover = setupDataRemover; } @Override @@ -24,5 +27,6 @@ public void accept(@NotNull SetupFinishEvent event) { Player player = MinecraftServer.getConnectionManager().getOnlinePlayerByUuid(setupData.getId()); this.instanceSwitcher.accept(player); setupData.reset(); + this.setupDataRemover.get(setupData.getId()); } } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/player/SetupPlayer.java b/setup/src/main/java/net/theevilreaper/bounce/setup/player/SetupPlayer.java new file mode 100644 index 00000000..fbf2edf1 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/player/SetupPlayer.java @@ -0,0 +1,33 @@ +package net.theevilreaper.bounce.setup.player; + +import net.minestom.server.coordinate.BlockVec; +import net.minestom.server.entity.Player; +import net.minestom.server.network.player.GameProfile; +import net.minestom.server.network.player.PlayerConnection; +import org.jetbrains.annotations.Nullable; + +public class SetupPlayer extends Player { + + private @Nullable BlockVec leftCorner; + private @Nullable BlockVec rightCorner; + + public SetupPlayer(PlayerConnection playerConnection, GameProfile gameProfile) { + super(playerConnection, gameProfile); + } + + public void setLeftCorner(@Nullable BlockVec leftCorner) { + this.leftCorner = leftCorner; + } + + public void setRightCorner(@Nullable BlockVec rightCorner) { + this.rightCorner = rightCorner; + } + + public @Nullable BlockVec getRightCorner() { + return rightCorner; + } + + public @Nullable BlockVec getLeftCorner() { + return leftCorner; + } +} diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/player/package-info.java b/setup/src/main/java/net/theevilreaper/bounce/setup/player/package-info.java new file mode 100644 index 00000000..b7713ad1 --- /dev/null +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/player/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.theevilreaper.bounce.setup.player; + +import org.jetbrains.annotations.NotNullByDefault; \ No newline at end of file diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/util/LoreHelper.java b/setup/src/main/java/net/theevilreaper/bounce/setup/util/LoreHelper.java index 202190e1..0a84becc 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/util/LoreHelper.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/util/LoreHelper.java @@ -16,6 +16,7 @@ public final class LoreHelper { private static final Component DISPLAY_NAME = Component.text("Boost Value", NamedTextColor.GREEN); + private static final Component WEIGHT_DISPLAY_NAME = Component.text("Weight", NamedTextColor.LIGHT_PURPLE); private static final Component CURRENT_VALUE = Component.text("Current:", NamedTextColor.GRAY).append(Component.space()); private static final Component LEFT_CLICK = miniMessage().deserialize("Left-click: Increase the value"); @@ -32,6 +33,20 @@ public static ItemStack getPushValue(PushEntry pushEntry) { .build(); } + public static ItemStack getWeight(PushEntry pushEntry) { + List lore = new ArrayList<>(); + lore.add(Component.empty()); + String formatted = String.format(java.util.Locale.ROOT, "%.1f%% (%.3f)", pushEntry.getWeight() * 100.0, pushEntry.getWeight()); + lore.add(CURRENT_VALUE.append(Component.text(formatted, NamedTextColor.YELLOW))); + lore.add(Component.empty()); + lore.add(miniMessage().deserialize("Click: Open dialog to edit chance")); + lore.add(Component.empty()); + return ItemStack.builder(Material.NETHER_STAR) + .customName(WEIGHT_DISPLAY_NAME) + .lore(lore) + .build(); + } + private LoreHelper() { // Prevent instantiation } diff --git a/setup/src/main/java/net/theevilreaper/bounce/setup/util/SetupMessages.java b/setup/src/main/java/net/theevilreaper/bounce/setup/util/SetupMessages.java index a13439d3..0b87b06a 100644 --- a/setup/src/main/java/net/theevilreaper/bounce/setup/util/SetupMessages.java +++ b/setup/src/main/java/net/theevilreaper/bounce/setup/util/SetupMessages.java @@ -12,11 +12,13 @@ public class SetupMessages extends Messages { public static final Component TELEPORT_CLICK; public static final Component DELETE_CLICK; + public static final Component CLICK_TO_EDIT; static { NO_SPACE_SEPARATOR = Component.text("ยป", NamedTextColor.GRAY); SELECT_MAP_FIRST = withPrefix(Component.text("Please select a map first!", NamedTextColor.RED)); INVALID_NAME = withPrefix(Component.text("Invalid name for the map", NamedTextColor.RED)); + CLICK_TO_EDIT = Component.text("Click to edit", NamedTextColor.GRAY); TELEPORT_CLICK = NO_SPACE_SEPARATOR .append(Component.space()) diff --git a/setup/src/test/java/net/theevilreaper/bounce/setup/builder/GameMapBuilderTest.java b/setup/src/test/java/net/theevilreaper/bounce/setup/builder/GameMapBuilderTest.java index c2ed4e88..e9d287f1 100644 --- a/setup/src/test/java/net/theevilreaper/bounce/setup/builder/GameMapBuilderTest.java +++ b/setup/src/test/java/net/theevilreaper/bounce/setup/builder/GameMapBuilderTest.java @@ -1,12 +1,17 @@ package net.theevilreaper.bounce.setup.builder; import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; import net.minestom.server.instance.block.Block; +import net.theevilreaper.bounce.common.ground.Area; +import net.theevilreaper.bounce.common.ground.GroundArea; import net.theevilreaper.bounce.common.map.GameMap; import net.theevilreaper.bounce.common.push.PushData; import net.theevilreaper.bounce.common.push.PushEntry; import org.junit.jupiter.api.Test; +import java.util.List; + import static org.junit.jupiter.api.Assertions.*; class GameMapBuilderTest { @@ -72,4 +77,141 @@ void testGameMapBuilderInitializationWithExistingData() { //assertTrue(anotherBuilder.getAuthors().contains("Test"), "Authors should contain 'Test'"); assertEquals(4, anotherBuilder.getPushDataBuilder().getPushValues().size(), "Push data should contain four entries"); } + + @Test + void testNewBuilderHasNoAreaAndDefaultInterval() { + GameMapBuilder builder = new GameMapBuilder(); + assertNull(builder.getArea()); + assertTrue(builder.getShuffleIntervalTicks() > 0, "A newly created map should have a sane default interval"); + assertTrue(builder.getReshufflePercentage() > 0, "A newly created map should have a sane default reshuffle percentage"); + } + + @Test + void testAreaAndShuffleIntervalRoundTripThroughBuild() { + GameMapBuilder builder = new GameMapBuilder(); + Area area = new GroundArea(Vec.ZERO, new Vec(5, 0, 5), Block.GLASS, PushData.builder().build()); + + builder.area(area).shuffleIntervalTicks(60).reshufflePercentage(0.4); + + assertEquals(area, builder.getArea()); + assertEquals(60, builder.getShuffleIntervalTicks()); + assertEquals(0.4, builder.getReshufflePercentage()); + + GameMap built = builder.build(); + assertEquals(area, built.getArea()); + assertEquals(60, built.getShuffleIntervalTicks()); + assertEquals(0.4, built.getReshufflePercentage()); + } + + @Test + void testReloadingExistingMapWithoutAreaKeepsDefaultInterval() { + GameMap gameMap = new GameMapBuilder().build(); + GameMapBuilder reloaded = new GameMapBuilder(gameMap); + + assertNull(reloaded.getArea()); + assertTrue(reloaded.getShuffleIntervalTicks() > 0); + assertTrue(reloaded.getReshufflePercentage() > 0); + } + + @Test + void testNewBuilderHasNoAreaCorners() { + GameMapBuilder builder = new GameMapBuilder(); + assertNull(builder.getPos1()); + assertNull(builder.getPos2()); + } + + @Test + void testAreaCornersRoundTripThroughSetters() { + GameMapBuilder builder = new GameMapBuilder(); + Vec pos1 = new Vec(1, 2, 3); + Vec pos2 = new Vec(4, 5, 6); + + builder.pos1(pos1).pos2(pos2); + + assertEquals(pos1, builder.getPos1()); + assertEquals(pos2, builder.getPos2()); + } + + @Test + void testReloadingExistingMapWithAreaRestoresCorners() { + GameMapBuilder builder = new GameMapBuilder(); + Area area = new GroundArea(new Vec(1, 2, 3), new Vec(4, 5, 6), Block.GLASS, PushData.builder().build()); + builder.area(area); + + GameMap gameMap = builder.build(); + GameMapBuilder reloaded = new GameMapBuilder(gameMap); + + assertEquals(area.min(), reloaded.getPos1()); + assertEquals(area.max(), reloaded.getPos2()); + } + + @Test + void testReloadingExistingMapWithoutAreaHasNoCorners() { + GameMap gameMap = new GameMapBuilder().build(); + GameMapBuilder reloaded = new GameMapBuilder(gameMap); + + assertNull(reloaded.getPos1()); + assertNull(reloaded.getPos2()); + } + + @Test + void testNewBuilderIsNotReadyToSave() { + GameMapBuilder builder = new GameMapBuilder(); + + assertFalse(builder.isReadyToSave()); + assertEquals(List.of("Name", "Spawn", "Game Spawn", "Area"), builder.getMissingFieldNames()); + } + + @Test + void testBuilderIsReadyToSaveOnceAllRequiredFieldsAreSet() { + GameMapBuilder builder = new GameMapBuilder(); + Area area = new GroundArea(Vec.ZERO, new Vec(5, 0, 5), Block.GLASS, PushData.builder().build()); + + builder.name("Test Map"); + builder.spawn(new Pos(1, 2, 3)); + builder.gameSpawn(new Pos(4, 5, 6)); + builder.area(area); + + assertTrue(builder.getMissingFieldNames().isEmpty()); + assertTrue(builder.isReadyToSave()); + } + + @Test + void testBuilderIsNotReadyToSaveWhenOnlySomeFieldsAreSet() { + GameMapBuilder builder = new GameMapBuilder(); + builder.name("Test Map").spawn(new Pos(1, 2, 3)); + + assertEquals(List.of("Game Spawn", "Area"), builder.getMissingFieldNames()); + assertFalse(builder.isReadyToSave()); + } + + private GameMapBuilder readyBuilderExceptPushData() { + GameMapBuilder builder = new GameMapBuilder(); + Area area = new GroundArea(Vec.ZERO, new Vec(5, 0, 5), Block.GLASS, PushData.builder().build()); + builder.name("Test Map"); + builder.spawn(new Pos(1, 2, 3)); + builder.gameSpawn(new Pos(4, 5, 6)); + builder.area(area); + return builder; + } + + @Test + void testBuilderIsNotReadyToSaveWhenNoPushEntryHasWeight() { + GameMapBuilder builder = readyBuilderExceptPushData(); + for (PushEntry entry : builder.getPushDataBuilder().getPushValues()) { + if (!entry.isGround()) entry.setWeight(0.0); + } + + assertEquals(List.of("Push Data"), builder.getMissingFieldNames()); + assertFalse(builder.isReadyToSave()); + } + + @Test + void testBuilderIsNotReadyToSaveWhenAnEntryHasNoValue() { + GameMapBuilder builder = readyBuilderExceptPushData(); + builder.getPushDataBuilder().getPushValues().get(1).setValue(0); + + assertEquals(List.of("Push Data"), builder.getMissingFieldNames()); + assertFalse(builder.isReadyToSave()); + } } \ No newline at end of file diff --git a/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/area/AreaViewInventoryIntegrationTest.java b/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/area/AreaViewInventoryIntegrationTest.java new file mode 100644 index 00000000..bfea5405 --- /dev/null +++ b/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/area/AreaViewInventoryIntegrationTest.java @@ -0,0 +1,99 @@ +package net.theevilreaper.bounce.setup.inventory.area; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.item.Material; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.theevilreaper.aves.inventory.layout.InventoryLayout; +import net.theevilreaper.bounce.setup.builder.GameMapBuilder; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MicrotusExtension.class) +class AreaViewInventoryIntegrationTest { + + private static final int SHUFFLE_INTERVAL_SLOT = 10; + private static final int POS1_SLOT = 11; + private static final int POS2_SLOT = 13; + private static final int RESHUFFLE_PERCENTAGE_SLOT = 16; + + @Test + void testPosSlotsUseTheAreaViewTypeMaterials(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameMapBuilder gameMapBuilder = new GameMapBuilder(); + + AreaViewInventory inventory = new AreaViewInventory(player, gameMapBuilder); + inventory.open(); + env.tick(); + + InventoryLayout dataLayout = inventory.getDataLayout(); + assertEquals(AreaViewType.LEFT_AREA_CORNER.getMaterial(), dataLayout.getSlot(POS1_SLOT).getItem().material()); + assertEquals(AreaViewType.RIGHT_AREA_CORNER.getMaterial(), dataLayout.getSlot(POS2_SLOT).getItem().material()); + + env.destroyInstance(instance, true); + } + + @Test + void testShuffleIntervalAndReshufflePercentageSlotsShowBuilderValues(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameMapBuilder gameMapBuilder = new GameMapBuilder(); + gameMapBuilder.shuffleIntervalTicks(60).reshufflePercentage(0.25); + + AreaViewInventory inventory = new AreaViewInventory(player, gameMapBuilder); + inventory.open(); + env.tick(); + + InventoryLayout dataLayout = inventory.getDataLayout(); + assertEquals(Material.CLOCK, dataLayout.getSlot(SHUFFLE_INTERVAL_SLOT).getItem().material()); + assertEquals(Material.TARGET, dataLayout.getSlot(RESHUFFLE_PERCENTAGE_SLOT).getItem().material()); + + env.destroyInstance(instance, true); + } + + @Test + void testSettingOnlyOneCornerDoesNotBuildAnArea(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + player.teleport(new Pos(1, 2, 3)).join(); + GameMapBuilder gameMapBuilder = new GameMapBuilder(); + + AreaViewInventory inventory = new AreaViewInventory(player, gameMapBuilder); + inventory.open(); + env.tick(); + + inventory.setPos1ToCurrentPosition(player); + + assertNull(gameMapBuilder.getArea(), "An area must not be built until both corners are set"); + + env.destroyInstance(instance, true); + } + + @Test + void testSettingBothCornersAutomaticallyBuildsTheArea(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + player.teleport(new Pos(1, 2, 3)).join(); + GameMapBuilder gameMapBuilder = new GameMapBuilder(); + + AreaViewInventory inventory = new AreaViewInventory(player, gameMapBuilder); + inventory.open(); + env.tick(); + + inventory.setPos1ToCurrentPosition(player); + inventory.setPos2ToCurrentPosition(player); + + assertNotNull(gameMapBuilder.getArea(), "The area must be built automatically once both corners are set"); + assertEquals(new Vec(1, 2, 3), gameMapBuilder.getArea().min()); + assertEquals(new Vec(1, 2, 3), gameMapBuilder.getArea().max()); + + env.destroyInstance(instance, true); + } +} diff --git a/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/area/AreaViewTypeTest.java b/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/area/AreaViewTypeTest.java new file mode 100644 index 00000000..af74b6fd --- /dev/null +++ b/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/area/AreaViewTypeTest.java @@ -0,0 +1,30 @@ +package net.theevilreaper.bounce.setup.inventory.area; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.minestom.server.component.DataComponents; +import net.minestom.server.item.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.junit.jupiter.api.Assertions.*; + +class AreaViewTypeTest { + + @ParameterizedTest(name = "Test item get for type: {0}") + @EnumSource(AreaViewType.class) + void testItemGet(@NotNull AreaViewType type) { + ItemStack item = type.getItem(); + assertNotNull(item, "Item should not be null for type: " + type); + assertEquals(type.getMaterial(), item.material(), "Material should match for type: " + type); + + assertTrue(item.has(DataComponents.CUSTOM_NAME), "Item should have a name component for type: " + type); + + Component nameComponent = item.get(DataComponents.CUSTOM_NAME); + assertNotNull(nameComponent, "Custom name component should not be null for type: " + type); + + String name = PlainTextComponentSerializer.plainText().serialize(nameComponent); + assertTrue(name.contains(type.getName()), "Name should match for type: " + type); + } +} diff --git a/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/ground/GroundValueInventoryIntegrationTest.java b/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/ground/GroundValueInventoryIntegrationTest.java new file mode 100644 index 00000000..172ea01d --- /dev/null +++ b/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/ground/GroundValueInventoryIntegrationTest.java @@ -0,0 +1,42 @@ +package net.theevilreaper.bounce.setup.inventory.ground; + +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.theevilreaper.aves.inventory.layout.InventoryLayout; +import net.theevilreaper.aves.inventory.slot.ISlot; +import net.theevilreaper.bounce.setup.builder.GameMapBuilder; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MicrotusExtension.class) +class GroundValueInventoryIntegrationTest { + + private static final int WEIGHT_SLOT = 13; + + @Test + void testWeightSlotShowsCurrentWeight(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameMapBuilder gameMapBuilder = new GameMapBuilder(); + + GroundValueInventory inventory = new GroundValueInventory(player, gameMapBuilder); + inventory.open(); + env.tick(); + + InventoryLayout dataLayout = inventory.getDataLayout(); + ISlot slot = dataLayout.getSlot(WEIGHT_SLOT); + assertNotNull(slot); + ItemStack item = slot.getItem(); + assertNotNull(item); + assertEquals(Material.NETHER_STAR, item.material()); + + env.destroyInstance(instance, true); + } +} diff --git a/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/overview/MapOverviewInventoryIntegrationTest.java b/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/overview/MapOverviewInventoryIntegrationTest.java index fce0b8a0..1c79a731 100644 --- a/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/overview/MapOverviewInventoryIntegrationTest.java +++ b/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/overview/MapOverviewInventoryIntegrationTest.java @@ -67,7 +67,7 @@ void testMapOverviewDataLayout(@NotNull Env env) { assertNotNull(dataLayout, "Data layout should not be null"); - int[] dataSlots = LayoutCalculator.from(10, 12, 14, 16); + int[] dataSlots = LayoutCalculator.from(10, 11, 12, 13, 14); OverviewType[] overviewTypes = OverviewType.getValues(); for (int i = 0; i < overviewTypes.length && i < dataSlots.length; i++) { diff --git a/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/push/PushValueInventoryIntegrationTest.java b/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/push/PushValueInventoryIntegrationTest.java new file mode 100644 index 00000000..00a85b7e --- /dev/null +++ b/setup/src/test/java/net/theevilreaper/bounce/setup/inventory/push/PushValueInventoryIntegrationTest.java @@ -0,0 +1,43 @@ +package net.theevilreaper.bounce.setup.inventory.push; + +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.theevilreaper.aves.inventory.layout.InventoryLayout; +import net.theevilreaper.aves.inventory.slot.ISlot; +import net.theevilreaper.bounce.setup.builder.GameMapBuilder; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MicrotusExtension.class) +class PushValueInventoryIntegrationTest { + + private static final int WEIGHT_SLOT = 13; + + @Test + void testWeightSlotShowsCurrentWeight(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameMapBuilder gameMapBuilder = new GameMapBuilder(); + + PushValueInventory inventory = new PushValueInventory(player, gameMapBuilder); + inventory.open(); + inventory.updateLayout(1); // index 0 is the ground entry, 1 is the first push entry + env.tick(); + + InventoryLayout dataLayout = inventory.getDataLayout(); + ISlot slot = dataLayout.getSlot(WEIGHT_SLOT); + assertNotNull(slot); + ItemStack item = slot.getItem(); + assertNotNull(item); + assertEquals(Material.NETHER_STAR, item.material()); + + env.destroyInstance(instance, true); + } +} diff --git a/src/main/java/net/theevilreaper/bounce/Bounce.java b/src/main/java/net/theevilreaper/bounce/Bounce.java index 8a0252ba..d808721a 100644 --- a/src/main/java/net/theevilreaper/bounce/Bounce.java +++ b/src/main/java/net/theevilreaper/bounce/Bounce.java @@ -15,7 +15,6 @@ import net.minestom.server.event.player.PlayerDisconnectEvent; import net.minestom.server.event.player.PlayerSpawnEvent; import net.minestom.server.instance.block.BlockManager; -import net.theevilreaper.aves.map.provider.MapProvider; import net.theevilreaper.bounce.block.BlockLoader; import net.theevilreaper.bounce.block.type.lantern.LanternBlockFactory; import net.theevilreaper.bounce.commands.StartCommand; diff --git a/src/main/java/net/theevilreaper/bounce/map/BounceInstance.java b/src/main/java/net/theevilreaper/bounce/map/BounceInstance.java new file mode 100644 index 00000000..b5f41a26 --- /dev/null +++ b/src/main/java/net/theevilreaper/bounce/map/BounceInstance.java @@ -0,0 +1,44 @@ +package net.theevilreaper.bounce.map; + +import net.minestom.server.instance.InstanceContainer; +import net.minestom.server.registry.RegistryKey; +import net.minestom.server.world.DimensionType; +import net.theevilreaper.bounce.common.ground.Area; +import net.theevilreaper.bounce.common.ground.AreaFiller; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; + +/** + * Custom {@link InstanceContainer} which reshuffles its configured {@link Area} on its own tick instead of relying + * on a separately scheduled task. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 1.0.0 + */ +public final class BounceInstance extends InstanceContainer { + + private final @Nullable Area area; + private final int shuffleIntervalTicks; + private final double reshufflePercentage; + + public BounceInstance(UUID uuid, RegistryKey dimensionType, @Nullable Area area, int shuffleIntervalTicks, double reshufflePercentage) { + super(uuid, dimensionType); + this.area = area; + this.shuffleIntervalTicks = shuffleIntervalTicks; + this.reshufflePercentage = reshufflePercentage; + } + + /** + * {@inheritDoc} + */ + @Override + public void tick(long time) { + super.tick(time); + if (area == null || shuffleIntervalTicks <= 0) return; + if (getWorldAge() % shuffleIntervalTicks == 0) { + AreaFiller.reshuffle(this, area, reshufflePercentage, getPlayers()); + } + } +} diff --git a/src/main/java/net/theevilreaper/bounce/map/BounceMapProvider.java b/src/main/java/net/theevilreaper/bounce/map/BounceMapProvider.java index bf583e5f..63c50563 100644 --- a/src/main/java/net/theevilreaper/bounce/map/BounceMapProvider.java +++ b/src/main/java/net/theevilreaper/bounce/map/BounceMapProvider.java @@ -7,6 +7,8 @@ import net.theevilreaper.aves.map.BaseMap; import net.theevilreaper.aves.map.MapEntry; import net.theevilreaper.aves.map.provider.AbstractMapProvider; +import net.theevilreaper.bounce.common.ground.Area; +import net.theevilreaper.bounce.common.ground.AreaFiller; import net.theevilreaper.bounce.common.map.GameMap; import net.theevilreaper.bounce.common.map.MapFilters; import net.theevilreaper.bounce.common.util.GsonUtil; @@ -14,6 +16,7 @@ import java.io.IOException; import java.nio.file.Path; import java.util.Optional; +import java.util.UUID; public class BounceMapProvider extends AbstractMapProvider { @@ -22,7 +25,6 @@ public class BounceMapProvider extends AbstractMapProvider { public BounceMapProvider(Path path) { super(GsonUtil.GSON_FILE_HANDLER, MapFilters::filterMapsForGame); this.loadMapEntries(path.resolve("maps")); - this.activeInstance = MinecraftServer.getInstanceManager().createInstanceContainer(); MapEntry mapEntry = this.getEntries().getFirst(); @@ -36,7 +38,9 @@ public BounceMapProvider(Path path) { throw new IllegalStateException("An error occurred while loading the map"); } - this.activeMap = loadedDataMap.get(); + GameMap gameMap = loadedDataMap.get(); + this.activeMap = gameMap; + this.activeInstance = new BounceInstance(UUID.randomUUID(), DimensionType.OVERWORLD, gameMap.getArea(), gameMap.getShuffleIntervalTicks(), gameMap.getReshufflePercentage()); this.falcoAnvilLoader = new FalcoAnvilLoader(mapEntry.getDirectoryRoot(), DimensionType.OVERWORLD.key()); this.activeInstance.setChunkLoader(this.falcoAnvilLoader); this.activeInstance.enableAutoChunkLoad(true); @@ -45,6 +49,11 @@ public BounceMapProvider(Path path) { defaultClock.rate(0f); } MinecraftServer.getInstanceManager().registerInstance(this.activeInstance); + + Area area = gameMap.getArea(); + if (area != null) { + AreaFiller.fill(this.activeInstance, area); + } } @Override diff --git a/src/test/java/net/theevilreaper/bounce/map/BounceInstanceIntegrationTest.java b/src/test/java/net/theevilreaper/bounce/map/BounceInstanceIntegrationTest.java new file mode 100644 index 00000000..e52d13ce --- /dev/null +++ b/src/test/java/net/theevilreaper/bounce/map/BounceInstanceIntegrationTest.java @@ -0,0 +1,116 @@ +package net.theevilreaper.bounce.map; + +import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; +import net.minestom.server.world.DimensionType; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.theevilreaper.bounce.common.ground.Area; +import net.theevilreaper.bounce.common.ground.GroundArea; +import net.theevilreaper.bounce.common.push.PushData; +import net.theevilreaper.bounce.common.push.PushEntry; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MicrotusExtension.class) +class BounceInstanceIntegrationTest { + + @Test + void testTickReshufflesAreaOnceIntervalElapses(@NotNull Env env) { + PushData pushData = new PushData(List.of(PushEntry.pushEntry(Block.DIAMOND_BLOCK, 1, 1.0))); + Area area = new GroundArea(new Vec(0, 0, 0), new Vec(4, 0, 0), Block.GLASS, pushData); + + BounceInstance instance = new BounceInstance(UUID.randomUUID(), DimensionType.OVERWORLD, area, 5, 0.1); + instance.setGenerator(unit -> unit.modifier().fillHeight(0, 40, Block.STONE)); + env.process().instance().registerInstance(instance); + + for (int x = 0; x <= 4; x++) { + instance.setBlock(x, 0, 0, Block.GLASS); + } + area.calculatePositions(instance); + + for (int i = 0; i < 4; i++) { + env.tick(); + } + for (int x = 0; x <= 4; x++) { + assertTrue(instance.getBlock(x, 0, 0).compare(Block.GLASS), "Area must not reshuffle before the configured interval elapses"); + } + + env.tick(); // 5th tick reaches the configured interval + + long diamondCount = 0; + for (int x = 0; x <= 4; x++) { + if (instance.getBlock(x, 0, 0).compare(Block.DIAMOND_BLOCK)) diamondCount++; + } + assertEquals(1, diamondCount, "10% of the 5 positions must be reshuffled to diamond block once the configured interval elapses"); + + env.destroyInstance(instance, true); + } + + @Test + void testTickHonorsAConfiguredReshufflePercentage(@NotNull Env env) { + PushData pushData = new PushData(List.of(PushEntry.pushEntry(Block.DIAMOND_BLOCK, 1, 1.0))); + Area area = new GroundArea(new Vec(0, 0, 0), new Vec(4, 0, 0), Block.GLASS, pushData); + + BounceInstance instance = new BounceInstance(UUID.randomUUID(), DimensionType.OVERWORLD, area, 5, 1.0); + instance.setGenerator(unit -> unit.modifier().fillHeight(0, 40, Block.STONE)); + env.process().instance().registerInstance(instance); + + for (int x = 0; x <= 4; x++) { + instance.setBlock(x, 0, 0, Block.GLASS); + } + area.calculatePositions(instance); + + for (int i = 0; i < 5; i++) { + env.tick(); + } + + for (int x = 0; x <= 4; x++) { + assertTrue(instance.getBlock(x, 0, 0).compare(Block.DIAMOND_BLOCK), "A 100% reshuffle percentage must reshuffle every position"); + } + + env.destroyInstance(instance, true); + } + + @Test + void testTickDoesNothingWhenNoAreaIsConfigured(@NotNull Env env) { + BounceInstance instance = new BounceInstance(UUID.randomUUID(), DimensionType.OVERWORLD, null, 5, 0.1); + instance.setGenerator(unit -> unit.modifier().fillHeight(0, 40, Block.STONE)); + env.process().instance().registerInstance(instance); + + assertDoesNotThrow(() -> { + for (int i = 0; i < 10; i++) { + env.tick(); + } + }); + + env.destroyInstance(instance, true); + } + + @Test + void testTickSkipsReshuffleWhenIntervalIsNotPositive(@NotNull Env env) { + PushData pushData = new PushData(List.of(PushEntry.pushEntry(Block.DIAMOND_BLOCK, 1, 1.0))); + Area area = new GroundArea(new Vec(0, 0, 0), new Vec(0, 0, 0), Block.GLASS, pushData); + + BounceInstance instance = new BounceInstance(UUID.randomUUID(), DimensionType.OVERWORLD, area, 0, 0.1); + instance.setGenerator(unit -> unit.modifier().fillHeight(0, 40, Block.STONE)); + env.process().instance().registerInstance(instance); + + instance.setBlock(0, 0, 0, Block.GLASS); + area.calculatePositions(instance); + + for (int i = 0; i < 10; i++) { + env.tick(); + } + + assertTrue(instance.getBlock(0, 0, 0).compare(Block.GLASS), "A non-positive interval must never trigger a reshuffle"); + + env.destroyInstance(instance, true); + } +}