merged = new HashSet<>(map.getPageFaces());
merged.addAll(load(mapFile));
- return new GameMap(
- map.name(),
- map.spawn(),
- map.getSlenderSpawn(),
- merged,
- map.getSurvivorSpawns(),
- map.builders(),
- map.getAtmosphere()
- );
+ return map.withPageFaces(merged);
}
}
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFactory.java b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFactory.java
index 107a82a1..7cbc95a0 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFactory.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFactory.java
@@ -7,7 +7,7 @@
/**
* The factory should be used to create references from a {@link PageEntity}.
* It's not possible to create a new instance from the entity class directly because the constructor of it is only visible to the package level.
- * @see PageEntity#PageEntity(Instance, Pos, int)
+ * @see PageEntity#PageEntity(PageResource, int)
* @author theEvilReaper
* @since 1.0.0
* @version 1.0.0
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageProvider.java b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageProvider.java
index 249fc99d..e9308ccb 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageProvider.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageProvider.java
@@ -209,6 +209,15 @@ public Component getPageStatus() {
.append(Component.text(this.maxPageAmount, NamedTextColor.RED));
}
+ /**
+ * Returns the number of pages found in this round.
+ *
+ * @return the number of found pages, never negative
+ */
+ public int foundPageCount() {
+ return this.foundPages.get();
+ }
+
/**
* Returns the max page amount.
*
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java
index 0d409333..df38817e 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java
@@ -11,8 +11,9 @@
* good together, but picking colors as variations on one hue almost always
* does. HSL lets us say "same hue, just a bit darker" or "same hue, more
* washed out" instead of guessing at RGB numbers by hand. That's exactly what
- * {@link SeededDimensionPreset} relies on to turn a single random seed into a
- * fog/sky/light color set that actually feels like it belongs together.
+ * {@link net.onelitefeather.cygnus.common.dimension.SeedDimensionPreset SeedDimensionPreset}
+ * relies on to turn a single random seed into a fog/sky/light color set that
+ * actually feels like it belongs together.
*
* If you're not familiar with HSL: hue is the color itself (0 = red, 120 =
* green, 240 = blue, and it loops back around at 360), saturation is how
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/CreekConfigTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/CreekConfigTest.java
new file mode 100644
index 00000000..4aeee8b4
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/CreekConfigTest.java
@@ -0,0 +1,123 @@
+package net.onelitefeather.cygnus.common.config;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreekConfigTest {
+
+ @Test
+ @DisplayName("The defaults are the values from the design")
+ void defaultsMatchTheDesign() {
+ CreekConfig config = CreekConfig.DEFAULT;
+
+ assertTrue(config.enabled());
+ assertTrue(config.activeWithLastSurvivor());
+ assertEquals(48, config.sightRange());
+ assertEquals(0.25D, config.stalkThreshold());
+ assertEquals(0.6D, config.huntThreshold());
+ assertEquals(20, config.stalkMinDistance());
+ assertEquals(35, config.stalkMaxDistance());
+ assertEquals(40, config.stalkMinAngle());
+ assertEquals(70, config.stalkMaxAngle());
+ assertEquals(1.5D, config.catchDistance());
+ assertEquals(15, config.personalSpace());
+ assertEquals(2, config.betrayalCatchCount());
+ assertEquals(0.15D, config.betrayalChance());
+ assertEquals(3.0D, config.routeLinkDistance());
+ assertEquals(0.15D, config.randomStopChance());
+ assertEquals(1500, config.randomStopMinMillis());
+ assertEquals(4000, config.randomStopMaxMillis());
+ }
+
+ @Test
+ @DisplayName("The link distance has to be above 0")
+ void linkDistanceAboveZero() {
+ CreekConfig config = CreekConfig.DEFAULT;
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> new CreekConfig(
+ config.enabled(), config.activeWithLastSurvivor(), config.sightRange(), config.sightViewAngle(),
+ config.wanderPauseMillis(), config.wanderSpeed(), config.huntSpeed(), config.stalkThreshold(), config.huntThreshold(),
+ config.stalkMinDistance(), config.stalkMaxDistance(), config.stalkMinAngle(), config.stalkMaxAngle(),
+ config.stalkRevealMillis(), config.stalkMinSeconds(), config.stalkMaxSeconds(), config.huntMaxSeconds(),
+ config.catchDistance(), config.vanishMinSeconds(), config.vanishMaxSeconds(), config.respawnMinDistance(),
+ config.personalSpace(), config.stuckMillis(), config.dreadPageWeight(), config.dreadTimeWeight(),
+ config.dreadIsolationWeight(), config.isolationRadius(), config.betrayalCatchCount(),
+ config.betrayalChance(), config.betrayalGlowSeconds(), config.slownessSeconds(), 0.0D,
+ config.randomStopChance(), config.randomStopMinMillis(), config.randomStopMaxMillis()));
+ assertTrue(exception.getMessage().contains("routeLinkDistance"));
+ }
+
+ @Test
+ @DisplayName("The stalk threshold has to stay below the hunt threshold")
+ void stalkThresholdBelowHunt() {
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> with(0.6D, 0.6D, 35, 40, 15));
+ assertTrue(exception.getMessage().contains("huntThreshold"));
+ }
+
+ @Test
+ @DisplayName("A hiding place inside the cone is rejected")
+ void hidingPlaceOutsideTheCone() {
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> with(0.25D, 0.6D, 40, 40, 15));
+ assertTrue(exception.getMessage().contains("stalkMinAngle"));
+ }
+
+ @Test
+ @DisplayName("The personal space has to fit inside the stalk band")
+ void personalSpaceInsideTheBand() {
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> with(0.25D, 0.6D, 35, 40, 20));
+ assertTrue(exception.getMessage().contains("stalkMinDistance"));
+ }
+
+ private static CreekConfig with(double stalkThreshold, double huntThreshold, int sightViewAngle,
+ int stalkMinAngle, int personalSpace) {
+ CreekConfig d = CreekConfig.DEFAULT;
+ return new CreekConfig(
+ d.enabled(), d.activeWithLastSurvivor(), d.sightRange(), sightViewAngle,
+ d.wanderPauseMillis(), d.wanderSpeed(), d.huntSpeed(), stalkThreshold, huntThreshold,
+ d.stalkMinDistance(), d.stalkMaxDistance(), stalkMinAngle, d.stalkMaxAngle(),
+ d.stalkRevealMillis(), d.stalkMinSeconds(), d.stalkMaxSeconds(), d.huntMaxSeconds(),
+ d.catchDistance(), d.vanishMinSeconds(), d.vanishMaxSeconds(), d.respawnMinDistance(),
+ personalSpace, d.stuckMillis(), d.dreadPageWeight(), d.dreadTimeWeight(),
+ d.dreadIsolationWeight(), d.isolationRadius(), d.betrayalCatchCount(),
+ d.betrayalChance(), d.betrayalGlowSeconds(), d.slownessSeconds(), d.routeLinkDistance(),
+ d.randomStopChance(), d.randomStopMinMillis(), d.randomStopMaxMillis());
+ }
+
+ @Test
+ @DisplayName("Hunting, he is faster than a walking survivor")
+ void huntIsFasterThanWalking() {
+ // A walking player covers about 0.216 blocks per tick; the speed attribute is blocks per tick.
+ assertTrue(CreekConfig.DEFAULT.huntSpeed() > 0.216D);
+ }
+
+ private static CreekConfig withRandomStops(double chance, int minMillis, int maxMillis) {
+ CreekConfig d = CreekConfig.DEFAULT;
+ return new CreekConfig(
+ d.enabled(), d.activeWithLastSurvivor(), d.sightRange(), d.sightViewAngle(),
+ d.wanderPauseMillis(), d.wanderSpeed(), d.huntSpeed(), d.stalkThreshold(), d.huntThreshold(),
+ d.stalkMinDistance(), d.stalkMaxDistance(), d.stalkMinAngle(), d.stalkMaxAngle(),
+ d.stalkRevealMillis(), d.stalkMinSeconds(), d.stalkMaxSeconds(), d.huntMaxSeconds(),
+ d.catchDistance(), d.vanishMinSeconds(), d.vanishMaxSeconds(), d.respawnMinDistance(),
+ d.personalSpace(), d.stuckMillis(), d.dreadPageWeight(), d.dreadTimeWeight(),
+ d.dreadIsolationWeight(), d.isolationRadius(), d.betrayalCatchCount(),
+ d.betrayalChance(), d.betrayalGlowSeconds(), d.slownessSeconds(), d.routeLinkDistance(),
+ chance, minMillis, maxMillis);
+ }
+
+ @Test
+ @DisplayName("Random stops need a chance between 0 and 1 and a range that does not run backwards")
+ void randomStopsAreChecked() {
+ assertThrows(IllegalArgumentException.class, () -> withRandomStops(1.5D, 1500, 4000));
+ assertThrows(IllegalArgumentException.class, () -> withRandomStops(0.15D, -1, 4000));
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> withRandomStops(0.15D, 5000, 4000));
+ assertTrue(exception.getMessage().contains("randomStopMinMillis"));
+ withRandomStops(0.0D, 0, 0);
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java
index d8880952..4464da05 100644
--- a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java
@@ -355,4 +355,59 @@ void testAnUnreadableVolumeFactorFallsBackToTheDefault(@TempDir Path tempDir) th
assertEquals(GameConfig.PageProximity.DEFAULT_VOLUME_FACTOR,
new GameConfigReader(tempDir).getConfig().pageProximity().volumeFactor());
}
+
+ @Test
+ void testCreekDefaultsWhenNotConfigured() {
+ GameConfig config = new GameConfigReader(Paths.get("src", "test", "resources")).getConfig();
+
+ assertEquals(CreekConfig.DEFAULT, config.creek());
+ }
+
+ @Test
+ void testCreekValuesAreRead(@TempDir Path tempDir) throws IOException {
+ Files.writeString(tempDir.resolve("config.properties"), """
+ minPlayers=2
+ creek.enabled=false
+ creek.huntThreshold=0.7
+ creek.stalkMaxDistance=40
+ creek.routeLinkDistance=4.5
+ creek.randomStopChance=0.4
+ creek.randomStopMinMillis=500
+ creek.randomStopMaxMillis=900
+ """);
+
+ CreekConfig creek = new GameConfigReader(tempDir).getConfig().creek();
+
+ assertFalse(creek.enabled());
+ assertEquals(0.7D, creek.huntThreshold(), 1.0E-9);
+ assertEquals(40, creek.stalkMaxDistance());
+ assertEquals(CreekConfig.DEFAULT.stalkMinDistance(), creek.stalkMinDistance());
+ assertEquals(4.5D, creek.routeLinkDistance(), 1.0E-9);
+ assertEquals(0.4D, creek.randomStopChance(), 1.0E-9);
+ assertEquals(500, creek.randomStopMinMillis());
+ assertEquals(900, creek.randomStopMaxMillis());
+ }
+
+ @Test
+ void testCreekRejectsValuesThatDoNotFit(@TempDir Path tempDir) throws IOException {
+ Files.writeString(tempDir.resolve("config.properties"), """
+ minPlayers=2
+ creek.enabled=false
+ creek.stalkThreshold=0.9
+ """);
+ GameConfigReader reader = new GameConfigReader(tempDir);
+
+ assertThrows(IllegalArgumentException.class, reader::getConfig);
+ }
+
+ @Test
+ void testAnUnreadableCreekValueFallsBackToTheDefault(@TempDir Path tempDir) throws IOException {
+ Files.writeString(tempDir.resolve("config.properties"), """
+ minPlayers=2
+ creek.huntThreshold=not-a-number
+ """);
+
+ assertEquals(CreekConfig.DEFAULT.huntThreshold(),
+ new GameConfigReader(tempDir).getConfig().creek().huntThreshold());
+ }
}
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java
index b44f1c8f..752fbbac 100644
--- a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java
@@ -40,7 +40,7 @@ void testInvalidLobbyAtmosphereShare() {
GameConfig defaults = GameConfig.DEFAULT;
assertRejected("Lobby atmosphere share must be between 0 and 1", () -> new GameConfig(
defaults.round(), defaults.teams(), null, defaults.resourcePack(), defaults.pageProximity(),
- defaults.damageSound(), defaults.glitch(), defaults.slenderStatic(), 1.5F));
+ defaults.damageSound(), defaults.glitch(), defaults.slenderStatic(), defaults.creek(), 1.5F));
}
@Test
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekLinksTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekLinksTest.java
new file mode 100644
index 00000000..7f3e6203
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekLinksTest.java
@@ -0,0 +1,77 @@
+package net.onelitefeather.cygnus.common.creek;
+
+import net.minestom.server.coordinate.Vec;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreekLinksTest {
+
+ private static CreekRoute route(String name, Vec... points) {
+ return CreekRoute.ofPositions(name, List.of(points));
+ }
+
+ /** A ends at (10,0,0); B starts 2 blocks away, C exactly 3, D 3.1. */
+ private static final List ROUTES = List.of(
+ route("A", new Vec(0, 0, 0), new Vec(10, 0, 0)),
+ route("B", new Vec(12, 0, 0), new Vec(20, 0, 0)),
+ route("C", new Vec(10, 0, 3), new Vec(10, 0, 20)),
+ route("D", new Vec(10, 0, -3.1), new Vec(10, 0, -20))
+ );
+
+ @Test
+ @DisplayName("Ends within the link distance are linked, the one just outside is not")
+ void linksFollowTheDistance() {
+ CreekLinks links = CreekLinks.of(ROUTES, 3.0D);
+
+ assertEquals(List.of(new CreekLinks.Node(1, 0), new CreekLinks.Node(2, 0)),
+ links.of(new CreekLinks.Node(0, 1)));
+ assertEquals(List.of(new CreekLinks.Node(0, 1)), links.of(new CreekLinks.Node(1, 0)));
+ assertTrue(links.of(new CreekLinks.Node(3, 0)).isEmpty());
+ }
+
+ @Test
+ @DisplayName("An end on the middle of another route is linked to that point, both ways")
+ void endOnAMiddleIsLinked() {
+ CreekLinks links = CreekLinks.of(List.of(
+ route("X", new Vec(0, 0, 0), new Vec(10, 0, 0), new Vec(20, 0, 0)),
+ route("Y", new Vec(10, 0, 2), new Vec(10, 0, 20))), 3.0D);
+
+ assertEquals(List.of(new CreekLinks.Node(0, 1)), links.of(new CreekLinks.Node(1, 0)));
+ assertEquals(List.of(new CreekLinks.Node(1, 0)), links.of(new CreekLinks.Node(0, 1)));
+ }
+
+ @Test
+ @DisplayName("Two middles close together are not linked")
+ void middlesNeverLink() {
+ CreekLinks links = CreekLinks.of(List.of(
+ route("X", new Vec(0, 0, 0), new Vec(10, 0, 0), new Vec(20, 0, 0)),
+ route("Z", new Vec(-5, 0, 1), new Vec(10, 0, 1), new Vec(25, 0, 1))), 3.0D);
+
+ assertTrue(links.of(new CreekLinks.Node(0, 1)).isEmpty());
+ assertTrue(links.of(new CreekLinks.Node(1, 1)).isEmpty());
+ }
+
+ @Test
+ @DisplayName("An end links to the nearest point of another route only")
+ void onlyTheNearestPoint() {
+ CreekLinks links = CreekLinks.of(List.of(
+ route("X", new Vec(-10, 0, 0), new Vec(1, 0, 0), new Vec(2, 0, 0), new Vec(20, 0, 0)),
+ route("W", new Vec(1, 0, 1), new Vec(1, 0, 20))), 3.0D);
+
+ assertEquals(List.of(new CreekLinks.Node(0, 1)), links.of(new CreekLinks.Node(1, 0)));
+ assertTrue(links.of(new CreekLinks.Node(0, 2)).isEmpty(), "the second closest point stays unlinked");
+ }
+
+ @Test
+ @DisplayName("The two ends of one route never link to each other")
+ void ownEndsNeverLink() {
+ CreekLinks links = CreekLinks.of(List.of(route("Short", new Vec(0, 0, 0), new Vec(1, 0, 0))), 3.0D);
+
+ assertTrue(links.of(new CreekLinks.Node(0, 0)).isEmpty());
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFileTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFileTest.java
new file mode 100644
index 00000000..393f91fa
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFileTest.java
@@ -0,0 +1,179 @@
+package net.onelitefeather.cygnus.common.creek;
+
+import com.google.gson.JsonSyntaxException;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.onelitefeather.cygnus.common.map.GameMap;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreekRoutesFileTest {
+
+ private static final CreekRoute NORTH = CreekRoute.ofPositions("Waldweg Nord",
+ List.of(new Vec(12.5, 80, 4.5), new Vec(20.5, 80, 9.5), new Vec(31.5, 81, 15.5)));
+ private static final CreekRoute SOUTH = CreekRoute.ofPositions("Waldweg Süd",
+ List.of(new Vec(0.5, 80, 0.5), new Vec(0.5, 80, 10.5)));
+
+ private static Path mapFile(Path root) {
+ return root.resolve("map.json");
+ }
+
+ @Test
+ @DisplayName("The file sits next to map.json")
+ void resolvesToASiblingCreekJson(@TempDir Path root) {
+ assertEquals(root.resolve("creek.json"), CreekRoutesFile.resolve(mapFile(root)));
+ }
+
+ @Test
+ @DisplayName("Without a file there are no routes")
+ void missingFileMeansNoRoutes(@TempDir Path root) {
+ assertTrue(CreekRoutesFile.load(mapFile(root)).isEmpty());
+ }
+
+ @Test
+ @DisplayName("Broken JSON names the file")
+ void brokenJsonNamesTheFile(@TempDir Path root) throws IOException {
+ Files.writeString(root.resolve("creek.json"), "[ { \"name\": ", StandardCharsets.UTF_8);
+
+ JsonSyntaxException exception = assertThrows(JsonSyntaxException.class, () -> CreekRoutesFile.load(mapFile(root)));
+ assertTrue(exception.getMessage().contains("creek.json"));
+ }
+
+ @Test
+ @DisplayName("Saved routes come back in the same order")
+ void savedRoutesCanBeLoadedBack(@TempDir Path root) {
+ CreekRoutesFile.save(mapFile(root), List.of(NORTH, SOUTH));
+
+ assertEquals(List.of(NORTH, SOUTH), CreekRoutesFile.load(mapFile(root)));
+ }
+
+ @Test
+ @DisplayName("Routes without a name or with too few points are skipped")
+ void invalidRoutesAreSkipped(@TempDir Path root) throws IOException {
+ Files.writeString(root.resolve("creek.json"), """
+ [
+ { "name": " ", "points": [ { "position": { "x": 0, "y": 80, "z": 0 } }, { "position": { "x": 5, "y": 80, "z": 0 } } ] },
+ { "name": "Stub", "points": [ { "position": { "x": 0, "y": 80, "z": 0 } } ] },
+ { "name": "Waldweg Süd", "points": [ { "position": { "x": 0.5, "y": 80, "z": 0.5 } }, { "position": { "x": 0.5, "y": 80, "z": 10.5 } } ] }
+ ]
+ """, StandardCharsets.UTF_8);
+
+ assertEquals(List.of(SOUTH), CreekRoutesFile.load(mapFile(root)));
+ }
+
+ @Test
+ @DisplayName("Only invalid routes leave nothing to walk")
+ void onlyInvalidRoutesMeansNoRoutes(@TempDir Path root) throws IOException {
+ Files.writeString(root.resolve("creek.json"), """
+ [ { "name": "Stub", "points": [ { "position": { "x": 0, "y": 80, "z": 0 } } ] } ]
+ """, StandardCharsets.UTF_8);
+
+ assertTrue(CreekRoutesFile.load(mapFile(root)).isEmpty());
+ }
+
+ @Test
+ @DisplayName("Of two routes with the same name only the first one stays")
+ void duplicateNamesKeepTheFirst(@TempDir Path root) {
+ CreekRoute copy = new CreekRoute("Waldweg Nord", SOUTH.points());
+ CreekRoutesFile.save(mapFile(root), List.of(NORTH, copy));
+
+ assertEquals(List.of(NORTH), CreekRoutesFile.load(mapFile(root)));
+ }
+
+ @Test
+ @DisplayName("The setup still gets routes that are not finished yet")
+ void uncheckedLoadKeepsIncompleteRoutes(@TempDir Path root) {
+ CreekRoute stub = CreekRoute.ofPositions("Stub", List.of(new Vec(0, 80, 0)));
+ CreekRoutesFile.save(mapFile(root), List.of(stub));
+
+ assertEquals(List.of(stub), CreekRoutesFile.loadUnchecked(mapFile(root)));
+ }
+
+ @Test
+ @DisplayName("Names are trimmed")
+ void namesAreTrimmed() {
+ assertEquals("Waldweg", new CreekRoute(" Waldweg ", SOUTH.points()).name());
+ }
+
+ @Test
+ @DisplayName("loadInto puts the routes into the map")
+ void loadIntoSetsTheRoutes(@TempDir Path root) {
+ CreekRoutesFile.save(mapFile(root), List.of(NORTH));
+ GameMap map = new GameMap("Forest", Pos.ZERO, Pos.ZERO, Set.of(), Set.of(), List.of(), null);
+
+ assertEquals(List.of(NORTH), CreekRoutesFile.loadInto(mapFile(root), map).getCreekRoutes());
+ }
+
+ @Test
+ @DisplayName("A point without a pause loads with no pause")
+ void oldFileHasNoPauses(@TempDir Path root) throws IOException {
+ Files.writeString(root.resolve("creek.json"), """
+ [ { "name": "Alt", "points": [ { "position": { "x": 0.5, "y": 80, "z": 0.5 } }, { "position": { "x": 0.5, "y": 80, "z": 10.5 } } ] } ]
+ """, StandardCharsets.UTF_8);
+
+ List routes = CreekRoutesFile.load(mapFile(root));
+
+ assertEquals(1, routes.size());
+ assertTrue(routes.getFirst().points().stream().allMatch(point -> point.pauseMillis() == 0));
+ }
+
+ @Test
+ @DisplayName("A route with a point from before positions were nested is skipped, not a crash")
+ void flatPointIsSkipped(@TempDir Path root) throws IOException {
+ Files.writeString(root.resolve("creek.json"), """
+ [
+ { "name": "Flach", "points": [ { "x": 0.5, "y": 80, "z": 0.5 }, { "x": 0.5, "y": 80, "z": 10.5 } ] },
+ { "name": "Waldweg Süd", "points": [ { "position": { "x": 0.5, "y": 80, "z": 0.5 } }, { "position": { "x": 0.5, "y": 80, "z": 10.5 } } ] }
+ ]
+ """, StandardCharsets.UTF_8);
+
+ assertEquals(List.of(SOUTH), CreekRoutesFile.load(mapFile(root)));
+ }
+
+ @Test
+ @DisplayName("Pauses survive saving and loading")
+ void pausesAreKept(@TempDir Path root) {
+ CreekRoute route = new CreekRoute("Pausen", List.of(
+ CreekWaypoint.of(new Vec(0.5, 80, 0.5)).withPause(2000),
+ CreekWaypoint.of(new Vec(5.5, 80, 0.5)),
+ CreekWaypoint.of(new Vec(9.5, 80, 0.5)).withPause(4000)));
+ CreekRoutesFile.save(mapFile(root), List.of(route));
+
+ assertEquals(List.of(route), CreekRoutesFile.load(mapFile(root)));
+ }
+
+ @Test
+ @DisplayName("A route with a negative pause is skipped")
+ void negativePauseIsSkipped(@TempDir Path root) throws IOException {
+ Files.writeString(root.resolve("creek.json"), """
+ [
+ { "name": "Kaputt", "points": [ { "position": { "x": 0, "y": 80, "z": 0 }, "pauseMillis": -5 }, { "position": { "x": 5, "y": 80, "z": 0 } } ] },
+ { "name": "Waldweg Süd", "points": [ { "position": { "x": 0.5, "y": 80, "z": 0.5 } }, { "position": { "x": 0.5, "y": 80, "z": 10.5 } } ] }
+ ]
+ """, StandardCharsets.UTF_8);
+
+ assertEquals(List.of(SOUTH), CreekRoutesFile.load(mapFile(root)));
+ }
+
+ @Test
+ @DisplayName("A waypoint knows its position")
+ void waypointPosition() {
+ CreekWaypoint point = CreekWaypoint.of(new Vec(1.5, 80, -2.5));
+
+ assertEquals(new Vec(1.5, 80, -2.5), point.position());
+ assertEquals(0, point.pauseMillis());
+ assertEquals(3000, point.withPause(3000).pauseMillis());
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapCreekRoutesTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapCreekRoutesTest.java
new file mode 100644
index 00000000..d9cd309f
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapCreekRoutesTest.java
@@ -0,0 +1,200 @@
+package net.onelitefeather.cygnus.common.map;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.minestom.server.utils.Direction;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import net.onelitefeather.cygnus.common.creek.CreekWaypoint;
+import net.onelitefeather.cygnus.common.page.PageFacesFile;
+import net.onelitefeather.cygnus.common.page.PageResource;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class GameMapCreekRoutesTest {
+
+ private static final CreekRoute ROUTE = CreekRoute.ofPositions("Waldweg", List.of(new Vec(0, 80, 0), new Vec(5, 80, 0)));
+
+ private static GameMap map() {
+ return new GameMap("Forest", Pos.ZERO, Pos.ZERO, Set.of(), Set.of(), List.of(), null);
+ }
+
+ @Test
+ @DisplayName("A map built the old way has no routes")
+ void oldConstructorHasNoRoutes() {
+ assertTrue(map().getCreekRoutes().isEmpty());
+ }
+
+ @Test
+ @DisplayName("Adding routes keeps everything else")
+ void withCreekRoutesKeepsTheRest() {
+ GameMap map = map().withCreekRoutes(List.of(ROUTE));
+
+ assertEquals(List.of(ROUTE), map.getCreekRoutes());
+ assertEquals("Forest", map.name());
+ }
+
+ @Test
+ @DisplayName("The builder manages routes and their points")
+ void builderManagesRoutes() {
+ GameMapBuilder builder = new GameMapBuilder();
+
+ assertTrue(builder.addCreekRoute("Waldweg"));
+ assertFalse(builder.addCreekRoute("Waldweg"), "names are unique");
+ assertFalse(builder.addCreekRoute(" "), "a name must not be empty");
+ assertFalse(builder.addCreekPoint("Unknown", Vec.ZERO));
+ assertTrue(builder.addCreekPoint("Waldweg", new Vec(0, 80, 0)));
+ assertTrue(builder.addCreekPoint("Waldweg", new Vec(5, 80, 0)));
+ assertTrue(builder.addCreekPoint("Waldweg", new Vec(9, 80, 0)));
+ assertTrue(builder.removeLastCreekPoint("Waldweg"));
+
+ assertEquals(List.of(ROUTE), builder.build().getCreekRoutes());
+ assertEquals(2, builder.getCreekRoutePoints("Waldweg").size());
+
+ assertTrue(builder.removeCreekRoute("Waldweg"));
+ assertFalse(builder.hasCreekRoute("Waldweg"));
+ }
+
+ @Test
+ @DisplayName("A builder made from a map copies its routes")
+ void builderCopiesRoutes() {
+ GameMapBuilder builder = new GameMapBuilder(map().withCreekRoutes(List.of(ROUTE)));
+
+ assertEquals(List.of(ROUTE), builder.getCreekRoutes());
+ }
+
+ @Test
+ @DisplayName("Merging page faces keeps the routes")
+ void pageMergeKeepsRoutes(@TempDir Path root) {
+ GameMap map = map().withCreekRoutes(List.of(ROUTE));
+ PageFacesFile.save(root.resolve("map.json"), Set.of(new PageResource(Vec.ZERO, Direction.NORTH)));
+
+ GameMap merged = PageFacesFile.loadInto(root.resolve("map.json"), map);
+
+ assertEquals(List.of(ROUTE), merged.getCreekRoutes());
+ }
+
+ @Test
+ @DisplayName("Pauses can be set on the start and the end")
+ void setsEndPauses() {
+ GameMapBuilder builder = new GameMapBuilder();
+ builder.addCreekRoute("Waldweg");
+
+ assertFalse(builder.setCreekEndPause("Waldweg", true, 1000), "no point yet");
+ builder.addCreekPoint("Waldweg", new Vec(0, 80, 0));
+ assertTrue(builder.setCreekEndPause("Waldweg", true, 1000));
+ assertFalse(builder.setCreekEndPause("Waldweg", false, 2000), "the end needs two points");
+ builder.addCreekPoint("Waldweg", new Vec(5, 80, 0));
+ assertTrue(builder.setCreekEndPause("Waldweg", false, 2000));
+ assertFalse(builder.setCreekEndPause("Waldweg", false, -1), "no negative pauses");
+ assertFalse(builder.setCreekEndPause("Unknown", true, 1000));
+
+ List points = builder.getCreekRoutePoints("Waldweg");
+ assertEquals(1000, points.getFirst().pauseMillis());
+ assertEquals(2000, points.getLast().pauseMillis());
+ }
+
+ @Test
+ @DisplayName("The end pause moves with the end")
+ void endPauseMovesWithTheEnd() {
+ GameMapBuilder builder = new GameMapBuilder();
+ builder.addCreekRoute("Waldweg");
+ builder.addCreekPoint("Waldweg", new Vec(0, 80, 0));
+ builder.addCreekPoint("Waldweg", new Vec(5, 80, 0));
+ builder.setCreekEndPause("Waldweg", false, 2000);
+
+ builder.addCreekPoint("Waldweg", new Vec(9, 80, 0));
+ List grown = builder.getCreekRoutePoints("Waldweg");
+ assertEquals(List.of(0, 0, 2000), grown.stream().map(CreekWaypoint::pauseMillis).toList());
+
+ builder.removeLastCreekPoint("Waldweg");
+ List shrunk = builder.getCreekRoutePoints("Waldweg");
+ assertEquals(List.of(0, 2000), shrunk.stream().map(CreekWaypoint::pauseMillis).toList());
+ }
+
+ @Test
+ @DisplayName("With one point left the start keeps its own pause")
+ void startKeepsItsPause() {
+ GameMapBuilder builder = new GameMapBuilder();
+ builder.addCreekRoute("Waldweg");
+ builder.addCreekPoint("Waldweg", new Vec(0, 80, 0));
+ builder.setCreekEndPause("Waldweg", true, 1000);
+ builder.addCreekPoint("Waldweg", new Vec(5, 80, 0));
+ builder.setCreekEndPause("Waldweg", false, 2000);
+
+ builder.removeLastCreekPoint("Waldweg");
+
+ assertEquals(List.of(1000), builder.getCreekRoutePoints("Waldweg").stream().map(CreekWaypoint::pauseMillis).toList());
+ }
+
+ /** A route from (0,80,0) over (5,80,0) to (9,80,0), paused 1000 ms at the start and 2000 ms at the end. */
+ private static GameMapBuilder threePointRoute() {
+ GameMapBuilder builder = new GameMapBuilder();
+ builder.addCreekRoute("Waldweg");
+ builder.addCreekPoint("Waldweg", new Vec(0, 80, 0));
+ builder.addCreekPoint("Waldweg", new Vec(5, 80, 0));
+ builder.addCreekPoint("Waldweg", new Vec(9, 80, 0));
+ builder.setCreekEndPause("Waldweg", true, 1000);
+ builder.setCreekEndPause("Waldweg", false, 2000);
+ return builder;
+ }
+
+ private static List positions(GameMapBuilder builder) {
+ return builder.getCreekRoutePoints("Waldweg").stream().map(CreekWaypoint::position).toList();
+ }
+
+ private static List pauses(GameMapBuilder builder) {
+ return builder.getCreekRoutePoints("Waldweg").stream().map(CreekWaypoint::pauseMillis).toList();
+ }
+
+ @Test
+ @DisplayName("A point in the middle can be removed")
+ void removesAMiddlePoint() {
+ GameMapBuilder builder = threePointRoute();
+
+ assertEquals(2, builder.removeCreekPoint("Waldweg", new Vec(5, 80, 0)));
+
+ assertEquals(List.of(new Vec(0, 80, 0), new Vec(9, 80, 0)), positions(builder));
+ assertEquals(List.of(1000, 2000), pauses(builder));
+ }
+
+ @Test
+ @DisplayName("Removing the first point hands its pause to the new first point")
+ void removesTheFirstPoint() {
+ GameMapBuilder builder = threePointRoute();
+
+ assertEquals(1, builder.removeCreekPoint("Waldweg", new Vec(0, 80, 0)));
+
+ assertEquals(List.of(new Vec(5, 80, 0), new Vec(9, 80, 0)), positions(builder));
+ assertEquals(List.of(1000, 2000), pauses(builder));
+ }
+
+ @Test
+ @DisplayName("Removing the last point hands its pause to the new last point")
+ void removesTheLastPointByPosition() {
+ GameMapBuilder builder = threePointRoute();
+
+ assertEquals(3, builder.removeCreekPoint("Waldweg", new Vec(9, 80, 0)));
+
+ assertEquals(List.of(new Vec(0, 80, 0), new Vec(5, 80, 0)), positions(builder));
+ assertEquals(List.of(1000, 2000), pauses(builder));
+ }
+
+ @Test
+ @DisplayName("Without a point at that spot nothing is removed")
+ void nothingToRemove() {
+ GameMapBuilder builder = threePointRoute();
+
+ assertEquals(-1, builder.removeCreekPoint("Waldweg", new Vec(3, 80, 0)));
+ assertEquals(-1, builder.removeCreekPoint("Unknown", new Vec(0, 80, 0)));
+ assertEquals(3, positions(builder).size());
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapTest.java
index b582126b..2a660fac 100644
--- a/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapTest.java
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapTest.java
@@ -130,4 +130,18 @@ void testGameMapCopyWithEmptyCollections() {
assertNotNull(builder.getPageFaces());
assertTrue(builder.getPageFaces().isEmpty());
}
+
+ @Test
+ void testWithPageFacesKeepsTheRest() {
+ GameMap map = new GameMap("Forest", Pos.ZERO, new Pos(1, 2, 3), Set.of(), Set.of(new Pos(4, 5, 6)), List.of("Builder"), null);
+ PageResource page = new PageResource(Vec.ZERO, Direction.NORTH);
+
+ GameMap copy = map.withPageFaces(Set.of(page));
+
+ assertEquals(Set.of(page), copy.getPageFaces());
+ assertEquals("Forest", copy.name());
+ assertEquals(new Pos(1, 2, 3), copy.getSlenderSpawn());
+ assertEquals(Set.of(new Pos(4, 5, 6)), copy.getSurvivorSpawns());
+ assertEquals(List.of("Builder"), copy.builders());
+ }
}
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/page/PageProviderTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/page/PageProviderTest.java
index 39bc60d8..7d3fcb42 100644
--- a/common/src/test/java/net/onelitefeather/cygnus/common/page/PageProviderTest.java
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/page/PageProviderTest.java
@@ -359,4 +359,18 @@ private static void runConcurrently(List tasks) throws Exception {
executor.shutdown();
}
}
+
+ @Test
+ void testFoundPageCount(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ PageProvider pageProvider = spawnedProvider(instance, MIN_ACTIVE_PAGE_COUNT + 1);
+ Player player = env.createPlayer(instance);
+
+ assertEquals(0, pageProvider.foundPageCount());
+
+ pageProvider.triggerPageFound(player, pageProvider.interactablePages().getFirst().getHitBoxUUID());
+
+ assertEquals(1, pageProvider.foundPageCount());
+ env.destroyInstance(instance, true);
+ }
}
diff --git a/config.properties.example b/config.properties.example
index 7ff986a8..4344fdbf 100644
--- a/config.properties.example
+++ b/config.properties.example
@@ -196,3 +196,35 @@ survivorTeamSize=12
# A map that declares no atmosphere has nothing to prepare anyone for; its lobby
# stays on the overworld regardless of this value.
#lobbyAtmosphereShare=0.3
+
+# --- The creek --------------------------------------------------------------
+# A second figure next to the slender, walked by the server. He wanders the map,
+# stalks a single survivor late in the round, hunts them once the dread is high
+# enough and punishes a catch - rarely by giving the survivor away to the slender.
+# The creek only takes part on maps whose creek.json holds at least one usable
+# route; everywhere else it stays away, even with creek.enabled=true.
+# Every key below maps to a field of CreekConfig. An unreadable value falls back
+# to its default; values that contradict each other are rejected.
+#creek.enabled=true
+#creek.activeWithLastSurvivor=true
+#creek.stalkThreshold=0.25
+#creek.huntThreshold=0.6
+#creek.stalkMinDistance=20
+#creek.stalkMaxDistance=35
+#creek.catchDistance=1.5
+# Speeds are in blocks per tick. A walking survivor covers about 0.216, so the
+# hunt speed has to stay above that or a hunted survivor simply walks away.
+#creek.wanderSpeed=0.07
+#creek.huntSpeed=0.25
+#creek.betrayalCatchCount=2
+#creek.betrayalChance=0.15
+# How close the ends of two routes in creek.json have to be (in blocks) for the
+# creek to switch from one route to the other.
+#creek.routeLinkDistance=3.0
+# After reaching a waypoint the creek sometimes stops for a moment. The chance is
+# between 0 and 1 (0 turns it off); the stop lasts between min and max milliseconds.
+# A pause set on the route end in creek.json and a random stop do not add up: the
+# longer one wins.
+#creek.randomStopChance=0.15
+#creek.randomStopMinMillis=1500
+#creek.randomStopMaxMillis=4000
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index eaddf023..69a2eef0 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -46,13 +46,17 @@
import net.onelitefeather.cygnus.damage.DamageSoundService;
import net.onelitefeather.cygnus.noise.SlenderStaticService;
import net.onelitefeather.cygnus.command.GlitchCommand;
+import net.onelitefeather.cygnus.command.CreekCommand;
import net.onelitefeather.cygnus.command.StartCommand;
import net.onelitefeather.cygnus.common.ListenerHandling;
import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap;
import net.onelitefeather.cygnus.common.config.GameConfig;
import net.onelitefeather.cygnus.common.config.GameConfigReader;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
import net.onelitefeather.cygnus.common.event.GamePreLaunchEvent;
import net.onelitefeather.cygnus.common.page.PageProvider;
+import net.onelitefeather.cygnus.common.map.GameMap;
import net.onelitefeather.cygnus.common.page.event.PageExpiredEvent;
import net.onelitefeather.cygnus.disclaimer.EpilepsyDisclaimer;
import net.onelitefeather.cygnus.event.GameFinishEvent;
@@ -88,6 +92,14 @@
import net.onelitefeather.cygnus.player.CygnusPlayer;
import net.onelitefeather.cygnus.resourcepack.ResourcePackService;
import net.onelitefeather.cygnus.stamina.SlenderBarTrigger;
+import net.onelitefeather.cygnus.creek.consequence.CatchEffects;
+import net.onelitefeather.cygnus.creek.body.CreakingBody;
+import net.onelitefeather.cygnus.creek.consequence.GlowReveal;
+import net.onelitefeather.cygnus.creek.dread.PageProgressDread;
+import net.onelitefeather.cygnus.creek.RoundClock;
+import net.onelitefeather.cygnus.creek.consequence.StagedCatchConsequence;
+import net.onelitefeather.cygnus.creek.debug.CreekDebug;
+import net.onelitefeather.cygnus.creek.CreekService;
import net.onelitefeather.cygnus.stamina.StaminaService;
import net.onelitefeather.cygnus.tunnelvision.OverlayTunnelVisionRenderer;
import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer;
@@ -101,6 +113,8 @@
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
+import java.util.List;
+import java.util.Random;
import java.util.function.Supplier;
/**
@@ -133,6 +147,8 @@ public final class Cygnus implements TeamCreator, ListenerHandling {
private final TunnelVisionRenderer tunnelVisionRenderer;
private final TunnelVisionService tunnelVisionService;
private final SlenderStaticService slenderStaticService;
+ private final CreekService creekService;
+ private final CreekDebug creekDebug;
public Cygnus() {
Path path = ServiceBootstrap.resolveWorkingDirectory();
@@ -192,6 +208,34 @@ public Cygnus() {
() -> TeamHelper.slenderOf(this.teamService));
this.tunnelVisionRenderer = new OverlayTunnelVisionRenderer(this.screenOverlay);
this.tunnelVisionService = new TunnelVisionService(this.tunnelVisionRenderer, player -> StaminaHelper.remainingShare(this.staminaService, player));
+ CreekConfig creekConfig = this.gameConfig.creek();
+ this.creekDebug = new CreekDebug();
+ // One clock for the service and the dread: the service starts it, the dread reads it.
+ RoundClock roundClock = new RoundClock(System::currentTimeMillis);
+ // Every step and every catch runs on the scheduler thread, but Random is thread-safe
+ // anyway, which keeps a stray call from elsewhere harmless.
+ Random creekRandom = new Random();
+ this.creekService = new CreekService(
+ creekConfig,
+ () -> TeamHelper.survivorsOf(this.teamService),
+ this.mapProvider.getActiveInstance(),
+ this::creekRoutes,
+ CreakingBody::spawn,
+ new PageProgressDread(
+ this.pageProvider::foundPageCount,
+ this.pageProvider::getMaxPageAmount,
+ roundClock::elapsedMillis,
+ this.gameConfig.round().gameTime(),
+ creekConfig),
+ new StagedCatchConsequence(
+ new CatchEffects(this.jumpscareManager::force, this.staminaService::getFoodBar, creekConfig.slownessSeconds()),
+ new GlowReveal(creekConfig.betrayalGlowSeconds()),
+ () -> TeamHelper.slenderOf(this.teamService),
+ creekConfig,
+ creekRandom),
+ roundClock,
+ creekRandom,
+ this.creekDebug);
this.initPhases();
this.initCommands();
this.initListener();
@@ -203,6 +247,7 @@ private void initCommands() {
var manager = MinecraftServer.getCommandManager();
manager.register(new StartCommand(this.linearPhaseSeries));
manager.register(new GlitchCommand(this.gazeSignal));
+ manager.register(new CreekCommand(this.creekDebug));
}
@@ -271,6 +316,11 @@ private void registerGameListener() {
// Outside registerOverlayListeners for the same reason as the damage sound: the static is
// heard, not drawn, so neither the resource pack nor the overlay gate has a say in it.
this.slenderStaticService.registerListener(handler);
+ // Outside registerOverlayListeners too: the creek is an entity in the world that the
+ // client draws like any other, not a camera overlay.
+ if (this.gameConfig.creek().enabled()) {
+ this.creekService.registerListener(handler);
+ }
this.registerOverlayListeners(handler);
}
@@ -313,6 +363,16 @@ private void initPhases() {
this.linearPhaseSeries.add(new RestartPhase());
}
+ /**
+ * Returns the creek routes of the current map.
+ *
+ * @return the routes, empty without a map or without routes
+ */
+ private List creekRoutes() {
+ GameMap map = this.mapProvider.getGameMap();
+ return map != null ? map.getCreekRoutes() : List.of();
+ }
+
private void finishGame() {
this.pageProvider.cleanUp();
this.staminaService.cleanUp();
diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/CreekCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/CreekCommand.java
new file mode 100644
index 00000000..c25b1fe6
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/command/CreekCommand.java
@@ -0,0 +1,59 @@
+package net.onelitefeather.cygnus.command;
+
+import net.kyori.adventure.permission.PermissionChecker;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.util.TriState;
+import net.minestom.server.command.CommandSender;
+import net.minestom.server.command.builder.Command;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.cygnus.common.Messages;
+import net.onelitefeather.cygnus.creek.debug.CreekDebug;
+
+/**
+ * Turns the creek's debug line in the action bar on or off. Meant for playtests.
+ *
+ * Requires {@value #PERMISSION}, like {@code GlitchCommand}: the line shows where the creek is
+ * and who it is after, which normal players must not know. Only players can use it, because the
+ * console has no action bar.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekCommand extends Command {
+
+ private static final String PERMISSION = "cygnus.command.creek";
+
+ /**
+ * Creates the command.
+ *
+ * @param debug the debug line to toggle for the sender
+ */
+ public CreekCommand(CreekDebug debug) {
+ super("creek");
+ setCondition((sender, commandString) -> sender instanceof Player);
+
+ setDefaultExecutor((sender, context) -> {
+ if (!(sender instanceof Player player)) return;
+
+ boolean watching = debug.toggle(player.getUuid());
+ sender.sendMessage(Messages.withPrefix(Component.text(
+ watching ? "Creek debug on." : "Creek debug off.", NamedTextColor.GRAY)));
+ if (watching && !debug.isActive()) {
+ player.sendActionBar(CreekDebug.INACTIVE);
+ }
+ });
+ }
+
+ /**
+ * Checks whether the sender may use this command. Works like {@code GlitchCommand}.
+ *
+ * @param sender the sender to check
+ * @return {@code true} if the sender holds {@value #PERMISSION}, {@code false} otherwise
+ */
+ private static boolean hasCreekPermission(CommandSender sender) {
+ return sender.getOrDefault(PermissionChecker.POINTER, PermissionChecker.always(TriState.FALSE))
+ .test(PERMISSION);
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java
new file mode 100644
index 00000000..dd0bde86
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java
@@ -0,0 +1,196 @@
+package net.onelitefeather.cygnus.creek;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.cygnus.creek.body.CreekBody;
+import net.onelitefeather.cygnus.creek.state.CreekActions;
+import net.onelitefeather.cygnus.creek.state.CreekContext;
+import net.onelitefeather.cygnus.creek.state.CreekState;
+import net.onelitefeather.cygnus.creek.state.SurvivorView;
+import net.onelitefeather.cygnus.creek.state.VanishState;
+import net.onelitefeather.cygnus.creek.world.RouteProvider;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * One creek in the round: its body, what it is doing right now, and the step that moves it on.
+ *
+ * The patrolling creek is one of these, and so is every variant. Each step it fills in who can see
+ * it in the shared snapshot of the survivors and lets its current state decide what happens next.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+final class Creek {
+
+ private final CreekBody body;
+ private final RouteProvider route;
+ private final CreekRound round;
+ private CreekState state;
+ private List lastViews = List.of();
+ private boolean entered;
+
+ /**
+ * Sets up a creek.
+ *
+ * @param body its body in the world
+ * @param route its own walker along the routes
+ * @param round what it shares with every other creek of the round
+ * @param initial the state it starts in
+ */
+ Creek(CreekBody body, RouteProvider route, CreekRound round, CreekState initial) {
+ this.body = body;
+ this.route = route;
+ this.round = round;
+ this.state = initial;
+ }
+
+ /**
+ * Runs one step.
+ *
+ * @param survivors the survivors of this step
+ * @param now the current time in milliseconds
+ */
+ void tick(SurvivorSnapshot survivors, long now) {
+ this.tick(survivors, Set.of(), now);
+ }
+
+ /**
+ * Runs one step, leaving some survivors out of it. The patrolling creek uses this for everyone
+ * who is haunted by a variant: they do not see it, and it does not pick them out.
+ *
+ * The snapshots in {@link #lastViews()} still cover every survivor.
+ *
+ *
+ * @param survivors the survivors of this step
+ * @param ignored the survivors this creek leaves alone in this step
+ * @param now the current time in milliseconds
+ */
+ void tick(SurvivorSnapshot survivors, Set ignored, long now) {
+ CreekContext ctx = this.context(survivors, ignored, now);
+ if (!this.entered) {
+ this.state.enter(ctx);
+ this.entered = true;
+ }
+ CreekState next = this.state.tick(ctx);
+ if (next != this.state) this.switchTo(next, ctx);
+ }
+
+ /**
+ * Sends the creek away for the rest of the round. Calling it again changes nothing.
+ *
+ * @param survivors the survivors of this step
+ * @param now the current time in milliseconds
+ */
+ void vanishForGood(SurvivorSnapshot survivors, long now) {
+ CreekContext ctx = this.context(survivors, Set.of(), now);
+ if (this.state instanceof VanishState vanish && vanish.isForever()) return;
+ // The state it had so far is never entered now, and never needs to be.
+ this.entered = true;
+ this.switchTo(VanishState.forever(), ctx);
+ }
+
+ /**
+ * Fills in who sees this creek and builds the context for the states.
+ */
+ private CreekContext context(SurvivorSnapshot survivors, Set ignored, long now) {
+ this.lastViews = this.views(survivors);
+ List noticed = ignored.isEmpty() ? this.lastViews
+ : this.lastViews.stream().filter(view -> !ignored.contains(view.id())).toList();
+ return new CreekContext(now, noticed, this.body, this.route, this.round.spots(),
+ new Actions(this.round, survivors, ignored, this.body.position()),
+ this.round.config(), this.round.random());
+ }
+
+ /**
+ * Tells for every survivor whether they see this creek.
+ *
+ * A survivor only counts as seeing the creek if it is shown to them at all. A variant is hidden
+ * from everyone but its target, so the others must not be able to scare it off.
+ *
+ *
+ * @param survivors the survivors of this step
+ * @return one view per survivor
+ */
+ List views(SurvivorSnapshot survivors) {
+ List players = survivors.players();
+ List base = survivors.views();
+ List views = new ArrayList<>(players.size());
+ for (int index = 0; index < players.size(); index++) {
+ Player survivor = players.get(index);
+ boolean sees = this.body.isVisibleTo(survivor.getUuid())
+ && this.round.sight().sees(survivor, this.body.entity());
+ views.add(base.get(index).withSeesCreek(sees));
+ }
+ return views;
+ }
+
+ /**
+ * The survivor snapshots from the last step.
+ *
+ * @return the views, empty before the first step
+ */
+ List lastViews() {
+ return this.lastViews;
+ }
+
+ CreekState state() {
+ return this.state;
+ }
+
+ CreekBody body() {
+ return this.body;
+ }
+
+ RouteProvider route() {
+ return this.route;
+ }
+
+ void remove() {
+ this.body.remove();
+ }
+
+ private void switchTo(CreekState next, CreekContext ctx) {
+ this.state = next;
+ next.enter(ctx);
+ }
+
+ /**
+ * Applies the consequences to the survivors of one step. Anyone who is no longer among them is
+ * left alone, and so is everyone this creek ignores in that step.
+ *
+ * @param round what the creek shares with the others of the round
+ * @param survivors the survivors of the step
+ * @param ignored the survivors the creek leaves alone in the step
+ * @param creek where the creek stands in the step
+ */
+ private record Actions(CreekRound round, SurvivorSnapshot survivors, Set ignored, Pos creek)
+ implements CreekActions {
+
+ @Override
+ public void caught(UUID survivor) {
+ Player player = this.survivors.player(survivor);
+ if (player != null) this.round.consequence().apply(player);
+ }
+
+ @Override
+ public void selected(UUID survivor) {
+ Player player = this.survivors.player(survivor);
+ if (player != null) this.round.patrol().selected(player, this.creek, this.survivors.players());
+ }
+
+ @Override
+ public void vanished(Pos where) {
+ // Whoever this creek leaves alone does not see it, so they do not see it vanish either.
+ List noticing = this.survivors.players().stream()
+ .filter(player -> !this.ignored.contains(player.getUuid()))
+ .toList();
+ this.round.patrol().vanished(where, noticing);
+ }
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java
new file mode 100644
index 00000000..7f1105b5
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java
@@ -0,0 +1,33 @@
+package net.onelitefeather.cygnus.creek;
+
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.creek.consequence.CatchConsequence;
+import net.onelitefeather.cygnus.creek.consequence.PatrolHelper;
+import net.onelitefeather.cygnus.creek.world.CreekSight;
+import net.onelitefeather.cygnus.creek.world.SpotFinder;
+
+import java.util.random.RandomGenerator;
+
+/**
+ * What every creek of a round shares: the patrolling one and each variant.
+ *
+ * @param sight decides whether a survivor sees a creek
+ * @param spots finds places where nobody can see a creek
+ * @param consequence what happens on a catch
+ * @param patrol what the patrolling creek does to the survivors around it
+ * @param config the settings
+ * @param random the random source
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+record CreekRound(
+ CreekSight sight,
+ SpotFinder spots,
+ CatchConsequence consequence,
+ PatrolHelper patrol,
+ CreekConfig config,
+ RandomGenerator random
+) {
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java
new file mode 100644
index 00000000..202f1770
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java
@@ -0,0 +1,224 @@
+package net.onelitefeather.cygnus.creek;
+
+import net.kyori.adventure.text.Component;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.Event;
+import net.minestom.server.event.EventNode;
+import net.minestom.server.instance.Instance;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import net.onelitefeather.cygnus.creek.body.CreekBody;
+import net.onelitefeather.cygnus.creek.consequence.CatchConsequence;
+import net.onelitefeather.cygnus.creek.consequence.PatrolHelper;
+import net.onelitefeather.cygnus.creek.debug.CreekDebug;
+import net.onelitefeather.cygnus.creek.dread.DreadSource;
+import net.onelitefeather.cygnus.creek.state.CreekState;
+import net.onelitefeather.cygnus.creek.state.SurvivorView;
+import net.onelitefeather.cygnus.creek.state.VanishState;
+import net.onelitefeather.cygnus.creek.world.CreekPaths;
+import net.onelitefeather.cygnus.creek.world.CreekSight;
+import net.onelitefeather.cygnus.creek.world.Ground;
+import net.onelitefeather.cygnus.creek.world.InstanceGround;
+import net.onelitefeather.cygnus.creek.world.PathRoute;
+import net.onelitefeather.cygnus.creek.world.SpotFinder;
+import net.onelitefeather.cygnus.event.GameFinishEvent;
+import net.onelitefeather.cygnus.event.GameStartEvent;
+import net.onelitefeather.cygnus.utils.RepeatingTask;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.random.RandomGenerator;
+
+/**
+ * Brings the creek into a round and takes it out again at the end.
+ *
+ * Like {@code SlenderGazeService}, it listens for the start and end of a round on its own. It does
+ * not need to hear about deaths or disconnects: those players are simply missing from the
+ * survivors on the next step, so a variant whose target is gone ends within 100 ms anyway.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(CreekService.class);
+
+ /** How long one step lasts, in milliseconds. */
+ static final int TICK_MILLIS = 100;
+
+ private final CreekConfig config;
+ private final Supplier> survivors;
+ private final Supplier extends @Nullable Instance> instance;
+ private final BiFunction bodies;
+ private final DreadSource dread;
+ private final CatchConsequence consequence;
+ private final RoundClock clock;
+ private final RandomGenerator random;
+ private final CreekDebug debug;
+ private final CreekSight sight;
+ private final Ground ground;
+ private final SpotFinder spots;
+ private final Supplier> routes;
+ private volatile @Nullable CreekRound round;
+ private final RepeatingTask task = new RepeatingTask(this::tick);
+ private volatile @Nullable Creek creek;
+ private volatile @Nullable CreekVariants variants;
+
+ /**
+ * Sets up the service.
+ *
+ * @param config the settings
+ * @param survivors supplies the survivors of the round
+ * @param instance supplies the instance of the round, or {@code null} while there is none
+ * @param routes supplies the creek routes of the current map; without any, the creek stays away
+ * @param bodies puts a creek body into the world
+ * @param dread rates how scared each survivor is
+ * @param consequence what happens on a catch
+ * @param clock the round's clock
+ * @param random the random source
+ * @param debug the debug line for playtests
+ */
+ public CreekService(CreekConfig config, Supplier> survivors,
+ Supplier extends @Nullable Instance> instance, Supplier> routes,
+ BiFunction bodies, DreadSource dread,
+ CatchConsequence consequence, RoundClock clock, RandomGenerator random,
+ CreekDebug debug) {
+ this.config = config;
+ this.survivors = survivors;
+ this.instance = instance;
+ this.bodies = bodies;
+ this.dread = dread;
+ this.consequence = consequence;
+ this.clock = clock;
+ this.random = random;
+ this.debug = debug;
+ this.sight = new CreekSight(config.sightRange(), config.sightViewAngle());
+ this.ground = new InstanceGround(instance);
+ this.spots = new SpotFinder(this.sight, this.ground);
+ this.routes = routes;
+ }
+
+ /**
+ * Listens for the start and end of a round.
+ *
+ * @param node the node to listen on
+ */
+ public void registerListener(EventNode node) {
+ node.addListener(GameStartEvent.class, _ -> this.start());
+ node.addListener(GameFinishEvent.class, _ -> this.stop());
+ }
+
+ /**
+ * Puts the creek into the world, hidden at first, and starts stepping it.
+ */
+ void start() {
+ if (this.creek != null) return;
+
+ Instance world = this.instance.get();
+ CreekPaths paths = CreekPaths.of(this.routes.get(), this.config.routeLinkDistance());
+ if (world == null || paths.isEmpty()) {
+ LOGGER.warn("The map has no usable creek routes, so the creek stays away this round");
+ return;
+ }
+ // Every creek walks with its own cursor, so this one belongs to the patrolling creek alone.
+ PathRoute pathRoute = new PathRoute(paths);
+ CreekRound round = new CreekRound(this.sight, this.spots, this.consequence,
+ new PatrolHelper(this.random), this.config, this.random);
+ this.round = round;
+ List points = paths.allPoints();
+
+ this.clock.start();
+ Pos point = points.get(this.random.nextInt(points.size()));
+ CreekBody body = this.bodies.apply(world, this.ground.settle(point).orElse(point));
+ // Start hidden. The creek and its variants only show up once the survivors have had time to
+ // spread out, the creek somewhere far away from all of them.
+ long showsUp = this.clock.now() + this.config.vanishMinSeconds() * 1000L;
+ this.creek = new Creek(body, pathRoute, round, new VanishState(showsUp));
+ this.variants = new CreekVariants(this.config, this.spots, this.random, showsUp,
+ (spot, state) -> new Creek(this.bodies.apply(world, spot), new PathRoute(paths), round, state));
+ this.debug.setActive(true);
+ this.task.start(TICK_MILLIS, ChronoUnit.MILLIS);
+ }
+
+ /**
+ * Runs one step: first the patrolling creek, then the variants. Survivors with a variant do not
+ * see the patrolling creek, and it does not pick them out.
+ *
+ * Once only one survivor is left and {@code activeWithLastSurvivor} is off, the patrolling creek
+ * is gone for good and every variant ends.
+ *
+ */
+ void tick() {
+ Creek current = this.creek;
+ if (current == null) return;
+ // One snapshot for every creek: where the survivors are and how scared they are is the same for all.
+ SurvivorSnapshot survivors = SurvivorSnapshot.take(this.survivors.get(), this.dread);
+ long now = this.clock.now();
+ CreekVariants currentVariants = this.variants;
+ // Count every survivor here: hiding the patrol from the haunted is not being down to the last one.
+ if (!this.config.activeWithLastSurvivor() && survivors.size() <= 1) {
+ current.vanishForGood(survivors, now);
+ if (currentVariants != null) currentVariants.stop();
+ } else {
+ // A haunted survivor already has a creek of their own, so the patrolling one leaves them be.
+ Set haunted = currentVariants == null ? Set.of() : currentVariants.running().keySet();
+ current.tick(survivors, haunted, now);
+ if (currentVariants != null) currentVariants.tick(survivors, now);
+ }
+ if (this.debug.hasWatchers()) {
+ Function names = id -> nameOf(survivors, id);
+ List views = current.lastViews();
+ Component line = CreekDebug.line(current.state(), current.body().position(), views, names,
+ current.route().describe(), now);
+ if (currentVariants != null) {
+ List states = currentVariants.running().values().stream().map(Creek::state).toList();
+ line = line.append(CreekDebug.variants(states, CreekVariants.capacity(views.size()), names, now));
+ }
+ this.debug.show(line);
+ }
+ }
+
+ private static String nameOf(SurvivorSnapshot survivors, UUID id) {
+ Player player = survivors.player(id);
+ return player != null ? player.getUsername() : id.toString().substring(0, 8);
+ }
+
+ /**
+ * Takes the creek and every variant out of the world and clears anything still running on the survivors.
+ */
+ void stop() {
+ this.task.stop();
+ CreekVariants currentVariants = this.variants;
+ this.variants = null;
+ if (currentVariants != null) currentVariants.stop();
+ Creek current = this.creek;
+ this.creek = null;
+ this.clock.reset();
+ if (current != null) current.remove();
+ this.consequence.cleanUp();
+ CreekRound currentRound = this.round;
+ this.round = null;
+ if (currentRound != null) currentRound.patrol().cleanUp();
+ this.debug.setActive(false);
+ }
+
+ @Nullable Creek creek() {
+ return this.creek;
+ }
+
+ @Nullable CreekVariants variants() {
+ return this.variants;
+ }
+
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java
new file mode 100644
index 00000000..9b7c65e5
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java
@@ -0,0 +1,210 @@
+package net.onelitefeather.cygnus.creek;
+
+import net.minestom.server.coordinate.Pos;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.creek.state.CreekState;
+import net.onelitefeather.cygnus.creek.state.DoneState;
+import net.onelitefeather.cygnus.creek.state.StalkState;
+import net.onelitefeather.cygnus.creek.state.SurvivorView;
+import net.onelitefeather.cygnus.creek.world.SpotFinder;
+
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.BiFunction;
+import java.util.function.Predicate;
+import java.util.random.RandomGenerator;
+
+/**
+ * The creek's variants: a personal creek for each haunted survivor, one that nobody else sees.
+ *
+ * The survivor with the highest dread gets the next one. It starts by stalking them and is gone
+ * once the stalk or the hunt is over. The more survivors there are, the more of them can be
+ * haunted at once, see {@link #capacity(int)}. After a variant, the survivor gets a breather before
+ * the next one.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+final class CreekVariants {
+
+ /** One variant for every this many survivors. */
+ static final int SURVIVORS_PER_VARIANT = 4;
+
+ /** Never more variants than this at once, however big the round. */
+ static final int MAX_VARIANTS = 3;
+
+ private final CreekConfig config;
+ private final SpotFinder spots;
+ private final RandomGenerator random;
+ private final long allowedAt;
+ private final BiFunction spawner;
+ private final Map running;
+ private final Map cooldowns;
+
+ /**
+ * Sets up the variants for a round.
+ *
+ * @param config the settings
+ * @param spots finds a spot next to a survivor, out of their view
+ * @param random the random source
+ * @param allowedAt the earliest time a variant may show up, in milliseconds
+ * @param spawner creates a variant's creek at a spot, starting in the given state
+ */
+ CreekVariants(CreekConfig config, SpotFinder spots, RandomGenerator random, long allowedAt,
+ BiFunction spawner) {
+ this.config = config;
+ this.spots = spots;
+ this.random = random;
+ this.allowedAt = allowedAt;
+ this.spawner = spawner;
+ this.running = new LinkedHashMap<>();
+ this.cooldowns = new HashMap<>();
+ }
+
+ /**
+ * How many survivors can be haunted at once: one per {@link #SURVIVORS_PER_VARIANT}
+ * survivors, rounded up, and never more than {@link #MAX_VARIANTS}.
+ *
+ * @param survivors the number of survivors still alive
+ * @return how many variants may run at once
+ */
+ static int capacity(int survivors) {
+ return Math.min(MAX_VARIANTS, (survivors + SURVIVORS_PER_VARIANT - 1) / SURVIVORS_PER_VARIANT);
+ }
+
+ /**
+ * How long a survivor's breather after a variant lasts: {@code vanishMaxSeconds} for a calm
+ * survivor, down to {@code vanishMinSeconds} for one who is scared to death.
+ *
+ * @param config the settings
+ * @param dread the survivor's dread
+ * @return the breather in milliseconds
+ */
+ static long cooldownMillis(CreekConfig config, double dread) {
+ double span = config.vanishMaxSeconds() - config.vanishMinSeconds();
+ double seconds = config.vanishMaxSeconds() - span * Math.clamp(dread, 0.0D, 1.0D);
+ return Math.round(seconds * 1000.0D);
+ }
+
+ /**
+ * Picks who to haunt next: the survivor with the highest dread, as long as it reaches the
+ * threshold. If two are equally scared, the one who strayed farthest from the others.
+ *
+ * @param views every survivor
+ * @param threshold the lowest dread that counts
+ * @param eligible whether a survivor may get a variant right now
+ * @return the survivor, or empty if nobody fits
+ */
+ static Optional pick(List views, double threshold, Predicate eligible) {
+ return views.stream()
+ .filter(view -> view.dread() >= threshold && eligible.test(view.id()))
+ .max(Comparator.comparingDouble(SurvivorView::dread)
+ .thenComparingDouble(view -> nearestOther(views, view)));
+ }
+
+ /**
+ * Runs one step: lets every variant act, clears away the finished ones and starts new ones
+ * while there is room.
+ *
+ * @param survivors the survivors of this step
+ * @param now the current time in milliseconds
+ */
+ void tick(SurvivorSnapshot survivors, long now) {
+ List views = survivors.views();
+ this.tickRunning(survivors, now);
+ if (now < this.allowedAt) return;
+ // Someone with no free spot next to them should not hold up everyone behind them.
+ Set noSpot = new HashSet<>();
+ int free = capacity(views.size()) - this.running.size();
+ while (free > 0) {
+ Optional target = pick(views, this.config.stalkThreshold(),
+ id -> this.isFree(id, now) && !noSpot.contains(id));
+ if (target.isEmpty()) return;
+ if (this.start(target.get(), now)) {
+ free--;
+ } else {
+ noSpot.add(target.get().id());
+ }
+ }
+ }
+
+ /**
+ * Tells which survivors are haunted right now, and by which creek.
+ *
+ * @return a copy, keyed by the haunted survivor
+ */
+ Map running() {
+ return Map.copyOf(this.running);
+ }
+
+ /**
+ * Removes every variant and forgets every breather.
+ */
+ void stop() {
+ for (Creek variant : this.running.values()) {
+ variant.remove();
+ }
+ this.running.clear();
+ this.cooldowns.clear();
+ }
+
+ private void tickRunning(SurvivorSnapshot survivors, long now) {
+ Iterator> iterator = this.running.entrySet().iterator();
+ while (iterator.hasNext()) {
+ Map.Entry entry = iterator.next();
+ Creek variant = entry.getValue();
+ variant.tick(survivors, now);
+ if (!(variant.state() instanceof DoneState)) continue;
+ variant.remove();
+ iterator.remove();
+ long cooldown = cooldownMillis(this.config, dreadOf(survivors.views(), entry.getKey()));
+ this.cooldowns.put(entry.getKey(), now + cooldown);
+ }
+ }
+
+ private boolean isFree(UUID id, long now) {
+ return !this.running.containsKey(id) && this.cooldowns.getOrDefault(id, Long.MIN_VALUE) <= now;
+ }
+
+ /**
+ * Places a new variant next to the survivor, out of their view.
+ *
+ * @return {@code false} if there was no free spot next to them in this step
+ */
+ private boolean start(SurvivorView view, long now) {
+ Optional spot = this.spots.beside(view.position(), config.stalkMinDistance(), config.stalkMaxDistance(),
+ config.stalkMinAngle(), config.stalkMaxAngle(), List.of(view.eyes()), config.personalSpace(),
+ this.random);
+ if (spot.isEmpty()) return false;
+
+ StalkState stalk = StalkState.starting(view.id(), now, this.config, this.random);
+ this.running.put(view.id(), this.spawner.apply(spot.get(), stalk));
+ return true;
+ }
+
+ private static double dreadOf(List views, UUID id) {
+ for (SurvivorView view : views) {
+ if (view.id().equals(id)) return view.dread();
+ }
+ return 0.0D;
+ }
+
+ private static double nearestOther(List views, SurvivorView view) {
+ double nearest = Double.MAX_VALUE;
+ for (SurvivorView other : views) {
+ if (other.id().equals(view.id())) continue;
+ nearest = Math.min(nearest, other.position().distance(view.position()));
+ }
+ return nearest;
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/RoundClock.java b/game/src/main/java/net/onelitefeather/cygnus/creek/RoundClock.java
new file mode 100644
index 00000000..c3a5fe91
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/RoundClock.java
@@ -0,0 +1,62 @@
+package net.onelitefeather.cygnus.creek;
+
+import java.util.function.LongSupplier;
+
+/**
+ * Keeps track of how long the current round has been going.
+ *
+ * The service starts and resets it, and the dread rating reads it. Sharing one clock saves the two
+ * from depending on each other.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class RoundClock {
+
+ private final LongSupplier clock;
+ private volatile long startedAt = -1L;
+
+ /**
+ * Sets up the clock.
+ *
+ * @param clock supplies the current time in milliseconds
+ */
+ public RoundClock(LongSupplier clock) {
+ this.clock = clock;
+ }
+
+ /**
+ * The current time.
+ *
+ * @return the time in milliseconds
+ */
+ public long now() {
+ return this.clock.getAsLong();
+ }
+
+ /**
+ * Marks the start of a round.
+ */
+ public void start() {
+ this.startedAt = this.clock.getAsLong();
+ }
+
+ /**
+ * Resets the clock when the round is over.
+ */
+ public void reset() {
+ this.startedAt = -1L;
+ }
+
+ /**
+ * How long the round has been going.
+ *
+ * @return the time in milliseconds, {@code 0} outside a round
+ */
+ public long elapsedMillis() {
+ long start = this.startedAt;
+ return start < 0 ? 0L : this.clock.getAsLong() - start;
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/SurvivorSnapshot.java b/game/src/main/java/net/onelitefeather/cygnus/creek/SurvivorSnapshot.java
new file mode 100644
index 00000000..6a778e56
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/SurvivorSnapshot.java
@@ -0,0 +1,106 @@
+package net.onelitefeather.cygnus.creek;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.cygnus.creek.dread.DreadSource;
+import net.onelitefeather.cygnus.creek.state.SurvivorView;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * The survivors of one step: the players and a snapshot of each of them.
+ *
+ * It is taken once per step and shared by every creek. Where a survivor stands and how scared they
+ * are is the same for all of them; only whether they see a creek differs, so every view here says
+ * {@code false} for that and each creek fills it in for itself.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+final class SurvivorSnapshot {
+
+ private final List players;
+ private final List views;
+
+ /**
+ * Pairs up the players with their views.
+ *
+ * @param players the survivors
+ * @param views one view per survivor, in the same order
+ */
+ SurvivorSnapshot(List players, List views) {
+ if (players.size() != views.size()) {
+ throw new IllegalArgumentException("Every survivor needs exactly one view");
+ }
+ this.players = List.copyOf(players);
+ this.views = List.copyOf(views);
+ }
+
+ /**
+ * Takes a snapshot of every survivor.
+ *
+ * @param players the survivors of the round
+ * @param dread rates how scared each survivor is
+ * @return the snapshot
+ */
+ static SurvivorSnapshot take(Collection players, DreadSource dread) {
+ List survivors = List.copyOf(players);
+ List views = new ArrayList<>(survivors.size());
+ for (Player survivor : survivors) {
+ Pos position = survivor.getPosition();
+ List others = new ArrayList<>(survivors.size());
+ for (Player other : survivors) {
+ if (other != survivor) others.add(other.getPosition());
+ }
+ views.add(new SurvivorView(survivor.getUuid(), position,
+ dread.dreadOf(survivor.getUuid(), position, others), false));
+ }
+ return new SurvivorSnapshot(survivors, views);
+ }
+
+ /**
+ * The survivors.
+ *
+ * @return the players, in the same order as {@link #views()}
+ */
+ List players() {
+ return this.players;
+ }
+
+ /**
+ * One view per survivor, none of them seeing a creek.
+ *
+ * @return the views, in the same order as {@link #players()}
+ */
+ List views() {
+ return this.views;
+ }
+
+ /**
+ * Looks up a survivor by id.
+ *
+ * @param id the id to look for
+ * @return the player, or {@code null} if they are not among the survivors
+ */
+ @Nullable Player player(UUID id) {
+ for (Player player : this.players) {
+ if (player.getUuid().equals(id)) return player;
+ }
+ return null;
+ }
+
+ /**
+ * How many survivors there are.
+ *
+ * @return the count
+ */
+ int size() {
+ return this.players.size();
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java
new file mode 100644
index 00000000..017ef83c
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java
@@ -0,0 +1,141 @@
+package net.onelitefeather.cygnus.creek.body;
+
+import net.minestom.server.collision.BoundingBox;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Entity;
+import net.minestom.server.entity.EntityCreature;
+import net.minestom.server.entity.EntityType;
+import net.minestom.server.entity.attribute.Attribute;
+import net.minestom.server.entity.metadata.monster.CreakingMeta;
+import net.minestom.server.instance.Instance;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * The creek in the world, dressed up as a vanilla creaking.
+ *
+ * An enderman was out of the question: the slender player already looks like one, and nobody could
+ * tell the two apart. Creakings only spawn from a creaking heart, and Cygnus has none, so the
+ * resource pack is free to give them a new look.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreakingBody implements CreekBody {
+
+ /** How far the goal has to move before the creek works out a new path, in blocks. */
+ static final double REPATH_DISTANCE = 1.0D;
+
+ private final EntityCreature entity;
+ private final Set viewers = ConcurrentHashMap.newKeySet();
+ private @Nullable Pos goal;
+
+ private CreakingBody(EntityCreature entity) {
+ this.entity = entity;
+ }
+
+ /**
+ * Puts a creaking into the world. Nobody sees it until {@link #showTo(Set)} says who may.
+ *
+ * @param instance the instance of the round
+ * @param position where it appears
+ * @return the body
+ */
+ public static CreakingBody spawn(Instance instance, Pos position) {
+ CreakingBody body = new CreakingBody(new EntityCreature(EntityType.CREAKING));
+ body.entity.getNavigator().setNodeGenerator(FoliageGroundGenerator::new);
+ body.entity.getNavigator().setNodeFollower(() -> new StepFollower(body.entity));
+ body.entity.updateViewableRule(player -> body.viewers.contains(player.getUuid()));
+ body.entity.setInstance(instance, position);
+ return body;
+ }
+
+ @Override
+ public Pos position() {
+ return this.entity.getPosition();
+ }
+
+ @Override
+ public void moveTo(Pos goal, double speed) {
+ this.entity.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(speed);
+ // A survivor on the move shifts a little between almost every two steps. Working out a
+ // new path every time would be expensive, so small shifts are ignored.
+ Pos current = this.goal;
+ if (current != null && current.distance(goal) <= REPATH_DISTANCE) return;
+ // Only remember goals the navigator took on. One it turned down (too close, same block,
+ // unloaded chunk) or one already reached must not stand in the way of the next.
+ boolean started = this.entity.getNavigator().setPathTo(goal, this.arrivalDistance(), () -> this.forget(goal));
+ this.goal = started ? goal : null;
+ }
+
+ private void forget(Pos reached) {
+ if (reached.equals(this.goal)) this.goal = null;
+ }
+
+ /**
+ * How close counts as arrived for Minestom's navigator: from the middle of the bounding box to
+ * one of its corners.
+ */
+ private double arrivalDistance() {
+ BoundingBox box = this.entity.getBoundingBox();
+ return Math.sqrt(box.width() * box.width() + box.depth() * box.depth()) / 2.0D;
+ }
+
+ @Override
+ public void stop() {
+ this.goal = null;
+ this.entity.getNavigator().reset();
+ }
+
+ @Override
+ public void teleport(Pos position) {
+ this.stop();
+ this.entity.teleport(position);
+ }
+
+ @Override
+ public void lookAt(Pos point) {
+ this.entity.lookAt(point);
+ }
+
+ @Override
+ public void showTo(Set viewers) {
+ this.viewers.clear();
+ this.viewers.addAll(viewers);
+ this.entity.updateViewableRule();
+ }
+
+ @Override
+ public boolean isVisibleTo(UUID viewer) {
+ return this.viewers.contains(viewer);
+ }
+
+ @Override
+ public void setAggressive(boolean aggressive) {
+ this.meta().setActive(aggressive);
+ }
+
+ @Override
+ public void setFrozen(boolean frozen) {
+ this.meta().setCanMove(!frozen);
+ }
+
+ @Override
+ public Entity entity() {
+ return this.entity;
+ }
+
+ @Override
+ public void remove() {
+ this.entity.remove();
+ }
+
+ private CreakingMeta meta() {
+ return (CreakingMeta) this.entity.getEntityMeta();
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreekBody.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreekBody.java
new file mode 100644
index 00000000..9669140a
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreekBody.java
@@ -0,0 +1,96 @@
+package net.onelitefeather.cygnus.creek.body;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Entity;
+
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * What the creek looks like in the world: the entity that gets drawn and moved around.
+ *
+ * The states only ever talk to this interface. That way the vanilla creaking can later make way
+ * for a custom model without the behaviour changing at all.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public interface CreekBody {
+
+ /**
+ * Where the creek's feet are.
+ *
+ * @return the position
+ */
+ Pos position();
+
+ /**
+ * Walks towards a goal.
+ *
+ * @param goal where to go
+ * @param speed how fast, in blocks per tick
+ */
+ void moveTo(Pos goal, double speed);
+
+ /**
+ * Stops walking.
+ */
+ void stop();
+
+ /**
+ * Moves the creek somewhere else in an instant.
+ *
+ * @param position where it ends up
+ */
+ void teleport(Pos position);
+
+ /**
+ * Turns the creek's head towards a point.
+ *
+ * @param point what to look at
+ */
+ void lookAt(Pos point);
+
+ /**
+ * Lets exactly these players see the creek.
+ *
+ * @param viewers who may see it; an empty set hides it from everyone
+ */
+ void showTo(Set viewers);
+
+ /**
+ * Tells whether a player can see the creek right now.
+ *
+ * @param viewer the player's id
+ * @return {@code true} if the creek is visible to them
+ */
+ boolean isVisibleTo(UUID viewer);
+
+ /**
+ * Switches the hunting look on or off. For the creaking, that means glowing eyes.
+ *
+ * @param aggressive {@code true} for the hunting look
+ */
+ void setAggressive(boolean aggressive);
+
+ /**
+ * Switches the frozen look on or off.
+ *
+ * @param frozen {@code true} while the hunted survivor is looking at the creek
+ */
+ void setFrozen(boolean frozen);
+
+ /**
+ * The entity behind the body, needed to check who can see it.
+ *
+ * @return the entity
+ */
+ Entity entity();
+
+ /**
+ * Takes the creek out of the world.
+ */
+ void remove();
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java
new file mode 100644
index 00000000..bd6558f3
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java
@@ -0,0 +1,45 @@
+package net.onelitefeather.cygnus.creek.body;
+
+import net.minestom.server.collision.BoundingBox;
+import net.minestom.server.collision.CollisionUtils;
+import net.minestom.server.collision.PhysicsResult;
+import net.minestom.server.coordinate.Point;
+import net.minestom.server.entity.pathfinding.generators.GroundNodeGenerator;
+import net.minestom.server.instance.block.Block;
+
+/**
+ * Plans paths like {@link GroundNodeGenerator}, but lets the creek walk through grass, ferns
+ * and leaf litter.
+ *
+ * Minestom only lets an entity step into a block of pure air, so a single tuft of grass made a
+ * spot unwalkable: the creek took detours, jumped around or did not move at all. Here only solid
+ * blocks and liquids get in the way.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+final class FoliageGroundGenerator extends GroundNodeGenerator {
+
+ @Override
+ public boolean canMoveTowards(Block.Getter getter, Point start, Point end, BoundingBox boundingBox) {
+ Block target = getter.getBlock(end);
+ if (target.solid() || target.liquid()) return false;
+ PhysicsResult result = sweep(getter, boundingBox, start, end);
+ return !result.collisionX() && !result.collisionY() && !result.collisionZ();
+ }
+
+ /**
+ * Moves the creek's bounding box from {@code start} to {@code end} and reports what it hits.
+ *
+ * @param getter the blocks of the world
+ * @param boundingBox the creek's bounding box
+ * @param start where the move starts
+ * @param end where the move ends
+ * @return the result of the move
+ */
+ private static PhysicsResult sweep(Block.Getter getter, BoundingBox boundingBox, Point start, Point end) {
+ return CollisionUtils.handlePhysics(getter, boundingBox, start.asPos(), end.sub(start).asVec(), null, false);
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java
new file mode 100644
index 00000000..ee4b113f
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java
@@ -0,0 +1,136 @@
+package net.onelitefeather.cygnus.creek.body;
+
+import net.minestom.server.collision.CollisionUtils;
+import net.minestom.server.collision.PhysicsResult;
+import net.minestom.server.coordinate.Point;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.minestom.server.entity.LivingEntity;
+import net.minestom.server.entity.attribute.Attribute;
+import net.minestom.server.entity.pathfinding.followers.GroundNodeFollower;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.utils.position.PositionUtils;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Moves the creek along its path like {@link GroundNodeFollower}, but steps up low blocks the
+ * way a vanilla mob does instead of hopping onto them.
+ *
+ * It differs from Minestom's follower in three ways:
+ *
+ *
+ * - Each step is checked a hair above the feet. Standing right on top of a slab, Minestom
+ * treats the next slab as a wall, and the creek would get stuck at the edge.
+ * - Anything up to its step height attribute high it simply walks up. A creaking's own is
+ * 1.0625, so like in vanilla it takes whole blocks without hopping, as survivors do in a round.
+ * It only jumps when stepping up gets it nowhere.
+ * - A path point counts as reached once the creek stands in its column and less than a block
+ * away in height. The path puts the floor of a slab a block too high, so on slabs the creek
+ * never quite arrived and kept jumping at the point.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+final class StepFollower extends GroundNodeFollower {
+
+ /** How far ahead it looks for a blocked step before deciding to jump, in blocks. */
+ private static final double LOOK_AHEAD = 0.3D;
+
+ private final LivingEntity entity;
+
+ StepFollower(LivingEntity entity) {
+ super(entity);
+ this.entity = entity;
+ }
+
+ /**
+ * The highest step the creek takes without jumping, from its step height attribute.
+ *
+ * @return the step height, in blocks
+ */
+ double stepHeight() {
+ return this.entity.getAttributeValue(Attribute.STEP_HEIGHT);
+ }
+
+ @Override
+ public void moveTowards(Point direction, double speed, Point lookAt) {
+ Instance instance = this.entity.getInstance();
+ if (instance == null) return;
+ Pos position = this.entity.getPosition();
+ double dx = direction.x() - position.x();
+ double dz = direction.z() - position.z();
+
+ // Never overshoot the point. Minestom slows down using the distance including height, so
+ // above a point one block lower the creek kept swinging past it and never dropped into a
+ // narrow gap.
+ double step = Math.min(speed, Math.hypot(dx, dz));
+
+ Pos moved = this.walk(instance, position, horizontal(dx, dz, step));
+ Point look = lookAt.sub(position);
+ float yaw = PositionUtils.getLookYaw(look.x(), look.z());
+ float pitch = PositionUtils.getLookPitch(look.x(), look.y(), look.z());
+ this.entity.refreshPosition(moved.withView(yaw, pitch));
+ }
+
+ @Override
+ public void jump(@Nullable Point point, @Nullable Point target) {
+ Instance instance = this.entity.getInstance();
+ if (instance == null || point == null || !this.entity.isOnGround()) return;
+ Pos position = this.entity.getPosition();
+ Vec ahead = horizontal(point.x() - position.x(), point.z() - position.z(), LOOK_AHEAD);
+ // Still room to walk, or a step it can walk up: no need to jump.
+ if (!this.blocked(instance, position.add(0, Vec.EPSILON, 0), ahead)) return;
+ if (!this.blocked(instance, this.lifted(instance, position), ahead)) return;
+ super.jump(point, target);
+ }
+
+ @Override
+ public boolean isAtPoint(Point point) {
+ Pos position = this.entity.getPosition();
+ return position.blockX() == point.blockX() && position.blockZ() == point.blockZ()
+ && Math.abs(position.y() - point.y()) < 1.0D;
+ }
+
+ /**
+ * Moves by {@code step}, walking up a low block if the flat move runs into it.
+ */
+ private Pos walk(Instance instance, Pos from, Vec step) {
+ PhysicsResult flat = this.sweep(instance, from.add(0, Vec.EPSILON, 0), step);
+ Pos flatEnd = flat.newPosition().withY(from.y());
+ if ((!flat.collisionX() && !flat.collisionZ()) || !this.entity.isOnGround()) return flatEnd;
+
+ PhysicsResult over = this.sweep(instance, this.lifted(instance, from), step);
+ if (horizontalDistance(from, over.newPosition()) <= horizontalDistance(from, flatEnd)) return flatEnd;
+ return this.sweep(instance, over.newPosition(), new Vec(0, -this.stepHeight(), 0)).newPosition();
+ }
+
+ /**
+ * Returns the position {@link #stepHeight()} higher, or lower if a ceiling is in the way.
+ */
+ private Pos lifted(Instance instance, Pos from) {
+ return this.sweep(instance, from.add(0, Vec.EPSILON, 0), new Vec(0, this.stepHeight(), 0)).newPosition();
+ }
+
+ private boolean blocked(Instance instance, Pos from, Vec step) {
+ PhysicsResult result = this.sweep(instance, from, step);
+ return result.collisionX() || result.collisionZ();
+ }
+
+ /**
+ * Moves the creek's bounding box by {@code move} from {@code from} and reports what it hits.
+ */
+ private PhysicsResult sweep(Instance instance, Pos from, Vec move) {
+ return CollisionUtils.handlePhysics(instance, this.entity.getBoundingBox(), from, move, null, false);
+ }
+
+ private static Vec horizontal(double dx, double dz, double length) {
+ double radians = Math.atan2(dz, dx);
+ return new Vec(Math.cos(radians) * length, 0, Math.sin(radians) * length);
+ }
+
+ private static double horizontalDistance(Pos from, Pos to) {
+ return Math.hypot(to.x() - from.x(), to.z() - from.z());
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/package-info.java
new file mode 100644
index 00000000..cdb81827
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * What the creek looks like in the world and how it moves.
+ */
+@NotNullByDefault
+package net.onelitefeather.cygnus.creek.body;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchConsequence.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchConsequence.java
new file mode 100644
index 00000000..ef952d87
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchConsequence.java
@@ -0,0 +1,27 @@
+package net.onelitefeather.cygnus.creek.consequence;
+
+import net.minestom.server.entity.Player;
+
+/**
+ * What happens to a survivor the creek catches.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+@FunctionalInterface
+public interface CatchConsequence {
+
+ /**
+ * Lets the catch hit the survivor.
+ *
+ * @param survivor the survivor who was caught
+ */
+ void apply(Player survivor);
+
+ /**
+ * Takes back anything that is still running. Called when the round ends.
+ */
+ default void cleanUp() {
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java
new file mode 100644
index 00000000..002f7d8e
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java
@@ -0,0 +1,81 @@
+package net.onelitefeather.cygnus.creek.consequence;
+
+import net.kyori.adventure.key.Key;
+import net.kyori.adventure.sound.Sound;
+import net.minestom.server.entity.Player;
+import net.minestom.server.potion.Potion;
+import net.minestom.server.potion.PotionEffect;
+import net.onelitefeather.cygnus.stamina.FoodBar;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+/**
+ * The usual price for being caught: a jump scare, an empty stamina bar and slowness.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CatchEffects implements CatchConsequence {
+
+ /** The sound of the stand-in scare, for when there is no corpse to show yet. */
+ static final Key SCARE_SOUND = Key.key("entity.creaking.activate");
+
+ /** How long the stand-in scare's darkness lasts, in ticks. */
+ static final int SCARE_DARKNESS_TICKS = 40;
+
+ private final Predicate jumpScare;
+ private final Function foodBars;
+ private final int slownessTicks;
+ private final TrackedEffects effects = new TrackedEffects();
+
+ /**
+ * Sets up the effects.
+ *
+ * @param jumpScare plays the jump scare and returns {@code false} if it could not
+ * @param foodBars returns a survivor's stamina bar, or {@code null} if they have none
+ * @param slownessSeconds how long a caught survivor is slowed
+ */
+ public CatchEffects(Predicate jumpScare, Function foodBars, int slownessSeconds) {
+ this.jumpScare = jumpScare;
+ this.foodBars = foodBars;
+ this.slownessTicks = slownessSeconds * 20;
+ }
+
+ @Override
+ public void apply(Player survivor) {
+ // The jump scare needs a corpse, so before the first death there is nothing to show.
+ // The stand-in makes sure a catch never goes unnoticed.
+ if (!this.jumpScare.test(survivor)) {
+ scareWithoutCorpse(survivor);
+ }
+ FoodBar bar = this.foodBars.apply(survivor);
+ if (bar != null) {
+ bar.drain();
+ }
+ if (this.slownessTicks > 0) {
+ this.effects.add(survivor, new Potion(PotionEffect.SLOWNESS, 0, this.slownessTicks));
+ }
+ }
+
+ @Override
+ public void cleanUp() {
+ this.effects.removeAll();
+ }
+
+ private static void scareWithoutCorpse(Player survivor) {
+ playScare(survivor);
+ survivor.addEffect(new Potion(PotionEffect.DARKNESS, 0, SCARE_DARKNESS_TICKS));
+ }
+
+ /**
+ * Plays the creek's scare sound to a player.
+ *
+ * @param player the player
+ */
+ static void playScare(Player player) {
+ player.playSound(Sound.sound(SCARE_SOUND, Sound.Source.HOSTILE, 1.0F, 0.6F));
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/GlowReveal.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/GlowReveal.java
new file mode 100644
index 00000000..4acdafc6
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/GlowReveal.java
@@ -0,0 +1,128 @@
+package net.onelitefeather.cygnus.creek.consequence;
+
+import net.kyori.adventure.key.Key;
+import net.kyori.adventure.sound.Sound;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.TextDecoration;
+import net.minestom.server.MinecraftServer;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Metadata;
+import net.minestom.server.entity.Player;
+import net.minestom.server.network.packet.server.play.EntityMetaDataPacket;
+import net.minestom.server.timer.Task;
+import net.minestom.server.timer.TaskSchedule;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Lets a survivor glow for a few seconds, but only in the slender's eyes.
+ *
+ * Glowing is a metadata flag, and Minestom sends metadata to everyone who can see the player. So
+ * the flag is never set on the survivor itself. Instead, only the slender gets a packet with it.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class GlowReveal implements SlenderReveal {
+
+ /** How often the glow packet goes out again, in ticks. */
+ static final int RESEND_TICKS = 5;
+
+ /** The glowing bit in the entity flags. */
+ static final byte GLOWING = 0x40;
+
+ static final Key SLENDER_SOUND = Key.key("block.bell.use");
+ static final Key SURVIVOR_SOUND = Key.key("entity.creaking.activate");
+ static final Component WARNING = Component.text("He knows where you are...", NamedTextColor.DARK_RED, TextDecoration.ITALIC);
+
+ private final int durationTicks;
+ private final Set running = ConcurrentHashMap.newKeySet();
+
+ /**
+ * Sets up the reveal.
+ *
+ * @param seconds how long the glow lasts
+ */
+ public GlowReveal(int seconds) {
+ this.durationTicks = seconds * 20;
+ }
+
+ @Override
+ public void reveal(Player survivor, Player slender) {
+ Pos where = survivor.getPosition();
+ slender.playSound(Sound.sound(SLENDER_SOUND, Sound.Source.HOSTILE, 1.0F, 0.5F), where.x(), where.y(), where.z());
+ // Let the survivor know too. Being given away without any hint would feel unfair.
+ survivor.playSound(Sound.sound(SURVIVOR_SOUND, Sound.Source.HOSTILE, 1.0F, 0.8F));
+ survivor.sendMessage(WARNING);
+
+ Reveal reveal = new Reveal(survivor, slender, this.durationTicks);
+ this.running.add(reveal);
+ slender.sendPacket(flagsPacket(survivor, true));
+ reveal.task = MinecraftServer.getSchedulerManager()
+ .buildTask(() -> this.step(reveal))
+ .delay(TaskSchedule.tick(RESEND_TICKS))
+ .repeat(TaskSchedule.tick(RESEND_TICKS))
+ .schedule();
+ }
+
+ private void step(Reveal reveal) {
+ reveal.ticksLeft -= RESEND_TICKS;
+ if (reveal.ticksLeft <= 0 || !reveal.survivor.isOnline() || !reveal.slender.isOnline()) {
+ this.end(reveal);
+ return;
+ }
+ // Whenever the survivor's metadata changes (sprinting, sneaking), the slender gets the real
+ // flags and the glow is gone. Sending it again keeps that gap short.
+ reveal.slender.sendPacket(flagsPacket(reveal.survivor, true));
+ }
+
+ @Override
+ public void cleanUp() {
+ for (Reveal reveal : List.copyOf(this.running)) {
+ this.end(reveal);
+ }
+ }
+
+ private void end(Reveal reveal) {
+ if (!this.running.remove(reveal)) return;
+ if (reveal.task != null) reveal.task.cancel();
+ if (reveal.slender.isOnline()) {
+ reveal.slender.sendPacket(flagsPacket(reveal.survivor, false));
+ }
+ }
+
+ /**
+ * Builds a packet with the survivor's current entity flags, with the glow bit set or cleared.
+ *
+ * The flags always go along. Otherwise the client would hold on to the last value it got, which
+ * after a reveal is the glow.
+ *
+ */
+ static EntityMetaDataPacket flagsPacket(Player survivor, boolean glowing) {
+ Metadata.Entry> entry = survivor.getMetadataPacket().entries().get(0);
+ byte flags = entry != null && entry.value() instanceof Byte value ? value : 0;
+ byte sent = glowing ? (byte) (flags | GLOWING) : (byte) (flags & ~GLOWING);
+ return new EntityMetaDataPacket(survivor.getEntityId(), Map.of(0, Metadata.Byte(sent)));
+ }
+
+ private static final class Reveal {
+
+ private final Player survivor;
+ private final Player slender;
+ private int ticksLeft;
+ private @Nullable Task task;
+
+ private Reveal(Player survivor, Player slender, int ticksLeft) {
+ this.survivor = survivor;
+ this.slender = slender;
+ this.ticksLeft = ticksLeft;
+ }
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelper.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelper.java
new file mode 100644
index 00000000..5ac21562
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelper.java
@@ -0,0 +1,175 @@
+package net.onelitefeather.cygnus.creek.consequence;
+
+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.potion.Potion;
+import net.minestom.server.potion.PotionEffect;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.random.RandomGenerator;
+
+/**
+ * What the patrolling creek does to the survivors around it.
+ *
+ * When it picks out a survivor who came too close, it is a coin toss: either the survivor freezes
+ * on the spot and everyone standing next to them goes blind for a moment, or the creek flings them
+ * away from itself. It is a scare, not a catch, so it never counts towards
+ * giving them away to the slender. When it vanishes at the end of its route, everyone close by
+ * hears it and goes blind the same way.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class PatrolHelper {
+
+ /** How long the survivor stays frozen, in ticks (2 seconds). */
+ static final int STUN_TICKS = 40;
+
+ /** Slowness VII: strong enough that the survivor cannot move at all. */
+ static final int STUN_AMPLIFIER = 6;
+
+ /** Everyone this close to the frozen survivor goes blind too, in blocks. */
+ static final double BLIND_RADIUS = 8.0D;
+
+ /** How long they stay blind, in ticks (2 seconds). */
+ static final int BLIND_TICKS = 40;
+
+ /** A fling needs at least this much room, in blocks. With less, it tries another way. */
+ static final double FLING_MIN_DISTANCE = 4.0D;
+
+ /** A fling goes at most this far, however much room there is, in blocks. */
+ static final double FLING_MAX_DISTANCE = 10.0D;
+
+ /**
+ * How fast to send a survivor for every block they should fly, in blocks per second. The client
+ * slows them down on its own, so this is worked out by hand: tune it if flings fall short or
+ * overshoot.
+ */
+ static final double FLING_SPEED_PER_BLOCK = 2.7D;
+
+ /** How fast a fling lifts the survivor off the ground, in blocks per second. */
+ static final double FLING_LIFT = 8.0D;
+
+ /** The ways a fling tries, in degrees off straight away from the creek, in this order. */
+ private static final double[] FLING_TURNS = {0.0D, 45.0D, -45.0D, 90.0D, -90.0D};
+
+ /** How finely the room in front of a survivor is measured, in blocks. */
+ private static final double ROOM_STEP = 0.5D;
+
+ /** The heights above the feet at which the room is measured, so the whole body fits through. */
+ private static final double[] BODY_HEIGHTS = {0.5D, 1.5D};
+
+ private final RandomGenerator random;
+ private final TrackedEffects effects = new TrackedEffects();
+
+ /**
+ * Sets up the consequence for a round.
+ *
+ * @param random tosses the coin between freezing and flinging
+ */
+ public PatrolHelper(RandomGenerator random) {
+ this.random = random;
+ }
+
+ /**
+ * Freezes the survivor or flings them away. If there is no room to fling them, they are frozen
+ * instead, so a selection never goes unnoticed.
+ *
+ * @param selected the survivor the creek picked out
+ * @param creek where the creek stands, to fling them away from it
+ * @param survivors every survivor of the round, to find the ones standing next to a frozen one
+ */
+ public void selected(Player selected, Pos creek, Collection survivors) {
+ if (this.random.nextBoolean() || !this.flingAway(selected, creek)) {
+ this.stun(selected, survivors);
+ }
+ }
+
+ /**
+ * Freezes the survivor for a moment and blinds the other survivors standing next to them.
+ */
+ void stun(Player selected, Collection survivors) {
+ CatchEffects.playScare(selected);
+ this.effects.add(selected, new Potion(PotionEffect.SLOWNESS, STUN_AMPLIFIER, STUN_TICKS));
+ for (Player other : near(selected.getPosition(), survivors)) {
+ if (other != selected) this.blind(other);
+ }
+ }
+
+ /**
+ * The creek vanished: every survivor close by hears it and goes blind for a moment.
+ *
+ * @param where where the creek vanished
+ * @param survivors the survivors it may blind
+ */
+ public void vanished(Pos where, Collection survivors) {
+ for (Player survivor : near(where, survivors)) {
+ CatchEffects.playScare(survivor);
+ this.blind(survivor);
+ }
+ }
+
+ private void blind(Player player) {
+ this.effects.add(player, new Potion(PotionEffect.BLINDNESS, 0, BLIND_TICKS));
+ }
+
+ /**
+ * The survivors within {@link #BLIND_RADIUS} of a point.
+ */
+ private static List near(Pos center, Collection survivors) {
+ return survivors.stream().filter(survivor -> survivor.getPosition().distance(center) <= BLIND_RADIUS).toList();
+ }
+
+ /**
+ * Flings the survivor away from the creek, as far as there is room but no further than
+ * {@link #FLING_MAX_DISTANCE}. Straight away comes first; if something stands in the way within
+ * {@link #FLING_MIN_DISTANCE}, it tries to the sides.
+ *
+ * @return {@code false} if there is not enough room in any of those ways
+ */
+ boolean flingAway(Player selected, Pos creek) {
+ Instance instance = selected.getInstance();
+ if (instance == null) return false;
+ Pos from = selected.getPosition();
+ double away = Math.atan2(from.z() - creek.z(), from.x() - creek.x());
+ for (double turn : FLING_TURNS) {
+ double angle = away + Math.toRadians(turn);
+ Vec direction = new Vec(Math.cos(angle), 0.0D, Math.sin(angle));
+ double room = room(instance, from, direction);
+ if (room < FLING_MIN_DISTANCE) continue;
+ Vec push = direction.mul(room * FLING_SPEED_PER_BLOCK);
+ selected.setVelocity(new Vec(push.x(), FLING_LIFT, push.z()));
+ CatchEffects.playScare(selected);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * How far the survivor can fly in a direction before hitting something, up to
+ * {@link #FLING_MAX_DISTANCE}. An unloaded chunk counts as a wall.
+ */
+ private static double room(Instance instance, Pos from, Vec direction) {
+ for (double distance = ROOM_STEP; distance <= FLING_MAX_DISTANCE; distance += ROOM_STEP) {
+ Pos ahead = from.add(direction.mul(distance));
+ if (!instance.isChunkLoaded(ahead)) return distance - ROOM_STEP;
+ for (double height : BODY_HEIGHTS) {
+ if (instance.getBlock(ahead.add(0.0D, height, 0.0D)).solid()) return distance - ROOM_STEP;
+ }
+ }
+ return FLING_MAX_DISTANCE;
+ }
+
+ /**
+ * Takes back every slowness and blindness handed out that is still running, so nothing lingers
+ * after the round.
+ */
+ public void cleanUp() {
+ this.effects.removeAll();
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SlenderReveal.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SlenderReveal.java
new file mode 100644
index 00000000..2659546e
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SlenderReveal.java
@@ -0,0 +1,28 @@
+package net.onelitefeather.cygnus.creek.consequence;
+
+import net.minestom.server.entity.Player;
+
+/**
+ * Gives a survivor's position away to the slender.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+@FunctionalInterface
+public interface SlenderReveal {
+
+ /**
+ * Shows the slender where a survivor is.
+ *
+ * @param survivor the survivor to give away
+ * @param slender the slender
+ */
+ void reveal(Player survivor, Player slender);
+
+ /**
+ * Ends every reveal that is still running.
+ */
+ default void cleanUp() {
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequence.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequence.java
new file mode 100644
index 00000000..329a68f7
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequence.java
@@ -0,0 +1,85 @@
+package net.onelitefeather.cygnus.creek.consequence;
+
+import net.minestom.server.entity.Player;
+import net.minestom.server.tag.Tag;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Supplier;
+import java.util.random.RandomGenerator;
+
+/**
+ * Punishes every catch, and sometimes gives the survivor away to the slender as well: always
+ * from the {@code betrayalCatchCount}-th catch on, and before that with a chance of
+ * {@code betrayalChance}.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class StagedCatchConsequence implements CatchConsequence {
+
+ /** How often a survivor has been caught since they were last given away. */
+ static final Tag CATCHES = Tag.Transient("creekCatches");
+
+ private final CatchConsequence effects;
+ private final SlenderReveal reveal;
+ private final Supplier<@Nullable Player> slender;
+ private final int betrayalCatchCount;
+ private final double betrayalChance;
+ private final RandomGenerator random;
+ private final Set counted = ConcurrentHashMap.newKeySet();
+
+ /**
+ * Sets up the consequence.
+ *
+ * @param effects the punishment for every catch
+ * @param reveal gives a survivor away to the slender
+ * @param slender supplies the slender, or {@code null} while there is none
+ * @param config the settings for giving survivors away
+ * @param random the random source
+ */
+ public StagedCatchConsequence(CatchConsequence effects, SlenderReveal reveal, Supplier<@Nullable Player> slender,
+ CreekConfig config, RandomGenerator random) {
+ this.effects = effects;
+ this.reveal = reveal;
+ this.slender = slender;
+ this.betrayalCatchCount = config.betrayalCatchCount();
+ this.betrayalChance = config.betrayalChance();
+ this.random = random;
+ }
+
+ @Override
+ public void apply(Player survivor) {
+ this.effects.apply(survivor);
+
+ Integer before = survivor.getTag(CATCHES);
+ int catches = before == null ? 1 : before + 1;
+ Player current = this.slender.get();
+ if (current != null && this.betrays(catches)) {
+ this.reveal.reveal(survivor, current);
+ survivor.removeTag(CATCHES);
+ this.counted.remove(survivor);
+ return;
+ }
+ survivor.setTag(CATCHES, catches);
+ this.counted.add(survivor);
+ }
+
+ private boolean betrays(int catches) {
+ return catches >= this.betrayalCatchCount || this.random.nextDouble() < this.betrayalChance;
+ }
+
+ @Override
+ public void cleanUp() {
+ this.effects.cleanUp();
+ this.reveal.cleanUp();
+ for (Player player : List.copyOf(this.counted)) {
+ player.removeTag(CATCHES);
+ }
+ this.counted.clear();
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffects.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffects.java
new file mode 100644
index 00000000..32ca5ba1
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffects.java
@@ -0,0 +1,59 @@
+package net.onelitefeather.cygnus.creek.consequence;
+
+import net.minestom.server.entity.Player;
+import net.minestom.server.potion.Potion;
+import net.minestom.server.potion.PotionEffect;
+import net.minestom.server.potion.TimedPotion;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Hands out potion effects and takes back exactly those again.
+ *
+ * Taking back only removes an effect that is still the one handed out here. If it has run out, or
+ * something else has put the same effect on the player since, it is left alone.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+final class TrackedEffects {
+
+ private final Map given = new ConcurrentHashMap<>();
+
+ /**
+ * Puts an effect on a player and remembers it.
+ *
+ * @param player the player
+ * @param potion the effect to put on them
+ */
+ void add(Player player, Potion potion) {
+ player.addEffect(potion);
+ TimedPotion active = player.getEffect(potion.effect());
+ if (active != null) this.given.put(new Key(player, potion.effect()), active);
+ }
+
+ /**
+ * Takes back every effect handed out that is still running, and forgets them all.
+ */
+ void removeAll() {
+ for (Map.Entry entry : List.copyOf(this.given.entrySet())) {
+ Player player = entry.getKey().player();
+ PotionEffect effect = entry.getKey().effect();
+ if (Objects.equals(player.getEffect(effect), entry.getValue())) {
+ player.removeEffect(effect);
+ }
+ }
+ this.given.clear();
+ }
+
+ /**
+ * One effect on one player. A new effect of the same kind replaces the one remembered before.
+ */
+ private record Key(Player player, PotionEffect effect) {
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/package-info.java
new file mode 100644
index 00000000..aa967e90
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * What happens to a survivor the creek catches or picks out.
+ */
+@NotNullByDefault
+package net.onelitefeather.cygnus.creek.consequence;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/debug/CreekDebug.java b/game/src/main/java/net/onelitefeather/cygnus/creek/debug/CreekDebug.java
new file mode 100644
index 00000000..4f60c13a
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/debug/CreekDebug.java
@@ -0,0 +1,227 @@
+package net.onelitefeather.cygnus.creek.debug;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.TextComponent;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.minestom.server.MinecraftServer;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.cygnus.creek.state.CreekState;
+import net.onelitefeather.cygnus.creek.state.DoneState;
+import net.onelitefeather.cygnus.creek.state.HuntState;
+import net.onelitefeather.cygnus.creek.state.PatrolState;
+import net.onelitefeather.cygnus.creek.state.StalkState;
+import net.onelitefeather.cygnus.creek.state.SurvivorView;
+import net.onelitefeather.cygnus.creek.state.VanishState;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+
+/**
+ * Shows what the creek is up to in the action bar of everyone who switched it on. Meant for
+ * playtests.
+ *
+ * Watchers see everything, whatever their role in the round. It is a tuning tool, not something
+ * players should have during a real game.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekDebug {
+
+ /** What watchers see while no round is running. */
+ public static final Component INACTIVE = Component.text("Creek inactive", NamedTextColor.GRAY);
+
+ /** Marks a survivor who can see the creek right now. The default Minecraft font has no emoji. */
+ static final String SEEN = "◉";
+
+ private final Set watchers = ConcurrentHashMap.newKeySet();
+ private volatile boolean active;
+
+ /**
+ * Switches the debug line on or off for a player.
+ *
+ * @param player the player's id
+ * @return {@code true} if the line is now on
+ */
+ public boolean toggle(UUID player) {
+ if (this.watchers.remove(player)) return false;
+ this.watchers.add(player);
+ return true;
+ }
+
+ /**
+ * Tells whether anyone has the debug line switched on.
+ *
+ * @return {@code true} if at least one player is watching
+ */
+ public boolean hasWatchers() {
+ return !this.watchers.isEmpty();
+ }
+
+ /**
+ * Tells whether the creek is part of a round right now.
+ *
+ * @return {@code true} while the creek is in the world
+ */
+ public boolean isActive() {
+ return this.active;
+ }
+
+ /**
+ * Tells the debug line whether the creek is part of a round. Once it leaves, watchers see
+ * "Creek inactive".
+ *
+ * @param active {@code true} while the creek is in the world
+ */
+ public void setActive(boolean active) {
+ this.active = active;
+ if (!active) this.show(INACTIVE);
+ }
+
+ /**
+ * Sends a line to every watcher who is online.
+ *
+ * @param line the line to show
+ */
+ public void show(Component line) {
+ for (UUID id : this.watchers) {
+ Player player = MinecraftServer.getConnectionManager().getOnlinePlayerByUuid(id);
+ if (player != null) player.sendActionBar(line);
+ }
+ }
+
+ /**
+ * Builds the debug line for one step.
+ *
+ * @param state the creek's state
+ * @param creek where the creek is
+ * @param views the survivors in that step
+ * @param names turns a player's id into a name
+ * @return the line
+ */
+ public static Component line(CreekState state, Pos creek, List views, Function names) {
+ return line(state, creek, views, names, "");
+ }
+
+ /**
+ * Builds the debug line for one step, together with where the creek is on its route.
+ *
+ * @param state the creek's state
+ * @param creek where the creek is
+ * @param views the survivors in that step
+ * @param names turns a player's id into a name
+ * @param route where the creek is on its route, or an empty string without a route
+ * @return the line
+ */
+ public static Component line(CreekState state, Pos creek, List views, Function names,
+ String route) {
+ return line(state, creek, views, names, route, Long.MAX_VALUE);
+ }
+
+ /**
+ * Builds the debug line of the patrolling creek.
+ *
+ * @param state the creek's state
+ * @param creek where the creek is
+ * @param views every survivor
+ * @param names turns a survivor's id into a name
+ * @param route where the creek is on its route, or an empty string
+ * @param now the current time in milliseconds, to show how long until it picks someone out again
+ * @return the line
+ */
+ public static Component line(CreekState state, Pos creek, List views, Function names,
+ String route, long now) {
+ TextComponent.Builder builder = Component.text();
+ builder.append(Component.text(label(state), NamedTextColor.RED));
+ if (!route.isEmpty()) {
+ builder.append(Component.text(" · " + route, NamedTextColor.AQUA));
+ }
+
+ target(state).ifPresent(id -> {
+ builder.append(Component.text(" → " + names.apply(id), NamedTextColor.GOLD));
+ views.stream()
+ .filter(view -> view.id().equals(id))
+ .findFirst()
+ .ifPresent(view -> builder.append(Component.text(
+ String.format(Locale.ROOT, " · %.1f m", view.position().distance(creek)),
+ NamedTextColor.GRAY)));
+ });
+
+ if (state instanceof PatrolState patrol && patrol.selectAllowedAt() > now) {
+ long seconds = (patrol.selectAllowedAt() - now + 999L) / 1000L;
+ builder.append(Component.text(" · select " + seconds + "s", NamedTextColor.GRAY));
+ }
+
+ for (SurvivorView view : views) {
+ builder.append(Component.text(" · ", NamedTextColor.DARK_GRAY));
+ builder.append(Component.text(
+ String.format(Locale.ROOT, "%s %.2f", names.apply(view.id()), view.dread()),
+ NamedTextColor.GRAY));
+ if (view.seesCreek()) {
+ builder.append(Component.text(" " + SEEN, NamedTextColor.YELLOW));
+ }
+ }
+ return builder.build();
+ }
+
+ /**
+ * Builds the part of the debug line about the variants: how many are running, and for each
+ * one who it haunts, what it is doing and how many seconds it has left.
+ *
+ * @param states the states of the running variants
+ * @param capacity how many variants may run at once
+ * @param names turns a survivor's id into a name
+ * @param now the current time in milliseconds
+ * @return the segment, starting with a separator
+ */
+ public static Component variants(List states, int capacity, Function names, long now) {
+ TextComponent.Builder builder = Component.text();
+ builder.append(Component.text(" | ", NamedTextColor.DARK_GRAY));
+ builder.append(Component.text("variants " + states.size() + "/" + capacity, NamedTextColor.LIGHT_PURPLE));
+ for (CreekState state : states) {
+ long endsAt = switch (state) {
+ case StalkState stalk -> stalk.endsAt();
+ case HuntState hunt -> hunt.endsAt();
+ default -> now;
+ };
+ String who = target(state).map(names).orElse("?");
+ long seconds = Math.max(0L, (endsAt - now) / 1000L);
+ builder.append(Component.text(" · " + who + " " + label(state) + " " + seconds + "s", NamedTextColor.GRAY));
+ }
+ return builder.build();
+ }
+
+ /**
+ * The name a state goes by in the debug line.
+ *
+ * @param state the state
+ * @return the name, for example {@code HUNT}
+ */
+ public static String label(CreekState state) {
+ return switch (state) {
+ case PatrolState _ -> "PATROL";
+ case StalkState _ -> "STALK";
+ case HuntState _ -> "HUNT";
+ case VanishState _ -> "VANISH";
+ case DoneState _ -> "DONE";
+ default -> state.getClass().getSimpleName();
+ };
+ }
+
+ private static Optional target(CreekState state) {
+ return switch (state) {
+ case StalkState stalk -> Optional.of(stalk.target());
+ case HuntState hunt -> Optional.of(hunt.target());
+ case PatrolState patrol -> patrol.staring();
+ default -> Optional.empty();
+ };
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/debug/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/debug/package-info.java
new file mode 100644
index 00000000..4466e163
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/debug/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * A debug line in the action bar, for playtests.
+ */
+@NotNullByDefault
+package net.onelitefeather.cygnus.creek.debug;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/dread/DreadSource.java b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/DreadSource.java
new file mode 100644
index 00000000..9fe2abd9
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/DreadSource.java
@@ -0,0 +1,31 @@
+package net.onelitefeather.cygnus.creek.dread;
+
+import net.minestom.server.coordinate.Pos;
+
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Rates how likely a survivor is to be haunted.
+ *
+ * The creek only ever asks this interface. A sanity system can take over the rating later without
+ * the creek noticing.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+@FunctionalInterface
+public interface DreadSource {
+
+ /**
+ * Rates one survivor.
+ *
+ * @param survivor the survivor to rate
+ * @param position where the survivor is
+ * @param others where all the other survivors are
+ * @return the dread, between {@code 0} and {@code 1}
+ */
+ double dreadOf(UUID survivor, Pos position, List others);
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/dread/PageProgressDread.java b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/PageProgressDread.java
new file mode 100644
index 00000000..e1c56be4
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/PageProgressDread.java
@@ -0,0 +1,72 @@
+package net.onelitefeather.cygnus.creek.dread;
+
+import net.minestom.server.coordinate.Pos;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.function.IntSupplier;
+import java.util.function.LongSupplier;
+
+/**
+ * Rates the dread from the pages found so far, how long the round has been going and whether a
+ * survivor is on their own.
+ *
+ * Pages and time are the same for everyone. Being alone is what sets a survivor apart, so the
+ * creek goes for those who wander off from the group.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class PageProgressDread implements DreadSource {
+
+ private final IntSupplier foundPages;
+ private final IntSupplier maxPages;
+ private final LongSupplier elapsedMillis;
+ private final long roundMillis;
+ private final CreekConfig config;
+
+ /**
+ * Sets up the rating.
+ *
+ * @param foundPages supplies how many pages have been found
+ * @param maxPages supplies how many pages there are in total
+ * @param elapsedMillis supplies how long the round has been going
+ * @param gameTimeSeconds how long a round lasts at most
+ * @param config the weights and how far apart counts as alone
+ */
+ public PageProgressDread(IntSupplier foundPages, IntSupplier maxPages, LongSupplier elapsedMillis,
+ int gameTimeSeconds, CreekConfig config) {
+ this.foundPages = foundPages;
+ this.maxPages = maxPages;
+ this.elapsedMillis = elapsedMillis;
+ this.roundMillis = gameTimeSeconds * 1000L;
+ this.config = config;
+ }
+
+ @Override
+ public double dreadOf(UUID survivor, Pos position, List others) {
+ double pages = share(this.foundPages.getAsInt(), this.maxPages.getAsInt());
+ double time = share(this.elapsedMillis.getAsLong(), this.roundMillis);
+ double isolated = this.isIsolated(position, others) ? 1.0D : 0.0D;
+ double dread = this.config.dreadPageWeight() * pages
+ + this.config.dreadTimeWeight() * time
+ + this.config.dreadIsolationWeight() * isolated;
+ return Math.clamp(dread, 0.0D, 1.0D);
+ }
+
+ private boolean isIsolated(Pos position, List others) {
+ double radius = this.config.isolationRadius();
+ for (Pos other : others) {
+ if (other.distance(position) <= radius) return false;
+ }
+ return true;
+ }
+
+ private static double share(double part, double whole) {
+ if (whole <= 0.0D) return 0.0D;
+ return Math.clamp(part / whole, 0.0D, 1.0D);
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/dread/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/package-info.java
new file mode 100644
index 00000000..aca6d5de
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * Rates how likely a survivor is to be haunted.
+ */
+@NotNullByDefault
+package net.onelitefeather.cygnus.creek.dread;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/package-info.java
new file mode 100644
index 00000000..b23cc459
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * The creek: a second, server-controlled figure next to the slender.
+ */
+@NotNullByDefault
+package net.onelitefeather.cygnus.creek;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.java
new file mode 100644
index 00000000..293cdebe
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.java
@@ -0,0 +1,40 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+
+import java.util.UUID;
+
+/**
+ * What a state can do to the survivors, beyond moving the creek's body.
+ *
+ * The states only know survivors by id. Whoever runs the creek looks the player up and applies the
+ * consequence, so the states stay free of server objects.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public interface CreekActions {
+
+ /**
+ * The creek caught a survivor.
+ *
+ * @param survivor the id of the survivor
+ */
+ void caught(UUID survivor);
+
+ /**
+ * The patrolling creek picked out a survivor.
+ *
+ * @param survivor the id of the survivor
+ */
+ void selected(UUID survivor);
+
+ /**
+ * The creek vanished on purpose, in front of whoever is nearby.
+ *
+ * @param where where it vanished
+ */
+ void vanished(Pos where);
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekContext.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekContext.java
new file mode 100644
index 00000000..83a0aec7
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekContext.java
@@ -0,0 +1,104 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.creek.body.CreekBody;
+import net.onelitefeather.cygnus.creek.world.RouteProvider;
+import net.onelitefeather.cygnus.creek.world.SpotFinder;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.random.RandomGenerator;
+
+/**
+ * Everything a state needs for one step.
+ *
+ * A fresh context is built for every step. It holds no server objects, so the states can be tested
+ * without a running server.
+ *
+ *
+ * @param now the current time in milliseconds
+ * @param survivors the survivors of the round
+ * @param body the creek's body
+ * @param route decides where the creek walks next
+ * @param spots finds places where nobody can see the creek
+ * @param actions what the states can do to a survivor
+ * @param config the settings
+ * @param random the random source
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public record CreekContext(
+ long now,
+ List survivors,
+ CreekBody body,
+ RouteProvider route,
+ SpotFinder spots,
+ CreekActions actions,
+ CreekConfig config,
+ RandomGenerator random
+) {
+
+ /**
+ * Looks up a survivor by id.
+ *
+ * @param id the id to look for
+ * @return the survivor, or empty if they are no longer in the round
+ */
+ public Optional survivor(UUID id) {
+ for (SurvivorView view : this.survivors) {
+ if (view.id().equals(id)) return Optional.of(view);
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * The ids of every survivor.
+ *
+ * @return the ids
+ */
+ public Set survivorIds() {
+ Set ids = new HashSet<>();
+ for (SurvivorView view : this.survivors) {
+ ids.add(view.id());
+ }
+ return Set.copyOf(ids);
+ }
+
+ /**
+ * Where every survivor's eyes are.
+ *
+ * @return the eye positions
+ */
+ public List observerEyes() {
+ return this.survivors.stream().map(SurvivorView::eyes).toList();
+ }
+
+ /**
+ * The highest dread among the survivors.
+ *
+ * @return the dread, {@code 0} without survivors
+ */
+ public double highestDread() {
+ return this.survivors.stream().mapToDouble(SurvivorView::dread).max().orElse(0.0D);
+ }
+
+ /**
+ * Checks that no survivor is closer to a point than the given distance.
+ *
+ * @param point the point to check
+ * @param distance the distance to keep, in blocks
+ * @return {@code true} if every survivor is at least that far away
+ */
+ public boolean farFromAll(Pos point, double distance) {
+ for (SurvivorView view : this.survivors) {
+ if (view.position().distance(point) < distance) return false;
+ }
+ return true;
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekState.java
new file mode 100644
index 00000000..a873243f
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekState.java
@@ -0,0 +1,26 @@
+package net.onelitefeather.cygnus.creek.state;
+
+/**
+ * One phase of the creek's behaviour, such as patrolling, stalking or hunting.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public interface CreekState {
+
+ /**
+ * Gets the body ready for this state. Called once, when the creek switches to it.
+ *
+ * @param ctx the current step
+ */
+ void enter(CreekContext ctx);
+
+ /**
+ * Runs one step.
+ *
+ * @param ctx the current step
+ * @return {@code this} to carry on, or the state to switch to
+ */
+ CreekState tick(CreekContext ctx);
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/DoneState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/DoneState.java
new file mode 100644
index 00000000..9660644a
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/DoneState.java
@@ -0,0 +1,35 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.onelitefeather.cygnus.creek.body.CreekBody;
+
+import java.util.Set;
+
+/**
+ * A variant whose stalk or hunt is over. It hides the creek and waits to be cleaned up.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class DoneState implements CreekState {
+
+ /** There is nothing to remember in this state, so one instance serves every variant. */
+ public static final DoneState INSTANCE = new DoneState();
+
+ private DoneState() {
+ }
+
+ @Override
+ public void enter(CreekContext ctx) {
+ CreekBody body = ctx.body();
+ body.stop();
+ body.setAggressive(false);
+ body.setFrozen(false);
+ body.showTo(Set.of());
+ }
+
+ @Override
+ public CreekState tick(CreekContext ctx) {
+ return this;
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java
new file mode 100644
index 00000000..8aae06f6
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java
@@ -0,0 +1,134 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.creek.body.CreekBody;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * The creek chases one survivor, but only moves while they are not looking at it.
+ *
+ * Look at it and it freezes. Let it get close enough and you are caught. The hunt is over after a
+ * catch, when time runs out or when the survivor is gone.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class HuntState implements CreekState {
+
+ /** How close to the survivor the creek lands after a shortcut, at the least, in blocks. */
+ static final double NEAR_MIN = 6.0D;
+
+ /** How close to the survivor the creek lands after a shortcut, at the most, in blocks. */
+ static final double NEAR_MAX = 10.0D;
+
+ /** How much closer the creek has to get to count as getting anywhere, in blocks. */
+ static final double PROGRESS = 0.5D;
+
+ private final UUID target;
+ private final long endsAt;
+ private double bestDistance = Double.MAX_VALUE;
+ private long progressSince;
+
+ /**
+ * Sets up the hunt.
+ *
+ * @param target the survivor being hunted
+ * @param endsAt when the hunt is over, in milliseconds
+ */
+ public HuntState(UUID target, long endsAt) {
+ this.target = target;
+ this.endsAt = endsAt;
+ }
+
+ /**
+ * Starts a hunt that lasts {@code huntMaxSeconds}.
+ *
+ * @param target the survivor to hunt
+ * @param ctx the current step
+ * @return the state
+ */
+ static HuntState starting(UUID target, CreekContext ctx) {
+ return new HuntState(target, ctx.now() + ctx.config().huntMaxSeconds() * 1000L);
+ }
+
+ /**
+ * Tells who is being hunted.
+ *
+ * @return the survivor's id
+ */
+ public UUID target() {
+ return this.target;
+ }
+
+ /**
+ * Tells when the hunt is over.
+ *
+ * @return the time in milliseconds
+ */
+ public long endsAt() {
+ return this.endsAt;
+ }
+
+ @Override
+ public void enter(CreekContext ctx) {
+ CreekBody body = ctx.body();
+ body.showTo(Set.of(this.target));
+ body.setAggressive(true);
+ body.setFrozen(false);
+ this.progressSince = ctx.now();
+ }
+
+ @Override
+ public CreekState tick(CreekContext ctx) {
+ Optional found = ctx.survivor(this.target);
+ if (found.isEmpty() || ctx.now() >= this.endsAt) return DoneState.INSTANCE;
+
+ SurvivorView view = found.get();
+ CreekConfig config = ctx.config();
+ CreekBody body = ctx.body();
+
+ if (view.seesCreek()) {
+ body.setFrozen(true);
+ body.stop();
+ this.progressSince = ctx.now();
+ return this;
+ }
+ body.setFrozen(false);
+
+ double distance = body.position().distance(view.position());
+ if (distance <= config.catchDistance()) {
+ ctx.actions().caught(this.target);
+ return DoneState.INSTANCE;
+ }
+
+ if (distance < this.bestDistance - PROGRESS) {
+ this.bestDistance = distance;
+ this.progressSince = ctx.now();
+ } else if (ctx.now() - this.progressSince >= config.stuckMillis()) {
+ this.shortcut(ctx, view);
+ return this;
+ }
+
+ body.lookAt(view.eyes());
+ body.moveTo(view.position(), config.huntSpeed());
+ return this;
+ }
+
+ /**
+ * When walking there gets the creek nowhere, it jumps close to the survivor instead, out of their view.
+ */
+ private void shortcut(CreekContext ctx, SurvivorView view) {
+ ctx.spots()
+ .beside(view.position(), NEAR_MIN, NEAR_MAX, ctx.config().stalkMinAngle(), 180.0D,
+ List.of(view.eyes()), NEAR_MIN, ctx.random())
+ .ifPresent(ctx.body()::teleport);
+ this.bestDistance = Double.MAX_VALUE;
+ this.progressSince = ctx.now();
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java
new file mode 100644
index 00000000..20f53da4
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java
@@ -0,0 +1,252 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.creek.body.CreekBody;
+import net.onelitefeather.cygnus.creek.world.RouteStep;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * The creek on its rounds: it walks its routes, and every survivor can see it.
+ *
+ * Come within {@link #SELECT_RADIUS} of it and it picks out whoever is closest, stares at them for a
+ * second and then freezes them or flings them away. After that it simply walks on and leaves
+ * everyone alone for {@link #SELECT_COOLDOWN_MILLIS}. Spot it from farther away and it stops and
+ * looks back at you for a moment. At some waypoints it rests for a while, either because the route
+ * says so or just by chance; whichever takes longer wins.
+ *
+ *
+ * At the end of a route no other route links to, it may vanish instead of resting there, which
+ * blinds anyone close by. It stays away for as long as it would have rested and then turns up
+ * somewhere else on the map, so it does not keep pacing one stretch of routes all round.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class PatrolState implements CreekState {
+
+ /** How close a survivor has to come before the creek picks them out, in blocks. */
+ static final double SELECT_RADIUS = 4.0D;
+
+ /** How long it stares at the chosen survivor before anything happens, in milliseconds. */
+ static final long STARE_MILLIS = 1000L;
+
+ /** How long it leaves everyone alone after picking someone out, in milliseconds. */
+ static final long SELECT_COOLDOWN_MILLIS = 10_000L;
+
+ /** How likely it vanishes on reaching a dead end instead of resting there. */
+ static final double DEAD_END_VANISH_CHANCE = 0.5D;
+
+ /** How close counts as having reached a waypoint, in blocks. */
+ static final double ARRIVED = 2.0D;
+
+ /** How far it has to get to count as moving at all, in blocks. */
+ static final double PROGRESS = 0.5D;
+
+ private @Nullable RouteStep goal;
+ private boolean watched;
+ private long pausedUntil;
+ private long restingUntil;
+ private Pos progressAt = Pos.ZERO;
+ private long progressSince;
+ private Set shownTo = Set.of();
+ private @Nullable UUID staring;
+ private long stareUntil;
+ private long selectAllowedAt;
+
+ /**
+ * Tells who the creek is staring at right now.
+ *
+ * @return the survivor's id, or empty while it stares at nobody
+ */
+ public Optional staring() {
+ return Optional.ofNullable(this.staring);
+ }
+
+ /**
+ * Tells when the creek may pick someone out again.
+ *
+ * @return the time in milliseconds
+ */
+ public long selectAllowedAt() {
+ return this.selectAllowedAt;
+ }
+
+ @Override
+ public void enter(CreekContext ctx) {
+ CreekBody body = ctx.body();
+ body.setAggressive(false);
+ body.setFrozen(false);
+ this.showToSurvivors(ctx);
+ this.markProgress(ctx.now(), body.position());
+ }
+
+ @Override
+ public CreekState tick(CreekContext ctx) {
+ CreekConfig config = ctx.config();
+ CreekBody body = ctx.body();
+ Pos here = body.position();
+ this.showToSurvivors(ctx);
+
+ if (this.stare(ctx)) return this;
+ if (ctx.now() >= this.selectAllowedAt && this.select(ctx, here)) return this;
+
+ Optional watcher = ctx.survivors().stream().filter(SurvivorView::seesCreek).findFirst();
+ if (watcher.isEmpty()) {
+ this.watched = false;
+ } else if (this.pause(ctx, watcher.get())) {
+ return this;
+ }
+
+ if (ctx.now() < this.restingUntil) {
+ body.stop();
+ return this;
+ }
+ RouteStep reached = this.goal;
+ if (reached != null && here.distance(reached.target()) < ARRIVED) {
+ this.goal = null;
+ if (reached.deadEnd() && ctx.random().nextDouble() < DEAD_END_VANISH_CHANCE) {
+ ctx.actions().vanished(here);
+ // Gone for as long as it would have rested here, then back somewhere else.
+ return new VanishState(ctx.now() + reached.pauseMillis());
+ }
+ long rest = this.restMillis(ctx, reached);
+ if (rest > 0) {
+ this.restingUntil = ctx.now() + rest;
+ body.stop();
+ return this;
+ }
+ }
+
+ if (this.needsNewGoal(ctx, here)) {
+ this.goal = ctx.route().next(here, _ -> true, ctx.random()).orElse(null);
+ this.markProgress(ctx.now(), here);
+ }
+ RouteStep next = this.goal;
+ if (next == null) {
+ body.stop();
+ return this;
+ }
+ body.moveTo(next.target(), config.wanderSpeed());
+ return this;
+ }
+
+ /**
+ * Keeps staring at the chosen survivor. Once the second is up, the consequence hits them.
+ *
+ * @return {@code true} while the stare lasts
+ */
+ private boolean stare(CreekContext ctx) {
+ UUID target = this.staring;
+ if (target == null) return false;
+ Optional view = ctx.survivor(target);
+ if (view.isEmpty()) {
+ // They left before the stare was over: nothing happens, and no cooldown starts.
+ this.staring = null;
+ return false;
+ }
+ CreekBody body = ctx.body();
+ if (ctx.now() < this.stareUntil) {
+ body.stop();
+ body.lookAt(view.get().eyes());
+ return true;
+ }
+ this.staring = null;
+ this.selectAllowedAt = ctx.now() + SELECT_COOLDOWN_MILLIS;
+ // They are most likely still looking at it. Walk on rather than stop again to look back.
+ this.watched = true;
+ this.markProgress(ctx.now(), body.position());
+ ctx.actions().selected(target);
+ return false;
+ }
+
+ /**
+ * Picks out the closest survivor within {@link #SELECT_RADIUS} and starts staring at them.
+ *
+ * @return {@code true} if someone was picked out
+ */
+ private boolean select(CreekContext ctx, Pos here) {
+ SurvivorView nearest = null;
+ double best = SELECT_RADIUS;
+ for (SurvivorView view : ctx.survivors()) {
+ double distance = view.position().distance(here);
+ if (distance <= best) {
+ best = distance;
+ nearest = view;
+ }
+ }
+ if (nearest == null) return false;
+ this.staring = nearest.id();
+ this.stareUntil = ctx.now() + STARE_MILLIS;
+ ctx.body().stop();
+ ctx.body().lookAt(nearest.eyes());
+ this.markProgress(ctx.now(), here);
+ return true;
+ }
+
+ /**
+ * Stops and looks back for a moment when someone spots the creek from afar.
+ *
+ * @return {@code true} while it is looking back
+ */
+ private boolean pause(CreekContext ctx, SurvivorView watcher) {
+ if (!this.watched) {
+ this.watched = true;
+ this.pausedUntil = ctx.now() + ctx.config().wanderPauseMillis();
+ }
+ if (ctx.now() >= this.pausedUntil) return false;
+
+ ctx.body().stop();
+ ctx.body().lookAt(watcher.eyes());
+ // Keep heading for the same point. Dropping it made the route hand out the next one, so the
+ // creek skipped points or turned around. Standing still on purpose is not being stuck.
+ this.markProgress(ctx.now(), ctx.body().position());
+ return true;
+ }
+
+ /**
+ * How long to rest at the point just reached: the route's pause, or a random stop if that
+ * is longer.
+ */
+ private long restMillis(CreekContext ctx, RouteStep reached) {
+ CreekConfig config = ctx.config();
+ int rest = reached.pauseMillis();
+ if (config.randomStopChance() > 0.0D && ctx.random().nextDouble() < config.randomStopChance()) {
+ int spread = config.randomStopMaxMillis() - config.randomStopMinMillis();
+ rest = Math.max(rest, config.randomStopMinMillis() + ctx.random().nextInt(spread + 1));
+ }
+ return rest;
+ }
+
+ private boolean needsNewGoal(CreekContext ctx, Pos here) {
+ RouteStep current = this.goal;
+ if (current == null || here.distance(current.target()) < ARRIVED) return true;
+ if (here.distance(this.progressAt) >= PROGRESS) {
+ this.markProgress(ctx.now(), here);
+ return false;
+ }
+ return ctx.now() - this.progressSince >= ctx.config().stuckMillis();
+ }
+
+ /**
+ * Shows the creek to exactly the current survivors. Anyone who stops being one (dead, or now
+ * the slender) loses sight of it right away.
+ */
+ private void showToSurvivors(CreekContext ctx) {
+ Set survivors = ctx.survivorIds();
+ if (survivors.equals(this.shownTo)) return;
+ this.shownTo = survivors;
+ ctx.body().showTo(survivors);
+ }
+
+ private void markProgress(long now, Pos here) {
+ this.progressAt = here;
+ this.progressSince = now;
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java
new file mode 100644
index 00000000..1e88a651
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java
@@ -0,0 +1,155 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.creek.body.CreekBody;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.random.RandomGenerator;
+
+/**
+ * The creek shadows one survivor from a distance, and only that survivor can see it.
+ *
+ * Look at it and it holds your gaze for a moment, then it is gone and turns up somewhere else,
+ * just out of view. The longer the stalk lasts, the closer it creeps: it starts at
+ * {@code stalkMinDistance} to {@code stalkMaxDistance} away and ends at {@link #END_MIN_DISTANCE}
+ * to {@link #END_MAX_DISTANCE}. The stalk is over when time runs out or the survivor is gone.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class StalkState implements CreekState {
+
+ /** How close the creek gets by the end of a stalk, in blocks. */
+ static final double END_MIN_DISTANCE = 8.0D;
+
+ /** How far away it may still be at the end of a stalk, in blocks. */
+ static final double END_MAX_DISTANCE = 15.0D;
+
+ private final UUID target;
+ private final long endsAt;
+ private long startedAt;
+ private long seenSince = -1L;
+
+ /**
+ * Sets up the stalk.
+ *
+ * @param target the survivor being stalked
+ * @param endsAt when the stalk is over, in milliseconds
+ */
+ public StalkState(UUID target, long endsAt) {
+ this.target = target;
+ this.endsAt = endsAt;
+ }
+
+ /**
+ * Starts a stalk that lasts somewhere between {@code stalkMinSeconds} and
+ * {@code stalkMaxSeconds}.
+ *
+ * @param target the survivor to stalk
+ * @param now the current time in milliseconds
+ * @param config the settings
+ * @param random the random source
+ * @return the state
+ */
+ public static StalkState starting(UUID target, long now, CreekConfig config, RandomGenerator random) {
+ long seconds = random.nextLong(config.stalkMinSeconds(), config.stalkMaxSeconds() + 1L);
+ return new StalkState(target, now + seconds * 1000L);
+ }
+
+ /**
+ * Tells who is being stalked.
+ *
+ * @return the survivor's id
+ */
+ public UUID target() {
+ return this.target;
+ }
+
+ /**
+ * Tells when the stalk is over.
+ *
+ * @return the time in milliseconds
+ */
+ public long endsAt() {
+ return this.endsAt;
+ }
+
+ @Override
+ public void enter(CreekContext ctx) {
+ this.startedAt = ctx.now();
+ CreekBody body = ctx.body();
+ body.stop();
+ body.setAggressive(false);
+ body.setFrozen(false);
+ body.showTo(Set.of(this.target));
+ }
+
+ @Override
+ public CreekState tick(CreekContext ctx) {
+ Optional found = ctx.survivor(this.target);
+ if (found.isEmpty() || ctx.now() >= this.endsAt) return DoneState.INSTANCE;
+
+ SurvivorView view = found.get();
+ CreekConfig config = ctx.config();
+ if (view.dread() >= config.huntThreshold()) return HuntState.starting(this.target, ctx);
+
+ CreekBody body = ctx.body();
+ body.lookAt(view.eyes());
+
+ if (view.seesCreek()) {
+ if (this.seenSince < 0) this.seenSince = ctx.now();
+ if (ctx.now() - this.seenSince >= config.stalkRevealMillis() && this.relocate(ctx, view)) {
+ this.seenSince = -1L;
+ }
+ return this;
+ }
+ this.seenSince = -1L;
+
+ double distance = body.position().distance(view.position());
+ if (distance < this.minDistance(ctx) || distance > this.maxDistance(ctx)) {
+ this.relocate(ctx, view);
+ }
+ return this;
+ }
+
+ /**
+ * Moves the creek to a new spot just outside the survivor's view.
+ *
+ * @return {@code false} if there was no free spot in this step
+ */
+ private boolean relocate(CreekContext ctx, SurvivorView view) {
+ CreekConfig config = ctx.config();
+ double min = this.minDistance(ctx);
+ // The band's own minimum keeps the creek off the survivor. The fixed personal space would
+ // find no spot at all once the band comes closer than that.
+ Optional spot = ctx.spots().beside(view.position(), min, this.maxDistance(ctx),
+ config.stalkMinAngle(), config.stalkMaxAngle(), List.of(view.eyes()), min, ctx.random());
+ spot.ifPresent(ctx.body()::teleport);
+ return spot.isPresent();
+ }
+
+ /**
+ * How far into the stalk it is: 0 at the start, 1 at the end.
+ */
+ private double progress(long now) {
+ long length = this.endsAt - this.startedAt;
+ if (length <= 0) return 1.0D;
+ return Math.clamp((double) (now - this.startedAt) / length, 0.0D, 1.0D);
+ }
+
+ private double minDistance(CreekContext ctx) {
+ double start = ctx.config().stalkMinDistance();
+ return start + (END_MIN_DISTANCE - start) * this.progress(ctx.now());
+ }
+
+ private double maxDistance(CreekContext ctx) {
+ double start = ctx.config().stalkMaxDistance();
+ return start + (END_MAX_DISTANCE - start) * this.progress(ctx.now());
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java
new file mode 100644
index 00000000..549d3186
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java
@@ -0,0 +1,42 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+
+import java.util.UUID;
+
+/**
+ * A snapshot of one survivor, taken once per step.
+ *
+ * @param id the survivor's id
+ * @param position where the survivor's feet are, including where they are looking
+ * @param dread how likely the survivor is to be haunted, from 0 to 1
+ * @param seesCreek whether the survivor can see the creek right now
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public record SurvivorView(UUID id, Pos position, double dread, boolean seesCreek) {
+
+ /** How high a standing player's eyes are, in blocks. */
+ public static final double EYE_HEIGHT = 1.62D;
+
+ /**
+ * Where the survivor's eyes are.
+ *
+ * @return the eye position, looking where the survivor looks
+ */
+ public Pos eyes() {
+ return this.position.add(0, EYE_HEIGHT, 0);
+ }
+
+ /**
+ * The same snapshot, with the given answer to whether the survivor sees the creek.
+ *
+ * @param sees whether the survivor sees the creek
+ * @return this view if nothing changes, otherwise a copy
+ */
+ public SurvivorView withSeesCreek(boolean sees) {
+ return sees == this.seesCreek ? this : new SurvivorView(this.id, this.position, this.dread, sees);
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/VanishState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/VanishState.java
new file mode 100644
index 00000000..6cf2a765
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/VanishState.java
@@ -0,0 +1,90 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+import net.onelitefeather.cygnus.creek.body.CreekBody;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * The creek is gone for a while: at the start of a round, or for good once it sits out the rest
+ * of it. When it comes back, it turns up somewhere nobody is looking.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class VanishState implements CreekState {
+
+ private final long until;
+
+ /**
+ * Sets up a vanish that ends at a fixed time.
+ *
+ * @param until when the creek may come back, in milliseconds
+ */
+ public VanishState(long until) {
+ this.until = until;
+ }
+
+ /**
+ * A vanish that never ends, for when the creek sits out the rest of the round.
+ *
+ * @return the state
+ */
+ public static VanishState forever() {
+ return new VanishState(Long.MAX_VALUE);
+ }
+
+ /**
+ * Tells when the creek may come back.
+ *
+ * @return the time in milliseconds, {@link Long#MAX_VALUE} for {@link #forever()}
+ */
+ public long until() {
+ return this.until;
+ }
+
+ /**
+ * Tells whether this vanish never ends.
+ *
+ * @return {@code true} for {@link #forever()}
+ */
+ public boolean isForever() {
+ return this.until == Long.MAX_VALUE;
+ }
+
+ @Override
+ public void enter(CreekContext ctx) {
+ CreekBody body = ctx.body();
+ body.stop();
+ body.setAggressive(false);
+ body.setFrozen(false);
+ body.showTo(Set.of());
+ }
+
+ @Override
+ public CreekState tick(CreekContext ctx) {
+ if (ctx.now() < this.until) return this;
+
+ List observers = ctx.observerEyes();
+ double distance = ctx.config().respawnMinDistance();
+ // Any point of the route will do. Asking the route for its next point would only offer the
+ // neighbours of where the creek disappeared, and that is often right next to a survivor.
+ // Checking them in random order and taking the first hidden one picks just as fairly as
+ // checking them all, but usually stops after one or two.
+ List points = new ArrayList<>(ctx.route().points());
+ Collections.shuffle(points, ctx.random());
+ for (Pos point : points) {
+ Optional spot = ctx.spots().hiddenSpotAt(point, observers, distance);
+ if (spot.isPresent()) {
+ ctx.body().teleport(spot.get());
+ return new PatrolState();
+ }
+ }
+ return this;
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/package-info.java
new file mode 100644
index 00000000..6d635664
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * How the creek behaves, one state at a time: patrolling, stalking, hunting, vanishing and done.
+ */
+@NotNullByDefault
+package net.onelitefeather.cygnus.creek.state;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java
new file mode 100644
index 00000000..ba50be54
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java
@@ -0,0 +1,129 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.onelitefeather.cygnus.common.creek.CreekLinks;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The creek routes of a map, with the links between them worked out once up front, see
+ * {@link CreekLinks}.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekPaths {
+
+ private final List routes;
+ private final CreekLinks links;
+ private final List allPoints;
+
+ private CreekPaths(List routes, CreekLinks links) {
+ this.routes = routes;
+ this.links = links;
+ List points = new ArrayList<>();
+ for (CreekRoute route : routes) {
+ route.points().forEach(point -> points.add(toPos(point.position())));
+ }
+ this.allPoints = List.copyOf(points);
+ }
+
+ /**
+ * Takes the routes of a map and works out the links between them.
+ *
+ * @param routes the valid routes of the map
+ * @param linkDistance how close an end has to be to a point of another route to link to it, in blocks
+ * @return the paths
+ */
+ public static CreekPaths of(List routes, double linkDistance) {
+ List frozenRoutes = List.copyOf(routes);
+ return new CreekPaths(frozenRoutes, CreekLinks.of(frozenRoutes, linkDistance));
+ }
+
+ /**
+ * Tells whether the map has no routes at all.
+ *
+ * @return {@code true} without routes
+ */
+ public boolean isEmpty() {
+ return this.routes.isEmpty();
+ }
+
+ /**
+ * How many routes there are.
+ *
+ * @return the number of routes
+ */
+ public int size() {
+ return this.routes.size();
+ }
+
+ /**
+ * One of the routes.
+ *
+ * @param index the route's index
+ * @return the route
+ */
+ public CreekRoute route(int index) {
+ return this.routes.get(index);
+ }
+
+ /**
+ * How many points a route has.
+ *
+ * @param route the route's index
+ * @return the number of points
+ */
+ public int pointCount(int route) {
+ return this.routes.get(route).points().size();
+ }
+
+ /**
+ * One point of a route.
+ *
+ * @param route the route's index
+ * @param index the point's index
+ * @return the point
+ */
+ public Pos point(int route, int index) {
+ return toPos(this.routes.get(route).points().get(index).position());
+ }
+
+ /**
+ * How long the creek rests at one point of a route.
+ *
+ * @param route the route's index
+ * @param index the point's index
+ * @return the pause, in milliseconds
+ */
+ public int pauseMillis(int route, int index) {
+ return this.routes.get(route).points().get(index).pauseMillis();
+ }
+
+ /**
+ * The points linked to a point, always in the same order.
+ *
+ * @param node the point
+ * @return the linked points, empty if there are none
+ */
+ public List links(CreekLinks.Node node) {
+ return this.links.of(node);
+ }
+
+ /**
+ * Every point of every route.
+ *
+ * @return the points, unmodifiable
+ */
+ public List allPoints() {
+ return this.allPoints;
+ }
+
+ private static Pos toPos(Vec point) {
+ return new Pos(point.x(), point.y(), point.z());
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekSight.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekSight.java
new file mode 100644
index 00000000..2bdb24db
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekSight.java
@@ -0,0 +1,65 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Point;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Entity;
+import net.minestom.server.entity.Player;
+import net.minestom.server.instance.Instance;
+import net.onelitefeather.cygnus.gaze.SlenderGaze;
+
+/**
+ * Works out whether a survivor can see the creek.
+ *
+ * It uses the same view cone as {@link SlenderGaze}: close enough, and not too far off to the side
+ * of where the survivor is looking. On top of that it checks the line of sight, because the creek
+ * is usually far away and often behind trees.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekSight {
+
+ private final SlenderGaze cone;
+
+ /**
+ * Sets up the check with the given cone.
+ *
+ * @param range how far away the creek can still be noticed, in blocks
+ * @param viewAngle how far off to the side it can still be noticed, in degrees from the view centre
+ */
+ public CreekSight(int range, int viewAngle) {
+ // SlenderGaze wants a close distance below its range. All that matters here is "inside
+ // the cone or not", and zero keeps the whole range.
+ this.cone = new SlenderGaze(range, 0, viewAngle);
+ }
+
+ /**
+ * Checks whether a point is inside someone's view cone. Blocks in between do not matter here.
+ *
+ * @param observer where the observer's eyes are, including where they are looking
+ * @param target the point to check
+ * @return {@code true} if the point is inside the cone
+ */
+ public boolean inView(Pos observer, Point target) {
+ return this.cone.levelOf(observer, target.asPos()) != SlenderGaze.NONE;
+ }
+
+ /**
+ * Checks whether a player can see the creek: it is inside their view cone and no block is in the
+ * way.
+ *
+ * @param observer the player
+ * @param creek the creek's entity
+ * @return {@code true} if the player can see the creek
+ */
+ public boolean sees(Player observer, Entity creek) {
+ Instance instance = observer.getInstance();
+ if (instance == null || !instance.equals(creek.getInstance())) return false;
+
+ Pos eyes = observer.getPosition().add(0, observer.getEyeHeight(), 0);
+ Pos body = creek.getPosition().add(0, creek.getEyeHeight(), 0);
+ return this.inView(eyes, body) && observer.hasLineOfSight(creek);
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/Ground.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/Ground.java
new file mode 100644
index 00000000..6ad23409
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/Ground.java
@@ -0,0 +1,24 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+
+import java.util.Optional;
+
+/**
+ * Puts a candidate spot onto a floor the creek can stand on.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+@FunctionalInterface
+public interface Ground {
+
+ /**
+ * Finds the floor below or near a candidate spot.
+ *
+ * @param candidate the spot to check
+ * @return the spot on the floor, or empty if there is no room to stand
+ */
+ Optional settle(Pos candidate);
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/InstanceGround.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/InstanceGround.java
new file mode 100644
index 00000000..82cf457f
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/InstanceGround.java
@@ -0,0 +1,60 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.instance.Instance;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Optional;
+import java.util.function.Supplier;
+
+/**
+ * Finds a floor by looking at the blocks of the round's instance.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class InstanceGround implements Ground {
+
+ /** How many blocks above the candidate the search starts. */
+ static final int SEARCH_UP = 4;
+
+ /** How many blocks below the candidate the search goes. */
+ static final int SEARCH_DOWN = 8;
+
+ /** How many free blocks the creek needs above its feet. A creaking is almost three blocks tall. */
+ static final int HEADROOM = 3;
+
+ private final Supplier extends @Nullable Instance> instance;
+
+ /**
+ * Sets up the floor search.
+ *
+ * @param instance supplies the round's instance, or {@code null} while there is none
+ */
+ public InstanceGround(Supplier extends @Nullable Instance> instance) {
+ this.instance = instance;
+ }
+
+ @Override
+ public Optional settle(Pos candidate) {
+ Instance world = this.instance.get();
+ if (world == null || !world.isChunkLoaded(candidate)) return Optional.empty();
+
+ int x = candidate.blockX();
+ int z = candidate.blockZ();
+ for (int y = candidate.blockY() + SEARCH_UP; y >= candidate.blockY() - SEARCH_DOWN; y--) {
+ if (world.getBlock(x, y - 1, z).solid() && hasRoom(world, x, y, z)) {
+ return Optional.of(new Pos(x + 0.5D, y, z + 0.5D));
+ }
+ }
+ return Optional.empty();
+ }
+
+ private static boolean hasRoom(Instance world, int x, int y, int z) {
+ for (int offset = 0; offset < HEADROOM; offset++) {
+ if (world.getBlock(x, y + offset, z).solid()) return false;
+ }
+ return true;
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java
new file mode 100644
index 00000000..0cb34109
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java
@@ -0,0 +1,182 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+import net.onelitefeather.cygnus.common.creek.CreekLinks;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.random.RandomGenerator;
+
+/**
+ * Walks the creek along the routes of a map.
+ *
+ * It remembers the route, the point and the direction. At the end of a route it picks at random
+ * between turning around and any route linked to that end. Where another route begins or ends in
+ * the middle of this one, it picks between walking on and turning onto that route. If the creek
+ * ends up far from its last point, after a teleport for example, it joins back in at the nearest
+ * point. Each step brings the pause of its point along, except for an end the creek is just leaving
+ * or a point it just stepped over to from another route.
+ *
+ *
+ * Because it remembers where its creek is, every creek needs a walker of its own. The
+ * {@link CreekPaths} behind it can be shared.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class PathRoute implements RouteProvider {
+
+ /** Farther than this from its last waypoint, the creek must have been moved and joins back in, in blocks. */
+ static final double REJOIN_DISTANCE = 8.0D;
+
+ private final CreekPaths paths;
+ private @Nullable Cursor cursor;
+
+ /**
+ * Sets up the route walker.
+ *
+ * @param paths the routes of the map
+ */
+ public PathRoute(CreekPaths paths) {
+ this.paths = paths;
+ }
+
+ @Override
+ public List points() {
+ return this.paths.allPoints();
+ }
+
+ @Override
+ public Optional next(Pos current, Predicate allowed, RandomGenerator random) {
+ Cursor last = this.cursor;
+ if (last == null || current.distance(this.point(last)) > REJOIN_DISTANCE) {
+ return this.rejoin(current, allowed, random);
+ }
+ Cursor ahead = this.advance(last, random);
+ if (allowed.test(this.point(ahead))) return this.moveTo(ahead);
+
+ Cursor back = this.behind(last);
+ if (back != null && allowed.test(this.point(back))) return this.moveTo(back);
+ return Optional.empty();
+ }
+
+ @Override
+ public String describe() {
+ Cursor last = this.cursor;
+ if (last == null) return "";
+ return this.paths.route(last.route()).name() + " " + (last.index() + 1) + "/" + this.paths.pointCount(last.route())
+ + " " + (last.direction() > 0 ? "→" : "←");
+ }
+
+ private Optional rejoin(Pos current, Predicate allowed, RandomGenerator random) {
+ CreekLinks.Node best = null;
+ double bestDistance = Double.MAX_VALUE;
+ for (int route = 0; route < this.paths.size(); route++) {
+ for (int index = 0; index < this.paths.pointCount(route); index++) {
+ Pos point = this.paths.point(route, index);
+ if (!allowed.test(point)) continue;
+ double distance = point.distance(current);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = new CreekLinks.Node(route, index);
+ }
+ }
+ }
+ if (best == null) return Optional.empty();
+ return this.moveTo(this.enter(best, false, random));
+ }
+
+ /**
+ * Picks the next waypoint. The last choice is always to carry on the way it is going, or to
+ * turn around at the end of a route; every link from here is another choice.
+ */
+ private Cursor advance(Cursor from, RandomGenerator random) {
+ int nextIndex = from.index() + from.direction();
+ boolean atEnd = nextIndex < 0 || nextIndex >= this.paths.pointCount(from.route());
+ // An end only offers its links once the creek gets there. In the middle, a crossing offers
+ // them on the way through, unless the creek has only just come over from the other route.
+ boolean crossing = !atEnd && !from.viaLink() && !this.isEnd(from);
+ List links = atEnd || crossing ? this.paths.links(this.node(from)) : List.of();
+ int choice = atEnd || !links.isEmpty() ? random.nextInt(links.size() + 1) : 0;
+ if (choice < links.size()) return this.enter(links.get(choice), true, random);
+ if (!atEnd) return new Cursor(from.route(), nextIndex, from.direction(), false);
+ Cursor back = this.behind(from);
+ return back != null ? back : from;
+ }
+
+ /**
+ * Puts the creek on a point. At an end there is only one way into the route, in the middle it
+ * picks a way at random.
+ */
+ private Cursor enter(CreekLinks.Node node, boolean viaLink, RandomGenerator random) {
+ int lastIndex = this.paths.pointCount(node.route()) - 1;
+ int direction = node.index() == 0 ? 1 : node.index() == lastIndex ? -1 : (random.nextBoolean() ? 1 : -1);
+ return new Cursor(node.route(), node.index(), direction, viaLink);
+ }
+
+ private @Nullable Cursor behind(Cursor from) {
+ int direction = -from.direction();
+ int index = from.index() + direction;
+ if (index < 0 || index >= this.paths.pointCount(from.route())) return null;
+ return new Cursor(from.route(), index, direction, false);
+ }
+
+ private Optional moveTo(Cursor next) {
+ this.cursor = next;
+ return Optional.of(new RouteStep(this.point(next), this.pauseAt(next), this.isDeadEnd(next)));
+ }
+
+ /**
+ * Tells whether the creek is heading for the end of a route that no other route is linked to.
+ * Walking away from an end does not count.
+ */
+ private boolean isDeadEnd(Cursor cursor) {
+ return this.reachesEnd(cursor) && this.paths.links(this.node(cursor)).isEmpty();
+ }
+
+ /**
+ * An end the creek walks away from is where it sets off, not where it rests. Neither is a point
+ * it just stepped over to from another route: it is where it already was.
+ */
+ private int pauseAt(Cursor cursor) {
+ boolean leavingEnd = this.isEnd(cursor) && !this.reachesEnd(cursor);
+ if (cursor.viaLink() || leavingEnd) return 0;
+ return this.paths.pauseMillis(cursor.route(), cursor.index());
+ }
+
+ private Pos point(Cursor cursor) {
+ return this.paths.point(cursor.route(), cursor.index());
+ }
+
+ private CreekLinks.Node node(Cursor cursor) {
+ return new CreekLinks.Node(cursor.route(), cursor.index());
+ }
+
+ private boolean isEnd(Cursor cursor) {
+ return cursor.index() == 0 || cursor.index() == this.paths.pointCount(cursor.route()) - 1;
+ }
+
+ /**
+ * Tells whether the creek is walking into an end of its route rather than away from it.
+ */
+ private boolean reachesEnd(Cursor cursor) {
+ return cursor.direction() > 0
+ ? cursor.index() == this.paths.pointCount(cursor.route()) - 1
+ : cursor.index() == 0;
+ }
+
+ /**
+ * Where the creek is on the routes.
+ *
+ * @param route the route's index
+ * @param index the index of the waypoint it was sent to last
+ * @param direction {@code 1} towards the end, {@code -1} towards the start
+ * @param viaLink whether it just stepped over to this point from another route
+ */
+ private record Cursor(int route, int index, int direction, boolean viaLink) {
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteProvider.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteProvider.java
new file mode 100644
index 00000000..c830cbd3
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteProvider.java
@@ -0,0 +1,50 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.random.RandomGenerator;
+
+/**
+ * Decides where the creek walks next.
+ *
+ * {@link PathRoute} walks the hand-placed routes of a map.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+@FunctionalInterface
+public interface RouteProvider {
+
+ /**
+ * Picks the next point.
+ *
+ * @param current where the creek is
+ * @param allowed which points the creek may go to
+ * @param random the random source
+ * @return the next point and its pause, or empty if none is allowed
+ */
+ Optional next(Pos current, Predicate allowed, RandomGenerator random);
+
+ /**
+ * Every point this route can hand out. Used to find a place to appear or come back.
+ *
+ * @return the points, possibly none
+ */
+ default List points() {
+ return List.of();
+ }
+
+ /**
+ * Describes where the creek is on this route, for the debug line.
+ *
+ * @return a short description, or an empty string if there is nothing to show
+ */
+ default String describe() {
+ return "";
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java
new file mode 100644
index 00000000..9e153321
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java
@@ -0,0 +1,27 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+
+/**
+ * The creek's next waypoint and how long it rests once it gets there.
+ *
+ * @param target where the creek walks to
+ * @param pauseMillis how long it rests after arriving, in milliseconds
+ * @param deadEnd whether the waypoint is the end of a route with no other route linked to it
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public record RouteStep(Pos target, int pauseMillis, boolean deadEnd) {
+
+ /**
+ * A step to a waypoint that is no dead end.
+ *
+ * @param target where the creek walks to
+ * @param pauseMillis how long it rests after arriving, in milliseconds
+ */
+ public RouteStep(Pos target, int pauseMillis) {
+ this(target, pauseMillis, false);
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/SpotFinder.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/SpotFinder.java
new file mode 100644
index 00000000..e94634e1
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/SpotFinder.java
@@ -0,0 +1,97 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.random.RandomGenerator;
+
+/**
+ * Finds spots where the creek can appear without anyone seeing it happen.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class SpotFinder {
+
+ /** How many candidate spots are tried per step. */
+ static final int ATTEMPTS = 16;
+
+ /** How high the middle of the creek's body is. That is the part a survivor would notice. */
+ public static final double BODY_CENTRE = 1.4D;
+
+ private final CreekSight sight;
+ private final Ground ground;
+
+ /**
+ * Sets up the spot finder.
+ *
+ * @param sight the view check a spot has to pass
+ * @param ground puts candidates onto a floor
+ */
+ public SpotFinder(CreekSight sight, Ground ground) {
+ this.sight = sight;
+ this.ground = ground;
+ }
+
+ /**
+ * Checks that nobody would see the creek at a spot.
+ *
+ * @param spot where the creek's feet would be
+ * @param observerEyes the eyes of everyone who could see the creek
+ * @param personalSpace how far away from everyone the spot has to be
+ * @return {@code true} if the spot is far enough away and outside every view cone
+ */
+ public boolean isHidden(Pos spot, List observerEyes, double personalSpace) {
+ Pos body = spot.add(0, BODY_CENTRE, 0);
+ for (Pos eyes : observerEyes) {
+ if (eyes.distance(spot) < personalSpace) return false;
+ if (this.sight.inView(eyes, body)) return false;
+ }
+ return true;
+ }
+
+ /**
+ * Puts a candidate onto the floor and keeps it only if nobody would see the creek there.
+ *
+ * @param candidate the spot to try
+ * @param observerEyes the eyes of everyone who could see the creek
+ * @param personalSpace how far away from everyone the spot has to be
+ * @return the hidden spot, or empty
+ */
+ public Optional hiddenSpotAt(Pos candidate, List observerEyes, double personalSpace) {
+ return this.ground.settle(candidate).filter(spot -> this.isHidden(spot, observerEyes, personalSpace));
+ }
+
+ /**
+ * Finds a hidden spot off to the side of someone's view.
+ *
+ * @param target where the target's feet are; their yaw is where they look
+ * @param minDistance how close to the target the spot may be at the least
+ * @param maxDistance how far from the target the spot may be at the most
+ * @param minAngle how far off to the side of their view it has to be at the least, in degrees
+ * @param maxAngle how far off to the side of their view it may be at the most, in degrees
+ * @param observerEyes the eyes of everyone who could see the creek
+ * @param personalSpace how far away from everyone the spot has to be
+ * @param random the random source
+ * @return a hidden spot, or empty if no candidate worked
+ */
+ public Optional beside(Pos target, double minDistance, double maxDistance, double minAngle,
+ double maxAngle, List observerEyes, double personalSpace,
+ RandomGenerator random) {
+ for (int attempt = 0; attempt < ATTEMPTS; attempt++) {
+ double offset = random.nextDouble(minAngle, maxAngle) * (random.nextBoolean() ? 1.0D : -1.0D);
+ double distance = random.nextDouble(minDistance, maxDistance);
+ Optional spot = this.hiddenSpotAt(step(target, target.yaw() + offset, distance), observerEyes, personalSpace);
+ if (spot.isPresent()) return spot;
+ }
+ return Optional.empty();
+ }
+
+ private static Pos step(Pos from, double yaw, double distance) {
+ double radians = Math.toRadians(yaw);
+ // Minecraft's yaw goes clockwise from south (+Z), so the direction is (-sin, cos).
+ return new Pos(from.x() - Math.sin(radians) * distance, from.y(), from.z() + Math.cos(radians) * distance);
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/package-info.java
new file mode 100644
index 00000000..b02235fd
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * Everything the creek needs to find its way: view checks, floors, waypoints and hidden spots.
+ */
+@NotNullByDefault
+package net.onelitefeather.cygnus.creek.world;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java b/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java
index 6693b14b..974f6755 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java
@@ -8,6 +8,7 @@
import net.minestom.server.timer.TaskSchedule;
import net.minestom.server.world.DimensionType;
import net.onelitefeather.cygnus.common.config.GameConfig;
+import net.onelitefeather.cygnus.common.creek.CreekRoutesFile;
import net.onelitefeather.cygnus.common.dimension.BlendedAtmosphere;
import net.onelitefeather.cygnus.common.dimension.DimensionAtmosphere;
import net.onelitefeather.cygnus.common.dimension.DimensionFactory;
@@ -161,7 +162,8 @@ private static String toKeyValue(String name) {
* Reads the game map from its map file.
*
* This also merges in the page faces from the sibling {@code pages.json}, if one exists, via
- * {@link PageFacesFile#loadInto(Path, GameMap)}.
+ * {@link PageFacesFile#loadInto(Path, GameMap)}. The creek routes come from the sibling
+ * {@code creek.json} via {@link CreekRoutesFile#loadInto(Path, GameMap)}.
*
* @return the loaded game map
* @throws IllegalStateException if the file cannot be read
@@ -169,7 +171,8 @@ private static String toKeyValue(String name) {
private GameMap readGameMap() {
GameMap map = this.fileHandler.load(this.gameEntry.getMapFile(), GameMap.class)
.orElseThrow(() -> new IllegalStateException("Failed to load GameMap from file: " + this.gameEntry.getMapFile()));
- return PageFacesFile.loadInto(this.gameEntry.getMapFile(), map);
+ Path mapFile = this.gameEntry.getMapFile();
+ return CreekRoutesFile.loadInto(mapFile, PageFacesFile.loadInto(mapFile, map));
}
public void loadGameMap() {
diff --git a/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java b/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java
index 547e4909..230e37a0 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java
@@ -24,8 +24,8 @@
* left, the way the static in Slender: The Eight Pages tightens with every page.
*
* Two things carry it. A carpet runs for as long as the round does and closes the gap between
- * its bursts from {@link GameConfig#slenderStaticQuietInterval()} down to
- * {@link GameConfig#slenderStaticFranticInterval()} as the pages disappear. On top of it, every
+ * its bursts from {@link GameConfig.SlenderStatic#quietInterval()} down to
+ * {@link GameConfig.SlenderStatic#franticInterval()} as the pages disappear. On top of it, every
* single find lands as a burst of its own, so he knows a page went the moment it went instead of
* on the next beat of the carpet.
*
diff --git a/game/src/main/java/net/onelitefeather/cygnus/page/PageProximityService.java b/game/src/main/java/net/onelitefeather/cygnus/page/PageProximityService.java
index 2af9c028..4dbdf674 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/page/PageProximityService.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/page/PageProximityService.java
@@ -29,7 +29,6 @@
* Phase 2 (20% < TTL ≤ 50%): Warning chime every 3 seconds (60 ticks) with standard pitch.
* Phase 3 (TTL ≤ 20%): Critical rapid chime every 1 second (20 ticks) with lower pitch and quieter volume.
*
- *
*
* @author TheMeinerLP
* @author theEvilReaper
diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java
index 38cf62d5..a82ff399 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java
@@ -136,4 +136,22 @@ public void switchToRegenerating() {
if (state == State.REGENERATING) return;
state = State.REGENERATING;
}
+
+ /**
+ * Empties the bar instantly and lets it regenerate.
+ *
+ * Used when the creek catches a survivor. Sprinting is blocked just like after running the
+ * bar empty, so the survivor cannot escape the slender for a while.
+ *
+ */
+ public void drain() {
+ this.currentSpeedCount = 0;
+ player.setExp(0.0F);
+ if (player.isSprinting()) {
+ player.setSprinting(false);
+ EventDispatcher.call(new PlayerStopSprintingEvent(player));
+ }
+ player.setBlockedSprinting(true);
+ state = State.REGENERATING;
+ }
}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java
new file mode 100644
index 00000000..ff291b44
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java
@@ -0,0 +1,208 @@
+package net.onelitefeather.cygnus.creek;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Player;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.potion.PotionEffect;
+import net.minestom.testing.Env;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.creek.body.CreakingBody;
+import net.onelitefeather.cygnus.creek.body.CreekBody;
+import net.onelitefeather.cygnus.creek.consequence.PatrolHelper;
+import net.onelitefeather.cygnus.creek.state.Contexts;
+import net.onelitefeather.cygnus.creek.state.DoneState;
+import net.onelitefeather.cygnus.creek.state.CreekContext;
+import net.onelitefeather.cygnus.creek.state.CreekState;
+import net.onelitefeather.cygnus.creek.state.HuntState;
+import net.onelitefeather.cygnus.creek.state.StalkState;
+import net.onelitefeather.cygnus.creek.state.SurvivorView;
+import net.onelitefeather.cygnus.creek.state.VanishState;
+import net.onelitefeather.cygnus.creek.state.PatrolState;
+import net.onelitefeather.cygnus.creek.world.CreekSight;
+import net.onelitefeather.cygnus.creek.world.SpotFinder;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Random;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreekIntegrationTest extends CygnusPlayerTestBase {
+
+ private static Creek creek(CreekBody body, CreekConfig config, CreekState initial) {
+ CreekSight sight = new CreekSight(config.sightRange(), config.sightViewAngle());
+ // nextBoolean() is true for -1, so a selection always ends in the stun.
+ PatrolHelper patrol = new PatrolHelper(() -> -1L);
+ CreekRound round = new CreekRound(sight, new SpotFinder(sight, Optional::of), _ -> {}, patrol, config,
+ new Random(3));
+ return new Creek(body, Contexts.route(), round, initial);
+ }
+
+ /** A snapshot of the players, none of them scared. */
+ private static SurvivorSnapshot survivors(Player... players) {
+ return SurvivorSnapshot.take(List.of(players), (_, _, _) -> 0.0D);
+ }
+
+ @Test
+ @DisplayName("Wandering, he is seen by the survivors but never by the slender")
+ void wanderingIsPublicButNotForTheSlender(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0));
+ Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20));
+
+ creek(body, CreekConfig.DEFAULT, new PatrolState()).tick(survivors(survivor), 0L);
+
+ assertTrue(body.entity().getViewers().contains(survivor));
+ assertFalse(body.entity().getViewers().contains(slender));
+ }
+
+ @Test
+ @DisplayName("A survivor looking at him counts as seeing him")
+ void lookingCountsAsSeeing(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20));
+ Creek creek = creek(body, CreekConfig.DEFAULT, new PatrolState());
+ creek.tick(survivors(survivor), 0L);
+
+ assertTrue(creek.views(survivors(survivor)).getFirst().seesCreek());
+ }
+
+ @Test
+ @DisplayName("Someone he is hidden from does not see him, however they look")
+ void hiddenFromNonTargets(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player target = env.createConnection().connect(instance, new Pos(10, 40, 0, 0, 0));
+ Player other = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20));
+ Creek creek = creek(body, CreekConfig.DEFAULT, new StalkState(target.getUuid(), Long.MAX_VALUE));
+ creek.tick(survivors(target, other), 0L);
+
+ SurvivorView otherView = creek.views(survivors(target, other)).stream()
+ .filter(view -> view.id().equals(other.getUuid()))
+ .findFirst().orElseThrow();
+ assertFalse(otherView.seesCreek());
+ }
+
+ @Test
+ @DisplayName("When the target leaves mid-hunt he is done")
+ void targetLeavesMidHunt(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player target = env.createConnection().connect(instance, new Pos(0, 40, 0, 180, 0));
+ Player other = env.createConnection().connect(instance, new Pos(40, 40, 40));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 10));
+ Creek creek = creek(body, CreekConfig.DEFAULT, new HuntState(target.getUuid(), Long.MAX_VALUE));
+ creek.tick(survivors(target, other), 0L);
+
+ creek.tick(survivors(other), 100L);
+
+ assertInstanceOf(DoneState.class, creek.state());
+ assertTrue(body.entity().getViewers().isEmpty());
+ }
+
+ @Test
+ @DisplayName("Vanishing for good hides him from everyone for the rest of the round")
+ void vanishForGood(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 30));
+ Creek creek = creek(body, CreekConfig.DEFAULT, new PatrolState());
+ creek.tick(survivors(survivor), 0L);
+
+ creek.vanishForGood(survivors(survivor), 100L);
+ CreekState vanished = creek.state();
+ creek.vanishForGood(survivors(survivor), 200L);
+
+ assertTrue(((VanishState) vanished).isForever());
+ assertSame(vanished, creek.state(), "calling it again changes nothing");
+ assertFalse(body.isVisibleTo(survivor.getUuid()));
+ }
+
+ @Test
+ @DisplayName("A state selecting a survivor hands them to the patrol helper")
+ void selectionReachesTheConsequence(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20));
+ CreekState selecting = new CreekState() {
+ @Override
+ public void enter(CreekContext ctx) {
+ }
+
+ @Override
+ public CreekState tick(CreekContext ctx) {
+ ctx.actions().selected(survivor.getUuid());
+ return this;
+ }
+ };
+
+ creek(body, CreekConfig.DEFAULT, selecting).tick(survivors(survivor), 0L);
+
+ assertTrue(survivor.hasEffect(PotionEffect.SLOWNESS));
+ }
+
+ @Test
+ @DisplayName("Vanishing blinds the survivors nearby, but not the ones the creek leaves alone")
+ void vanishSparesTheIgnored(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player haunted = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ Player other = env.createConnection().connect(instance, new Pos(3, 40, 0));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 2));
+ CreekState vanishing = new CreekState() {
+ @Override
+ public void enter(CreekContext ctx) {
+ }
+
+ @Override
+ public CreekState tick(CreekContext ctx) {
+ ctx.actions().vanished(ctx.body().position());
+ return this;
+ }
+ };
+
+ creek(body, CreekConfig.DEFAULT, vanishing).tick(survivors(haunted, other), Set.of(haunted.getUuid()), 0L);
+
+ assertTrue(other.hasEffect(PotionEffect.BLINDNESS));
+ assertFalse(haunted.hasEffect(PotionEffect.BLINDNESS));
+ }
+
+ @Test
+ @DisplayName("A haunted survivor does not see the patrolling creek, everyone else does")
+ void patrolIsHiddenFromTheHaunted(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player haunted = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0));
+ Player other = env.createConnection().connect(instance, new Pos(5, 40, 0, 0, 0));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20));
+
+ creek(body, CreekConfig.DEFAULT, new PatrolState())
+ .tick(survivors(haunted, other), Set.of(haunted.getUuid()), 0L);
+
+ assertFalse(body.isVisibleTo(haunted.getUuid()));
+ assertTrue(body.isVisibleTo(other.getUuid()));
+ }
+
+ @Test
+ @DisplayName("The patrolling creek never picks out a haunted survivor")
+ void patrolLeavesTheHauntedAlone(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player haunted = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0));
+ Player other = env.createConnection().connect(instance, new Pos(0, 40, 60, 0, 0));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 2));
+ Creek creek = creek(body, CreekConfig.DEFAULT, new PatrolState());
+
+ creek.tick(survivors(haunted, other), Set.of(haunted.getUuid()), 0L);
+ creek.tick(survivors(haunted, other), Set.of(haunted.getUuid()), 1000L);
+
+ assertFalse(haunted.hasEffect(PotionEffect.SLOWNESS));
+ assertEquals(2, creek.lastViews().size(), "the snapshots still cover every survivor");
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java
new file mode 100644
index 00000000..b1819484
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java
@@ -0,0 +1,311 @@
+package net.onelitefeather.cygnus.creek;
+
+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.minestom.server.network.packet.server.play.ActionBarPacket;
+import net.minestom.server.potion.PotionEffect;
+import net.minestom.testing.Collector;
+import net.minestom.testing.Env;
+import net.minestom.testing.TestConnection;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import net.onelitefeather.cygnus.creek.body.CreakingBody;
+import net.onelitefeather.cygnus.creek.consequence.CatchConsequence;
+import net.onelitefeather.cygnus.creek.debug.CreekDebug;
+import net.onelitefeather.cygnus.creek.state.Contexts;
+import net.onelitefeather.cygnus.creek.state.PatrolState;
+import net.onelitefeather.cygnus.creek.state.VanishState;
+import net.onelitefeather.cygnus.creek.world.PathRoute;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreekServiceIntegrationTest extends CygnusPlayerTestBase {
+
+ private final AtomicInteger cleanUps = new AtomicInteger();
+
+ private final CatchConsequence consequence = new CatchConsequence() {
+ @Override
+ public void apply(Player survivor) {
+ }
+
+ @Override
+ public void cleanUp() {
+ cleanUps.incrementAndGet();
+ }
+ };
+
+ private static final CreekRoute ROUTE = CreekRoute.ofPositions("Test", List.of(new Vec(10, 40, 10), new Vec(20, 40, 10)));
+
+ private CreekService service(Instance instance, Set survivors, List routes, AtomicLong clock) {
+ return new CreekService(CreekConfig.DEFAULT, () -> survivors, () -> instance, () -> routes,
+ CreakingBody::spawn, (_, _, _) -> 0.0D, consequence, new RoundClock(clock::get), new Random(3), new CreekDebug());
+ }
+
+ @Test
+ @DisplayName("At the start he is in the world, but out of sight")
+ void startsOutOfSight(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ CreekService service = service(instance, Set.of(survivor), List.of(ROUTE), new AtomicLong());
+
+ service.start();
+ service.tick();
+
+ Creek creek = service.creek();
+ assertNotNull(creek);
+ assertInstanceOf(VanishState.class, creek.state());
+ assertFalse(creek.body().isVisibleTo(survivor.getUuid()));
+ service.stop();
+ }
+
+ @Test
+ @DisplayName("Without a usable route the creek stays away")
+ void staysAwayWithoutRoutes(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ CreekService service = service(instance, Set.of(survivor), List.of(), new AtomicLong());
+
+ service.start();
+
+ assertNull(service.creek());
+ }
+
+ @Test
+ @DisplayName("The round time counts from the start")
+ void roundTimeCountsFromTheStart(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ AtomicLong clock = new AtomicLong(1000L);
+ RoundClock roundClock = new RoundClock(clock::get);
+ CreekService service = new CreekService(CreekConfig.DEFAULT, () -> Set.of(survivor), () -> instance,
+ () -> List.of(ROUTE), CreakingBody::spawn, (_, _, _) -> 0.0D, consequence, roundClock, new Random(3), new CreekDebug());
+
+ service.start();
+ clock.set(4000L);
+
+ assertEquals(3000L, roundClock.elapsedMillis());
+ service.stop();
+ assertEquals(0L, roundClock.elapsedMillis());
+ }
+
+ @Test
+ @DisplayName("Only players watching the debug get the line")
+ void debugGoesToWatchersOnly(Env env) {
+ Instance instance = env.createFlatInstance();
+ TestConnection watcherConnection = env.createConnection();
+ Player watcher = watcherConnection.connect(instance, new Pos(0, 40, 0));
+ TestConnection otherConnection = env.createConnection();
+ Player other = otherConnection.connect(instance, new Pos(3, 40, 0));
+ CreekDebug debug = new CreekDebug();
+ debug.toggle(watcher.getUuid());
+ CreekService service = new CreekService(CreekConfig.DEFAULT, () -> Set.of(watcher, other), () -> instance,
+ () -> List.of(ROUTE), CreakingBody::spawn, (_, _, _) -> 0.0D, consequence,
+ new RoundClock(new AtomicLong()::get), new Random(3), debug);
+ Collector watched = watcherConnection.trackIncoming(ActionBarPacket.class);
+ Collector unwatched = otherConnection.trackIncoming(ActionBarPacket.class);
+
+ service.start();
+ service.tick();
+
+ assertFalse(watched.collect().isEmpty());
+ assertTrue(unwatched.collect().isEmpty());
+ service.stop();
+ }
+
+ @Test
+ @DisplayName("With routes on the map the creek walks them")
+ void routesAreUsedWhenThereAreAny(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ CreekService service = service(instance, Set.of(survivor), List.of(ROUTE), new AtomicLong());
+
+ service.start();
+
+ Creek creek = service.creek();
+ assertNotNull(creek);
+ assertInstanceOf(PathRoute.class, creek.route());
+ service.stop();
+ }
+
+ @Test
+ @DisplayName("At the end he and every variant are removed and every consequence is cleaned up")
+ void stopRemovesEverythingAndCleansUp(Env env) {
+ Round round = this.roundWithVariant(env, CreekConfig.DEFAULT);
+ Creek creek = round.service().creek();
+ CreekVariants variants = round.service().variants();
+ assertNotNull(creek);
+ assertNotNull(variants);
+ Creek variant = variants.running().get(round.scared().getUuid());
+ assertNotNull(variant);
+
+ round.service().stop();
+
+ assertTrue(creek.body().entity().isRemoved());
+ assertTrue(variant.body().entity().isRemoved());
+ assertNull(round.service().creek());
+ assertNull(round.service().variants());
+ assertEquals(1, cleanUps.get());
+ }
+
+ @Test
+ @DisplayName("At the end no stun from the patrol is left on a survivor")
+ void stopClearsTheSelectionEffects(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0));
+ // Walled in closely on every side: there is no room to fling them, so a selection always stuns.
+ for (int i = -3; i <= 3; i++) {
+ for (int y = 40; y <= 41; y++) {
+ instance.setBlock(i, y, -63, Block.STONE);
+ instance.setBlock(i, y, -56, Block.STONE);
+ }
+ }
+ for (int z = -63; z <= -56; z++) {
+ for (int y = 40; y <= 41; y++) {
+ instance.setBlock(-3, y, z, Block.STONE);
+ instance.setBlock(3, y, z, Block.STONE);
+ }
+ }
+ AtomicLong clock = new AtomicLong();
+ CreekService service = service(instance, Set.of(survivor), List.of(ROUTE), clock);
+ service.start();
+ clock.set(CreekConfig.DEFAULT.vanishMaxSeconds() * 1000L + 1L);
+ service.tick();
+ Creek creek = service.creek();
+ assertNotNull(creek);
+ assertInstanceOf(PatrolState.class, creek.state());
+
+ creek.body().teleport(new Pos(0, 40, -58));
+ service.tick();
+ clock.addAndGet(1000L);
+ service.tick();
+ assertTrue(survivor.hasEffect(PotionEffect.SLOWNESS), "the patrol stunned the survivor");
+
+ service.stop();
+
+ assertFalse(survivor.hasEffect(PotionEffect.SLOWNESS));
+ }
+
+ @Test
+ @DisplayName("After the start delay he patrols, a scared survivor gets a variant and no longer sees the patrol")
+ void patrolsAndHidesFromTheHaunted(Env env) {
+ Round round = this.roundWithVariant(env, CreekConfig.DEFAULT);
+ Creek creek = round.service().creek();
+ CreekVariants variants = round.service().variants();
+ assertNotNull(creek);
+ assertNotNull(variants);
+ assertInstanceOf(PatrolState.class, creek.state());
+ assertTrue(variants.running().containsKey(round.scared().getUuid()));
+ assertTrue(creek.body().isVisibleTo(round.scared().getUuid()), "the variant only just started");
+
+ round.service().tick();
+
+ assertFalse(creek.body().isVisibleTo(round.scared().getUuid()));
+ assertTrue(creek.body().isVisibleTo(round.other().getUuid()));
+ round.service().stop();
+ }
+
+ @Test
+ @DisplayName("Every creek walks the routes with a walker of its own")
+ void everyCreekHasItsOwnWalker(Env env) {
+ Round round = this.roundWithVariant(env, CreekConfig.DEFAULT);
+ Creek creek = round.service().creek();
+ CreekVariants variants = round.service().variants();
+ assertNotNull(creek);
+ assertNotNull(variants);
+ Creek variant = variants.running().get(round.scared().getUuid());
+ assertNotNull(variant);
+
+ assertInstanceOf(PathRoute.class, variant.route());
+ assertNotSame(creek.route(), variant.route());
+ assertEquals(creek.route().points(), variant.route().points());
+ round.service().stop();
+ }
+
+ @Test
+ @DisplayName("Each survivor's dread is worked out once per step, however many creeks there are")
+ void dreadOncePerStep(Env env) {
+ Round round = this.roundWithVariant(env, CreekConfig.DEFAULT);
+ CreekVariants variants = round.service().variants();
+ assertNotNull(variants);
+ assertEquals(1, variants.running().size());
+ round.dreadCalls().set(0);
+
+ round.service().tick();
+
+ assertEquals(2, round.dreadCalls().get());
+ round.service().stop();
+ }
+
+ @Test
+ @DisplayName("With the last survivor and the setting off, he is gone for good and every variant ends")
+ void goneForGoodWithTheLastSurvivor(Env env) {
+ Round round = this.roundWithVariant(env, Contexts.withoutLastSurvivor());
+ Creek creek = round.service().creek();
+ CreekVariants variants = round.service().variants();
+ assertNotNull(creek);
+ assertNotNull(variants);
+ Creek variant = variants.running().get(round.scared().getUuid());
+ assertNotNull(variant);
+ round.service().tick();
+ assertInstanceOf(PatrolState.class, creek.state(),
+ "hiding the patrol from the haunted survivor is not being down to the last one");
+
+ round.survivors().set(Set.of(round.scared()));
+ round.service().tick();
+
+ assertInstanceOf(VanishState.class, creek.state());
+ assertTrue(((VanishState) creek.state()).isForever());
+ assertTrue(variants.running().isEmpty());
+ assertTrue(variant.body().entity().isRemoved());
+ round.service().stop();
+ }
+
+ /**
+ * A started round with two survivors far from the route, of whom only the first is scared
+ * enough for a variant. It runs one step past the start delay: the patrol is out, and the
+ * variant has just started.
+ */
+ private Round roundWithVariant(Env env, CreekConfig config) {
+ Instance instance = env.createFlatInstance();
+ // Far from the route points, so the vanish finds a hidden spot to come back at.
+ Player scared = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0));
+ Player other = env.createConnection().connect(instance, new Pos(0, 40, -160, 0, 0));
+ AtomicReference> survivors = new AtomicReference<>(Set.of(scared, other));
+ AtomicInteger dreadCalls = new AtomicInteger();
+ AtomicLong clock = new AtomicLong();
+ CreekService service = new CreekService(config, survivors::get, () -> instance, () -> List.of(ROUTE),
+ CreakingBody::spawn, (id, _, _) -> {
+ dreadCalls.incrementAndGet();
+ return id.equals(scared.getUuid()) ? 0.5D : 0.0D;
+ }, consequence,
+ new RoundClock(clock::get), new Random(3), new CreekDebug());
+ service.start();
+ clock.set(CreekConfig.DEFAULT.vanishMaxSeconds() * 1000L + 1L);
+ service.tick();
+ return new Round(service, scared, other, survivors, dreadCalls);
+ }
+
+ private record Round(CreekService service, Player scared, Player other, AtomicReference> survivors,
+ AtomicInteger dreadCalls) {
+ }
+
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java
new file mode 100644
index 00000000..267db4bd
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java
@@ -0,0 +1,216 @@
+package net.onelitefeather.cygnus.creek;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Player;
+import net.minestom.server.instance.Instance;
+import net.minestom.testing.Env;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.creek.body.CreakingBody;
+import net.onelitefeather.cygnus.creek.consequence.CatchConsequence;
+import net.onelitefeather.cygnus.creek.consequence.PatrolHelper;
+import net.onelitefeather.cygnus.creek.state.Contexts;
+import net.onelitefeather.cygnus.creek.state.CreekState;
+import net.onelitefeather.cygnus.creek.state.SurvivorView;
+import net.onelitefeather.cygnus.creek.world.CreekSight;
+import net.onelitefeather.cygnus.creek.world.Ground;
+import net.onelitefeather.cygnus.creek.world.SpotFinder;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Random;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BiFunction;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreekVariantsIntegrationTest extends CygnusPlayerTestBase {
+
+ private static final CreekConfig CONFIG = CreekConfig.DEFAULT;
+ private static final CreekSight SIGHT = new CreekSight(CONFIG.sightRange(), CONFIG.sightViewAngle());
+ private static final CatchConsequence NO_CATCH = new CatchConsequence() {
+ @Override
+ public void apply(Player survivor) {
+ }
+
+ @Override
+ public void cleanUp() {
+ }
+ };
+ private static final PatrolHelper PATROL = new PatrolHelper(new Random(1));
+
+ private static BiFunction spawner(Instance instance, CreekConfig config, SpotFinder spots) {
+ CreekRound round = new CreekRound(SIGHT, spots, NO_CATCH, PATROL, config, new Random(3));
+ return (spot, initial) -> new Creek(CreakingBody.spawn(instance, spot), Contexts.route(), round, initial);
+ }
+
+ private static CreekVariants variants(Instance instance, CreekConfig config, Ground ground, long allowedAt) {
+ SpotFinder spots = new SpotFinder(SIGHT, ground);
+ return new CreekVariants(config, spots, new Random(5), allowedAt, spawner(instance, config, spots));
+ }
+
+ private static SurvivorView view(Player player, double dread) {
+ return new SurvivorView(player.getUuid(), player.getPosition(), dread, false);
+ }
+
+ private static Player join(Env env, Instance instance, double x) {
+ return env.createConnection().connect(instance, new Pos(x, 40, 0, 0, 0));
+ }
+
+ @Test
+ @DisplayName("A variant starts for the survivor with the highest dread and only they see it")
+ void startsForTheHighestDread(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player first = join(env, instance, 0);
+ Player second = join(env, instance, 10);
+ CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L);
+ List survivors = List.of(first, second);
+ List views = List.of(view(first, 0.5D), view(second, 0.3D));
+
+ variants.tick(new SurvivorSnapshot(survivors, views), 0L);
+ variants.tick(new SurvivorSnapshot(survivors, views), 100L);
+
+ assertEquals(1, variants.running().size(), "two survivors allow one variant");
+ Creek variant = variants.running().get(first.getUuid());
+ assertTrue(variant.body().isVisibleTo(first.getUuid()));
+ assertFalse(variant.body().isVisibleTo(second.getUuid()));
+ }
+
+ @Test
+ @DisplayName("Five survivors allow two variants, one per survivor")
+ void twoVariantsForFiveSurvivors(Env env) {
+ Instance instance = env.createFlatInstance();
+ List survivors = List.of(join(env, instance, 0), join(env, instance, 100), join(env, instance, 200),
+ join(env, instance, 300), join(env, instance, 400));
+ List views = List.of(view(survivors.get(0), 0.5D), view(survivors.get(1), 0.4D),
+ view(survivors.get(2), 0.3D), view(survivors.get(3), 0.0D), view(survivors.get(4), 0.0D));
+ CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L);
+
+ variants.tick(new SurvivorSnapshot(survivors, views), 0L);
+ variants.tick(new SurvivorSnapshot(survivors, views), 100L);
+
+ assertEquals(2, variants.running().size());
+ assertTrue(variants.running().containsKey(survivors.get(0).getUuid()));
+ assertTrue(variants.running().containsKey(survivors.get(1).getUuid()));
+ }
+
+ @Test
+ @DisplayName("Nothing starts before the allowed time")
+ void waitsForTheAllowedTime(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player first = join(env, instance, 0);
+ CreekVariants variants = variants(instance, CONFIG, Optional::of, 1000L);
+
+ variants.tick(new SurvivorSnapshot(List.of(first), List.of(view(first, 0.9D))), 999L);
+
+ assertTrue(variants.running().isEmpty());
+ }
+
+ @Test
+ @DisplayName("After a variant ends, its target has a cooldown")
+ void cooldownAfterTheEnd(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player first = join(env, instance, 0);
+ Player second = join(env, instance, 10);
+ List survivors = List.of(first, second);
+ List views = List.of(view(first, 0.5D), view(second, 0.0D));
+ CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L);
+ variants.tick(new SurvivorSnapshot(survivors, views), 0L);
+ Creek variant = variants.running().get(first.getUuid());
+
+ // the stalk lasts at most stalkMaxSeconds (90 s)
+ variants.tick(new SurvivorSnapshot(survivors, views), 100_000L);
+ assertTrue(variants.running().isEmpty());
+ assertTrue(variant.body().entity().isRemoved());
+
+ // dread 0.5 gives a 30 s cooldown with the default vanish times
+ variants.tick(new SurvivorSnapshot(survivors, views), 129_999L);
+ assertTrue(variants.running().isEmpty());
+
+ variants.tick(new SurvivorSnapshot(survivors, views), 130_000L);
+ assertEquals(1, variants.running().size());
+ }
+
+ @Test
+ @DisplayName("A variant ends when its target leaves")
+ void endsWhenTheTargetLeaves(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player first = join(env, instance, 0);
+ Player second = join(env, instance, 10);
+ CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L);
+ variants.tick(new SurvivorSnapshot(List.of(first, second), List.of(view(first, 0.5D), view(second, 0.0D))), 0L);
+ Creek variant = variants.running().get(first.getUuid());
+
+ variants.tick(new SurvivorSnapshot(List.of(second), List.of(view(second, 0.0D))), 100L);
+
+ assertTrue(variants.running().isEmpty());
+ assertTrue(variant.body().entity().isRemoved());
+ }
+
+ @Test
+ @DisplayName("A dropping capacity does not cut a running variant short")
+ void droppingCapacityKeepsRunningVariants(Env env) {
+ Instance instance = env.createFlatInstance();
+ List survivors = List.of(join(env, instance, 0), join(env, instance, 100), join(env, instance, 200),
+ join(env, instance, 300), join(env, instance, 400));
+ List views = List.of(view(survivors.get(0), 0.5D), view(survivors.get(1), 0.4D),
+ view(survivors.get(2), 0.3D), view(survivors.get(3), 0.0D), view(survivors.get(4), 0.0D));
+ CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L);
+ variants.tick(new SurvivorSnapshot(survivors, views), 0L);
+
+ variants.tick(new SurvivorSnapshot(survivors.subList(0, 4), views.subList(0, 4)), 100L);
+
+ assertEquals(2, variants.running().size());
+ }
+
+ @Test
+ @DisplayName("Without a spot nothing starts, the next step tries again")
+ void retriesWithoutASpot(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player first = join(env, instance, 0);
+ AtomicBoolean floor = new AtomicBoolean(false);
+ CreekVariants variants = variants(instance, CONFIG,
+ candidate -> floor.get() ? Optional.of(candidate) : Optional.empty(), 0L);
+
+ variants.tick(new SurvivorSnapshot(List.of(first), List.of(view(first, 0.5D))), 0L);
+ assertTrue(variants.running().isEmpty());
+
+ floor.set(true);
+ variants.tick(new SurvivorSnapshot(List.of(first), List.of(view(first, 0.5D))), 100L);
+ assertEquals(1, variants.running().size());
+ }
+
+ @Test
+ @DisplayName("A survivor without a free spot does not block the next one in line")
+ void skipsASurvivorWithoutASpot(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player first = join(env, instance, 0);
+ Player second = join(env, instance, 200);
+ // no floor near the first survivor, floor everywhere near the second
+ CreekVariants variants = variants(instance, CONFIG,
+ candidate -> candidate.x() > 100 ? Optional.of(candidate) : Optional.empty(), 0L);
+
+ variants.tick(new SurvivorSnapshot(List.of(first, second), List.of(view(first, 0.5D), view(second, 0.3D))), 0L);
+
+ assertEquals(List.of(second.getUuid()), List.copyOf(variants.running().keySet()));
+ }
+
+ @Test
+ @DisplayName("Stopping removes every variant")
+ void stopRemovesEveryVariant(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player first = join(env, instance, 0);
+ CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L);
+ variants.tick(new SurvivorSnapshot(List.of(first), List.of(view(first, 0.5D))), 0L);
+ Creek variant = variants.running().get(first.getUuid());
+
+ variants.stop();
+
+ assertTrue(variants.running().isEmpty());
+ assertTrue(variant.body().entity().isRemoved());
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java
new file mode 100644
index 00000000..b22a9699
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java
@@ -0,0 +1,81 @@
+package net.onelitefeather.cygnus.creek;
+
+import net.minestom.server.coordinate.Pos;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.creek.state.SurvivorView;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class CreekVariantsTest {
+
+ private static final UUID FIRST = UUID.randomUUID();
+ private static final UUID SECOND = UUID.randomUUID();
+ private static final UUID THIRD = UUID.randomUUID();
+
+ @Test
+ @DisplayName("One variant per four survivors, at most three")
+ void capacity() {
+ assertEquals(0, CreekVariants.capacity(0));
+ assertEquals(1, CreekVariants.capacity(1));
+ assertEquals(1, CreekVariants.capacity(4));
+ assertEquals(2, CreekVariants.capacity(5));
+ assertEquals(2, CreekVariants.capacity(8));
+ assertEquals(3, CreekVariants.capacity(9));
+ assertEquals(3, CreekVariants.capacity(12));
+ assertEquals(3, CreekVariants.capacity(40));
+ }
+
+ @Test
+ @DisplayName("The breather after a variant shrinks as the dread grows")
+ void cooldownFollowsDread() {
+ assertEquals(40_000L, CreekVariants.cooldownMillis(CreekConfig.DEFAULT, 0.0D));
+ assertEquals(30_000L, CreekVariants.cooldownMillis(CreekConfig.DEFAULT, 0.5D));
+ assertEquals(20_000L, CreekVariants.cooldownMillis(CreekConfig.DEFAULT, 1.0D));
+ }
+
+ @Test
+ @DisplayName("The survivor with the highest dread above the threshold is picked")
+ void picksTheHighestDread() {
+ List views = List.of(
+ new SurvivorView(FIRST, new Pos(0, 40, 0), 0.3D, false),
+ new SurvivorView(SECOND, new Pos(10, 40, 0), 0.5D, false),
+ new SurvivorView(THIRD, new Pos(20, 40, 0), 0.1D, false));
+
+ assertEquals(Optional.of(views.get(1)), CreekVariants.pick(views, 0.25D, _ -> true));
+ }
+
+ @Test
+ @DisplayName("Nobody above the threshold, nobody is picked")
+ void picksNobodyBelowTheThreshold() {
+ List views = List.of(new SurvivorView(FIRST, new Pos(0, 40, 0), 0.2D, false));
+
+ assertEquals(Optional.empty(), CreekVariants.pick(views, 0.25D, _ -> true));
+ }
+
+ @Test
+ @DisplayName("Survivors that are not eligible are skipped")
+ void skipsIneligibleSurvivors() {
+ List views = List.of(
+ new SurvivorView(FIRST, new Pos(0, 40, 0), 0.9D, false),
+ new SurvivorView(SECOND, new Pos(10, 40, 0), 0.5D, false));
+
+ assertEquals(Optional.of(views.get(1)), CreekVariants.pick(views, 0.25D, id -> !id.equals(FIRST)));
+ }
+
+ @Test
+ @DisplayName("On a tie the one who strayed from the group is picked")
+ void prefersTheLonelyOne() {
+ List views = List.of(
+ new SurvivorView(FIRST, new Pos(0, 40, -60), 0.3D, false),
+ new SurvivorView(SECOND, new Pos(5, 40, -60), 0.3D, false),
+ new SurvivorView(THIRD, new Pos(0, 40, -160), 0.3D, false));
+
+ assertEquals(THIRD, CreekVariants.pick(views, 0.25D, _ -> true).orElseThrow().id());
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/SurvivorSnapshotIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/SurvivorSnapshotIntegrationTest.java
new file mode 100644
index 00000000..003ff7be
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/SurvivorSnapshotIntegrationTest.java
@@ -0,0 +1,66 @@
+package net.onelitefeather.cygnus.creek;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Player;
+import net.minestom.server.instance.Instance;
+import net.minestom.testing.Env;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.creek.state.SurvivorView;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class SurvivorSnapshotIntegrationTest extends CygnusPlayerTestBase {
+
+ @Test
+ @DisplayName("The snapshot rates each survivor against the others, and nobody sees a creek yet")
+ void takesOneViewPerSurvivor(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player first = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ Player second = env.createConnection().connect(instance, new Pos(10, 40, 0));
+ List> others = new ArrayList<>();
+
+ SurvivorSnapshot survivors = SurvivorSnapshot.take(List.of(first, second), (id, _, positions) -> {
+ others.add(positions);
+ return id.equals(first.getUuid()) ? 0.7D : 0.2D;
+ });
+
+ assertEquals(List.of(first, second), survivors.players());
+ SurvivorView view = survivors.views().getFirst();
+ assertEquals(first.getUuid(), view.id());
+ assertEquals(first.getPosition(), view.position());
+ assertEquals(0.7D, view.dread());
+ assertFalse(survivors.views().stream().anyMatch(SurvivorView::seesCreek));
+ assertEquals(List.of(List.of(second.getPosition()), List.of(first.getPosition())), others,
+ "each survivor is rated against everyone but themselves");
+ }
+
+ @Test
+ @DisplayName("A survivor is found by id, anyone else is not")
+ void findsPlayers(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player first = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ SurvivorSnapshot survivors = SurvivorSnapshot.take(List.of(first), (_, _, _) -> 0.0D);
+
+ assertSame(first, survivors.player(first.getUuid()));
+ assertNull(survivors.player(UUID.randomUUID()));
+ }
+
+ @Test
+ @DisplayName("Players and views have to match up")
+ void rejectsMismatchedViews(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player first = env.createConnection().connect(instance, new Pos(0, 40, 0));
+
+ assertThrows(IllegalArgumentException.class, () -> new SurvivorSnapshot(List.of(first), List.of()));
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java
new file mode 100644
index 00000000..401086e7
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java
@@ -0,0 +1,195 @@
+package net.onelitefeather.cygnus.creek.body;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.EntityCreature;
+import net.minestom.server.entity.attribute.Attribute;
+import net.minestom.server.entity.Player;
+import net.minestom.server.entity.metadata.monster.CreakingMeta;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.instance.block.Block;
+import net.minestom.testing.Env;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreakingBodyIntegrationTest extends CygnusPlayerTestBase {
+
+ @Test
+ @DisplayName("He is shown to the chosen viewers and nobody else")
+ void showsHimOnlyToTheChosen(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player first = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ Player second = env.createConnection().connect(instance, new Pos(2, 40, 0));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 10));
+
+ assertTrue(body.entity().getViewers().isEmpty(), "nobody sees him before he is shown");
+
+ body.showTo(Set.of(first.getUuid()));
+
+ assertTrue(body.entity().getViewers().contains(first));
+ assertFalse(body.entity().getViewers().contains(second));
+ assertTrue(body.isVisibleTo(first.getUuid()));
+
+ body.showTo(Set.of());
+
+ assertTrue(body.entity().getViewers().isEmpty());
+ }
+
+ @Test
+ @DisplayName("The hunting look lights his eyes and freezing stops him")
+ void looksFollowTheState(Env env) {
+ Instance instance = env.createFlatInstance();
+ env.createConnection().connect(instance, new Pos(0, 40, 0));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 10));
+ CreakingMeta meta = (CreakingMeta) body.entity().getEntityMeta();
+
+ body.setAggressive(true);
+ assertTrue(meta.isActive());
+
+ body.setFrozen(true);
+ assertFalse(meta.canMove());
+
+ body.setFrozen(false);
+ assertTrue(meta.canMove());
+ }
+
+ @Test
+ @DisplayName("Removing takes him out of the world")
+ void removeTakesHimOut(Env env) {
+ Instance instance = env.createFlatInstance();
+ env.createConnection().connect(instance, new Pos(0, 40, 0));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 10));
+
+ body.remove();
+
+ assertTrue(body.entity().isRemoved());
+ }
+
+ @Test
+ @DisplayName("A goal the navigator turned down does not block the next one")
+ void rejectedGoalDoesNotBlockTheNext(Env env) {
+ Instance instance = env.createFlatInstance();
+ env.createConnection().connect(instance, new Pos(0, 40, 0));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40, 10.5));
+
+ // Same block as he stands in: the navigator has nothing to do and turns it down.
+ body.moveTo(new Pos(0.7, 40, 10.7), 0.25D);
+ Pos next = new Pos(1.4, 40, 10.9);
+ body.moveTo(next, 0.25D);
+
+ assertEquals(next, ((EntityCreature) body.entity()).getNavigator().getGoalPosition());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"minecraft:leaf_litter", "minecraft:short_grass", "minecraft:fern"})
+ @DisplayName("He walks through plants on the ground")
+ void walksThroughPlants(String plant, Env env) {
+ Instance instance = env.createFlatInstance();
+ env.createConnection().connect(instance, new Pos(0, 40, 0));
+ Block cover = Block.fromKey(plant);
+ for (int x = -2; x <= 14; x++) {
+ for (int z = 5; z <= 15; z++) instance.setBlock(x, 40, z, cover);
+ }
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40, 10.5));
+ Pos goal = new Pos(10.5, 40, 10.5);
+
+ walk(env, body, goal);
+
+ assertTrue(body.position().distance(goal) <= 1.0D, "he gets through the " + plant + ", stands at " + body.position());
+ }
+
+ @Test
+ @DisplayName("He walks along a row of slabs without hopping")
+ void walksOverSlabsWithoutHopping(Env env) {
+ Instance instance = env.createFlatInstance();
+ env.createConnection().connect(instance, new Pos(0, 40, 0));
+ for (int x = 0; x <= 12; x++) {
+ instance.setBlock(x, 40, 10, Block.STONE_SLAB);
+ }
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40.5, 10.5));
+ Pos goal = new Pos(10.5, 40.5, 10.5);
+
+ double highest = walk(env, body, goal);
+
+ assertTrue(body.position().distance(goal) <= 1.0D, "he reaches the end of the slabs, stands at " + body.position());
+ assertTrue(highest < 41.0D, "he does not jump on flat slabs, highest y " + highest);
+ }
+
+ @Test
+ @DisplayName("He climbs steps made of slabs")
+ void climbsSlabSteps(Env env) {
+ Instance instance = env.createFlatInstance();
+ env.createConnection().connect(instance, new Pos(0, 40, 0));
+ // floor 40 -> slab 40.5 -> block 41 -> slab 41.5 -> block 42
+ // across the whole width, so he cannot walk around them
+ for (int z = 0; z <= 20; z++) {
+ instance.setBlock(3, 40, z, Block.STONE_SLAB);
+ for (int x = 4; x <= 20; x++) instance.setBlock(x, 40, z, Block.STONE);
+ instance.setBlock(5, 41, z, Block.STONE_SLAB);
+ for (int x = 6; x <= 20; x++) instance.setBlock(x, 41, z, Block.STONE);
+ }
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40, 10.5));
+ Pos goal = new Pos(10.5, 42, 10.5);
+
+ walk(env, body, goal);
+
+ assertTrue(body.position().distance(goal) <= 1.0D, "he gets to the top, stands at " + body.position());
+ }
+
+ @Test
+ @DisplayName("The step he walks up comes from his step height attribute")
+ void stepHeightFollowsTheAttribute(Env env) {
+ Instance instance = env.createFlatInstance();
+ env.createConnection().connect(instance, new Pos(0, 40, 0));
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40, 0.5));
+ EntityCreature creature = (EntityCreature) body.entity();
+ StepFollower follower = new StepFollower(creature);
+ assertEquals(1.0625D, follower.stepHeight(), 1.0E-9, "a creaking's own step, as in vanilla");
+
+ creature.getAttribute(Attribute.STEP_HEIGHT).setBaseValue(0.6D);
+
+ assertEquals(0.6D, follower.stepHeight(), 1.0E-9);
+ }
+
+ @Test
+ @DisplayName("He drops into a gap only one block wide")
+ void dropsIntoANarrowGap(Env env) {
+ Instance instance = env.createFlatInstance();
+ env.createConnection().connect(instance, new Pos(0, 40, 0));
+ for (int x = -2; x <= 12; x++) {
+ for (int z = 5; z <= 15; z++) instance.setBlock(x, 40, z, Block.STONE);
+ }
+ instance.setBlock(8, 40, 10, Block.AIR);
+ // off the block grid, so full steps never land him exactly in the middle of the gap
+ CreakingBody body = CreakingBody.spawn(instance, new Pos(0.6, 41, 10.37));
+ Pos goal = new Pos(8.5, 40, 10.5);
+
+ walk(env, body, goal);
+
+ assertTrue(body.position().distance(goal) <= 1.0D, "he gets down into the gap, stands at " + body.position());
+ }
+
+ /**
+ * Lets him settle, sends him to the goal and ticks until he is there or 10 seconds are over.
+ *
+ * @return the highest y he reached on the way
+ */
+ private static double walk(Env env, CreakingBody body, Pos goal) {
+ for (int i = 0; i < 20; i++) env.tick();
+ body.moveTo(goal, 0.25D);
+ double highest = body.position().y();
+ for (int i = 0; i < 200 && body.position().distance(goal) > 1.0D; i++) {
+ env.tick();
+ highest = Math.max(highest, body.position().y());
+ }
+ return highest;
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/CatchEffectsIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/CatchEffectsIntegrationTest.java
new file mode 100644
index 00000000..bc5d7a9a
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/CatchEffectsIntegrationTest.java
@@ -0,0 +1,59 @@
+package net.onelitefeather.cygnus.creek.consequence;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Player;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.potion.PotionEffect;
+import net.minestom.testing.Env;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.player.CygnusPlayer;
+import net.onelitefeather.cygnus.stamina.FoodBar;
+import net.onelitefeather.cygnus.stamina.StaminaFactory;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CatchEffectsIntegrationTest extends CygnusPlayerTestBase {
+
+ @Test
+ @DisplayName("A catch drains the stamina and slows the survivor")
+ void drainsAndSlows(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ FoodBar bar = (FoodBar) StaminaFactory.createFoodStamina((CygnusPlayer) survivor);
+ CatchEffects effects = new CatchEffects(_ -> true, _ -> bar, 4);
+
+ effects.apply(survivor);
+
+ assertEquals(0.0f, bar.remainingShare(), 1.0E-6f);
+ assertTrue(survivor.hasEffect(PotionEffect.SLOWNESS));
+ }
+
+ @Test
+ @DisplayName("Without a corpse to borrow, the scare still happens")
+ void scaresWithoutACorpse(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ CatchEffects effects = new CatchEffects(_ -> false, _ -> null, 4);
+
+ effects.apply(survivor);
+
+ assertTrue(survivor.hasEffect(PotionEffect.DARKNESS));
+ }
+
+ @Test
+ @DisplayName("Cleaning up lifts the slowness")
+ void cleanUpLiftsTheSlowness(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ CatchEffects effects = new CatchEffects(_ -> true, _ -> null, 4);
+ effects.apply(survivor);
+
+ effects.cleanUp();
+
+ assertFalse(survivor.hasEffect(PotionEffect.SLOWNESS));
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/GlowRevealIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/GlowRevealIntegrationTest.java
new file mode 100644
index 00000000..db9e660c
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/GlowRevealIntegrationTest.java
@@ -0,0 +1,104 @@
+package net.onelitefeather.cygnus.creek.consequence;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Metadata;
+import net.minestom.server.entity.Player;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.network.packet.server.play.EntityMetaDataPacket;
+import net.minestom.server.network.packet.server.play.SystemChatPacket;
+import net.minestom.testing.Collector;
+import net.minestom.testing.Env;
+import net.minestom.testing.TestConnection;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class GlowRevealIntegrationTest extends CygnusPlayerTestBase {
+
+ @Test
+ @DisplayName("The slender sees the survivor glow at once")
+ void slenderSeesTheGlow(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ TestConnection slenderConnection = env.createConnection();
+ Player slender = slenderConnection.connect(instance, new Pos(5, 40, 0));
+ Collector packets = slenderConnection.trackIncoming(EntityMetaDataPacket.class);
+ GlowReveal reveal = new GlowReveal(6);
+
+ reveal.reveal(survivor, slender);
+
+ assertTrue(packets.collect().stream().anyMatch(packet -> glows(packet, survivor)));
+ reveal.cleanUp();
+ }
+
+ @Test
+ @DisplayName("Sprinting wipes the glow only until the next resend")
+ void glowComesBackAfterSprinting(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ TestConnection slenderConnection = env.createConnection();
+ Player slender = slenderConnection.connect(instance, new Pos(5, 40, 0));
+ GlowReveal reveal = new GlowReveal(6);
+ reveal.reveal(survivor, slender);
+ Collector packets = slenderConnection.trackIncoming(EntityMetaDataPacket.class);
+
+ survivor.setSprinting(true);
+ for (int tick = 0; tick < GlowReveal.RESEND_TICKS + 1; tick++) {
+ env.tick();
+ }
+
+ List sent = packets.collect().stream()
+ .filter(packet -> packet.entityId() == survivor.getEntityId() && packet.entries().containsKey(0))
+ .toList();
+ assertFalse(sent.isEmpty());
+ assertTrue(glows(sent.getLast(), survivor), "the resend has to bring the glow back");
+ reveal.cleanUp();
+ }
+
+ @Test
+ @DisplayName("Cleaning up takes the glow back")
+ void cleanUpTakesTheGlowBack(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ TestConnection slenderConnection = env.createConnection();
+ Player slender = slenderConnection.connect(instance, new Pos(5, 40, 0));
+ GlowReveal reveal = new GlowReveal(6);
+ reveal.reveal(survivor, slender);
+ Collector packets = slenderConnection.trackIncoming(EntityMetaDataPacket.class);
+
+ reveal.cleanUp();
+
+ List sent = packets.collect();
+ assertFalse(sent.isEmpty());
+ assertNotNull(sent.getLast().entries().get(0), "the flags have to be sent explicitly");
+ assertFalse(glows(sent.getLast(), survivor));
+ }
+
+ @Test
+ @DisplayName("The survivor is warned")
+ void survivorIsWarned(Env env) {
+ Instance instance = env.createFlatInstance();
+ TestConnection survivorConnection = env.createConnection();
+ Player survivor = survivorConnection.connect(instance, new Pos(0, 40, 0));
+ Player slender = env.createConnection().connect(instance, new Pos(5, 40, 0));
+ Collector messages = survivorConnection.trackIncoming(SystemChatPacket.class);
+ GlowReveal reveal = new GlowReveal(6);
+
+ reveal.reveal(survivor, slender);
+
+ assertFalse(messages.collect().isEmpty());
+ reveal.cleanUp();
+ }
+
+ private static boolean glows(EntityMetaDataPacket packet, Player survivor) {
+ if (packet.entityId() != survivor.getEntityId()) return false;
+ Metadata.Entry> flags = packet.entries().get(0);
+ return flags != null && flags.value() instanceof Byte value && (value & GlowReveal.GLOWING) != 0;
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelperIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelperIntegrationTest.java
new file mode 100644
index 00000000..e7c73433
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelperIntegrationTest.java
@@ -0,0 +1,167 @@
+package net.onelitefeather.cygnus.creek.consequence;
+
+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.minestom.server.potion.PotionEffect;
+import net.minestom.testing.Env;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Random;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PatrolHelperIntegrationTest extends CygnusPlayerTestBase {
+
+ @Test
+ @DisplayName("A stun slows the selected survivor and does not count as a catch")
+ void stunSlowsTheSelected(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ PatrolHelper patrol = new PatrolHelper(new Random(1));
+
+ patrol.stun(selected, List.of(selected));
+
+ assertTrue(selected.hasEffect(PotionEffect.SLOWNESS));
+ assertFalse(selected.hasEffect(PotionEffect.BLINDNESS));
+ assertFalse(selected.hasTag(StagedCatchConsequence.CATCHES));
+ }
+
+ @Test
+ @DisplayName("A stun blinds other survivors nearby, nobody else")
+ void stunBlindsOtherSurvivorsNearby(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ Player near = env.createConnection().connect(instance, new Pos(5, 40, 0));
+ Player far = env.createConnection().connect(instance, new Pos(20, 40, 0));
+ Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0));
+ PatrolHelper patrol = new PatrolHelper(new Random(1));
+
+ patrol.stun(selected, List.of(selected, near, far));
+
+ assertTrue(near.hasEffect(PotionEffect.BLINDNESS));
+ assertFalse(far.hasEffect(PotionEffect.BLINDNESS));
+ assertFalse(slender.hasEffect(PotionEffect.BLINDNESS), "only survivors go blind");
+ assertFalse(selected.hasEffect(PotionEffect.BLINDNESS));
+ }
+
+ @Test
+ @DisplayName("Vanishing blinds the survivors nearby, nobody else")
+ void vanishBlindsSurvivorsNearby(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player near = env.createConnection().connect(instance, new Pos(5, 40, 0));
+ Player far = env.createConnection().connect(instance, new Pos(20, 40, 0));
+ Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0));
+ PatrolHelper patrol = new PatrolHelper(new Random(1));
+
+ patrol.vanished(new Pos(0, 40, 0), List.of(near, far));
+
+ assertTrue(near.hasEffect(PotionEffect.BLINDNESS));
+ assertFalse(far.hasEffect(PotionEffect.BLINDNESS));
+ assertFalse(slender.hasEffect(PotionEffect.BLINDNESS), "only survivors go blind");
+ }
+
+ /** Builds a two blocks high wall of stone, on the floor of a flat instance. */
+ private static void wall(Instance instance, int fromX, int toX, int fromZ, int toZ) {
+ for (int x = fromX; x <= toX; x++) {
+ for (int z = fromZ; z <= toZ; z++) {
+ instance.setBlock(x, 40, z, Block.STONE);
+ instance.setBlock(x, 41, z, Block.STONE);
+ }
+ }
+ }
+
+ private static Instance flat(Env env) {
+ Instance instance = env.createFlatInstance();
+ for (int x = -1; x <= 1; x++) {
+ for (int z = -1; z <= 1; z++) {
+ instance.loadChunk(x, z).join();
+ }
+ }
+ return instance;
+ }
+
+ @Test
+ @DisplayName("On open ground a fling sends the survivor the full distance away from the creek")
+ void flingsAwayFromTheCreek(Env env) {
+ Instance instance = flat(env);
+ Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5));
+ PatrolHelper patrol = new PatrolHelper(new Random(1));
+
+ assertTrue(patrol.flingAway(selected, new Pos(8.5, 40, 6.5)));
+
+ Vec velocity = selected.getVelocity();
+ assertEquals(PatrolHelper.FLING_MAX_DISTANCE * PatrolHelper.FLING_SPEED_PER_BLOCK, velocity.z(), 1.0E-6);
+ assertEquals(0.0D, velocity.x(), 1.0E-6);
+ assertEquals(PatrolHelper.FLING_LIFT, velocity.y(), 1.0E-6);
+ }
+
+ @Test
+ @DisplayName("With a tree in the way the fling only goes as far as there is room")
+ void flingsOnlyAsFarAsThereIsRoom(Env env) {
+ Instance instance = flat(env);
+ Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5));
+ wall(instance, 8, 8, 14, 14);
+ PatrolHelper patrol = new PatrolHelper(new Random(1));
+
+ assertTrue(patrol.flingAway(selected, new Pos(8.5, 40, 6.5)));
+
+ double distance = selected.getVelocity().z() / PatrolHelper.FLING_SPEED_PER_BLOCK;
+ assertTrue(distance >= PatrolHelper.FLING_MIN_DISTANCE && distance < 6.0D, "flung " + distance + " blocks");
+ }
+
+ @Test
+ @DisplayName("With the way ahead blocked the fling goes to a free side")
+ void flingsToAFreeSide(Env env) {
+ Instance instance = flat(env);
+ Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5));
+ wall(instance, 0, 15, 10, 10);
+ PatrolHelper patrol = new PatrolHelper(new Random(1));
+
+ assertTrue(patrol.flingAway(selected, new Pos(8.5, 40, 6.5)));
+
+ Vec velocity = selected.getVelocity();
+ assertEquals(0.0D, velocity.z(), 1.0E-6, "straight and diagonal are blocked");
+ assertTrue(Math.abs(velocity.x()) >= PatrolHelper.FLING_MIN_DISTANCE * PatrolHelper.FLING_SPEED_PER_BLOCK);
+ }
+
+ @Test
+ @DisplayName("Boxed in on every side, the survivor is stunned instead")
+ void stunsWhenBoxedIn(Env env) {
+ Instance instance = flat(env);
+ Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5));
+ wall(instance, 6, 11, 6, 6);
+ wall(instance, 6, 11, 11, 11);
+ wall(instance, 6, 6, 6, 11);
+ wall(instance, 11, 11, 6, 11);
+ // nextBoolean() is false for 0, so selected() tries the fling first.
+ PatrolHelper patrol = new PatrolHelper(() -> 0L);
+
+ patrol.selected(selected, new Pos(8.5, 40, 7.5), List.of(selected));
+
+ assertEquals(Vec.ZERO, selected.getVelocity());
+ assertTrue(selected.hasEffect(PotionEffect.SLOWNESS));
+ }
+
+ @Test
+ @DisplayName("Cleaning up removes every slowness and blindness it applied")
+ void cleanUpRemovesTheEffects(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ Player near = env.createConnection().connect(instance, new Pos(5, 40, 0));
+ PatrolHelper patrol = new PatrolHelper(new Random(1));
+ patrol.stun(selected, List.of(selected, near));
+
+ patrol.cleanUp();
+
+ assertFalse(selected.hasEffect(PotionEffect.SLOWNESS));
+ assertFalse(near.hasEffect(PotionEffect.BLINDNESS));
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequenceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequenceIntegrationTest.java
new file mode 100644
index 00000000..217819f3
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequenceIntegrationTest.java
@@ -0,0 +1,126 @@
+package net.onelitefeather.cygnus.creek.consequence;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Player;
+import net.minestom.server.instance.Instance;
+import net.minestom.testing.Env;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import org.jetbrains.annotations.Nullable;
+import net.onelitefeather.cygnus.creek.state.Contexts;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StagedCatchConsequenceIntegrationTest extends CygnusPlayerTestBase {
+
+ private final List punished = new ArrayList<>();
+ private final List revealed = new ArrayList<>();
+ private int cleanUps;
+
+ private final CatchConsequence effects = new CatchConsequence() {
+ @Override
+ public void apply(Player survivor) {
+ punished.add(survivor);
+ }
+
+ @Override
+ public void cleanUp() {
+ cleanUps++;
+ }
+ };
+
+ private final SlenderReveal reveal = new SlenderReveal() {
+ @Override
+ public void reveal(Player survivor, Player slender) {
+ revealed.add(new Player[]{survivor, slender});
+ }
+
+ @Override
+ public void cleanUp() {
+ cleanUps++;
+ }
+ };
+
+ private StagedCatchConsequence consequence(@Nullable Player slender, double draw) {
+ return new StagedCatchConsequence(effects, reveal, () -> slender, CreekConfig.DEFAULT, Contexts.rolling(draw));
+ }
+
+ @Test
+ @DisplayName("The first catch only punishes")
+ void firstCatchPunishes(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ Player slender = env.createConnection().connect(instance, new Pos(5, 40, 0));
+
+ consequence(slender, 0.99D).apply(survivor);
+
+ assertEquals(List.of(survivor), punished);
+ assertTrue(revealed.isEmpty());
+ assertEquals(1, survivor.getTag(StagedCatchConsequence.CATCHES));
+ }
+
+ @Test
+ @DisplayName("The second catch gives the survivor away")
+ void secondCatchBetrays(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ Player slender = env.createConnection().connect(instance, new Pos(5, 40, 0));
+ StagedCatchConsequence consequence = consequence(slender, 0.99D);
+
+ consequence.apply(survivor);
+ consequence.apply(survivor);
+
+ assertEquals(1, revealed.size());
+ assertEquals(survivor, revealed.getFirst()[0]);
+ assertEquals(slender, revealed.getFirst()[1]);
+ assertNull(survivor.getTag(StagedCatchConsequence.CATCHES), "the count starts over");
+ }
+
+ @Test
+ @DisplayName("A lucky draw gives the survivor away early")
+ void luckyDrawBetraysEarly(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ Player slender = env.createConnection().connect(instance, new Pos(5, 40, 0));
+
+ consequence(slender, 0.0D).apply(survivor);
+
+ assertEquals(1, revealed.size());
+ }
+
+ @Test
+ @DisplayName("Without a slender nobody is given away")
+ void withoutASlender(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ StagedCatchConsequence consequence = consequence(null, 0.0D);
+
+ consequence.apply(survivor);
+ consequence.apply(survivor);
+
+ assertTrue(revealed.isEmpty());
+ assertEquals(2, survivor.getTag(StagedCatchConsequence.CATCHES));
+ }
+
+ @Test
+ @DisplayName("Cleaning up forgets the count and cleans the parts")
+ void cleanUpForgets(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ StagedCatchConsequence consequence = consequence(null, 0.99D);
+ consequence.apply(survivor);
+
+ consequence.cleanUp();
+
+ assertNull(survivor.getTag(StagedCatchConsequence.CATCHES));
+ assertEquals(2, cleanUps);
+ }
+
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffectsIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffectsIntegrationTest.java
new file mode 100644
index 00000000..4ef16944
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffectsIntegrationTest.java
@@ -0,0 +1,62 @@
+package net.onelitefeather.cygnus.creek.consequence;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Player;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.potion.Potion;
+import net.minestom.server.potion.PotionEffect;
+import net.minestom.testing.Env;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TrackedEffectsIntegrationTest extends CygnusPlayerTestBase {
+
+ @Test
+ @DisplayName("Removing takes back every effect it handed out")
+ void removesItsOwnEffects(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ TrackedEffects effects = new TrackedEffects();
+ effects.add(player, new Potion(PotionEffect.SLOWNESS, 0, 100));
+ effects.add(player, new Potion(PotionEffect.BLINDNESS, 0, 100));
+
+ effects.removeAll();
+
+ assertFalse(player.hasEffect(PotionEffect.SLOWNESS));
+ assertFalse(player.hasEffect(PotionEffect.BLINDNESS));
+ }
+
+ @Test
+ @DisplayName("An effect something else put on the player since is left alone")
+ void leavesForeignEffects(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ TrackedEffects effects = new TrackedEffects();
+ effects.add(player, new Potion(PotionEffect.SLOWNESS, 0, 100));
+ player.addEffect(new Potion(PotionEffect.SLOWNESS, 2, 400));
+
+ effects.removeAll();
+
+ assertTrue(player.hasEffect(PotionEffect.SLOWNESS));
+ assertEquals(2, player.getEffectLevel(PotionEffect.SLOWNESS));
+ }
+
+ @Test
+ @DisplayName("Handing out the same effect again replaces the one it remembers")
+ void remembersTheLatest(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ TrackedEffects effects = new TrackedEffects();
+ effects.add(player, new Potion(PotionEffect.SLOWNESS, 0, 100));
+ effects.add(player, new Potion(PotionEffect.SLOWNESS, 6, 40));
+
+ effects.removeAll();
+
+ assertFalse(player.hasEffect(PotionEffect.SLOWNESS));
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/debug/CreekDebugTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/debug/CreekDebugTest.java
new file mode 100644
index 00000000..62824215
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/debug/CreekDebugTest.java
@@ -0,0 +1,150 @@
+package net.onelitefeather.cygnus.creek.debug;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
+import net.minestom.server.coordinate.Pos;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.creek.state.Contexts;
+import net.onelitefeather.cygnus.creek.state.CreekContext;
+import net.onelitefeather.cygnus.creek.state.CreekState;
+import net.onelitefeather.cygnus.creek.state.DoneState;
+import net.onelitefeather.cygnus.creek.state.HuntState;
+import net.onelitefeather.cygnus.creek.state.StalkState;
+import net.onelitefeather.cygnus.creek.state.SurvivorView;
+import net.onelitefeather.cygnus.creek.state.VanishState;
+import net.onelitefeather.cygnus.creek.state.PatrolState;
+import net.onelitefeather.cygnus.creek.state.RecordingBody;
+import net.onelitefeather.cygnus.creek.world.CreekSight;
+import net.onelitefeather.cygnus.creek.world.SpotFinder;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Random;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreekDebugTest {
+
+ private static final UUID STEVE = UUID.randomUUID();
+ private static final UUID ALEX = UUID.randomUUID();
+ private static final Map NAMES = Map.of(STEVE, "Steve", ALEX, "Alex");
+
+ private static String plain(Component component) {
+ return PlainTextComponentSerializer.plainText().serialize(component);
+ }
+
+ @Test
+ @DisplayName("A hunt names its target, the distance, every dread and who sees him")
+ void huntLine() {
+ List views = List.of(
+ new SurvivorView(STEVE, new Pos(0, 40, 12.4), 0.62D, true),
+ new SurvivorView(ALEX, new Pos(50, 40, 0), 0.18D, false));
+
+ Component line = CreekDebug.line(new HuntState(STEVE, Long.MAX_VALUE), new Pos(0, 40, 0), views, NAMES::get);
+
+ assertEquals("HUNT → Steve · 12.4 m · Steve 0.62 ◉ · Alex 0.18", plain(line));
+ }
+
+ @Test
+ @DisplayName("Patrolling there is no target, only the dread of everyone")
+ void patrolLine() {
+ List views = List.of(new SurvivorView(STEVE, new Pos(0, 40, 30), 0.1D, false));
+
+ Component line = CreekDebug.line(new PatrolState(), Pos.ZERO, views, NAMES::get);
+
+ assertEquals("PATROL · Steve 0.10", plain(line));
+ }
+
+ @Test
+ @DisplayName("Every state has its own label")
+ void labels() {
+ assertEquals("STALK", CreekDebug.label(new StalkState(STEVE, 0L)));
+ assertEquals("VANISH", CreekDebug.label(VanishState.forever()));
+ assertEquals("PATROL", CreekDebug.label(new PatrolState()));
+ assertEquals("DONE", CreekDebug.label(DoneState.INSTANCE));
+ }
+
+ @Test
+ @DisplayName("Toggling switches a watcher on and off again")
+ void toggle() {
+ CreekDebug debug = new CreekDebug();
+
+ assertTrue(debug.toggle(STEVE));
+ assertTrue(debug.hasWatchers());
+ assertFalse(debug.toggle(STEVE));
+ assertFalse(debug.hasWatchers());
+ }
+
+ @Test
+ @DisplayName("The route description follows the label")
+ void routeIsShown() {
+ List views = List.of(new SurvivorView(STEVE, new Pos(0, 40, 30), 0.1D, false));
+
+ Component line = CreekDebug.line(new PatrolState(), Pos.ZERO, views, NAMES::get, "Waldweg Nord 3/7 →");
+
+ assertEquals("PATROL · Waldweg Nord 3/7 → · Steve 0.10", plain(line));
+ }
+
+ @Test
+ @DisplayName("Without a route description the line is unchanged")
+ void emptyRouteAddsNothing() {
+ List views = List.of(new SurvivorView(STEVE, new Pos(0, 40, 30), 0.1D, false));
+
+ assertEquals("PATROL · Steve 0.10", plain(CreekDebug.line(new PatrolState(), Pos.ZERO, views, NAMES::get, "")));
+ }
+
+ private static final Pos HERE = new Pos(0, 40, 0);
+ private static final SpotFinder SPOTS = new SpotFinder(new CreekSight(48, 35), Optional::of);
+
+ @Test
+ @DisplayName("While staring the patrol names the selected survivor")
+ void patrolStareIsShown() {
+ PatrolState patrol = new PatrolState();
+ List views = List.of(new SurvivorView(STEVE, new Pos(0, 40, 2), 0.1D, false));
+ CreekContext ctx = new CreekContext(0L, views, new RecordingBody(HERE), Contexts.route(new Pos(30, 40, 0)),
+ SPOTS, Contexts.NO_ACTIONS, CreekConfig.DEFAULT, new Random(1));
+ patrol.enter(ctx);
+ patrol.tick(ctx);
+
+ assertEquals("PATROL → Steve · 2.0 m · Steve 0.10",
+ plain(CreekDebug.line(patrol, HERE, views, NAMES::get, "", 0L)));
+ }
+
+ @Test
+ @DisplayName("The variants show how many run and who they haunt")
+ void variantsSegment() {
+ List states = List.of(new StalkState(STEVE, 34_000L), new HuntState(ALEX, 12_000L));
+
+ assertEquals(" | variants 2/2 · Steve STALK 34s · Alex HUNT 12s",
+ plain(CreekDebug.variants(states, 2, NAMES::get, 0L)));
+ }
+
+ @Test
+ @DisplayName("Without variants only the count is shown")
+ void noVariants() {
+ assertEquals(" | variants 0/1", plain(CreekDebug.variants(List.of(), 1, NAMES::get, 0L)));
+ }
+
+ @Test
+ @DisplayName("During the selection cooldown the patrol shows the seconds left")
+ void selectCooldownIsShown() {
+ PatrolState patrol = new PatrolState();
+ List near = List.of(new SurvivorView(STEVE, new Pos(0, 40, 2), 0.1D, false));
+ RecordingBody body = new RecordingBody(HERE);
+ CreekContext start = new CreekContext(0L, near, body, Contexts.route(new Pos(30, 40, 0)), SPOTS,
+ Contexts.NO_ACTIONS, CreekConfig.DEFAULT, new Random(1));
+ patrol.enter(start);
+ patrol.tick(start);
+ patrol.tick(new CreekContext(1000L, near, body, Contexts.route(new Pos(30, 40, 0)), SPOTS,
+ Contexts.NO_ACTIONS, CreekConfig.DEFAULT, new Random(1)));
+
+ assertEquals("PATROL · select 7s · Steve 0.10",
+ plain(CreekDebug.line(patrol, HERE, near, NAMES::get, "", 4000L)));
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/dread/PageProgressDreadTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/dread/PageProgressDreadTest.java
new file mode 100644
index 00000000..4a34d479
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/dread/PageProgressDreadTest.java
@@ -0,0 +1,70 @@
+package net.onelitefeather.cygnus.creek.dread;
+
+import net.minestom.server.coordinate.Pos;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class PageProgressDreadTest {
+
+ private static final UUID ID = UUID.randomUUID();
+ private static final Pos HERE = new Pos(0, 40, 0);
+ private static final List NEIGHBOUR = List.of(new Pos(5, 40, 0));
+
+ private static PageProgressDread dread(int found, int max, long elapsedMillis, int gameTimeSeconds) {
+ return new PageProgressDread(() -> found, () -> max, () -> elapsedMillis, gameTimeSeconds, CreekConfig.DEFAULT);
+ }
+
+ @Test
+ @DisplayName("Nothing found, no time gone, not alone: no dread")
+ void nothingYet() {
+ assertEquals(0.0D, dread(0, 8, 0, 900).dreadOf(ID, HERE, NEIGHBOUR), 1.0E-9);
+ }
+
+ @Test
+ @DisplayName("Every page found weighs 0.6")
+ void allPages() {
+ assertEquals(0.6D, dread(8, 8, 0, 900).dreadOf(ID, HERE, NEIGHBOUR), 1.0E-9);
+ }
+
+ @Test
+ @DisplayName("Half the round gone weighs 0.15")
+ void halfTheRound() {
+ assertEquals(0.15D, dread(0, 8, 450_000, 900).dreadOf(ID, HERE, NEIGHBOUR), 1.0E-9);
+ }
+
+ @Test
+ @DisplayName("Nobody within the radius adds 0.1")
+ void aloneAddsIsolation() {
+ assertEquals(0.1D, dread(0, 8, 0, 900).dreadOf(ID, HERE, List.of(new Pos(30, 40, 0))), 1.0E-9);
+ }
+
+ @Test
+ @DisplayName("The last survivor is always alone")
+ void lastSurvivorIsAlone() {
+ assertEquals(0.1D, dread(0, 8, 0, 900).dreadOf(ID, HERE, List.of()), 1.0E-9);
+ }
+
+ @Test
+ @DisplayName("Everything at once is full dread")
+ void everything() {
+ assertEquals(1.0D, dread(8, 8, 900_000, 900).dreadOf(ID, HERE, List.of()), 1.0E-9);
+ }
+
+ @Test
+ @DisplayName("Values past their maximum are clamped")
+ void clamped() {
+ assertEquals(1.0D, dread(12, 8, 2_000_000, 900).dreadOf(ID, HERE, List.of()), 1.0E-9);
+ }
+
+ @Test
+ @DisplayName("Without pages or round time nothing divides by zero")
+ void noDivisionByZero() {
+ assertEquals(0.0D, dread(0, 0, 0, 0).dreadOf(ID, HERE, NEIGHBOUR), 1.0E-9);
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java
new file mode 100644
index 00000000..4dd9c0c7
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java
@@ -0,0 +1,144 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.creek.body.CreekBody;
+import net.onelitefeather.cygnus.creek.world.CreekSight;
+import net.onelitefeather.cygnus.creek.world.RouteProvider;
+import net.onelitefeather.cygnus.creek.world.RouteStep;
+import net.onelitefeather.cygnus.creek.world.SpotFinder;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.Random;
+import java.util.UUID;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+import java.util.random.RandomGenerator;
+
+/**
+ * Builds contexts for the state tests: the design's settings, ground everywhere, fixed chance.
+ */
+public final class Contexts {
+
+ /** The defaults, without random stops, so wandering tests do not depend on chance. */
+ static final CreekConfig CONFIG = randomStops(0.0D, 0, 0);
+ static final CreekSight SIGHT = new CreekSight(CONFIG.sightRange(), CONFIG.sightViewAngle());
+ static final SpotFinder SPOTS = new SpotFinder(SIGHT, Optional::of);
+
+ /** Actions that do nothing. */
+ public static final CreekActions NO_ACTIONS = actions(_ -> {}, _ -> {});
+
+ /** Actions that hand every catch and selection to the given consumers. */
+ public static CreekActions actions(Consumer caught, Consumer selected) {
+ return actions(caught, selected, _ -> {});
+ }
+
+ /** Actions that hand every catch, selection and vanish to the given consumers. */
+ public static CreekActions actions(Consumer caught, Consumer selected, Consumer vanished) {
+ return new CreekActions() {
+ @Override
+ public void caught(UUID survivor) {
+ caught.accept(survivor);
+ }
+
+ @Override
+ public void selected(UUID survivor) {
+ selected.accept(survivor);
+ }
+
+ @Override
+ public void vanished(Pos where) {
+ vanished.accept(where);
+ }
+ };
+ }
+
+ /** A random source that answers every roll with the given value. */
+ public static RandomGenerator rolling(double value) {
+ return new RandomGenerator() {
+ @Override
+ public long nextLong() {
+ return 0L;
+ }
+
+ @Override
+ public double nextDouble() {
+ return value;
+ }
+ };
+ }
+
+ static CreekContext context(long now, CreekBody body, RouteProvider route, List caught,
+ SurvivorView... survivors) {
+ return new CreekContext(now, List.of(survivors), body, route, SPOTS, actions(caught::add, _ -> {}), CONFIG,
+ new Random(7));
+ }
+
+ static CreekContext context(long now, CreekBody body, RouteProvider route, CreekConfig config,
+ SurvivorView... survivors) {
+ return new CreekContext(now, List.of(survivors), body, route, SPOTS, NO_ACTIONS, config, new Random(7));
+ }
+
+ /** A context with the given actions and random source. */
+ static CreekContext context(long now, CreekBody body, RouteProvider route, CreekActions actions,
+ RandomGenerator random, SurvivorView... survivors) {
+ return new CreekContext(now, List.of(survivors), body, route, SPOTS, actions, CONFIG, random);
+ }
+
+ /** A context that records every survivor the state selects. */
+ static CreekContext selecting(long now, CreekBody body, RouteProvider route, List selected,
+ SurvivorView... survivors) {
+ return new CreekContext(now, List.of(survivors), body, route, SPOTS, actions(_ -> {}, selected::add), CONFIG,
+ new Random(7));
+ }
+
+ /** The defaults with other random stops. */
+ static CreekConfig randomStops(double chance, int minMillis, int maxMillis) {
+ return copy(CreekConfig.DEFAULT.activeWithLastSurvivor(), chance, minMillis, maxMillis);
+ }
+
+ /** The defaults, but the creek sits out the round once only one survivor is left. */
+ public static CreekConfig withoutLastSurvivor() {
+ CreekConfig d = CreekConfig.DEFAULT;
+ return copy(false, d.randomStopChance(), d.randomStopMinMillis(), d.randomStopMaxMillis());
+ }
+
+ private static CreekConfig copy(boolean activeWithLastSurvivor, double chance, int minMillis, int maxMillis) {
+ CreekConfig d = CreekConfig.DEFAULT;
+ return new CreekConfig(
+ d.enabled(), activeWithLastSurvivor, d.sightRange(), d.sightViewAngle(),
+ d.wanderPauseMillis(), d.wanderSpeed(), d.huntSpeed(), d.stalkThreshold(), d.huntThreshold(),
+ d.stalkMinDistance(), d.stalkMaxDistance(), d.stalkMinAngle(), d.stalkMaxAngle(),
+ d.stalkRevealMillis(), d.stalkMinSeconds(), d.stalkMaxSeconds(), d.huntMaxSeconds(),
+ d.catchDistance(), d.vanishMinSeconds(), d.vanishMaxSeconds(), d.respawnMinDistance(),
+ d.personalSpace(), d.stuckMillis(), d.dreadPageWeight(), d.dreadTimeWeight(),
+ d.dreadIsolationWeight(), d.isolationRadius(), d.betrayalCatchCount(),
+ d.betrayalChance(), d.betrayalGlowSeconds(), d.slownessSeconds(), d.routeLinkDistance(),
+ chance, minMillis, maxMillis);
+ }
+
+ /** A route that offers the first allowed point at least a block away. */
+ public static RouteProvider route(Pos... points) {
+ return new RouteProvider() {
+ @Override
+ public Optional next(Pos current, Predicate allowed,
+ RandomGenerator random) {
+ return Arrays.stream(points)
+ .filter(point -> point.distance(current) >= 1.0D)
+ .filter(allowed)
+ .findFirst()
+ .map(point -> new RouteStep(point, 0));
+ }
+
+ @Override
+ public List points() {
+ return List.of(points);
+ }
+ };
+ }
+
+ private Contexts() {
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/CreekContextTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/CreekContextTest.java
new file mode 100644
index 00000000..679bf7fa
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/CreekContextTest.java
@@ -0,0 +1,59 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreekContextTest {
+
+ private static final UUID FIRST = UUID.randomUUID();
+ private static final UUID SECOND = UUID.randomUUID();
+
+ private static CreekContext context() {
+ return Contexts.context(0L, new RecordingBody(Pos.ZERO), Contexts.route(), new ArrayList<>(),
+ new SurvivorView(FIRST, new Pos(0, 40, 0), 0.2D, false),
+ new SurvivorView(SECOND, new Pos(30, 40, 0), 0.5D, true));
+ }
+
+ @Test
+ @DisplayName("A survivor is found by id")
+ void findsSurvivor() {
+ assertEquals(SECOND, context().survivor(SECOND).orElseThrow().id());
+ assertTrue(context().survivor(UUID.randomUUID()).isEmpty());
+ }
+
+ @Test
+ @DisplayName("The ids of every survivor")
+ void survivorIds() {
+ assertEquals(Set.of(FIRST, SECOND), context().survivorIds());
+ }
+
+ @Test
+ @DisplayName("The highest dread wins, and none means zero")
+ void highestDread() {
+ assertEquals(0.5D, context().highestDread());
+ assertEquals(0.0D, Contexts.context(0L, new RecordingBody(Pos.ZERO), Contexts.route(), new ArrayList<>()).highestDread());
+ }
+
+ @Test
+ @DisplayName("Far from all means far from every survivor")
+ void farFromAll() {
+ assertTrue(context().farFromAll(new Pos(15, 40, 20), 15));
+ assertFalse(context().farFromAll(new Pos(5, 40, 0), 15));
+ }
+
+ @Test
+ @DisplayName("Observer eyes sit above the feet")
+ void observerEyes() {
+ assertEquals(List.of(new Pos(0, 41.62, 0), new Pos(30, 41.62, 0)), context().observerEyes());
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateTest.java
new file mode 100644
index 00000000..9c7ba153
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateTest.java
@@ -0,0 +1,108 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HuntStateTest {
+
+ private static final UUID TARGET = UUID.randomUUID();
+ /** At the origin, looking towards negative Z - away from where he comes from. */
+ private static final Pos TARGET_POS = new Pos(0, 40, 0, 180, 0);
+
+ private static SurvivorView target(boolean sees) {
+ return new SurvivorView(TARGET, TARGET_POS, 0.7D, sees);
+ }
+
+ private static CreekContext at(long now, RecordingBody body, List caught, boolean sees) {
+ return Contexts.context(now, body, Contexts.route(), caught, target(sees));
+ }
+
+ @Test
+ @DisplayName("Entering shows him to the target and lights his eyes")
+ void enterLightsTheEyes() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 10));
+ new HuntState(TARGET, 30_000L).enter(at(0L, body, new ArrayList<>(), false));
+
+ assertEquals(Set.of(TARGET), body.viewers);
+ assertTrue(body.aggressive);
+ }
+
+ @Test
+ @DisplayName("Seen, he freezes")
+ void freezesWhenSeen() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 10));
+ HuntState state = new HuntState(TARGET, 30_000L);
+
+ assertSame(state, state.tick(at(0L, body, new ArrayList<>(), true)));
+ assertTrue(body.frozen);
+ assertNull(body.goal);
+ }
+
+ @Test
+ @DisplayName("Unseen, he closes in")
+ void closesInWhenUnseen() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 10));
+ new HuntState(TARGET, 30_000L).tick(at(0L, body, new ArrayList<>(), false));
+
+ assertFalse(body.frozen);
+ assertEquals(TARGET_POS, body.goal);
+ assertEquals(Contexts.CONFIG.huntSpeed(), body.speed);
+ }
+
+ @Test
+ @DisplayName("Close enough and unseen, he catches the target")
+ void catchesTheTarget() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 1));
+ List caught = new ArrayList<>();
+
+ CreekState next = new HuntState(TARGET, 30_000L).tick(at(0L, body, caught, false));
+
+ assertEquals(List.of(TARGET), caught);
+ assertSame(DoneState.INSTANCE, next);
+ }
+
+ @Test
+ @DisplayName("Close enough but seen, he does not")
+ void noCatchWhileSeen() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 1));
+ List caught = new ArrayList<>();
+
+ new HuntState(TARGET, 30_000L).tick(at(0L, body, caught, true));
+
+ assertTrue(caught.isEmpty());
+ }
+
+ @Test
+ @DisplayName("When the time is up he gives up")
+ void givesUp() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 10));
+ assertSame(DoneState.INSTANCE, new HuntState(TARGET, 30_000L).tick(at(30_000L, body, new ArrayList<>(), false)));
+ }
+
+ @Test
+ @DisplayName("Stuck, he takes a shortcut close to the target")
+ void takesAShortcut() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 10));
+ HuntState state = new HuntState(TARGET, 30_000L);
+ state.enter(at(0L, body, new ArrayList<>(), false));
+
+ state.tick(at(0L, body, new ArrayList<>(), false));
+ state.tick(at(3000L, body, new ArrayList<>(), false));
+
+ assertEquals(1, body.teleports.size());
+ double distance = body.position.distance(TARGET_POS);
+ assertTrue(distance >= HuntState.NEAR_MIN - 1.0E-6 && distance <= HuntState.NEAR_MAX + 1.0E-6, "distance was " + distance);
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java
new file mode 100644
index 00000000..2a684bef
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java
@@ -0,0 +1,400 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import net.onelitefeather.cygnus.creek.world.CreekPaths;
+import net.onelitefeather.cygnus.creek.world.PathRoute;
+import net.onelitefeather.cygnus.creek.world.RouteProvider;
+import net.onelitefeather.cygnus.creek.world.RouteStep;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PatrolStateTest {
+
+ private static final UUID FIRST = UUID.randomUUID();
+ private static final UUID SECOND = UUID.randomUUID();
+ private static final Pos A = new Pos(30, 40, 0);
+ private static final Pos B = new Pos(-30, 40, 0);
+
+ private static SurvivorView far(UUID id, double dread, boolean sees) {
+ return new SurvivorView(id, new Pos(0, 40, -60, 0, 0), dread, sees);
+ }
+
+ private static SurvivorView at(UUID id, double z) {
+ return new SurvivorView(id, new Pos(0, 40, z), 0.0D, false);
+ }
+
+ // ---- walking, carried over from the wander behaviour ----
+
+ @Test
+ @DisplayName("Entering shows him to every survivor")
+ void enterShowsHimToEveryone() {
+ RecordingBody body = new RecordingBody(Pos.ZERO);
+ new PatrolState().enter(Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(),
+ far(FIRST, 0.0D, false), new SurvivorView(SECOND, new Pos(40, 40, 40), 0.0D, false)));
+
+ assertEquals(Set.of(FIRST, SECOND), body.viewers);
+ }
+
+ @Test
+ @DisplayName("He walks to the next point")
+ void walksToTheNextPoint() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ PatrolState state = new PatrolState();
+ CreekContext ctx = Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.0D, false));
+ state.enter(ctx);
+
+ assertSame(state, state.tick(ctx));
+ assertEquals(A, body.goal);
+ assertEquals(Contexts.CONFIG.wanderSpeed(), body.speed);
+ }
+
+ @Test
+ @DisplayName("Spotted from afar, he stops and looks back for a moment")
+ void pausesWhenSpotted() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ PatrolState state = new PatrolState();
+ SurvivorView watcher = far(FIRST, 0.0D, true);
+ state.enter(Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), watcher));
+
+ state.tick(Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), watcher));
+ assertNull(body.goal);
+ assertEquals(watcher.eyes(), body.lookedAt);
+
+ state.tick(Contexts.context(1499L, body, Contexts.route(A), new ArrayList<>(), watcher));
+ assertNull(body.goal);
+
+ state.tick(Contexts.context(1500L, body, Contexts.route(A), new ArrayList<>(), watcher));
+ assertEquals(A, body.goal);
+ }
+
+ @Test
+ @DisplayName("Making no progress, he picks another point")
+ void picksAnotherPointWhenStuck() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ AtomicInteger calls = new AtomicInteger();
+ RouteProvider cycling = (_, _, _) -> Optional.of(new RouteStep(calls.getAndIncrement() % 2 == 0 ? A : B, 0));
+ PatrolState state = new PatrolState();
+ state.enter(Contexts.context(0L, body, cycling, new ArrayList<>(), far(FIRST, 0.0D, false)));
+
+ state.tick(Contexts.context(0L, body, cycling, new ArrayList<>(), far(FIRST, 0.0D, false)));
+ assertEquals(A, body.goal);
+
+ state.tick(Contexts.context(2999L, body, cycling, new ArrayList<>(), far(FIRST, 0.0D, false)));
+ assertEquals(A, body.goal);
+
+ state.tick(Contexts.context(3000L, body, cycling, new ArrayList<>(), far(FIRST, 0.0D, false)));
+ assertEquals(B, body.goal);
+ }
+
+ @Test
+ @DisplayName("With nowhere to go he stands still")
+ void standsStillWithoutAPoint() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ PatrolState state = new PatrolState();
+ CreekContext ctx = Contexts.context(0L, body, Contexts.route(), new ArrayList<>(), far(FIRST, 0.0D, false));
+ state.enter(ctx);
+
+ assertSame(state, state.tick(ctx));
+ assertNull(body.goal);
+ assertTrue(body.stops > 0);
+ }
+
+ @Test
+ @DisplayName("Someone who leaves the survivors stops seeing him")
+ void dropsViewersWhoLeaveTheSurvivors() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ PatrolState state = new PatrolState();
+ SurvivorView second = new SurvivorView(SECOND, new Pos(40, 40, 40), 0.0D, false);
+ state.enter(Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.0D, false), second));
+
+ state.tick(Contexts.context(100L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.0D, false)));
+
+ assertEquals(Set.of(FIRST), body.viewers);
+ }
+
+ /** Hands out A, then B, then A again, each with the given pause. */
+ private static RouteProvider alternating(int pauseMillis) {
+ AtomicInteger calls = new AtomicInteger();
+ return (_, _, _) -> Optional.of(new RouteStep(calls.getAndIncrement() % 2 == 0 ? A : B, pauseMillis));
+ }
+
+ private static CreekContext at(long now, RecordingBody body, RouteProvider route, CreekConfig config) {
+ return Contexts.context(now, body, route, config, far(FIRST, 0.0D, false));
+ }
+
+ @Test
+ @DisplayName("Arriving at a point with a pause, it rests there")
+ void restsAtAPointWithAPause() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ RouteProvider route = alternating(2000);
+ PatrolState state = new PatrolState();
+ state.enter(at(0L, body, route, Contexts.CONFIG));
+ state.tick(at(0L, body, route, Contexts.CONFIG));
+ assertEquals(A, body.goal);
+
+ body.position = A;
+ state.tick(at(100L, body, route, Contexts.CONFIG));
+ assertNull(body.goal);
+ state.tick(at(2099L, body, route, Contexts.CONFIG));
+ assertNull(body.goal);
+
+ state.tick(at(2100L, body, route, Contexts.CONFIG));
+ assertEquals(B, body.goal);
+ }
+
+ @Test
+ @DisplayName("Getting stuck is no reason to rest")
+ void noRestWhenStuck() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ RouteProvider route = alternating(5000);
+ PatrolState state = new PatrolState();
+ state.enter(at(0L, body, route, Contexts.CONFIG));
+ state.tick(at(0L, body, route, Contexts.CONFIG));
+
+ state.tick(at(3000L, body, route, Contexts.CONFIG));
+
+ assertEquals(B, body.goal);
+ }
+
+ @Test
+ @DisplayName("A random stop holds it at a point without a pause")
+ void randomStop() {
+ CreekConfig config = Contexts.randomStops(1.0D, 1000, 1000);
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ RouteProvider route = alternating(0);
+ PatrolState state = new PatrolState();
+ state.enter(at(0L, body, route, config));
+ state.tick(at(0L, body, route, config));
+
+ body.position = A;
+ state.tick(at(100L, body, route, config));
+ state.tick(at(1099L, body, route, config));
+ assertNull(body.goal);
+
+ state.tick(at(1100L, body, route, config));
+ assertEquals(B, body.goal);
+ }
+
+ @Test
+ @DisplayName("Pause and random stop do not add up, the longer one wins")
+ void longerHaltWins() {
+ CreekConfig config = Contexts.randomStops(1.0D, 1000, 1000);
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ RouteProvider route = alternating(3000);
+ PatrolState state = new PatrolState();
+ state.enter(at(0L, body, route, config));
+ state.tick(at(0L, body, route, config));
+
+ body.position = A;
+ state.tick(at(100L, body, route, config));
+ state.tick(at(3099L, body, route, config));
+ assertNull(body.goal);
+
+ state.tick(at(3100L, body, route, config));
+ assertEquals(B, body.goal);
+ }
+
+ @Test
+ @DisplayName("Without a chance and without a pause it walks on right away")
+ void noHaltWithoutChanceOrPause() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ RouteProvider route = alternating(0);
+ PatrolState state = new PatrolState();
+ state.enter(at(0L, body, route, Contexts.CONFIG));
+ state.tick(at(0L, body, route, Contexts.CONFIG));
+
+ body.position = A;
+ state.tick(at(100L, body, route, Contexts.CONFIG));
+
+ assertEquals(B, body.goal);
+ }
+
+ // ---- selecting ----
+
+ @Test
+ @DisplayName("Within the radius he selects the nearest survivor and stares at them")
+ void selectsTheNearestWithinTheRadius() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ PatrolState state = new PatrolState();
+ List selected = new ArrayList<>();
+ SurvivorView first = at(FIRST, 3);
+ SurvivorView second = at(SECOND, 2);
+ state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, first, second));
+
+ state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, first, second));
+
+ assertEquals(Optional.of(SECOND), state.staring());
+ assertEquals(second.eyes(), body.lookedAt);
+ assertNull(body.goal);
+ assertTrue(selected.isEmpty(), "the consequence waits for the end of the stare");
+ }
+
+ @Test
+ @DisplayName("Nobody within the radius, nobody is selected")
+ void selectsNobodyOutsideTheRadius() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ PatrolState state = new PatrolState();
+ List selected = new ArrayList<>();
+ state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, at(FIRST, 5)));
+
+ state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, at(FIRST, 5)));
+
+ assertEquals(Optional.empty(), state.staring());
+ assertEquals(A, body.goal);
+ }
+
+ @Test
+ @DisplayName("After staring for a second he applies the consequence and walks on")
+ void staresThenAppliesAndWalksOn() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ PatrolState state = new PatrolState();
+ List selected = new ArrayList<>();
+ state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2)));
+ state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2)));
+
+ state.tick(Contexts.selecting(999L, body, Contexts.route(A), selected, at(SECOND, 2)));
+ assertTrue(selected.isEmpty());
+ assertNull(body.goal);
+
+ state.tick(Contexts.selecting(1000L, body, Contexts.route(A), selected, at(SECOND, 2)));
+ assertEquals(List.of(SECOND), selected);
+ assertEquals(A, body.goal);
+ assertEquals(Optional.empty(), state.staring());
+ assertEquals(11_000L, state.selectAllowedAt());
+ }
+
+ @Test
+ @DisplayName("During the cooldown he selects nobody")
+ void selectsNobodyDuringTheCooldown() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ PatrolState state = new PatrolState();
+ List selected = new ArrayList<>();
+ state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2)));
+ state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2)));
+ state.tick(Contexts.selecting(1000L, body, Contexts.route(A), selected, at(SECOND, 2)));
+
+ state.tick(Contexts.selecting(10_999L, body, Contexts.route(A), selected, at(SECOND, 2)));
+ assertEquals(Optional.empty(), state.staring());
+ assertEquals(A, body.goal);
+
+ state.tick(Contexts.selecting(11_000L, body, Contexts.route(A), selected, at(SECOND, 2)));
+ assertEquals(Optional.of(SECOND), state.staring());
+ }
+
+ @Test
+ @DisplayName("If the selected survivor leaves during the stare, nothing happens and no cooldown starts")
+ void dropsTheSelectionWhenTheSurvivorLeaves() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ PatrolState state = new PatrolState();
+ List selected = new ArrayList<>();
+ state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2), far(FIRST, 0.0D, false)));
+ state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2), far(FIRST, 0.0D, false)));
+
+ state.tick(Contexts.selecting(500L, body, Contexts.route(A), selected, far(FIRST, 0.0D, false)));
+ assertTrue(selected.isEmpty());
+ assertEquals(Optional.empty(), state.staring());
+
+ state.tick(Contexts.selecting(600L, body, Contexts.route(A), selected, at(SECOND, 2), far(FIRST, 0.0D, false)));
+ assertEquals(Optional.of(SECOND), state.staring());
+ }
+
+ @Test
+ @DisplayName("After looking back he walks on to the point he was heading for")
+ void keepsHisPointAfterAPause() {
+ PathRoute route = new PathRoute(CreekPaths.of(List.of(CreekRoute.ofPositions("R",
+ List.of(new Vec(0, 40, 0), new Vec(20, 40, 0), new Vec(40, 40, 0)))), 3.0D));
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ PatrolState state = new PatrolState();
+ SurvivorView calm = far(FIRST, 0.0D, false);
+ state.enter(Contexts.context(0L, body, route, new ArrayList<>(), calm));
+ state.tick(Contexts.context(0L, body, route, new ArrayList<>(), calm));
+ state.tick(Contexts.context(100L, body, route, new ArrayList<>(), calm));
+ assertEquals(new Pos(20, 40, 0), body.goal);
+
+ body.position = new Pos(10, 40, 0);
+ SurvivorView watcher = far(FIRST, 0.0D, true);
+ state.tick(Contexts.context(200L, body, route, new ArrayList<>(), watcher));
+ assertNull(body.goal, "he stops to look back");
+
+ state.tick(Contexts.context(1700L, body, route, new ArrayList<>(), watcher));
+ assertEquals(new Pos(20, 40, 0), body.goal, "he goes on to the point he was heading for");
+ }
+
+ // ---- vanishing at a dead end ----
+
+ /** A route that always heads for A with the given pause, as a dead end or not. */
+ private static RouteProvider towardsA(int pauseMillis, boolean deadEnd) {
+ return (_, _, _) -> Optional.of(new RouteStep(A, pauseMillis, deadEnd));
+ }
+
+ private static CreekContext deadEndContext(long now, RecordingBody body, RouteProvider route, List vanished,
+ double roll) {
+ return Contexts.context(now, body, route, Contexts.actions(_ -> {}, _ -> {}, vanished::add), Contexts.rolling(roll), far(FIRST, 0.0D, false));
+ }
+
+ @Test
+ @DisplayName("At a dead end he may vanish, gone for as long as he would have rested there")
+ void vanishesAtADeadEnd() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ RouteProvider route = towardsA(3000, true);
+ List vanished = new ArrayList<>();
+ PatrolState state = new PatrolState();
+ state.enter(deadEndContext(0L, body, route, vanished, 0.0D));
+ state.tick(deadEndContext(0L, body, route, vanished, 0.0D));
+ assertEquals(A, body.goal);
+
+ body.position = A;
+ CreekState next = state.tick(deadEndContext(100L, body, route, vanished, 0.0D));
+
+ VanishState vanish = assertInstanceOf(VanishState.class, next);
+ assertEquals(3100L, vanish.until());
+ assertEquals(List.of(A), vanished);
+ }
+
+ @Test
+ @DisplayName("Missing the roll at a dead end, he rests and walks on as anywhere else")
+ void restsWhenTheRollMisses() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ RouteProvider route = towardsA(3000, true);
+ List vanished = new ArrayList<>();
+ PatrolState state = new PatrolState();
+ state.enter(deadEndContext(0L, body, route, vanished, 0.99D));
+ state.tick(deadEndContext(0L, body, route, vanished, 0.99D));
+
+ body.position = A;
+ assertSame(state, state.tick(deadEndContext(100L, body, route, vanished, 0.99D)));
+ assertNull(body.goal, "he rests at the end");
+ assertTrue(vanished.isEmpty());
+ }
+
+ @Test
+ @DisplayName("A point that is no dead end never makes him vanish")
+ void noVanishWithoutADeadEnd() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ RouteProvider route = towardsA(3000, false);
+ List vanished = new ArrayList<>();
+ PatrolState state = new PatrolState();
+ state.enter(deadEndContext(0L, body, route, vanished, 0.0D));
+ state.tick(deadEndContext(0L, body, route, vanished, 0.0D));
+
+ body.position = A;
+ assertSame(state, state.tick(deadEndContext(100L, body, route, vanished, 0.0D)));
+ assertTrue(vanished.isEmpty());
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/RecordingBody.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/RecordingBody.java
new file mode 100644
index 00000000..750308de
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/RecordingBody.java
@@ -0,0 +1,91 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Entity;
+import net.onelitefeather.cygnus.creek.body.CreekBody;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * A body that only records what the states ask of it.
+ */
+public final class RecordingBody implements CreekBody {
+
+ Pos position;
+ @Nullable Pos goal;
+ double speed;
+ int stops;
+ final List teleports = new ArrayList<>();
+ Set viewers = Set.of();
+ boolean aggressive;
+ boolean frozen;
+ @Nullable Pos lookedAt;
+ boolean removed;
+
+ public RecordingBody(Pos position) {
+ this.position = position;
+ }
+
+ @Override
+ public Pos position() {
+ return this.position;
+ }
+
+ @Override
+ public void moveTo(Pos goal, double speed) {
+ this.goal = goal;
+ this.speed = speed;
+ }
+
+ @Override
+ public void stop() {
+ this.goal = null;
+ this.stops++;
+ }
+
+ @Override
+ public void teleport(Pos position) {
+ this.goal = null;
+ this.position = position;
+ this.teleports.add(position);
+ }
+
+ @Override
+ public void lookAt(Pos point) {
+ this.lookedAt = point;
+ }
+
+ @Override
+ public void showTo(Set viewers) {
+ this.viewers = Set.copyOf(viewers);
+ }
+
+ @Override
+ public boolean isVisibleTo(UUID viewer) {
+ return this.viewers.contains(viewer);
+ }
+
+ @Override
+ public void setAggressive(boolean aggressive) {
+ this.aggressive = aggressive;
+ }
+
+ @Override
+ public void setFrozen(boolean frozen) {
+ this.frozen = frozen;
+ }
+
+ @Override
+ public Entity entity() {
+ throw new UnsupportedOperationException("The recording body has no entity");
+ }
+
+ @Override
+ public void remove() {
+ this.removed = true;
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/StalkStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/StalkStateTest.java
new file mode 100644
index 00000000..b8a41356
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/StalkStateTest.java
@@ -0,0 +1,148 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+import net.onelitefeather.cygnus.creek.world.SpotFinder;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StalkStateTest {
+
+ private static final UUID TARGET = UUID.randomUUID();
+ private static final UUID OTHER = UUID.randomUUID();
+ /** 25 blocks away, behind and to the side of a target looking towards positive Z. */
+ private static final Pos IN_THE_BAND = new Pos(15, 40, -20);
+
+ private static SurvivorView target(double dread, boolean sees) {
+ return new SurvivorView(TARGET, new Pos(0, 40, 0, 0, 0), dread, sees);
+ }
+
+ private static CreekContext at(long now, RecordingBody body, SurvivorView... survivors) {
+ return Contexts.context(now, body, Contexts.route(), new ArrayList<>(), survivors);
+ }
+
+ @Test
+ @DisplayName("Entering shows him to the target alone")
+ void enterShowsOnlyTheTarget() {
+ RecordingBody body = new RecordingBody(IN_THE_BAND);
+ new StalkState(TARGET, 60_000L).enter(at(0L, body, target(0.3D, false),
+ new SurvivorView(OTHER, new Pos(40, 40, 40), 0.0D, false)));
+
+ assertEquals(Set.of(TARGET), body.viewers);
+ }
+
+ @Test
+ @DisplayName("Without the target he is done")
+ void vanishesWithoutTheTarget() {
+ RecordingBody body = new RecordingBody(IN_THE_BAND);
+ assertSame(DoneState.INSTANCE, new StalkState(TARGET, 60_000L).tick(at(0L, body)));
+ }
+
+ @Test
+ @DisplayName("When the time is up he is done")
+ void vanishesWhenTheTimeIsUp() {
+ RecordingBody body = new RecordingBody(IN_THE_BAND);
+ assertSame(DoneState.INSTANCE, new StalkState(TARGET, 1000L).tick(at(1000L, body, target(0.3D, false))));
+ }
+
+ @Test
+ @DisplayName("At the hunt threshold the stalk turns into a hunt")
+ void turnsIntoAHunt() {
+ RecordingBody body = new RecordingBody(IN_THE_BAND);
+ assertInstanceOf(HuntState.class, new StalkState(TARGET, 60_000L).tick(at(0L, body, target(0.6D, false))));
+ }
+
+ @Test
+ @DisplayName("Seen, he lingers for a moment and then moves out of sight")
+ void lingersThenMoves() {
+ RecordingBody body = new RecordingBody(IN_THE_BAND);
+ StalkState state = new StalkState(TARGET, 60_000L);
+
+ state.tick(at(0L, body, target(0.3D, true)));
+ state.tick(at(699L, body, target(0.3D, true)));
+ assertTrue(body.teleports.isEmpty(), "he has to be seen for a moment first");
+
+ state.tick(at(700L, body, target(0.3D, true)));
+
+ assertEquals(1, body.teleports.size());
+ Pos spot = body.position;
+ double distance = spot.distance(target(0.3D, true).position());
+ assertTrue(distance >= 20 - 1.0E-6 && distance <= 35 + 1.0E-6, "distance was " + distance);
+ assertFalse(Contexts.SIGHT.inView(target(0.3D, true).eyes(), spot.add(0, SpotFinder.BODY_CENTRE, 0)));
+ }
+
+ @Test
+ @DisplayName("When the target comes too close he moves back into the band")
+ void relocatesWhenTooClose() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, -10));
+ new StalkState(TARGET, 60_000L).tick(at(0L, body, target(0.3D, false)));
+
+ assertEquals(1, body.teleports.size());
+ }
+
+ @Test
+ @DisplayName("Inside the band and unseen he just stares")
+ void staresInsideTheBand() {
+ RecordingBody body = new RecordingBody(IN_THE_BAND);
+ StalkState state = new StalkState(TARGET, 60_000L);
+
+ assertSame(state, state.tick(at(0L, body, target(0.3D, false))));
+ assertTrue(body.teleports.isEmpty());
+ assertEquals(target(0.3D, false).eyes(), body.lookedAt);
+ }
+
+ private static double distanceToTarget(RecordingBody body) {
+ return body.position.distance(target(0.3D, false).position());
+ }
+
+ @Test
+ @DisplayName("Halfway through the stalk the band has shrunk to about 14 to 25 blocks")
+ void bandShrinksByHalfway() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, -17));
+ StalkState state = new StalkState(TARGET, 60_000L);
+ state.enter(at(0L, body, target(0.3D, false)));
+
+ state.tick(at(30_000L, body, target(0.3D, false)));
+
+ assertTrue(body.teleports.isEmpty(), "17 blocks is inside the band halfway through");
+ }
+
+ @Test
+ @DisplayName("Late in the stalk he closes in when he is too far away")
+ void closesInLate() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, -18));
+ StalkState state = new StalkState(TARGET, 60_000L);
+ state.enter(at(0L, body, target(0.3D, false)));
+
+ state.tick(at(59_000L, body, target(0.3D, false)));
+
+ assertEquals(1, body.teleports.size());
+ double distance = distanceToTarget(body);
+ assertTrue(distance >= 8.0D - 1.0E-6 && distance <= 15.5D, "distance was " + distance);
+ }
+
+ @Test
+ @DisplayName("Seen late in the stalk he comes back closer")
+ void comesBackCloserWhenSeenLate() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, -12));
+ StalkState state = new StalkState(TARGET, 60_000L);
+ state.enter(at(0L, body, target(0.3D, false)));
+
+ state.tick(at(58_000L, body, target(0.3D, true)));
+ assertTrue(body.teleports.isEmpty(), "12 blocks is inside the late band");
+ state.tick(at(58_700L, body, target(0.3D, true)));
+
+ assertEquals(1, body.teleports.size());
+ double distance = distanceToTarget(body);
+ assertTrue(distance >= 8.0D - 1.0E-6 && distance <= 15.5D, "distance was " + distance);
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java
new file mode 100644
index 00000000..cb1800fa
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java
@@ -0,0 +1,124 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import net.onelitefeather.cygnus.creek.world.CreekPaths;
+import net.onelitefeather.cygnus.creek.world.PathRoute;
+import net.onelitefeather.cygnus.creek.world.RouteProvider;
+import net.onelitefeather.cygnus.creek.world.SpotFinder;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.Random;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class VanishStateTest {
+
+ private static final UUID SURVIVOR = UUID.randomUUID();
+ /** At the origin, looking towards positive Z. */
+ private static final SurvivorView WATCHER = new SurvivorView(SURVIVOR, new Pos(0, 40, 0, 0, 0), 0.0D, false);
+ private static final Pos AHEAD = new Pos(0, 40, 20);
+ private static final Pos BEHIND = new Pos(0, 40, -40);
+
+ @Test
+ @DisplayName("Entering hides and calms him")
+ void enterHidesHim() {
+ RecordingBody body = new RecordingBody(new Pos(50, 40, 50));
+ body.viewers = Set.of(SURVIVOR);
+ body.aggressive = true;
+ body.frozen = true;
+
+ new VanishState(1000L).enter(Contexts.context(0L, body, Contexts.route(), new ArrayList<>(), WATCHER));
+
+ assertTrue(body.viewers.isEmpty());
+ assertFalse(body.aggressive);
+ assertFalse(body.frozen);
+ assertEquals(1, body.stops);
+ }
+
+ @Test
+ @DisplayName("He stays away until the cooldown is over")
+ void staysAwayUntilTheCooldown() {
+ RecordingBody body = new RecordingBody(new Pos(50, 40, 50));
+ VanishState state = new VanishState(1000L);
+
+ assertSame(state, state.tick(Contexts.context(999L, body, Contexts.route(BEHIND), new ArrayList<>(), WATCHER)));
+ assertTrue(body.teleports.isEmpty());
+ }
+
+ @Test
+ @DisplayName("He comes back at a hidden point far from everyone")
+ void comesBackHidden() {
+ RecordingBody body = new RecordingBody(new Pos(50, 40, 50));
+ VanishState state = new VanishState(1000L);
+
+ CreekState next = state.tick(Contexts.context(1000L, body, Contexts.route(AHEAD, BEHIND), new ArrayList<>(), WATCHER));
+
+ assertInstanceOf(PatrolState.class, next);
+ assertEquals(List.of(BEHIND), body.teleports);
+ }
+
+ @Test
+ @DisplayName("Without a fitting point he stays away")
+ void staysAwayWithoutAPoint() {
+ RecordingBody body = new RecordingBody(new Pos(50, 40, 50));
+ VanishState state = new VanishState(1000L);
+
+ assertSame(state, state.tick(Contexts.context(1000L, body, Contexts.route(AHEAD), new ArrayList<>(), WATCHER)));
+ }
+
+ @Test
+ @DisplayName("Coming back, he stops looking once he has found a hidden point")
+ void stopsAtTheFirstHiddenPoint() {
+ RecordingBody body = new RecordingBody(new Pos(50, 40, 50));
+ AtomicInteger checked = new AtomicInteger();
+ SpotFinder counting = new SpotFinder(Contexts.SIGHT, candidate -> {
+ checked.incrementAndGet();
+ return Optional.of(candidate);
+ });
+ Pos[] behind = new Pos[20];
+ for (int i = 0; i < behind.length; i++) behind[i] = new Pos(i, 40, -40 - i);
+ CreekContext ctx = new CreekContext(1000L, List.of(WATCHER), body, Contexts.route(behind), counting,
+ Contexts.NO_ACTIONS, Contexts.CONFIG, new Random(7));
+
+ assertInstanceOf(PatrolState.class, new VanishState(1000L).tick(ctx));
+ assertEquals(1, checked.get(), "every point behind the survivor is hidden, so the first one will do");
+ }
+
+ @Test
+ @DisplayName("Forever means forever")
+ void foreverNeverEnds() {
+ VanishState state = VanishState.forever();
+ RecordingBody body = new RecordingBody(new Pos(50, 40, 50));
+
+ assertTrue(state.isForever());
+ assertSame(state, state.tick(Contexts.context(Long.MAX_VALUE - 1, body, Contexts.route(BEHIND), new ArrayList<>(), WATCHER)));
+ }
+
+ @Test
+ @DisplayName("On a route it comes back far away, not next to where it vanished")
+ void comesBackAnywhereOnTheRoute() {
+ // the route runs right past the survivor at the origin and ends far behind them
+ RouteProvider route = new PathRoute(CreekPaths.of(List.of(CreekRoute.ofPositions("Weg", List.of(
+ new Vec(0, 40, 10), new Vec(0, 40, 5), new Vec(0, 40, -5), new Vec(0, 40, -40)))), 3.0D));
+ route.next(new Pos(0, 40, 5), _ -> true, new java.util.Random(1));
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 5));
+
+ CreekState next = new VanishState(0L).tick(Contexts.context(0L, body, route, new ArrayList<>(), WATCHER));
+
+ assertInstanceOf(PatrolState.class, next);
+ assertEquals(List.of(new Pos(0, 40, -40)), body.teleports);
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java
new file mode 100644
index 00000000..dc349e7a
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java
@@ -0,0 +1,62 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.onelitefeather.cygnus.common.creek.CreekLinks;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import net.onelitefeather.cygnus.common.creek.CreekWaypoint;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreekPathsTest {
+
+ private static CreekRoute route(String name, Vec... points) {
+ return CreekRoute.ofPositions(name, List.of(points));
+ }
+
+ /** A ends at (10,0,0); B starts 2 blocks away, C exactly 3, D 3.1. */
+ private static final List ROUTES = List.of(
+ route("A", new Vec(0, 0, 0), new Vec(10, 0, 0)),
+ route("B", new Vec(12, 0, 0), new Vec(20, 0, 0)),
+ route("C", new Vec(10, 0, 3), new Vec(10, 0, 20)),
+ route("D", new Vec(10, 0, -3.1), new Vec(10, 0, -20))
+ );
+
+ @Test
+ @DisplayName("Links come from CreekLinks, by route and point")
+ void linksByNode() {
+ CreekPaths paths = CreekPaths.of(ROUTES, 3.0D);
+
+ assertEquals(List.of(new CreekLinks.Node(1, 0), new CreekLinks.Node(2, 0)),
+ paths.links(new CreekLinks.Node(0, 1)));
+ }
+
+ @Test
+ @DisplayName("Points come out as Pos")
+ void pointsAsPos() {
+ CreekPaths paths = CreekPaths.of(ROUTES, 3.0D);
+
+ assertEquals(4, paths.size());
+ assertEquals(new Pos(12, 0, 0), paths.point(1, 0));
+ assertEquals(new Pos(20, 0, 0), paths.point(1, 1));
+ assertEquals(8, paths.allPoints().size());
+ assertTrue(CreekPaths.of(List.of(), 3.0D).isEmpty());
+ }
+
+ @Test
+ @DisplayName("Each point reports its pause")
+ void pausesPerPoint() {
+ CreekRoute route = new CreekRoute("P", List.of(
+ CreekWaypoint.of(new Vec(0, 0, 0)).withPause(1000),
+ CreekWaypoint.of(new Vec(10, 0, 0))));
+ CreekPaths paths = CreekPaths.of(List.of(route), 3.0D);
+
+ assertEquals(1000, paths.pauseMillis(0, 0));
+ assertEquals(0, paths.pauseMillis(0, 1));
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightIntegrationTest.java
new file mode 100644
index 00000000..3ed7f82a
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightIntegrationTest.java
@@ -0,0 +1,38 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.EntityCreature;
+import net.minestom.server.entity.EntityType;
+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.onelitefeather.cygnus.CygnusPlayerTestBase;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreekSightIntegrationTest extends CygnusPlayerTestBase {
+
+ @Test
+ @DisplayName("A wall between them hides him")
+ void wallHidesHim(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player survivor = env.createConnection().connect(instance, new Pos(0.5, 40, 0.5, 0, 0));
+ EntityCreature creek = new EntityCreature(EntityType.CREAKING);
+ creek.setInstance(instance, new Pos(0.5, 40, 10.5)).join();
+ CreekSight sight = new CreekSight(48, 35);
+
+ assertTrue(sight.sees(survivor, creek), "nothing stands between them yet");
+
+ for (int y = 40; y < 44; y++) {
+ for (int x = -2; x <= 2; x++) {
+ instance.setBlock(x, y, 5, Block.STONE);
+ }
+ }
+
+ assertFalse(sight.sees(survivor, creek), "a wall hides him");
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightTest.java
new file mode 100644
index 00000000..857870ea
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightTest.java
@@ -0,0 +1,41 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreekSightTest {
+
+ /** Eyes of a survivor standing at the origin and looking towards positive Z. */
+ private static final Pos EYES = new Pos(0, 41.62, 0, 0, 0);
+ private static final CreekSight SIGHT = new CreekSight(48, 35);
+
+ @Test
+ @DisplayName("Straight ahead he is seen")
+ void straightAheadIsSeen() {
+ assertTrue(SIGHT.inView(EYES, new Vec(0, 41.62, 20)));
+ }
+
+ @Test
+ @DisplayName("Behind the survivor he is not")
+ void behindIsNotSeen() {
+ assertFalse(SIGHT.inView(EYES, new Vec(0, 41.62, -20)));
+ }
+
+ @Test
+ @DisplayName("Beyond the range he is not")
+ void beyondRangeIsNotSeen() {
+ assertFalse(SIGHT.inView(EYES, new Vec(0, 41.62, 60)));
+ }
+
+ @Test
+ @DisplayName("Beside the cone he is not")
+ void besideTheConeIsNotSeen() {
+ double radians = Math.toRadians(45);
+ assertFalse(SIGHT.inView(EYES, new Vec(-Math.sin(radians) * 10, 41.62, Math.cos(radians) * 10)));
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/InstanceGroundIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/InstanceGroundIntegrationTest.java
new file mode 100644
index 00000000..15dd75bc
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/InstanceGroundIntegrationTest.java
@@ -0,0 +1,35 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.instance.Instance;
+import net.minestom.testing.Env;
+import net.minestom.testing.extension.MicrotusExtension;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@ExtendWith(MicrotusExtension.class)
+class InstanceGroundIntegrationTest {
+
+ @Test
+ @DisplayName("A spot in the air settles onto the floor, centred on its block")
+ void settlesOntoTheFloor(Env env) {
+ Instance instance = env.createFlatInstance();
+ instance.loadChunk(0, 0).join();
+ InstanceGround ground = new InstanceGround(() -> instance);
+
+ assertEquals(new Pos(3.5, 40, 3.5), ground.settle(new Pos(3.2, 43, 3.7)).orElseThrow());
+ }
+
+ @Test
+ @DisplayName("An unloaded chunk offers no spot")
+ void unloadedChunkHasNoSpot(Env env) {
+ Instance instance = env.createFlatInstance();
+ InstanceGround ground = new InstanceGround(() -> instance);
+
+ assertTrue(ground.settle(new Pos(10_000, 40, 10_000)).isEmpty());
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java
new file mode 100644
index 00000000..55291b7c
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java
@@ -0,0 +1,291 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import net.onelitefeather.cygnus.common.creek.CreekWaypoint;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.random.RandomGenerator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PathRouteTest {
+
+ private static final Predicate ANYWHERE = _ -> true;
+
+ private static final CreekRoute A = CreekRoute.ofPositions("A",
+ List.of(new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0)));
+ /** Starts 2 blocks after the end of A, so the two are linked. */
+ private static final CreekRoute B = CreekRoute.ofPositions("B",
+ List.of(new Vec(22, 40, 0), new Vec(22, 40, 10)));
+
+ private static PathRoute route(CreekRoute... routes) {
+ return new PathRoute(CreekPaths.of(List.of(routes), 3.0D));
+ }
+
+ /** Always answers with the given index (capped to the bound) and forwards for booleans. */
+ private static RandomGenerator fixed(int index) {
+ return new RandomGenerator() {
+ @Override
+ public long nextLong() {
+ return 0L;
+ }
+
+ @Override
+ public int nextInt(int bound) {
+ return Math.min(index, bound - 1);
+ }
+
+ @Override
+ public boolean nextBoolean() {
+ return true;
+ }
+ };
+ }
+
+ private static RouteStep step(PathRoute route, Pos current, RandomGenerator random) {
+ return route.next(current, ANYWHERE, random).orElseThrow();
+ }
+
+ private static Pos next(PathRoute route, Pos current, RandomGenerator random) {
+ return step(route, current, random).target();
+ }
+
+ private static CreekRoute paused(String name, int startPause, int endPause, Vec... positions) {
+ List points = new ArrayList<>(CreekRoute.ofPositions(name, List.of(positions)).points());
+ points.set(0, points.getFirst().withPause(startPause));
+ points.set(points.size() - 1, points.getLast().withPause(endPause));
+ return new CreekRoute(name, points);
+ }
+
+ @Test
+ @DisplayName("Without a position it joins at the nearest point")
+ void joinsAtTheNearestPoint() {
+ PathRoute route = route(A);
+
+ assertEquals(new Pos(10, 40, 0), next(route, new Pos(9, 41, 1), fixed(0)));
+ assertEquals("A 2/3 →", route.describe());
+ }
+
+ @Test
+ @DisplayName("It walks the points in order")
+ void walksInOrder() {
+ PathRoute route = route(A);
+ Pos at = next(route, new Pos(0, 40, 0), fixed(0));
+
+ at = next(route, at, fixed(0));
+ assertEquals(new Pos(10, 40, 0), at);
+ at = next(route, at, fixed(0));
+ assertEquals(new Pos(20, 40, 0), at);
+ }
+
+ @Test
+ @DisplayName("At an end without links it turns around")
+ void turnsAroundWithoutLinks() {
+ PathRoute route = route(A);
+ Pos at = next(route, new Pos(0, 40, 0), fixed(0));
+ at = next(route, at, fixed(0));
+ at = next(route, at, fixed(0));
+
+ assertEquals(new Pos(10, 40, 0), next(route, at, fixed(0)));
+ assertEquals("A 2/3 ←", route.describe());
+ }
+
+ @Test
+ @DisplayName("At a linked end it can switch to the other route")
+ void takesALink() {
+ PathRoute route = route(A, B);
+ Pos at = next(route, new Pos(10, 40, 0), fixed(0));
+ at = next(route, at, fixed(0));
+
+ at = next(route, at, fixed(0));
+ assertEquals(new Pos(22, 40, 0), at, "the linked end is the stepping stone");
+ assertEquals(new Pos(22, 40, 10), next(route, at, fixed(0)));
+ }
+
+ @Test
+ @DisplayName("At a linked end it can also turn around")
+ void canStillTurnAround() {
+ PathRoute route = route(A, B);
+ Pos at = next(route, new Pos(10, 40, 0), fixed(0));
+ at = next(route, at, fixed(0));
+
+ assertEquals(new Pos(10, 40, 0), next(route, at, fixed(1)));
+ }
+
+ @Test
+ @DisplayName("After a teleport it rejoins where it is now")
+ void rejoinsAfterATeleport() {
+ PathRoute route = route(A, B);
+ next(route, new Pos(0, 40, 0), fixed(0));
+
+ assertEquals(new Pos(22, 40, 10), next(route, new Pos(22, 41, 12), fixed(0)));
+ }
+
+ @Test
+ @DisplayName("A blocked point makes it turn around")
+ void turnsAroundWhenBlocked() {
+ PathRoute route = route(A);
+ Pos at = next(route, new Pos(10, 40, 0), fixed(0));
+
+ Optional next = route.next(at, point -> point.x() < 15, fixed(0)).map(RouteStep::target);
+
+ assertEquals(Optional.of(new Pos(0, 40, 0)), next);
+ }
+
+ @Test
+ @DisplayName("With both ways blocked it stays put, and moves on once a way is free")
+ void waitsUntilAWayIsFree() {
+ PathRoute route = route(A);
+ Pos at = next(route, new Pos(10, 40, 0), fixed(0));
+
+ assertTrue(route.next(at, point -> point.x() == 10, fixed(0)).isEmpty());
+ assertEquals(new Pos(20, 40, 0), next(route, at, fixed(0)));
+ }
+
+ @Test
+ @DisplayName("With no point allowed at all it waits, then joins")
+ void joinsOnceAPointIsAllowed() {
+ PathRoute route = route(A);
+
+ assertTrue(route.next(new Pos(0, 40, 0), _ -> false, fixed(0)).isEmpty());
+ assertEquals(new Pos(0, 40, 0), next(route, new Pos(0, 40, 0), fixed(0)));
+ }
+
+ @Test
+ @DisplayName("Before it joined there is nothing to describe")
+ void emptyDescriptionBeforeJoining() {
+ assertEquals("", route(A).describe());
+ assertEquals(5, route(A, B).points().size());
+ }
+
+ @Test
+ @DisplayName("Reaching an end in walking direction carries its pause")
+ void endsInWalkingDirectionPause() {
+ PathRoute route = route(paused("A", 1000, 2000, new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0)));
+
+ assertEquals(0, step(route, new Pos(0, 40, 0), fixed(0)).pauseMillis(), "it walks away from the start");
+ assertEquals(0, step(route, new Pos(0, 40, 0), fixed(0)).pauseMillis());
+ RouteStep end = step(route, new Pos(10, 40, 0), fixed(0));
+ assertEquals(new RouteStep(new Pos(20, 40, 0), 2000, true), end);
+ assertEquals(0, step(route, end.target(), fixed(0)).pauseMillis());
+ assertEquals(new RouteStep(new Pos(0, 40, 0), 1000, true), step(route, new Pos(10, 40, 0), fixed(0)));
+ }
+
+ @Test
+ @DisplayName("Heading for an end without links is a dead end, heading anywhere else is not")
+ void marksDeadEnds() {
+ PathRoute route = route(A);
+
+ assertFalse(step(route, new Pos(0, 40, 0), fixed(0)).deadEnd(), "joining at the start walks away from it");
+ assertFalse(step(route, new Pos(0, 40, 0), fixed(0)).deadEnd(), "the middle is no end");
+ assertTrue(step(route, new Pos(10, 40, 0), fixed(0)).deadEnd());
+ assertFalse(step(route, new Pos(20, 40, 0), fixed(0)).deadEnd(), "turning around leaves the end");
+ assertTrue(step(route, new Pos(10, 40, 0), fixed(0)).deadEnd(), "the start is a dead end too");
+ }
+
+ @Test
+ @DisplayName("A linked end is no dead end")
+ void linkedEndIsNoDeadEnd() {
+ PathRoute route = route(A, B);
+ next(route, new Pos(10, 40, 0), fixed(0));
+
+ assertFalse(step(route, new Pos(10, 40, 0), fixed(0)).deadEnd());
+ }
+
+ /** X runs along the x axis; Y starts right next to X's middle point and runs off along z. */
+ private static final CreekRoute X = CreekRoute.ofPositions("X",
+ List.of(new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0)));
+ private static final CreekRoute Y = CreekRoute.ofPositions("Y",
+ List.of(new Vec(10, 40, 2), new Vec(10, 40, 12), new Vec(10, 40, 22)));
+
+ @Test
+ @DisplayName("At a crossing in the middle it can turn onto the other route")
+ void turnsAtACrossing() {
+ PathRoute route = route(X, Y);
+ Pos at = next(route, new Pos(0, 40, 0), fixed(0));
+ at = next(route, at, fixed(0));
+ assertEquals(new Pos(10, 40, 0), at);
+
+ RouteStep turn = step(route, at, fixed(0));
+ assertEquals(new RouteStep(new Pos(10, 40, 2), 0), turn, "the other route's end is the stepping stone");
+ assertEquals(new Pos(10, 40, 12), next(route, turn.target(), fixed(0)));
+ }
+
+ @Test
+ @DisplayName("At a crossing in the middle it can also walk on")
+ void walksOnAtACrossing() {
+ PathRoute route = route(X, Y);
+ Pos at = next(route, new Pos(0, 40, 0), fixed(0));
+ at = next(route, at, fixed(0));
+
+ assertEquals(new Pos(20, 40, 0), next(route, at, fixed(1)));
+ }
+
+ @Test
+ @DisplayName("Right after taking a link it does not jump straight back")
+ void noJumpBackAfterALink() {
+ PathRoute route = route(X, Y);
+ Pos at = next(route, new Pos(0, 40, 0), fixed(0));
+ at = next(route, at, fixed(0));
+ at = next(route, at, fixed(0));
+ assertEquals(new Pos(10, 40, 2), at);
+
+ assertEquals(new Pos(10, 40, 12), next(route, at, fixed(0)));
+ }
+
+ @Test
+ @DisplayName("From an end it can join the middle of another route and walk on from there")
+ void joinsAMiddleFromAnEnd() {
+ PathRoute route = route(X, Y);
+ next(route, new Pos(10, 40, 22), fixed(0));
+ Pos at = next(route, new Pos(10, 40, 22), fixed(0));
+ RouteStep start = step(route, at, fixed(0));
+ assertEquals(new Pos(10, 40, 2), start.target());
+ assertFalse(start.deadEnd(), "an end on another route's middle is no dead end");
+
+ RouteStep join = step(route, start.target(), fixed(0));
+ assertEquals(new RouteStep(new Pos(10, 40, 0), 0), join, "no second stop where the routes meet");
+ assertEquals(new Pos(20, 40, 0), next(route, join.target(), fixed(0)), "it walks on, not back");
+ }
+
+ @Test
+ @DisplayName("The start of a linked route is no second stop")
+ void noPauseAtTheSteppingStone() {
+ PathRoute route = route(
+ paused("A", 0, 2000, new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0)),
+ paused("B", 3000, 0, new Vec(22, 40, 0), new Vec(22, 40, 10)));
+ next(route, new Pos(10, 40, 0), fixed(0));
+
+ assertEquals(2000, step(route, new Pos(10, 40, 0), fixed(0)).pauseMillis());
+ assertEquals(new RouteStep(new Pos(22, 40, 0), 0), step(route, new Pos(20, 40, 0), fixed(0)));
+ }
+
+ @Test
+ @DisplayName("Rejoining at an end does not stop there")
+ void noPauseWhenRejoiningAtAnEnd() {
+ PathRoute route = route(paused("A", 1000, 2000, new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0)));
+
+ assertEquals(new RouteStep(new Pos(20, 40, 0), 0), step(route, new Pos(21, 40, 0), fixed(0)));
+ }
+
+ @Test
+ @DisplayName("A point in the middle keeps its pause")
+ void middlePointKeepsItsPause() {
+ PathRoute route = route(new CreekRoute("M", List.of(
+ CreekWaypoint.of(new Vec(0, 40, 0)),
+ CreekWaypoint.of(new Vec(10, 40, 0)).withPause(1500),
+ CreekWaypoint.of(new Vec(20, 40, 0)))));
+ next(route, new Pos(0, 40, 0), fixed(0));
+
+ assertEquals(new RouteStep(new Pos(10, 40, 0), 1500), step(route, new Pos(0, 40, 0), fixed(0)));
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/SpotFinderTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/SpotFinderTest.java
new file mode 100644
index 00000000..3a10537f
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/SpotFinderTest.java
@@ -0,0 +1,58 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Random;
+import java.util.random.RandomGenerator;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SpotFinderTest {
+
+ private static final CreekSight SIGHT = new CreekSight(48, 35);
+ private static final SpotFinder SPOTS = new SpotFinder(SIGHT, Optional::of);
+ private static final Pos TARGET = new Pos(0, 40, 0, 0, 0);
+ private static final Pos TARGET_EYES = TARGET.add(0, 1.62, 0);
+
+ @Test
+ @DisplayName("A spot beside the view stays in the band and out of the cone")
+ void besideStaysOutOfTheCone() {
+ RandomGenerator random = new Random(1);
+ for (int i = 0; i < 50; i++) {
+ Pos spot = SPOTS.beside(TARGET, 20, 35, 40, 70, List.of(TARGET_EYES), 15, random).orElseThrow();
+ double distance = spot.distance(TARGET);
+ assertTrue(distance >= 20 - 1.0E-6 && distance <= 35 + 1.0E-6, "distance was " + distance);
+ assertFalse(SIGHT.inView(TARGET_EYES, spot.add(0, SpotFinder.BODY_CENTRE, 0)));
+ }
+ }
+
+ @Test
+ @DisplayName("A spot straight ahead is not hidden")
+ void aheadIsNotHidden() {
+ assertFalse(SPOTS.isHidden(new Pos(0, 40, 20), List.of(TARGET_EYES), 15));
+ }
+
+ @Test
+ @DisplayName("A spot too close is not hidden, even behind")
+ void tooCloseIsNotHidden() {
+ assertFalse(SPOTS.isHidden(new Pos(0, 40, -5), List.of(TARGET_EYES), 15));
+ }
+
+ @Test
+ @DisplayName("A spot far behind is hidden")
+ void farBehindIsHidden() {
+ assertTrue(SPOTS.isHidden(new Pos(0, 40, -20), List.of(TARGET_EYES), 15));
+ }
+
+ @Test
+ @DisplayName("Without ground there is no spot")
+ void noGroundNoSpot() {
+ SpotFinder nowhere = new SpotFinder(SIGHT, _ -> Optional.empty());
+ assertTrue(nowhere.beside(TARGET, 20, 35, 40, 70, List.of(TARGET_EYES), 15, new Random(1)).isEmpty());
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/listener/game/GameFinishListenerTest.java b/game/src/test/java/net/onelitefeather/cygnus/listener/game/GameFinishListenerTest.java
new file mode 100644
index 00000000..41c45650
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/listener/game/GameFinishListenerTest.java
@@ -0,0 +1,74 @@
+package net.onelitefeather.cygnus.listener.game;
+
+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.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.common.Tags;
+import net.onelitefeather.cygnus.common.config.GameConfig;
+import net.onelitefeather.cygnus.event.GameFinishEvent;
+import net.onelitefeather.cygnus.player.CygnusPlayer;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Covers the round-end cleanup that used to be missing: a player who survived to the round's
+ * natural end (as opposed to dying or quitting, which already clear this via
+ * {@link net.onelitefeather.cygnus.listener.PlayerDeathListener} and
+ * {@link net.onelitefeather.cygnus.listener.PlayerQuitListener}) kept carrying
+ * {@link Tags#TEAM_KEY} and the leftover SlenderEye item into the {@code RestartPhase} countdown,
+ * which let the item and sprinting keep behaving as if the round were still live.
+ */
+@Disabled
+class GameFinishListenerTest extends CygnusPlayerTestBase {
+
+ @Test
+ void testSlenderLosesTeamTagAndItemsOnRoundFinish(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer slender = (CygnusPlayer) env.createPlayer(instance);
+ slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY);
+ slender.getInventory().addItemStack(ItemStack.of(Material.ENDER_EYE));
+
+ GameFinishListener listener = new GameFinishListener();
+ listener.accept(new GameFinishEvent(GameFinishEvent.Reason.ALL_PAGES_FOUND));
+
+ assertFalse(slender.hasTag(Tags.TEAM_KEY), "the slender role must not survive round end");
+ assertTrue(slender.getInventory().getItemStack(0).isAir(), "the leftover SlenderEye must be gone");
+
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testSurvivorLosesTeamTagOnRoundFinish(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer survivor = (CygnusPlayer) env.createPlayer(instance);
+ survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY);
+
+ GameFinishListener listener = new GameFinishListener();
+ listener.accept(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER));
+
+ assertFalse(survivor.hasTag(Tags.TEAM_KEY), "the survivor role must not survive round end");
+
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testSpectatorKeepsItsTagOnRoundFinish(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ Player spectator = env.createPlayer(instance);
+ spectator.setTag(Tags.TEAM_KEY, GameConfig.SPECTATOR_KEY);
+
+ GameFinishListener listener = new GameFinishListener();
+ listener.accept(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER));
+
+ assertTrue(spectator.hasTag(Tags.TEAM_KEY), "spectators are not part of the round-end reset");
+
+ env.destroyInstance(instance, true);
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java
index 4a674a43..245a5a88 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java
@@ -1,11 +1,14 @@
package net.onelitefeather.cygnus.map;
import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
import net.minestom.server.instance.InstanceContainer;
import net.minestom.server.world.DimensionType;
import net.minestom.server.world.attribute.EnvironmentAttribute;
import net.minestom.testing.Env;
import net.minestom.testing.extension.MicrotusExtension;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import net.onelitefeather.cygnus.common.creek.CreekRoutesFile;
import net.onelitefeather.cygnus.common.dimension.MapAtmosphere;
import net.onelitefeather.cygnus.common.dimension.StaticDimensionPreset;
import net.onelitefeather.cygnus.common.map.GameMap;
@@ -211,6 +214,21 @@ void loadsPageFacesFromTheSiblingPagesJson(Env env, @TempDir Path root) throws I
env.destroyInstance(gameInstance, true);
}
+ @Test
+ void testGameMapCarriesTheCreekRoutes(Env env, @TempDir Path root) throws IOException {
+ GameMapProvider provider = createProvider(root);
+ Path mapFile = root.resolve("game").resolve("maps").resolve(ARENA_NAME).resolve("map.json");
+ CreekRoute route = CreekRoute.ofPositions("Waldweg", List.of(new Vec(0, 80, 0), new Vec(5, 80, 0)));
+ CreekRoutesFile.save(mapFile, List.of(route));
+
+ provider.loadGameMap();
+
+ assertEquals(List.of(route), provider.getGameMap().getCreekRoutes());
+ InstanceContainer gameInstance = (InstanceContainer) provider.getActiveInstance().get();
+ provider.close();
+ env.destroyInstance(gameInstance, true);
+ }
+
/**
* Creates a map directory layout the provider accepts and returns a provider reading it.
*
diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java
index 1fe45c52..b764e5b8 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java
@@ -10,6 +10,7 @@
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* Verifies the stamina share other systems read off the survivor's bar.
@@ -29,4 +30,21 @@ void freshBarIsFull(Env env) {
assertEquals(1.0f, bar.remainingShare(), 1.0E-6f);
}
+
+ @Test
+ @DisplayName("Draining empties the bar and leaves it to regenerate")
+ void drainEmptiesTheBar(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ FoodBar bar = (FoodBar) StaminaFactory.createFoodStamina((CygnusPlayer) player);
+
+ bar.drain();
+
+ assertEquals(0.0f, bar.remainingShare(), 1.0E-6f);
+ assertFalse(bar.canConsume(), "an empty bar must not allow sprinting again straight away");
+
+ bar.consume();
+
+ assertEquals(1.0f / 20, bar.remainingShare(), 1.0E-6f);
+ }
}
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/SetupExtension.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/SetupExtension.java
index 619f1cd8..bad5131d 100644
--- a/setup/src/main/java/net/onelitefeather/cygnus/setup/SetupExtension.java
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/SetupExtension.java
@@ -29,6 +29,7 @@
import net.onelitefeather.cygnus.setup.listener.PageCreationListener;
import net.onelitefeather.cygnus.setup.listener.PlayerSpawnListener;
import net.onelitefeather.cygnus.setup.listener.SetupItemListener;
+import net.onelitefeather.cygnus.setup.listener.CreekRouteListener;
import net.onelitefeather.cygnus.setup.listener.SpawnCreationListener;
import net.onelitefeather.cygnus.setup.listener.data.PlayerRemoveDataListener;
import net.onelitefeather.cygnus.setup.listener.dialog.DialogPayloadListener;
@@ -99,6 +100,10 @@ private void registerSetupComponents() {
.ignoreCancelled(false)
.handler(new SpawnCreationListener(this.dataService))
.build());
+ manager.addListener(EventListener.builder(PlayerBlockBreakEvent.class)
+ .ignoreCancelled(false)
+ .handler(new CreekRouteListener(this.dataService))
+ .build());
manager.addListener(AddEntityToInstanceEvent.class, new InstanceAddListener(instanceUUID));
manager.addListener(RemoveEntityFromInstanceEvent.class, new InstanceRemoveListener(instanceUUID));
registerCancelListener(manager);
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java
new file mode 100644
index 00000000..ad6e2514
--- /dev/null
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java
@@ -0,0 +1,194 @@
+package net.onelitefeather.cygnus.setup.creek;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.minestom.server.MinecraftServer;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.minestom.server.entity.Entity;
+import net.minestom.server.entity.EntityType;
+import net.minestom.server.entity.Player;
+import net.minestom.server.entity.metadata.display.AbstractDisplayMeta;
+import net.minestom.server.entity.metadata.display.TextDisplayMeta;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.network.packet.server.play.ParticlePacket;
+import net.minestom.server.particle.Particle;
+import net.minestom.server.timer.Task;
+import net.minestom.server.timer.TaskSchedule;
+import net.onelitefeather.cygnus.common.creek.CreekLinks;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import net.onelitefeather.cygnus.common.creek.CreekWaypoint;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Supplier;
+
+/**
+ * Shows the creek route being edited to the player editing it: particles at every point and along
+ * the route, the route's name above its start, and a marker on every point that links to another route.
+ * Without a route being edited it shows nothing.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekRoutePreview {
+
+ /** How often the particles are drawn, in ticks. */
+ static final int INTERVAL_TICKS = 10;
+
+ /** Distance between two particles on a line, in blocks. */
+ static final double LINE_STEP = 0.5D;
+
+ /** How far above the start point the name floats, in blocks. */
+ static final double LABEL_HEIGHT = 2.5D;
+
+ private static final Particle POINT_PARTICLE = Particle.FLAME;
+ private static final Particle LINE_PARTICLE = Particle.CRIT;
+ private static final Particle LINK_PARTICLE = Particle.HAPPY_VILLAGER;
+
+ private final Player player;
+ private final Supplier> routes;
+ private final Supplier<@Nullable String> active;
+ private final double linkDistance;
+ private final List labels = new ArrayList<>();
+ private @Nullable Task task;
+
+ /**
+ * Creates the preview.
+ *
+ * @param player the player who sees it
+ * @param routes supplies the current routes
+ * @param active supplies the name of the route being edited, or {@code null}
+ * @param linkDistance how close an end has to be to a point of another route to link to it, in blocks
+ */
+ public CreekRoutePreview(Player player, Supplier> routes, Supplier<@Nullable String> active,
+ double linkDistance) {
+ this.player = player;
+ this.routes = routes;
+ this.active = active;
+ this.linkDistance = linkDistance;
+ }
+
+ /**
+ * Starts drawing. Does nothing if it is already running.
+ */
+ public void show() {
+ if (this.task != null) return;
+ this.refreshLabels();
+ this.task = MinecraftServer.getSchedulerManager()
+ .buildTask(this::draw)
+ .repeat(TaskSchedule.tick(INTERVAL_TICKS))
+ .schedule();
+ }
+
+ /**
+ * Stops drawing and removes the names.
+ */
+ public void hide() {
+ if (this.task != null) {
+ this.task.cancel();
+ this.task = null;
+ }
+ this.labels.forEach(Entity::remove);
+ this.labels.clear();
+ }
+
+ /**
+ * Rebuilds the floating name after the edited route changed. Without a route being edited the
+ * name goes away.
+ */
+ public void refreshLabels() {
+ this.labels.forEach(Entity::remove);
+ this.labels.clear();
+ Instance instance = this.player.getInstance();
+ CreekRoute route = this.activeRoute(this.routes.get());
+ if (instance == null || route == null || route.points().isEmpty()) return;
+
+ Vec start = route.points().getFirst().position();
+ Entity label = new Entity(EntityType.TEXT_DISPLAY);
+ TextDisplayMeta meta = (TextDisplayMeta) label.getEntityMeta();
+ meta.setText(Component.text(route.name(), NamedTextColor.GREEN));
+ meta.setBillboardRenderConstraints(AbstractDisplayMeta.BillboardConstraints.CENTER);
+ label.setAutoViewable(false);
+ label.setInstance(instance, new Pos(start.x(), start.y() + LABEL_HEIGHT, start.z()))
+ .thenRun(() -> label.addViewer(this.player));
+ this.labels.add(label);
+ }
+
+ private void draw() {
+ if (!this.player.isOnline()) {
+ this.hide();
+ return;
+ }
+ List routes = this.routes.get();
+ CreekRoute route = this.activeRoute(routes);
+ if (route == null) return;
+
+ List points = route.points().stream().map(CreekWaypoint::position).toList();
+ for (int i = 0; i < points.size(); i++) {
+ this.particle(POINT_PARTICLE, points.get(i));
+ if (i + 1 < points.size()) {
+ line(points.get(i), points.get(i + 1), LINE_STEP).forEach(spot -> this.particle(LINE_PARTICLE, spot));
+ }
+ }
+ linkedPoints(route, routes, this.linkDistance).forEach(point -> this.particle(LINK_PARTICLE, point.add(0, 0.5, 0)));
+ }
+
+ private @Nullable CreekRoute activeRoute(List routes) {
+ String activeName = this.active.get();
+ if (activeName == null) return null;
+ for (CreekRoute route : routes) {
+ if (route.name().equals(activeName)) return route;
+ }
+ return null;
+ }
+
+ private void particle(Particle particle, Vec spot) {
+ this.player.sendPacket(new ParticlePacket(particle, spot.x(), spot.y() + 0.2D, spot.z(), 0f, 0f, 0f, 0f, 1));
+ }
+
+ /**
+ * Returns the spots between two points, one every {@code step} blocks, without the points
+ * themselves.
+ *
+ * @param from the first point
+ * @param to the second point
+ * @param step the distance between two spots
+ * @return the spots
+ */
+ static List line(Vec from, Vec to, double step) {
+ double length = from.distance(to);
+ List spots = new ArrayList<>();
+ for (double travelled = step; travelled < length - 1.0E-9; travelled += step) {
+ double share = travelled / length;
+ spots.add(from.add(to.sub(from).mul(share)));
+ }
+ return spots;
+ }
+
+ /**
+ * Returns the points of a route that link to another route, as {@link CreekLinks} links them
+ * for the game.
+ *
+ * @param route the route whose points are checked
+ * @param routes all routes of the map, the checked one included
+ * @param distance the link distance, in blocks
+ * @return the linked points of {@code route}
+ */
+ static Set linkedPoints(CreekRoute route, List routes, double distance) {
+ int index = routes.indexOf(route);
+ if (index < 0) return Set.of();
+ CreekLinks links = CreekLinks.of(routes, distance);
+ Set linked = new HashSet<>();
+ for (int point = 0; point < route.points().size(); point++) {
+ if (!links.of(new CreekLinks.Node(index, point)).isEmpty()) {
+ linked.add(route.points().get(point).position());
+ }
+ }
+ return linked;
+ }
+}
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/package-info.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/package-info.java
new file mode 100644
index 00000000..f7aeaf6e
--- /dev/null
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * Setup tools for the creek routes of a map.
+ */
+@NotNullByDefault
+package net.onelitefeather.cygnus.setup.creek;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java
index 768bb70b..65241b10 100644
--- a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java
@@ -7,12 +7,22 @@
import net.minestom.server.coordinate.Pos;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.entity.Player;
+import net.minestom.server.event.EventDispatcher;
import net.minestom.server.inventory.InventoryType;
import net.minestom.server.utils.Direction;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import net.onelitefeather.cygnus.common.creek.CreekRoutesFile;
+import net.onelitefeather.cygnus.common.creek.CreekWaypoint;
import net.onelitefeather.cygnus.common.map.GameMap;
import net.onelitefeather.cygnus.common.map.GameMapBuilder;
import net.onelitefeather.cygnus.common.util.GsonHelper;
+import net.onelitefeather.cygnus.setup.creek.CreekRoutePreview;
+import net.onelitefeather.cygnus.setup.event.dialog.DialogContext;
+import net.onelitefeather.cygnus.setup.event.dialog.DialogRequestEvent;
+import net.onelitefeather.cygnus.setup.event.dialog.DialogTarget;
import net.onelitefeather.cygnus.setup.inventory.page.PageHeaderFormatter;
+import net.onelitefeather.cygnus.setup.inventory.slot.CreekRouteSlot;
import net.onelitefeather.cygnus.setup.inventory.slot.PageSlot;
import net.onelitefeather.cygnus.setup.inventory.view.InventoryMode;
import net.onelitefeather.cygnus.setup.inventory.view.MapDataOverviewInventory;
@@ -31,6 +41,7 @@
import net.theevilreaper.aves.inventory.util.LayoutCalculator;
import net.theevilreaper.aves.map.BaseMapBuilder;
import net.theevilreaper.aves.map.MapEntry;
+import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
@@ -41,9 +52,14 @@ public class GameData extends InstanceSetupData {
private final MapDataOverviewInventory inventory;
private final SurvivorViewInventory survivorInventory;
private final PageableInventory pageInventory;
+ private final PageableInventory creekRouteInventory;
+ private final List creekRouteSlots;
+ private final CreekRoutePreview creekRoutePreview;
private GameMapBuilder gameMapBuilder;
private boolean pageMode;
private boolean survivorMode;
+ private boolean creekRouteMode;
+ private @Nullable String activeCreekRoute;
/**
* Constructs a new GameData instance.
@@ -52,12 +68,70 @@ public class GameData extends InstanceSetupData {
* @param mapEntry the map entry associated with this game data
*/
public GameData(Player player, MapEntry mapEntry) {
- super(player.getUuid(), mapEntry, BossBar.Color.RED);
+ super(player, mapEntry, BossBar.Color.RED);
this.loadData();
-
+ this.creekRouteSlots = new ArrayList<>();
this.inventory = new MapDataOverviewInventory(player, this.gameMapBuilder, InventoryMode.GAME);
this.survivorInventory = new SurvivorViewInventory(player, this.gameMapBuilder);
this.pageInventory = createPageInventory(player);
+ this.creekRouteInventory = createCreekRouteInventory(player);
+ this.creekRoutePreview = new CreekRoutePreview(player, () -> this.gameMapBuilder.getCreekRoutes(),
+ () -> this.activeCreekRoute, CreekConfig.DEFAULT.routeLinkDistance());
+ this.refreshCreekRoutes();
+ }
+
+ /**
+ * Creates the {@link PageableInventory} to display the creek routes of the map.
+ *
+ * @param player who owns the inventory
+ * @return the created inventory
+ */
+ private PageableInventory createCreekRouteInventory(Player player) {
+ InventoryLayout layout = InventoryLayout.fromType(InventoryType.CHEST_6_ROW);
+ layout.setItems(LayoutCalculator.fillRow(InventoryType.CHEST_1_ROW), SetupItems.DECORATION_PANE);
+ layout.setItems(LayoutCalculator.fillRow(InventoryType.CHEST_6_ROW), SetupItems.DECORATION_PANE);
+ return PageableInventory.builder()
+ .titleData(TitleData.builder()
+ .title(Component.text("Creek routes - "))
+ .pageMapper(PageHeaderFormatter::format)
+ .showPageNumbers(true)
+ .build())
+ .player(player)
+ .slotRange(LayoutCalculator.quad(InventoryType.CHEST_1_ROW.getSize(), InventoryType.CHEST_5_ROW.getSize() - 1))
+ .layout(layout)
+ .values(new ArrayList<>())
+ .build();
+ }
+
+ /**
+ * Rebuilds the route overview after routes or points changed.
+ */
+ private void refreshCreekRoutes() {
+ this.creekRouteSlots.forEach(this.creekRouteInventory::remove);
+ this.creekRouteSlots.clear();
+ for (CreekRoute route : this.gameMapBuilder.getCreekRoutes()) {
+ CreekRouteSlot slot = new CreekRouteSlot(route, route.name().equals(this.activeCreekRoute),
+ this::selectFromOverview, this::deleteFromOverview);
+ this.creekRouteSlots.add(slot);
+ this.creekRouteInventory.add(slot);
+ }
+ if (this.hasCreekRouteMode()) {
+ this.creekRoutePreview.refreshLabels();
+ }
+ }
+
+ private void selectFromOverview(Player player, String name) {
+ if (this.selectCreekRoute(name)) {
+ player.sendMessage(SetupMessages.getCreekRouteSelected(name));
+ player.sendMessage(SetupMessages.CREEK_POINT_HINT);
+ SetupItems.setCreekRouteEditItems(player);
+ }
+ }
+
+ private void deleteFromOverview(Player player, String name) {
+ if (this.deleteCreekRoute(name)) {
+ player.sendMessage(SetupMessages.getCreekRouteDeleted(name));
+ }
}
/**
@@ -130,6 +204,160 @@ public void swapSurvivorMode() {
this.survivorMode = !this.survivorMode;
}
+ /**
+ * Returns whether the creek route mode is on.
+ *
+ * @return {@code true} while routes are being edited
+ */
+ public boolean hasCreekRouteMode() {
+ return this.creekRouteMode;
+ }
+
+ /**
+ * Turns the creek route mode on or off.
+ */
+ public void swapCreekRouteMode() {
+ this.creekRouteMode = !this.creekRouteMode;
+ }
+
+ /**
+ * Creates a creek route and makes it the active one.
+ *
+ * @param name the route's name
+ * @return {@code false} if the name is empty or taken
+ */
+ public boolean createCreekRoute(String name) {
+ String trimmed = name.trim();
+ if (!this.gameMapBuilder.addCreekRoute(trimmed)) return false;
+ this.activeCreekRoute = trimmed;
+ this.refreshCreekRoutes();
+ return true;
+ }
+
+ /**
+ * Makes a creek route the active one.
+ *
+ * @param name the route's name
+ * @return {@code false} if there is no such route
+ */
+ public boolean selectCreekRoute(String name) {
+ if (!this.gameMapBuilder.hasCreekRoute(name)) return false;
+ this.activeCreekRoute = name;
+ this.refreshCreekRoutes();
+ return true;
+ }
+
+ /**
+ * Deletes a creek route.
+ *
+ * @param name the route's name
+ * @return {@code false} if there was no such route
+ */
+ public boolean deleteCreekRoute(String name) {
+ if (!this.gameMapBuilder.removeCreekRoute(name)) return false;
+ if (name.equals(this.activeCreekRoute)) this.activeCreekRoute = null;
+ this.refreshCreekRoutes();
+ return true;
+ }
+
+ /**
+ * Appends a point to the active creek route.
+ *
+ * @param point where the creek's feet stand
+ * @return {@code false} without an active route
+ */
+ public boolean addCreekPoint(Vec point) {
+ String activeRoute = this.activeCreekRoute;
+ if (activeRoute == null || !this.gameMapBuilder.addCreekPoint(activeRoute, point)) return false;
+ this.refreshCreekRoutes();
+ return true;
+ }
+
+ /**
+ * Removes the point of the active creek route that sits at the given position.
+ *
+ * @param point the position of the point to remove
+ * @return the number of the removed point, counting from 1, or {@code -1} without an active
+ * route or without a point there
+ */
+ public int removeCreekPointAt(Vec point) {
+ String activeRoute = this.activeCreekRoute;
+ if (activeRoute == null) return -1;
+ int removed = this.gameMapBuilder.removeCreekPoint(activeRoute, point);
+ if (removed > 0) this.refreshCreekRoutes();
+ return removed;
+ }
+
+ /**
+ * Removes the last point of the active creek route.
+ *
+ * @return {@code false} without an active route or without points
+ */
+ public boolean removeLastCreekPoint() {
+ String activeRoute = this.activeCreekRoute;
+ if (activeRoute == null || !this.gameMapBuilder.removeLastCreekPoint(activeRoute)) return false;
+ this.refreshCreekRoutes();
+ return true;
+ }
+
+ /**
+ * Sets how long the creek waits at the start of the active route.
+ *
+ * @param millis the pause, in milliseconds
+ * @return {@code false} without an active route or without a point
+ */
+ public boolean setCreekStartPause(int millis) {
+ return this.setCreekPause(true, millis);
+ }
+
+ /**
+ * Sets how long the creek waits at the end of the active route.
+ *
+ * @param millis the pause, in milliseconds
+ * @return {@code false} without an active route or with fewer than two points
+ */
+ public boolean setCreekEndPause(int millis) {
+ return this.setCreekPause(false, millis);
+ }
+
+ private boolean setCreekPause(boolean atStart, int millis) {
+ String activeRoute = this.activeCreekRoute;
+ if (activeRoute == null || !this.gameMapBuilder.setCreekEndPause(activeRoute, atStart, millis)) return false;
+ this.refreshCreekRoutes();
+ return true;
+ }
+
+ /**
+ * Stops editing the active creek route. The route itself stays.
+ *
+ * @return {@code false} if no route was being edited
+ */
+ public boolean finishCreekRoute() {
+ if (this.activeCreekRoute == null) return false;
+ this.activeCreekRoute = null;
+ this.refreshCreekRoutes();
+ return true;
+ }
+
+ /**
+ * Returns the name of the creek route being edited.
+ *
+ * @return the name, or {@code null} if none is active
+ */
+ public @Nullable String activeCreekRoute() {
+ return this.activeCreekRoute;
+ }
+
+ /**
+ * Returns how many points the active creek route has.
+ *
+ * @return the number of points, {@code 0} without an active route
+ */
+ public int activeCreekPointCount() {
+ String activeRoute = this.activeCreekRoute;
+ return activeRoute == null ? 0 : this.gameMapBuilder.getCreekRoutePoints(activeRoute).size();
+ }
+
/**
* {@inheritDoc}
*/
@@ -258,6 +486,80 @@ public void handleItemInteraction(Player player, byte tagValue) {
return;
}
+ if (SetupItemId.CREEK_ROUTES == tagValue) {
+ if (!hasCreekRouteMode() && (hasPageMode() || hasSurvivorMode())) {
+ player.sendMessage(SetupMessages.getModeInform(hasPageMode() ? "page" : "survivor"));
+ return;
+ }
+ this.swapCreekRouteMode();
+ if (hasCreekRouteMode()) {
+ player.sendMessage(SetupMessages.CREEK_MODE_ENABLED);
+ player.sendMessage(SetupMessages.getModeInform("creek route"));
+ SetupItems.setCreekRouteOverviewItems(player);
+ this.creekRoutePreview.show();
+ } else {
+ this.finishCreekRoute();
+ this.creekRoutePreview.hide();
+ player.sendMessage(SetupMessages.CREEK_MODE_DISABLED);
+ SetupItems.setGameLayout(player);
+ }
+ return;
+ }
+ if (SetupItemId.CREEK_LEAVE == tagValue) {
+ if (hasCreekRouteMode()) {
+ this.swapCreekRouteMode();
+ }
+ this.finishCreekRoute();
+ this.creekRoutePreview.hide();
+ player.sendMessage(SetupMessages.CREEK_MODE_DISABLED);
+ SetupItems.setGameLayout(player);
+ return;
+ }
+ if (SetupItemId.CREEK_NEW == tagValue) {
+ EventDispatcher.call(new DialogRequestEvent(player, DialogTarget.CREEK_ROUTE_NAME));
+ return;
+ }
+ if (SetupItemId.CREEK_UNDO == tagValue) {
+ String activeRoute = this.activeCreekRoute;
+ if (activeRoute == null) {
+ player.sendMessage(SetupMessages.NO_ACTIVE_CREEK_ROUTE);
+ } else if (!this.removeLastCreekPoint()) {
+ player.sendMessage(SetupMessages.NO_CREEK_POINT_TO_REMOVE);
+ } else {
+ player.sendMessage(SetupMessages.getCreekPointRemoved(activeRoute, this.activeCreekPointCount()));
+ }
+ return;
+ }
+ if (SetupItemId.CREEK_LIST == tagValue) {
+ this.creekRouteInventory.open();
+ return;
+ }
+ if (SetupItemId.CREEK_FINISH == tagValue) {
+ String activeRoute = this.activeCreekRoute;
+ int pointCount = this.activeCreekPointCount();
+ if (activeRoute != null && this.finishCreekRoute()) {
+ player.sendMessage(SetupMessages.getCreekRouteFinished(activeRoute, pointCount));
+ if (pointCount < CreekRoute.MIN_POINTS) {
+ player.sendMessage(SetupMessages.CREEK_ROUTE_NOT_USABLE);
+ }
+ }
+ SetupItems.setCreekRouteOverviewItems(player);
+ return;
+ }
+ if (SetupItemId.CREEK_PAUSE == tagValue) {
+ String activeRoute = this.activeCreekRoute;
+ if (activeRoute == null) {
+ player.sendMessage(SetupMessages.NO_ACTIVE_CREEK_ROUTE);
+ return;
+ }
+ List points = this.gameMapBuilder.getCreekRoutePoints(activeRoute);
+ int startMillis = points.isEmpty() ? 0 : points.getFirst().pauseMillis();
+ int endMillis = points.size() < CreekRoute.MIN_POINTS ? 0 : points.getLast().pauseMillis();
+ EventDispatcher.call(new DialogRequestEvent(player, DialogTarget.CREEK_ROUTE_PAUSE,
+ new DialogContext.CreekPauseContext(activeRoute, startMillis, endMillis)));
+ return;
+ }
+
super.handleItemInteraction(player, tagValue);
}
@@ -290,7 +592,6 @@ public void handleDataContextDelete(MapDataCategory category, Point point) {
this.gameMapBuilder.removeSurvivorSpawn(pos);
this.triggerUpdate(InventoryTarget.SURVIVOR);
} else if (category == MapDataCategory.PAGE) {
- Player player = MinecraftServer.getConnectionManager().getOnlinePlayerByUuid(this.uuid);
PageResource pageResource = null;
if (player instanceof SetupPlayer setupPlayer) {
pageResource = setupPlayer.getPageResource();
@@ -312,6 +613,7 @@ public void save() {
}
GameMap map = this.gameMapBuilder.build();
PageFacesFile.save(mapEntry.getMapFile(), map.getPageFaces());
+ CreekRoutesFile.save(mapEntry.getMapFile(), map.getCreekRoutes());
GsonHelper.FILE_HANDLER.save(mapEntry.getMapFile(), map);
}
@@ -330,9 +632,12 @@ public void teleport(Player player) {
@Override
public void reset() {
super.reset();
+ this.bossBar.removeViewer(player);
this.survivorInventory.unregister();
this.inventory.unregister();
this.pageInventory.unregister();
+ this.creekRouteInventory.unregister();
+ this.creekRoutePreview.hide();
}
/**
@@ -344,6 +649,7 @@ public void loadData() {
this.mapEntry.hasMapFile()
? GsonHelper.FILE_HANDLER.load(mapEntry.getMapFile(), GameMap.class)
.map(map -> PageFacesFile.loadInto(mapEntry.getMapFile(), map))
+ .map(map -> map.withCreekRoutes(CreekRoutesFile.loadUnchecked(mapEntry.getMapFile())))
: Optional.empty();
this.gameMapBuilder = mapData
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java
index 6127b7a5..cfc94269 100644
--- a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java
@@ -34,7 +34,7 @@ public abstract class InstanceSetupData implements SetupData {
protected static final Pos SPAWN_POINT = new Pos(0, 100, 0);
- protected UUID uuid;
+ protected Player player;
protected MapEntry mapEntry;
protected @Nullable InstanceContainer instance;
protected @Nullable FalcoAnvilLoader chunkLoader;
@@ -44,12 +44,12 @@ public abstract class InstanceSetupData implements SetupData {
/**
* Creates a new setup data container.
*
- * @param uuid unique identifier of this setup session
+ * @param player who owns the setup
* @param mapEntry map entry associated with the setup
* @param color boss bar color used for setup feedback
*/
- protected InstanceSetupData(UUID uuid, MapEntry mapEntry, BossBar.Color color) {
- this.uuid = uuid;
+ protected InstanceSetupData(Player player, MapEntry mapEntry, BossBar.Color color) {
+ this.player = player;
this.mapEntry = mapEntry;
this.bossBar = BossBar.bossBar(Component.empty(), 1, color, BossBar.Overlay.PROGRESS);
}
@@ -170,7 +170,7 @@ public MapEntry getMapEntry() {
*/
@Override
public UUID getId() {
- return this.uuid;
+ return this.player.getUuid();
}
/**
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java
index 6bb9e6d0..595036b7 100644
--- a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java
@@ -28,7 +28,7 @@ public final class LobbyData extends InstanceSetupData {
* @param mapEntry that contains the map
*/
public LobbyData(Player player, MapEntry mapEntry) {
- super(player.getUuid(), mapEntry, BossBar.Color.GREEN);
+ super(player, mapEntry, BossBar.Color.GREEN);
this.loadData();
this.viewInventory = new MapDataOverviewInventory(player, this.mapBuilder, InventoryMode.LOBBY);
}
@@ -120,6 +120,7 @@ public void teleport(Player player) {
@Override
public void reset() {
super.reset();
+ this.bossBar.removeViewer(player);
this.viewInventory.unregister();
}
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/AtmosphereDialogs.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/AtmosphereDialogs.java
index f4b6283e..f299b012 100644
--- a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/AtmosphereDialogs.java
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/AtmosphereDialogs.java
@@ -133,7 +133,7 @@ public static void openValueDialog(Player player, MapAtmosphere atmosphere) {
range.end(128f);
range.step(1f);
range.initial(atmosphere.fogStartDistance());
- range.labelFormat("%s: %.0f blocks");
+ range.labelFormat("%s: %s blocks");
});
meta.range(FOG_END_INPUT, range -> {
range.label(Component.text("Fully fogged at"));
@@ -142,7 +142,7 @@ public static void openValueDialog(Player player, MapAtmosphere atmosphere) {
range.end(384f);
range.step(1f);
range.initial(atmosphere.fogEndDistance());
- range.labelFormat("%s: %.0f blocks");
+ range.labelFormat("%s: %s blocks");
});
meta.range(SKY_FOG_END_INPUT, range -> {
range.label(Component.text("Horizon haze at"));
@@ -151,7 +151,7 @@ public static void openValueDialog(Player player, MapAtmosphere atmosphere) {
range.end(256f);
range.step(1f);
range.initial(atmosphere.skyFogEndDistance());
- range.labelFormat("%s: %.0f blocks");
+ range.labelFormat("%s: %s blocks");
});
meta.range(SKY_LIGHT_INPUT, range -> {
range.label(Component.text("Sky light"));
@@ -160,7 +160,7 @@ public static void openValueDialog(Player player, MapAtmosphere atmosphere) {
range.end(100f);
range.step(1f);
range.initial(atmosphere.skyLightFactor() * SKY_LIGHT_SCALE);
- range.labelFormat("%s: %.0f");
+ range.labelFormat("%s: %s");
});
meta.text(FOG_COLOR_INPUT, text -> text.label(Component.text("Fog color"))
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/CreekRouteDialogs.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/CreekRouteDialogs.java
new file mode 100644
index 00000000..d485ffcf
--- /dev/null
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/CreekRouteDialogs.java
@@ -0,0 +1,102 @@
+package net.onelitefeather.cygnus.setup.dialog;
+
+import net.kyori.adventure.key.Key;
+import net.kyori.adventure.text.Component;
+import net.minestom.server.dialog.DialogAction;
+import net.minestom.server.dialog.DialogAfterAction;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.cygnus.setup.event.dialog.DialogContext;
+import net.onelitefeather.cygnus.setup.util.DialogBase;
+import net.onelitefeather.pica.dialog.DialogTemplate;
+import net.onelitefeather.pica.dialog.type.DialogType;
+
+/**
+ * The dialog that asks for the name of a new creek route.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekRouteDialogs extends DialogBase {
+
+ public static final Key ROUTE_NAME_KEY = create("creek_route_name");
+ public static final Key PAUSE_KEY = create("creek_route_pause");
+ public static final String START_PAUSE_INPUT = "start";
+ public static final String END_PAUSE_INPUT = "end";
+
+ /** Long enough for a descriptive name, short enough for the debug line. */
+ private static final int MAX_NAME_LENGTH = 48;
+
+ /** Longest pause the dialog offers, in seconds. */
+ private static final float MAX_PAUSE_SECONDS = 30f;
+
+ /**
+ * Opens the dialog.
+ *
+ * @param player the player
+ */
+ public static void openNameDialog(Player player) {
+ DialogTemplate dialogTemplate = DialogType.confirm(ROUTE_NAME_KEY)
+ .meta(dialogMeta -> {
+ dialogMeta.closeWithEscape(false);
+ dialogMeta.pause(false);
+ dialogMeta.afterAction(DialogAfterAction.CLOSE);
+ dialogMeta.title(Component.text("Creek route"));
+ dialogMeta.emptyMessage();
+ dialogMeta.messageBody(template ->
+ template.contents(Component.text("Enter the name of the new route")));
+ dialogMeta.text("name", textInputTemplate ->
+ textInputTemplate.maxLength(MAX_NAME_LENGTH).initial(""));
+ })
+ .yesButton(button -> button.width(101).label(Component.text("Create"))
+ .action(new DialogAction.DynamicCustom(ROUTE_NAME_KEY, getEmptyPayload())))
+ .noButton(button -> button.width(101).label(NO_COMPONENT))
+ .build();
+ dialogTemplate.open(player);
+ }
+
+ /**
+ * Opens the dialog for the pauses at the start and the end of a route.
+ *
+ * @param player the player
+ * @param context the route and its current pauses
+ */
+ public static void openPauseDialog(Player player, DialogContext.CreekPauseContext context) {
+ DialogTemplate dialogTemplate = DialogType.confirm(PAUSE_KEY)
+ .meta(dialogMeta -> {
+ dialogMeta.closeWithEscape(true);
+ dialogMeta.pause(false);
+ dialogMeta.afterAction(DialogAfterAction.CLOSE);
+ dialogMeta.title(Component.text("Creek route"));
+ dialogMeta.emptyMessage();
+ dialogMeta.messageBody(template ->
+ template.contents(Component.text("Pauses of " + context.route())));
+ dialogMeta.range(START_PAUSE_INPUT, range -> {
+ range.label(Component.text("Pause at the start"));
+ range.width(202);
+ range.start(0f);
+ range.end(MAX_PAUSE_SECONDS);
+ range.step(0.5f);
+ range.initial(context.startMillis() / 1000f);
+ range.labelFormat("%s: %s s");
+ });
+ dialogMeta.range(END_PAUSE_INPUT, range -> {
+ range.label(Component.text("Pause at the end"));
+ range.width(202);
+ range.start(0f);
+ range.end(MAX_PAUSE_SECONDS);
+ range.step(0.5f);
+ range.initial(context.endMillis() / 1000f);
+ range.labelFormat("%s: %s s");
+ });
+ })
+ .yesButton(button -> button.width(101).label(Component.text("Save"))
+ .action(new DialogAction.DynamicCustom(PAUSE_KEY, getEmptyPayload())))
+ .noButton(button -> button.width(101).label(NO_COMPONENT))
+ .build();
+ dialogTemplate.open(player);
+ }
+
+ private CreekRouteDialogs() {
+ }
+}
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRouteNameHandler.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRouteNameHandler.java
new file mode 100644
index 00000000..060634f5
--- /dev/null
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRouteNameHandler.java
@@ -0,0 +1,47 @@
+package net.onelitefeather.cygnus.setup.dialog.handler;
+
+import net.kyori.adventure.nbt.CompoundBinaryTag;
+import net.kyori.adventure.nbt.StringBinaryTag;
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.player.PlayerCustomClickEvent;
+import net.onelitefeather.cygnus.setup.data.GameData;
+import net.onelitefeather.cygnus.setup.item.SetupItems;
+import net.onelitefeather.cygnus.setup.util.SetupMessages;
+import net.onelitefeather.guira.SetupDataService;
+
+/**
+ * Creates a creek route from the name entered in the dialog.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekRouteNameHandler implements DialogHandler {
+
+ private final SetupDataService dataService;
+
+ public CreekRouteNameHandler(SetupDataService dataService) {
+ this.dataService = dataService;
+ }
+
+ @Override
+ public void handle(PlayerCustomClickEvent event, CompoundBinaryTag payload) {
+ if (!(payload.get("name") instanceof StringBinaryTag nameTag)) return;
+ Player player = event.getPlayer();
+ String name = nameTag.value().trim();
+ this.dataService.get(player.getUuid()).ifPresent(data -> {
+ if (!(data instanceof GameData gameData)) return;
+ if (name.isEmpty()) {
+ player.sendMessage(SetupMessages.EMPTY_NAME);
+ return;
+ }
+ if (!gameData.createCreekRoute(name)) {
+ player.sendMessage(SetupMessages.getDuplicateCreekRoute(name));
+ return;
+ }
+ player.sendMessage(SetupMessages.getCreekRouteCreated(name));
+ player.sendMessage(SetupMessages.CREEK_POINT_HINT);
+ SetupItems.setCreekRouteEditItems(player);
+ });
+ }
+}
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRoutePauseHandler.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRoutePauseHandler.java
new file mode 100644
index 00000000..d119cf2c
--- /dev/null
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRoutePauseHandler.java
@@ -0,0 +1,70 @@
+package net.onelitefeather.cygnus.setup.dialog.handler;
+
+import net.kyori.adventure.nbt.BinaryTag;
+import net.kyori.adventure.nbt.CompoundBinaryTag;
+import net.kyori.adventure.nbt.DoubleBinaryTag;
+import net.kyori.adventure.nbt.FloatBinaryTag;
+import net.kyori.adventure.nbt.IntBinaryTag;
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.player.PlayerCustomClickEvent;
+import net.onelitefeather.cygnus.setup.data.GameData;
+import net.onelitefeather.cygnus.setup.dialog.CreekRouteDialogs;
+import net.onelitefeather.cygnus.setup.util.SetupMessages;
+import net.onelitefeather.guira.SetupDataService;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Applies the pauses from the dialog to the active creek route.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekRoutePauseHandler implements DialogHandler {
+
+ private final SetupDataService dataService;
+
+ public CreekRoutePauseHandler(SetupDataService dataService) {
+ this.dataService = dataService;
+ }
+
+ @Override
+ public void handle(PlayerCustomClickEvent event, CompoundBinaryTag payload) {
+ int startMillis = millis(payload.get(CreekRouteDialogs.START_PAUSE_INPUT));
+ int endMillis = millis(payload.get(CreekRouteDialogs.END_PAUSE_INPUT));
+ if (startMillis < 0 || endMillis < 0) return;
+ Player player = event.getPlayer();
+ this.dataService.get(player.getUuid()).ifPresent(data -> {
+ if (!(data instanceof GameData gameData)) return;
+ String activeRoute = gameData.activeCreekRoute();
+ if (activeRoute == null) {
+ player.sendMessage(SetupMessages.NO_ACTIVE_CREEK_ROUTE);
+ return;
+ }
+ if (!gameData.setCreekStartPause(startMillis)) {
+ player.sendMessage(SetupMessages.CREEK_PAUSE_NEEDS_POINT);
+ return;
+ }
+ if (!gameData.setCreekEndPause(endMillis)) {
+ player.sendMessage(SetupMessages.getCreekStartPauseSet(activeRoute, startMillis));
+ player.sendMessage(SetupMessages.CREEK_END_PAUSE_NEEDS_TWO_POINTS);
+ return;
+ }
+ player.sendMessage(SetupMessages.getCreekPausesSet(activeRoute, startMillis, endMillis));
+ });
+ }
+
+ /**
+ * Turns a slider value in seconds into milliseconds.
+ *
+ * @return the milliseconds, or {@code -1} if the client sent nothing usable
+ */
+ private static int millis(@Nullable BinaryTag tag) {
+ return switch (tag) {
+ case FloatBinaryTag value -> Math.round(value.value() * 1000f);
+ case DoubleBinaryTag value -> (int) Math.round(value.value() * 1000D);
+ case IntBinaryTag value -> value.value() * 1000;
+ case null, default -> -1;
+ };
+ }
+}
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogContext.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogContext.java
index bcd9768c..8935d17a 100644
--- a/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogContext.java
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogContext.java
@@ -18,7 +18,8 @@ public sealed interface DialogContext permits
DialogContext.PositionContent,
DialogContext.AuthorAmount,
DialogContext.PageContent,
- DialogContext.AtmosphereContext {
+ DialogContext.AtmosphereContext,
+ DialogContext.CreekPauseContext {
/**
* Specific context for the update or deletion of a name.
@@ -65,4 +66,16 @@ record PageContent(PageResource resource) implements DialogContext {
record AtmosphereContext(MapAtmosphere atmosphere) implements DialogContext {
}
+
+
+ /**
+ * Carries the creek route whose pauses are edited, so the dialog can pre-fill its sliders.
+ *
+ * @param route the route's name
+ * @param startMillis the current pause at the start, in milliseconds
+ * @param endMillis the current pause at the end, in milliseconds
+ */
+ record CreekPauseContext(String route, int startMillis, int endMillis) implements DialogContext {
+
+ }
}
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogTarget.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogTarget.java
index 3006252e..dcb74bf1 100644
--- a/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogTarget.java
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogTarget.java
@@ -15,4 +15,6 @@ public enum DialogTarget {
UPDATE_NAME,
ATMOSPHERE_PRESET,
ATMOSPHERE_VALUES,
+ CREEK_ROUTE_NAME,
+ CREEK_ROUTE_PAUSE,
}
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlot.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlot.java
new file mode 100644
index 00000000..8a8db4c8
--- /dev/null
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlot.java
@@ -0,0 +1,86 @@
+package net.onelitefeather.cygnus.setup.inventory.slot;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.minestom.server.entity.Player;
+import net.minestom.server.inventory.click.Click;
+import net.minestom.server.item.ItemStack;
+import net.minestom.server.item.Material;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import net.onelitefeather.cygnus.common.creek.CreekWaypoint;
+import net.theevilreaper.aves.inventory.click.ClickHolder;
+import net.theevilreaper.aves.inventory.slot.Slot;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.function.BiConsumer;
+
+import static net.onelitefeather.cygnus.setup.util.SetupMessages.DELETE_CLICK;
+import static net.onelitefeather.cygnus.setup.util.SetupMessages.SELECT_CLICK;
+
+/**
+ * One creek route in the route overview. Left click selects it, right click deletes it.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekRouteSlot extends Slot {
+
+ private final CreekRoute route;
+ private final boolean active;
+
+ /**
+ * Creates the slot.
+ *
+ * @param route the route to show
+ * @param active whether the route is being edited
+ * @param select called with the clicking player and the name on a left click
+ * @param delete called with the clicking player and the name on a right click
+ */
+ public CreekRouteSlot(CreekRoute route, boolean active, BiConsumer select,
+ BiConsumer delete) {
+ this.route = route;
+ this.active = active;
+ String name = route.name();
+ this.setClick((player, _, click, _, result) -> {
+ result.accept(ClickHolder.cancelClick());
+ switch (click) {
+ case Click.Left _ -> {
+ player.closeInventory();
+ select.accept(player, name);
+ }
+ case Click.Right _ -> delete.accept(player, name);
+ default -> {
+ // Nothing to do here
+ }
+ }
+ });
+ }
+
+ @Override
+ public ItemStack getItem() {
+ List points = this.route.points();
+ int startPause = points.isEmpty() ? 0 : points.getFirst().pauseMillis();
+ int endPause = points.size() < CreekRoute.MIN_POINTS ? 0 : points.getLast().pauseMillis();
+ return ItemStack.builder(Material.LEAD)
+ .customName(Component.text(this.route.name(), this.active ? NamedTextColor.GREEN : NamedTextColor.YELLOW))
+ .lore(List.of(
+ Component.empty(),
+ Component.text("Points: ", NamedTextColor.GRAY)
+ .append(Component.text(points.size(), NamedTextColor.GOLD)),
+ Component.text("Pause: start ", NamedTextColor.GRAY)
+ .append(Component.text(seconds(startPause), NamedTextColor.GOLD))
+ .append(Component.text(" · end ", NamedTextColor.GRAY))
+ .append(Component.text(seconds(endPause), NamedTextColor.GOLD)),
+ Component.empty(),
+ SELECT_CLICK,
+ DELETE_CLICK,
+ Component.empty()))
+ .build();
+ }
+
+ private static String seconds(int millis) {
+ return String.format(Locale.ROOT, "%.1f s", millis / 1000.0D);
+ }
+}
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItemId.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItemId.java
index 16737e4b..2e199caf 100644
--- a/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItemId.java
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItemId.java
@@ -18,6 +18,13 @@ public final class SetupItemId {
public static final byte SURVIVOR = (byte) 0x06;
public static final byte SPAWNS = (byte) 0x07;
public static final byte LEAVE_MODE = (byte) 0x08;
+ public static final byte CREEK_ROUTES = (byte) 0x09;
+ public static final byte CREEK_NEW = (byte) 0x0A;
+ public static final byte CREEK_UNDO = (byte) 0x0B;
+ public static final byte CREEK_LIST = (byte) 0x0C;
+ public static final byte CREEK_LEAVE = (byte) 0x0D;
+ public static final byte CREEK_PAUSE = (byte) 0x0E;
+ public static final byte CREEK_FINISH = (byte) 0x0F;
private SetupItemId() {
// Nothing to do here
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItems.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItems.java
index 39358213..6c99e3e0 100644
--- a/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItems.java
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItems.java
@@ -29,6 +29,8 @@ public final class SetupItems {
private static final HotBarLayout gameSetupLayout;
private static final HotBarLayout pageLayout;
private static final HotBarLayout survivorSpawnLayout;
+ private static final HotBarLayout creekRouteOverviewLayout;
+ private static final HotBarLayout creekRouteEditLayout;
static {
DECORATION_PANE = ItemStack.builder(Material.BLACK_STAINED_GLASS_PANE)
@@ -59,13 +61,13 @@ public final class SetupItems {
lobbySetupLayout.set(6, saveItem);
gameSetupLayout = new HotBarLayout();
- gameSetupLayout.set(1, ItemStack.builder(Material.COMPASS)
+ gameSetupLayout.set(0, ItemStack.builder(Material.COMPASS)
.customName(Component.text("Data", NamedTextColor.AQUA))
.lore(getLore(SPACE_SEPARATOR.append(Component.text("View basic map data", NamedTextColor.WHITE))))
.set(Tags.ITEM_TAG, SetupItemId.DATA)
.build()
);
- gameSetupLayout.set(3, ItemStack.builder(Material.PAPER)
+ gameSetupLayout.set(1, ItemStack.builder(Material.PAPER)
.customName(Component.text("Page", NamedTextColor.AQUA))
.lore(
getLore(
@@ -80,7 +82,7 @@ public final class SetupItems {
.build()
);
- gameSetupLayout.set(5, ItemStack.builder(Material.MINECART)
+ gameSetupLayout.set(4, ItemStack.builder(Material.MINECART)
.customName(Component.text("Survivor", NamedTextColor.GREEN))
.lore(
getLore(
@@ -93,7 +95,60 @@ public final class SetupItems {
.set(Tags.ITEM_TAG, SetupItemId.SURVIVOR)
.build()
);
- gameSetupLayout.set(7, saveItem);
+ gameSetupLayout.set(7, ItemStack.builder(Material.LEAD)
+ .customName(Component.text("Creek routes", NamedTextColor.DARK_GREEN))
+ .lore(getLore(SPACE_SEPARATOR
+ .append(Component.text("Switch to the ", NamedTextColor.WHITE))
+ .append(Component.text("Creek route ", NamedTextColor.DARK_GREEN))
+ .append(Component.text("setup mode", NamedTextColor.WHITE))))
+ .set(Tags.ITEM_TAG, SetupItemId.CREEK_ROUTES)
+ .build()
+ );
+ gameSetupLayout.set(8, saveItem);
+
+ ItemStack leaveCreekMode = ItemStack.builder(Material.BARRIER)
+ .customName(Component.text("Leave mode", NamedTextColor.RED))
+ .lore(getLore(SPACE_SEPARATOR.append(Component.text("Exit the current mode", NamedTextColor.WHITE))))
+ .set(Tags.ITEM_TAG, SetupItemId.CREEK_LEAVE)
+ .build();
+
+ creekRouteOverviewLayout = new HotBarLayout();
+ creekRouteOverviewLayout.set(0, ItemStack.builder(Material.NAME_TAG)
+ .customName(Component.text("New route", NamedTextColor.GREEN))
+ .lore(getLore(SPACE_SEPARATOR.append(Component.text("Create a route and name it", NamedTextColor.WHITE))))
+ .set(Tags.ITEM_TAG, SetupItemId.CREEK_NEW)
+ .build()
+ );
+ creekRouteOverviewLayout.set(4, ItemStack.builder(Material.LEAD)
+ .customName(Component.text("Routes", NamedTextColor.AQUA))
+ .lore(getLore(SPACE_SEPARATOR.append(Component.text("Edit or delete a route", NamedTextColor.WHITE))))
+ .set(Tags.ITEM_TAG, SetupItemId.CREEK_LIST)
+ .build()
+ );
+ creekRouteOverviewLayout.set(8, leaveCreekMode);
+
+ creekRouteEditLayout = new HotBarLayout();
+ creekRouteEditLayout.set(0, ItemStack.builder(Material.FEATHER)
+ .customName(Component.text("Remove last point", NamedTextColor.YELLOW))
+ .lore(getLore(
+ SPACE_SEPARATOR.append(Component.text("Undo the last point of the route", NamedTextColor.WHITE)),
+ SPACE_SEPARATOR.append(Component.text("Break a block to add or remove a point", NamedTextColor.WHITE))))
+ .set(Tags.ITEM_TAG, SetupItemId.CREEK_UNDO)
+ .build()
+ );
+ creekRouteEditLayout.set(2, ItemStack.builder(Material.CLOCK)
+ .customName(Component.text("Pauses", NamedTextColor.GOLD))
+ .lore(getLore(SPACE_SEPARATOR.append(Component.text("Set how long the creek waits at the ends", NamedTextColor.WHITE))))
+ .set(Tags.ITEM_TAG, SetupItemId.CREEK_PAUSE)
+ .build()
+ );
+ creekRouteEditLayout.set(6, ItemStack.builder(Material.EMERALD)
+ .customName(Component.text("Finish route", NamedTextColor.GREEN))
+ .lore(getLore(SPACE_SEPARATOR.append(Component.text("Stop editing and go back to the routes", NamedTextColor.WHITE))))
+ .set(Tags.ITEM_TAG, SetupItemId.CREEK_FINISH)
+ .build()
+ );
+ creekRouteEditLayout.set(8, leaveCreekMode);
pageLayout = new HotBarLayout();
pageLayout.set(2, ItemStack.builder(Material.COMPASS)
@@ -198,4 +253,24 @@ public static void setPageItems(Player player) {
private SetupItems() {
}
-}
\ No newline at end of file
+
+ /**
+ * Sets the items for the creek route setup while no route is being edited.
+ *
+ * @param player the player
+ */
+ public static void setCreekRouteOverviewItems(Player player) {
+ creekRouteOverviewLayout.apply(player);
+ player.setHeldItemSlot(DEFAULT);
+ }
+
+ /**
+ * Sets the items for editing a creek route.
+ *
+ * @param player the player
+ */
+ public static void setCreekRouteEditItems(Player player) {
+ creekRouteEditLayout.apply(player);
+ player.setHeldItemSlot(DEFAULT);
+ }
+}
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java
new file mode 100644
index 00000000..e581cde1
--- /dev/null
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java
@@ -0,0 +1,75 @@
+package net.onelitefeather.cygnus.setup.listener;
+
+import net.minestom.server.coordinate.Point;
+import net.minestom.server.coordinate.Vec;
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.player.PlayerBlockBreakEvent;
+import net.minestom.server.instance.block.Block;
+import net.onelitefeather.cygnus.setup.data.GameData;
+import net.onelitefeather.cygnus.setup.util.SetupMessages;
+import net.onelitefeather.cygnus.setup.util.SetupTags;
+import net.onelitefeather.guira.SetupDataService;
+import net.onelitefeather.guira.data.SetupData;
+
+import java.util.function.Consumer;
+
+/**
+ * Appends a point to the active creek route when a block is broken in the creek route mode, or
+ * removes the point that already sits on that block.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekRouteListener implements Consumer {
+
+ private final SetupDataService setupService;
+
+ public CreekRouteListener(SetupDataService setupService) {
+ this.setupService = setupService;
+ }
+
+ @Override
+ public void accept(PlayerBlockBreakEvent event) {
+ event.setCancelled(true);
+
+ Player player = event.getPlayer();
+
+ if (!player.hasTag(SetupTags.SETUP_ID_TAG)) return;
+
+ SetupData setupData = this.setupService.get(player.getUuid()).orElse(null);
+
+ if (setupData == null) return;
+
+ if (!(setupData instanceof GameData gameData) || !gameData.hasCreekRouteMode()) return;
+
+ String activeRoute = gameData.activeCreekRoute();
+ if (activeRoute == null) {
+ player.sendMessage(SetupMessages.NO_ACTIVE_CREEK_ROUTE);
+ return;
+ }
+
+ Vec point = pointOnTop(event.getBlockPosition(), event.getBlock());
+ int removed = gameData.removeCreekPointAt(point);
+ if (removed > 0) {
+ player.sendMessage(SetupMessages.getCreekPointRemovedAt(activeRoute, removed, gameData.activeCreekPointCount()));
+ return;
+ }
+
+ gameData.addCreekPoint(point);
+ player.sendMessage(SetupMessages.getCreekPointAdded(activeRoute, gameData.activeCreekPointCount()));
+ }
+
+ /**
+ * Returns where the creek's feet stand on a block: in the middle, on top of its collision
+ * shape. A slab puts the feet half a block up, a plant without collision at its own bottom.
+ *
+ * @param position the position of the clicked block
+ * @param block the clicked block
+ * @return the point
+ */
+ static Vec pointOnTop(Point position, Block block) {
+ double top = block.registry().collisionShape().relativeEnd().y();
+ return new Vec(position.blockX() + 0.5D, position.blockY() + top, position.blockZ() + 0.5D);
+ }
+}
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogPayloadListener.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogPayloadListener.java
index 7a5b92c0..13a2b308 100644
--- a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogPayloadListener.java
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogPayloadListener.java
@@ -6,7 +6,10 @@
import net.minestom.server.event.player.PlayerCustomClickEvent;
import net.onelitefeather.cygnus.setup.atmosphere.AtmospherePreviewService;
import net.onelitefeather.cygnus.setup.dialog.AtmosphereDialogs;
+import net.onelitefeather.cygnus.setup.dialog.CreekRouteDialogs;
import net.onelitefeather.cygnus.setup.dialog.MapDialogs;
+import net.onelitefeather.cygnus.setup.dialog.handler.CreekRouteNameHandler;
+import net.onelitefeather.cygnus.setup.dialog.handler.CreekRoutePauseHandler;
import net.onelitefeather.cygnus.setup.dialog.handler.AtmosphereAdjustHandler;
import net.onelitefeather.cygnus.setup.dialog.handler.AtmosphereConfirmHandler;
import net.onelitefeather.cygnus.setup.dialog.handler.AtmospherePresetHandler;
@@ -29,6 +32,8 @@ public class DialogPayloadListener implements Consumer {
public DialogPayloadListener(SetupDataService dataService, AtmospherePreviewService previewService) {
this.handlers = Map.ofEntries(
Map.entry(MapDialogs.MAP_KEY, new MapNameHandler(dataService)),
+ Map.entry(CreekRouteDialogs.ROUTE_NAME_KEY, new CreekRouteNameHandler(dataService)),
+ Map.entry(CreekRouteDialogs.PAUSE_KEY, new CreekRoutePauseHandler(dataService)),
Map.entry(MapDialogs.AUTHOR_AMOUNT_KEY, new AuthorAmountHandler()),
Map.entry(MapDialogs.AUTHOR_INPUT_ENTRY_KEY, new AuthorInputHandler(dataService)),
Map.entry(MapDialogs.NON_DYNAMIC_DELETE_KEY, new NonDynamicDataHandler(dataService)),
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogRequestListener.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogRequestListener.java
index 2cc690fa..82c27013 100644
--- a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogRequestListener.java
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogRequestListener.java
@@ -3,6 +3,7 @@
import net.minestom.server.entity.Player;
import net.onelitefeather.cygnus.setup.dialog.AtmosphereDialogs;
import net.onelitefeather.cygnus.setup.dialog.AuthorDialogs;
+import net.onelitefeather.cygnus.setup.dialog.CreekRouteDialogs;
import net.onelitefeather.cygnus.setup.dialog.MapDialogs;
import net.onelitefeather.cygnus.setup.event.dialog.DialogContext;
import net.onelitefeather.cygnus.setup.event.dialog.DialogRequestEvent;
@@ -20,6 +21,12 @@ public void accept(DialogRequestEvent event) {
switch (target) {
case CREATE_NAME -> MapDialogs.openNameCreateDialog(player);
+ case CREEK_ROUTE_NAME -> CreekRouteDialogs.openNameDialog(player);
+ case CREEK_ROUTE_PAUSE -> {
+ if (context instanceof DialogContext.CreekPauseContext pauseContext) {
+ CreekRouteDialogs.openPauseDialog(player, pauseContext);
+ }
+ }
case UPDATE_NAME -> {
if (context instanceof DialogContext.NameContext(String name)) {
MapDialogs.openNameUpdateDialog(player, name);
diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/util/SetupMessages.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/util/SetupMessages.java
index 2ee31d36..ef87975f 100644
--- a/setup/src/main/java/net/onelitefeather/cygnus/setup/util/SetupMessages.java
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/util/SetupMessages.java
@@ -12,6 +12,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.Locale;
/**
* The class contains method and static constant values to handle specific messages during the setup
@@ -35,6 +36,15 @@ public final class SetupMessages {
public static final Component SURVIVOR_MODE_ENABLED;
public static final Component SURVIVOR_MODE_DISABLED;
public static final Component DUPLICATE_PAGE;
+ public static final Component CREEK_MODE_ENABLED;
+ public static final Component CREEK_MODE_DISABLED;
+ public static final Component NO_ACTIVE_CREEK_ROUTE;
+ public static final Component NO_CREEK_POINT_TO_REMOVE;
+ public static final Component SELECT_CLICK;
+ public static final Component CREEK_PAUSE_NEEDS_POINT;
+ public static final Component CREEK_END_PAUSE_NEEDS_TWO_POINTS;
+ public static final Component CREEK_ROUTE_NOT_USABLE;
+ public static final Component CREEK_POINT_HINT;
static {
SPACE_SEPARATOR = Component.text("» ", NamedTextColor.GRAY);
@@ -50,6 +60,16 @@ public final class SetupMessages {
.append(Component.space())
.append(Component.text("teleport", NamedTextColor.GREEN));
+ SELECT_CLICK = NO_SPACE_SEPARATOR
+ .append(Component.space())
+ .append(Component.text("Left", NamedTextColor.GREEN))
+ .append(Component.space())
+ .append(Component.text("click", NamedTextColor.GRAY))
+ .append(Component.space())
+ .append(Component.text("->", NamedTextColor.GRAY))
+ .append(Component.space())
+ .append(Component.text("edit", NamedTextColor.GREEN));
+
DELETE_CLICK = NO_SPACE_SEPARATOR
.append(Component.space())
.append(Component.text("Right", NamedTextColor.RED))
@@ -89,6 +109,34 @@ public final class SetupMessages {
DUPLICATE_PAGE = Messages.withPrefix(
Component.text("A page with this direction already exists at this position", NamedTextColor.RED)
);
+ CREEK_MODE_ENABLED = Messages.withPrefix(
+ Component.text("The creek route mode has been", NamedTextColor.GRAY)
+ .append(Component.space())
+ .append(Component.text("enabled", NamedTextColor.GREEN))
+ );
+ CREEK_MODE_DISABLED = Messages.withPrefix(
+ Component.text("The creek route mode has been", NamedTextColor.GRAY)
+ .append(Component.space())
+ .append(Component.text("disabled", NamedTextColor.RED))
+ );
+ NO_ACTIVE_CREEK_ROUTE = Messages.withPrefix(
+ Component.text("Create or select a creek route first", NamedTextColor.RED)
+ );
+ NO_CREEK_POINT_TO_REMOVE = Messages.withPrefix(
+ Component.text("The active creek route has no point to remove", NamedTextColor.RED)
+ );
+ CREEK_PAUSE_NEEDS_POINT = Messages.withPrefix(
+ Component.text("Add a point to the creek route before setting its pauses", NamedTextColor.RED)
+ );
+ CREEK_END_PAUSE_NEEDS_TWO_POINTS = Messages.withPrefix(
+ Component.text("The end pause needs a creek route with at least two points", NamedTextColor.RED)
+ );
+ CREEK_ROUTE_NOT_USABLE = Messages.withPrefix(
+ Component.text("The creek route needs at least two points before the game uses it", NamedTextColor.RED)
+ );
+ CREEK_POINT_HINT = Messages.withPrefix(
+ Component.text("Break a block to add a point on top of it, break it again to remove the point", NamedTextColor.GRAY)
+ );
}
private SetupMessages() {
@@ -121,6 +169,148 @@ private SetupMessages() {
return Messages.withMiniPrefix("Don't forget to disable mode", modeTag);
}
+ /**
+ * Creates a new {@link Component} instance which confirms that a creek route was created.
+ *
+ * @param name of the created route
+ * @return the created component
+ */
+ @Contract(value = "_ -> new", pure = true)
+ public static @NotNull Component getCreekRouteCreated(@NotNull String name) {
+ return Messages.withMiniPrefix("Created creek route ", nameTag(name));
+ }
+
+ /**
+ * Creates a new {@link Component} instance which informs the player that the route name is taken.
+ *
+ * @param name which is already taken
+ * @return the created component
+ */
+ @Contract(value = "_ -> new", pure = true)
+ public static @NotNull Component getDuplicateCreekRoute(@NotNull String name) {
+ return Messages.withMiniPrefix("A creek route with the name already exists", nameTag(name));
+ }
+
+ /**
+ * Creates a new {@link Component} instance which confirms that a point was added to a creek route.
+ *
+ * @param name of the route
+ * @param count of points the route has now
+ * @return the created component
+ */
+ @Contract(value = "_, _ -> new", pure = true)
+ public static @NotNull Component getCreekPointAdded(@NotNull String name, int count) {
+ return Messages.withMiniPrefix("Added point to ",
+ nameTag(name), countTag(count));
+ }
+
+ /**
+ * Creates a new {@link Component} instance which confirms that the last point of a creek route was removed.
+ *
+ * @param name of the route
+ * @param count of points the route has left
+ * @return the created component
+ */
+ @Contract(value = "_, _ -> new", pure = true)
+ public static @NotNull Component getCreekPointRemoved(@NotNull String name, int count) {
+ return Messages.withMiniPrefix("Removed the last point of ( left)",
+ nameTag(name), countTag(count));
+ }
+
+ /**
+ * Creates a new {@link Component} instance which confirms that a certain point of a creek route was removed.
+ *
+ * @param name of the route
+ * @param number of the removed point, counting from 1
+ * @param count of points the route has left
+ * @return the created component
+ */
+ @Contract(value = "_, _, _ -> new", pure = true)
+ public static @NotNull Component getCreekPointRemovedAt(@NotNull String name, int number, int count) {
+ return Messages.withMiniPrefix("Removed point of ( left)",
+ nameTag(name), countTag(count),
+ TagResolver.builder().tag("number", Tag.inserting(Component.text(number))).build());
+ }
+
+ /**
+ * Creates a new {@link Component} instance which confirms that a creek route is now being edited.
+ *
+ * @param name of the selected route
+ * @return the created component
+ */
+ @Contract(value = "_ -> new", pure = true)
+ public static @NotNull Component getCreekRouteSelected(@NotNull String name) {
+ return Messages.withMiniPrefix("Now editing creek route ", nameTag(name));
+ }
+
+ /**
+ * Creates a new {@link Component} instance which confirms that a creek route was deleted.
+ *
+ * @param name of the deleted route
+ * @return the created component
+ */
+ @Contract(value = "_ -> new", pure = true)
+ public static @NotNull Component getCreekRouteDeleted(@NotNull String name) {
+ return Messages.withMiniPrefix("Deleted creek route ", nameTag(name));
+ }
+
+ /**
+ * Creates a new {@link Component} instance which confirms that a creek route is no longer edited.
+ *
+ * @param name of the route
+ * @param count of points the route has
+ * @return the created component
+ */
+ @Contract(value = "_, _ -> new", pure = true)
+ public static @NotNull Component getCreekRouteFinished(@NotNull String name, int count) {
+ return Messages.withMiniPrefix("Finished creek route ( points)",
+ nameTag(name), countTag(count));
+ }
+
+ /**
+ * Creates a new {@link Component} instance which confirms the pauses of a creek route.
+ *
+ * @param name of the route
+ * @param startMillis pause at the start, in milliseconds
+ * @param endMillis pause at the end, in milliseconds
+ * @return the created component
+ */
+ @Contract(value = "_, _, _ -> new", pure = true)
+ public static @NotNull Component getCreekPausesSet(@NotNull String name, int startMillis, int endMillis) {
+ return Messages.withMiniPrefix(
+ "Set the pauses of : start s, end s",
+ nameTag(name), secondsTag("start", startMillis), secondsTag("end", endMillis));
+ }
+
+ /**
+ * Creates a new {@link Component} instance which confirms the start pause of a creek route.
+ *
+ * @param name of the route
+ * @param startMillis pause at the start, in milliseconds
+ * @return the created component
+ */
+ @Contract(value = "_, _ -> new", pure = true)
+ public static @NotNull Component getCreekStartPauseSet(@NotNull String name, int startMillis) {
+ return Messages.withMiniPrefix("Set the start pause of to s",
+ nameTag(name), secondsTag("start", startMillis));
+ }
+
+ /**
+ * Route names come from player input, so they are inserted as plain text instead of being parsed.
+ */
+ private static TagResolver nameTag(String name) {
+ return TagResolver.builder().tag("name", Tag.inserting(Component.text(name))).build();
+ }
+
+ private static TagResolver countTag(int count) {
+ return TagResolver.builder().tag("count", Tag.inserting(Component.text(count))).build();
+ }
+
+ private static TagResolver secondsTag(String key, int millis) {
+ String seconds = String.format(Locale.ROOT, "%.1f", millis / 1000.0D);
+ return TagResolver.builder().tag(key, Tag.inserting(Component.text(seconds))).build();
+ }
+
/**
* Returns a list that contains the given components which some additional empty components
*
diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java
new file mode 100644
index 00000000..31ac3724
--- /dev/null
+++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java
@@ -0,0 +1,46 @@
+package net.onelitefeather.cygnus.setup.creek;
+
+import net.minestom.server.coordinate.Vec;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class CreekRoutePreviewTest {
+
+ @Test
+ @DisplayName("A line has a particle every step, ends excluded")
+ void lineSteps() {
+ List line = CreekRoutePreview.line(new Vec(0, 80, 0), new Vec(2, 80, 0), 0.5D);
+
+ assertEquals(List.of(new Vec(0.5, 80, 0), new Vec(1, 80, 0), new Vec(1.5, 80, 0)), line);
+ }
+
+ @Test
+ @DisplayName("Ends of the edited route are marked if another route's end is close")
+ void linkedEndsOfTheEditedRoute() {
+ CreekRoute a = CreekRoute.ofPositions("A", List.of(new Vec(0, 80, 0), new Vec(10, 80, 0)));
+ CreekRoute b = CreekRoute.ofPositions("B", List.of(new Vec(12, 80, 0), new Vec(20, 80, 0)));
+ CreekRoute c = CreekRoute.ofPositions("C", List.of(new Vec(50, 80, 0), new Vec(60, 80, 0)));
+ List routes = List.of(a, b, c);
+
+ assertEquals(Set.of(new Vec(10, 80, 0)), CreekRoutePreview.linkedPoints(a, routes, 3.0D));
+ assertEquals(Set.of(new Vec(12, 80, 0)), CreekRoutePreview.linkedPoints(b, routes, 3.0D));
+ assertEquals(Set.of(), CreekRoutePreview.linkedPoints(c, routes, 3.0D));
+ }
+
+ @Test
+ @DisplayName("Where another route's end meets the middle, both points are marked")
+ void crossingsInTheMiddle() {
+ CreekRoute middle = CreekRoute.ofPositions("M", List.of(new Vec(0, 80, 0), new Vec(10, 80, 0), new Vec(20, 80, 0)));
+ CreekRoute branch = CreekRoute.ofPositions("N", List.of(new Vec(10, 80, 2), new Vec(10, 80, 20)));
+ List routes = List.of(middle, branch);
+
+ assertEquals(Set.of(new Vec(10, 80, 0)), CreekRoutePreview.linkedPoints(middle, routes, 3.0D));
+ assertEquals(Set.of(new Vec(10, 80, 2)), CreekRoutePreview.linkedPoints(branch, routes, 3.0D));
+ }
+}
diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/data/GameDataTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/GameDataTest.java
index 9ffe1307..46c152a9 100644
--- a/setup/src/test/java/net/onelitefeather/cygnus/setup/data/GameDataTest.java
+++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/GameDataTest.java
@@ -7,8 +7,13 @@
import net.minestom.server.utils.Direction;
import net.minestom.testing.Env;
import net.minestom.testing.extension.MicrotusExtension;
+import net.onelitefeather.cygnus.common.Tags;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import net.onelitefeather.cygnus.common.creek.CreekRoutesFile;
+import net.onelitefeather.cygnus.common.creek.CreekWaypoint;
import net.onelitefeather.cygnus.common.map.GameMap;
import net.onelitefeather.cygnus.common.map.GameMapBuilder;
+import net.onelitefeather.cygnus.setup.item.SetupItemId;
import net.onelitefeather.cygnus.setup.map.MapDataCategory;
import net.theevilreaper.aves.map.MapEntry;
import org.junit.jupiter.api.Test;
@@ -20,10 +25,12 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ExtendWith(MicrotusExtension.class)
@@ -153,4 +160,178 @@ void loadDataReadsPageFacesBackFromTheSiblingFile(Env env, @TempDir Path root) t
env.destroyInstance(instance, true);
}
+
+ @Test
+ void testCreekRouteManagement(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createPlayer(instance);
+ GameData gameData = new GameData(player, MapEntry.of(Paths.get("")));
+
+ assertFalse(gameData.addCreekPoint(Vec.ZERO), "no active route yet");
+ assertTrue(gameData.createCreekRoute("Waldweg"));
+ assertFalse(gameData.createCreekRoute("Waldweg"));
+ assertEquals("Waldweg", gameData.activeCreekRoute());
+ assertTrue(gameData.addCreekPoint(new Vec(1, 80, 1)));
+ assertTrue(gameData.addCreekPoint(new Vec(5, 80, 1)));
+ assertEquals(2, gameData.activeCreekPointCount());
+ assertTrue(gameData.removeLastCreekPoint());
+ assertEquals(1, gameData.activeCreekPointCount());
+
+ assertTrue(gameData.createCreekRoute("Lichtung"));
+ assertTrue(gameData.selectCreekRoute("Waldweg"));
+ assertFalse(gameData.selectCreekRoute("Unknown"));
+ assertTrue(gameData.deleteCreekRoute("Waldweg"));
+ assertNull(gameData.activeCreekRoute(), "deleting the active route clears the selection");
+
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testSwapCreekRouteMode(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createPlayer(instance);
+ GameData gameData = new GameData(player, MapEntry.of(Paths.get("")));
+
+ assertFalse(gameData.hasCreekRouteMode());
+ gameData.swapCreekRouteMode();
+ assertTrue(gameData.hasCreekRouteMode());
+
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void saveAndLoadKeepAnUnfinishedCreekRoute(Env env, @TempDir Path root) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createPlayer(instance);
+ MapEntry mapEntry = MapEntry.of(root);
+ GameData gameData = new GameData(player, mapEntry);
+ gameData.createCreekRoute("Stub");
+ gameData.addCreekPoint(new Vec(1, 80, 1));
+
+ gameData.save();
+ GameData reloaded = new GameData(player, mapEntry);
+
+ assertEquals(List.of(CreekRoute.ofPositions("Stub", List.of(new Vec(1, 80, 1)))),
+ ((GameMapBuilder) reloaded.getMapBuilder()).getCreekRoutes());
+ assertTrue(Files.exists(CreekRoutesFile.resolve(root.resolve("map.json"))));
+
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testCreekRouteItemTogglesTheMode(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createPlayer(instance);
+ GameData gameData = new GameData(player, MapEntry.of(Paths.get("")));
+
+ gameData.handleItemInteraction(player, SetupItemId.CREEK_ROUTES);
+ assertTrue(gameData.hasCreekRouteMode());
+ gameData.handleItemInteraction(player, SetupItemId.CREEK_LEAVE);
+ assertFalse(gameData.hasCreekRouteMode());
+
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testCreekRouteModeStaysOffWhileAnotherModeIsActive(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createPlayer(instance);
+ GameData gameData = new GameData(player, MapEntry.of(Paths.get("")));
+ gameData.swapPageMode();
+
+ gameData.handleItemInteraction(player, SetupItemId.CREEK_ROUTES);
+
+ assertFalse(gameData.hasCreekRouteMode());
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testCreekRoutePauses(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createPlayer(instance);
+ GameData gameData = new GameData(player, MapEntry.of(Paths.get("")));
+
+ assertFalse(gameData.setCreekStartPause(1000), "no active route");
+ gameData.createCreekRoute("Waldweg");
+ assertFalse(gameData.setCreekStartPause(1000), "no point yet");
+ gameData.addCreekPoint(new Vec(1, 80, 1));
+ assertTrue(gameData.setCreekStartPause(1000));
+ assertFalse(gameData.setCreekEndPause(2000), "the end needs two points");
+ gameData.addCreekPoint(new Vec(5, 80, 1));
+ assertTrue(gameData.setCreekEndPause(2000));
+
+ List points = ((GameMapBuilder) gameData.getMapBuilder()).getCreekRoutePoints("Waldweg");
+ assertEquals(1000, points.getFirst().pauseMillis());
+ assertEquals(2000, points.getLast().pauseMillis());
+
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testFinishCreekRoute(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createPlayer(instance);
+ GameData gameData = new GameData(player, MapEntry.of(Paths.get("")));
+
+ assertFalse(gameData.finishCreekRoute(), "nothing to finish yet");
+ gameData.createCreekRoute("Waldweg");
+ gameData.addCreekPoint(new Vec(1, 80, 1));
+
+ assertTrue(gameData.finishCreekRoute());
+ assertNull(gameData.activeCreekRoute());
+ assertTrue(((GameMapBuilder) gameData.getMapBuilder()).hasCreekRoute("Waldweg"), "finishing keeps the route");
+
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testFinishItemSwitchesBackToTheOverview(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createPlayer(instance);
+ GameData gameData = new GameData(player, MapEntry.of(Paths.get("")));
+ gameData.handleItemInteraction(player, SetupItemId.CREEK_ROUTES);
+ gameData.createCreekRoute("Waldweg");
+
+ gameData.handleItemInteraction(player, SetupItemId.CREEK_FINISH);
+
+ assertNull(gameData.activeCreekRoute());
+ assertEquals(SetupItemId.CREEK_NEW, player.getInventory().getItemStack(0).getTag(Tags.ITEM_TAG).byteValue());
+
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testLeavingTheCreekRouteModeFinishesTheRoute(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createPlayer(instance);
+ GameData gameData = new GameData(player, MapEntry.of(Paths.get("")));
+ gameData.handleItemInteraction(player, SetupItemId.CREEK_ROUTES);
+ gameData.createCreekRoute("Waldweg");
+
+ gameData.handleItemInteraction(player, SetupItemId.CREEK_LEAVE);
+
+ assertFalse(gameData.hasCreekRouteMode());
+ assertNull(gameData.activeCreekRoute());
+
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testRemoveCreekPointAt(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createPlayer(instance);
+ GameData gameData = new GameData(player, MapEntry.of(Paths.get("")));
+
+ assertEquals(-1, gameData.removeCreekPointAt(new Vec(5, 80, 1)), "no active route");
+ gameData.createCreekRoute("Waldweg");
+ gameData.addCreekPoint(new Vec(1, 80, 1));
+ gameData.addCreekPoint(new Vec(5, 80, 1));
+ gameData.addCreekPoint(new Vec(9, 80, 1));
+
+ assertEquals(2, gameData.removeCreekPointAt(new Vec(5, 80, 1)));
+ assertEquals(2, gameData.activeCreekPointCount());
+ assertEquals(-1, gameData.removeCreekPointAt(new Vec(5, 80, 1)), "already gone");
+
+ env.destroyInstance(instance, true);
+ }
}
diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java
index 3272ae56..988c9d34 100644
--- a/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java
+++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java
@@ -14,7 +14,6 @@
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
-import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -34,7 +33,7 @@ class InstanceSetupDataIntegrationTest {
@Test
void testInstanceUsesFalcoChunkLoader(Env env, @TempDir Path worldRoot) {
- TestSetupData data = new TestSetupData(MapEntry.of(worldRoot));
+ TestSetupData data = new TestSetupData(owner(env), MapEntry.of(worldRoot));
data.loadData();
assertNotNull(data.instance);
@@ -47,7 +46,7 @@ void testInstanceUsesFalcoChunkLoader(Env env, @TempDir Path worldRoot) {
@Test
void testResetClosesChunkLoader(Env env, @TempDir Path worldRoot) {
- TestSetupData data = new TestSetupData(MapEntry.of(worldRoot));
+ TestSetupData data = new TestSetupData(owner(env), MapEntry.of(worldRoot));
data.loadData();
assertNotNull(data.instance);
@@ -57,8 +56,8 @@ void testResetClosesChunkLoader(Env env, @TempDir Path worldRoot) {
}
@Test
- void testResetWithoutInstanceIsHarmless(@TempDir Path worldRoot) {
- TestSetupData data = new TestSetupData(MapEntry.of(worldRoot));
+ void testResetWithoutInstanceIsHarmless(Env env, @TempDir Path worldRoot) {
+ TestSetupData data = new TestSetupData(owner(env), MapEntry.of(worldRoot));
data.reset();
@@ -66,14 +65,21 @@ void testResetWithoutInstanceIsHarmless(@TempDir Path worldRoot) {
assertNull(data.chunkLoader);
}
+ /**
+ * Creates the player who owns the setup. Nothing in these tests looks at them.
+ */
+ private static Player owner(Env env) {
+ return env.createPlayer(env.createFlatInstance());
+ }
+
/**
* Minimal {@link InstanceSetupData} which only creates the instance, so the test observes the
* chunk loader handling of the base class and nothing else.
*/
private static final class TestSetupData extends InstanceSetupData {
- private TestSetupData(MapEntry mapEntry) {
- super(UUID.randomUUID(), mapEntry, BossBar.Color.WHITE);
+ private TestSetupData(Player owner, MapEntry mapEntry) {
+ super(owner, mapEntry, BossBar.Color.WHITE);
}
@Override
diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlotTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlotTest.java
new file mode 100644
index 00000000..32ca30fb
--- /dev/null
+++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlotTest.java
@@ -0,0 +1,36 @@
+package net.onelitefeather.cygnus.setup.inventory.slot;
+
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
+import net.minestom.server.component.DataComponents;
+import net.minestom.server.coordinate.Vec;
+import net.minestom.server.item.ItemStack;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
+import net.onelitefeather.cygnus.common.creek.CreekWaypoint;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CreekRouteSlotTest {
+
+ @Test
+ @DisplayName("The item shows the route's name, point count and pauses")
+ void itemShowsNameAndPoints() {
+ CreekRoute route = new CreekRoute("Waldweg", List.of(
+ CreekWaypoint.of(new Vec(0, 80, 0)).withPause(2000),
+ CreekWaypoint.of(new Vec(5, 80, 0)),
+ CreekWaypoint.of(new Vec(9, 80, 0)),
+ CreekWaypoint.of(new Vec(12, 80, 0)).withPause(4000)));
+ ItemStack item = new CreekRouteSlot(route, true, (_, _) -> {}, (_, _) -> {}).getItem();
+
+ assertEquals("Waldweg", PlainTextComponentSerializer.plainText().serialize(item.get(DataComponents.CUSTOM_NAME)));
+ List lore = item.get(DataComponents.LORE).stream()
+ .map(line -> PlainTextComponentSerializer.plainText().serialize(line))
+ .toList();
+ assertTrue(lore.contains("Points: 4"));
+ assertTrue(lore.contains("Pause: start 2.0 s · end 4.0 s"));
+ }
+}
diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/item/SetupItemsTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/item/SetupItemsTest.java
index 28b4dd0f..8bd64603 100644
--- a/setup/src/test/java/net/onelitefeather/cygnus/setup/item/SetupItemsTest.java
+++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/item/SetupItemsTest.java
@@ -67,4 +67,31 @@ private void assertItem(@NotNull Player player, int slot, byte itemId) {
assertNotEquals(Material.AIR, itemStack.material());
assertEquals(itemId, itemStack.getTag(Tags.ITEM_TAG).byteValue());
}
+
+ @Test
+ void testCreekRouteOverviewItemSet(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createPlayer(instance);
+
+ SetupItems.setCreekRouteOverviewItems(player);
+ assertItem(player, 0, SetupItemId.CREEK_NEW);
+ assertItem(player, 4, SetupItemId.CREEK_LIST);
+ assertItem(player, 8, SetupItemId.CREEK_LEAVE);
+
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testCreekRouteEditItemSet(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createPlayer(instance);
+
+ SetupItems.setCreekRouteEditItems(player);
+ assertItem(player, 0, SetupItemId.CREEK_UNDO);
+ assertItem(player, 2, SetupItemId.CREEK_PAUSE);
+ assertItem(player, 6, SetupItemId.CREEK_FINISH);
+ assertItem(player, 8, SetupItemId.CREEK_LEAVE);
+
+ env.destroyInstance(instance, true);
+ }
}
diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListenerTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListenerTest.java
new file mode 100644
index 00000000..9c697344
--- /dev/null
+++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListenerTest.java
@@ -0,0 +1,31 @@
+package net.onelitefeather.cygnus.setup.listener;
+
+import net.minestom.server.coordinate.Vec;
+import net.minestom.server.instance.block.Block;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class CreekRouteListenerTest {
+
+ @Test
+ @DisplayName("A point sits in the middle on top of the clicked block")
+ void pointOnTopOfTheBlock() {
+ assertEquals(new Vec(3.5, 81, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.STONE));
+ }
+
+ @Test
+ @DisplayName("A point sits on the real height of a lower block")
+ void pointOnTopOfALowerBlock() {
+ assertEquals(new Vec(3.5, 80.5, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.STONE_SLAB));
+ assertEquals(new Vec(3.5, 80.9375, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.DIRT_PATH));
+ assertEquals(new Vec(3.5, 80.0625, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.WHITE_CARPET));
+ }
+
+ @Test
+ @DisplayName("A point on a plant sits where the plant grows")
+ void pointOnAPlant() {
+ assertEquals(new Vec(3.5, 80, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.FERN));
+ }
+}