From 285ff9e14bda2563823a3b136d614aaa7c45ebbb Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:36:15 +0200
Subject: [PATCH 01/92] feat(creek): add config record
---
.../cygnus/common/config/CreekConfig.java | 170 ++++++++++++++++++
.../cygnus/common/config/CreekConfigTest.java | 76 ++++++++
2 files changed, 246 insertions(+)
create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java
create mode 100644 common/src/test/java/net/onelitefeather/cygnus/common/config/CreekConfigTest.java
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java
new file mode 100644
index 00000000..8ef874f8
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java
@@ -0,0 +1,170 @@
+package net.onelitefeather.cygnus.common.config;
+
+/**
+ * Tunes the creek, the figure that walks the map next to the slender.
+ *
+ * Every number his behavior depends on lives here, so distances and timings can be tuned in a
+ * playtest without touching the code. The compact constructor rejects combinations that would
+ * break the behavior outright rather than merely make it feel off. a stalk band that runs
+ * backwards, or a hiding place inside the cone that counts as being seen the moment he gets there.
+ *
+ *
+ * @param enabled whether the creek takes part in a round at all
+ * @param activeWithLastSurvivor whether he keeps going once only one survivor is left
+ * @param sightRange how far away a survivor can notice him, in blocks
+ * @param sightViewAngle how far off the center of a survivor's view he may stand and still
+ * count as seen, in degrees
+ * @param wanderPauseMillis how long he stands still when someone spots him while wandering
+ * @param wanderSpeed his movement speed while wandering, in blocks per tick
+ * @param huntSpeed his movement speed while hunting, in blocks per tick; a walking
+ * survivor covers about 0.216, so this has to stay above that
+ * @param stalkThreshold the dread at which he picks a survivor to stalk
+ * @param huntThreshold the dread at which a stalk turns into a hunt
+ * @param stalkMinDistance the closest he stands to the survivor he stalks, in blocks
+ * @param stalkMaxDistance the farthest he stands from the survivor he stalks, in blocks
+ * @param stalkMinAngle the smallest angle off the survivor's view he places himself at
+ * @param stalkMaxAngle the largest angle off the survivor's view he places himself at
+ * @param stalkRevealMillis how long he lets himself be seen before he moves on
+ * @param stalkMinSeconds the shortest a stalk lasts
+ * @param stalkMaxSeconds the longest a stalk lasts
+ * @param huntMaxSeconds the longest a hunt lasts before he gives up
+ * @param catchDistance how close he has to come to catch a survivor, in blocks
+ * @param vanishMinSeconds the shortest he stays away, reached at full dread
+ * @param vanishMaxSeconds the longest he stays away, reached at no dread
+ * @param respawnMinDistance how far from every survivor he reappears, in blocks
+ * @param personalSpace how close he lets a survivor come outside a hunt, in blocks
+ * @param stuckMillis how long he may make no progress before he takes a shortcut
+ * @param dreadPageWeight the share of the dread that comes from the pages found
+ * @param dreadTimeWeight the share of the dread that comes from the elapsed round time
+ * @param dreadIsolationWeight the share of the dread that comes from being alone
+ * @param isolationRadius how far the nearest other survivor has to be to count as alone
+ * @param betrayalCatchCount from which catch on he always gives a survivor away
+ * @param betrayalChance the chance he gives a survivor away before that
+ * @param betrayalGlowSeconds how long the slender sees a betrayed survivor glow
+ * @param slownessSeconds how long a caught survivor is slowed
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public record CreekConfig(
+ boolean enabled,
+ boolean activeWithLastSurvivor,
+ int sightRange,
+ int sightViewAngle,
+ int wanderPauseMillis,
+ double wanderSpeed,
+ double huntSpeed,
+ double stalkThreshold,
+ double huntThreshold,
+ int stalkMinDistance,
+ int stalkMaxDistance,
+ int stalkMinAngle,
+ int stalkMaxAngle,
+ int stalkRevealMillis,
+ int stalkMinSeconds,
+ int stalkMaxSeconds,
+ int huntMaxSeconds,
+ double catchDistance,
+ int vanishMinSeconds,
+ int vanishMaxSeconds,
+ int respawnMinDistance,
+ int personalSpace,
+ int stuckMillis,
+ double dreadPageWeight,
+ double dreadTimeWeight,
+ double dreadIsolationWeight,
+ int isolationRadius,
+ int betrayalCatchCount,
+ double betrayalChance,
+ int betrayalGlowSeconds,
+ int slownessSeconds
+) {
+
+ /**
+ * The values from the design: on, and tuned for a round of about fifteen minutes.
+ */
+ public static final CreekConfig DEFAULT = new CreekConfig(
+ true, true,
+ 48, 35,
+ 1500, 0.07D, 0.25D,
+ 0.25D, 0.6D,
+ 20, 35, 40, 70, 700, 45, 90,
+ 30, 1.5D,
+ 20, 40, 30, 15, 3000,
+ 0.6D, 0.3D, 0.1D, 25,
+ 2, 0.15D, 6, 4
+ );
+
+ /**
+ * Rejects values that would break the creek's behavior.
+ *
+ * @throws IllegalArgumentException if a value is out of range or two values do not fit together
+ */
+ public CreekConfig {
+ atLeast("sightRange", sightRange, 1);
+ between("sightViewAngle", sightViewAngle, 1, GameConfig.MAX_GLITCH_VIEW_ANGLE);
+ atLeast("wanderPauseMillis", wanderPauseMillis, 0);
+ positive("wanderSpeed", wanderSpeed);
+ positive("huntSpeed", huntSpeed);
+ positive("stalkThreshold", stalkThreshold);
+ between("huntThreshold", huntThreshold, 0.0D, 1.0D);
+ below("stalkThreshold", stalkThreshold, "huntThreshold", huntThreshold);
+ atLeast("personalSpace", personalSpace, 1);
+ below("personalSpace", personalSpace, "stalkMinDistance", stalkMinDistance);
+ below("stalkMinDistance", stalkMinDistance, "stalkMaxDistance", stalkMaxDistance);
+ // A hiding place inside the cone would count as being seen the moment he got there.
+ below("sightViewAngle", sightViewAngle, "stalkMinAngle", stalkMinAngle);
+ below("stalkMinAngle", stalkMinAngle, "stalkMaxAngle", stalkMaxAngle);
+ between("stalkMaxAngle", stalkMaxAngle, 1, 180);
+ atLeast("stalkRevealMillis", stalkRevealMillis, 0);
+ atLeast("stalkMinSeconds", stalkMinSeconds, 1);
+ notAbove("stalkMinSeconds", stalkMinSeconds, "stalkMaxSeconds", stalkMaxSeconds);
+ atLeast("huntMaxSeconds", huntMaxSeconds, 1);
+ positive("catchDistance", catchDistance);
+ atLeast("vanishMinSeconds", vanishMinSeconds, 0);
+ notAbove("vanishMinSeconds", vanishMinSeconds, "vanishMaxSeconds", vanishMaxSeconds);
+ atLeast("respawnMinDistance", respawnMinDistance, 1);
+ atLeast("stuckMillis", stuckMillis, 1);
+ atLeast("dreadPageWeight", dreadPageWeight, 0.0D);
+ atLeast("dreadTimeWeight", dreadTimeWeight, 0.0D);
+ atLeast("dreadIsolationWeight", dreadIsolationWeight, 0.0D);
+ atLeast("isolationRadius", isolationRadius, 1);
+ atLeast("betrayalCatchCount", betrayalCatchCount, 1);
+ between("betrayalChance", betrayalChance, 0.0D, 1.0D);
+ atLeast("betrayalGlowSeconds", betrayalGlowSeconds, 1);
+ atLeast("slownessSeconds", slownessSeconds, 0);
+ }
+
+ private static void atLeast(String name, double value, double minimum) {
+ if (value < minimum) {
+ throw new IllegalArgumentException(name + " (" + value + ") must be at least " + minimum);
+ }
+ }
+
+ private static void positive(String name, double value) {
+ if (value <= 0.0D) {
+ throw new IllegalArgumentException(name + " (" + value + ") must be above 0");
+ }
+ }
+
+ private static void between(String name, double value, double minimum, double maximum) {
+ if (value < minimum || value > maximum) {
+ throw new IllegalArgumentException(
+ name + " (" + value + ") must be between " + minimum + " and " + maximum);
+ }
+ }
+
+ private static void below(String lowerName, double lower, String upperName, double upper) {
+ if (lower >= upper) {
+ throw new IllegalArgumentException(
+ lowerName + " (" + lower + ") must be below " + upperName + " (" + upper + ")");
+ }
+ }
+
+ private static void notAbove(String lowerName, double lower, String upperName, double upper) {
+ if (lower > upper) {
+ throw new IllegalArgumentException(
+ lowerName + " (" + lower + ") must not be above " + upperName + " (" + upper + ")");
+ }
+ }
+}
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..ad8a8a07
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/CreekConfigTest.java
@@ -0,0 +1,76 @@
+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());
+ }
+
+ @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());
+ }
+
+ @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);
+ }
+}
From 40ead280de1a96be35d3e8e51eca06f7a54bd8b2 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:36:36 +0200
Subject: [PATCH 02/92] chore(config): add new config usage
---
.../cygnus/common/config/GameConfig.java | 15 ++--
.../common/config/GameConfigBuilder.java | 0
.../common/config/GameConfigReader.java | 78 +++++++++++++++++++
.../common/config/InternalGameConfig.java | 0
.../common/config/GameConfigReaderTest.java | 35 +++++++++
5 files changed, 120 insertions(+), 8 deletions(-)
create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigBuilder.java
create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/config/InternalGameConfig.java
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java
index 628e3686..b8393c7d 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java
@@ -53,16 +53,12 @@ public record GameConfig(
* Spawning immediately at {@code GameStartEvent} let survivors grab a page before they had even
* moved from the spawn point. The delay gives them time to spread across the map first.
*
- *
- * @since 2.15.0
*/
public static final int PAGE_SPAWN_DELAY = 10;
/**
* How many seconds {@link #PAGE_SPAWN_DELAY} may randomly shift up or down, re-rolled every
* round, so the moment the first pages appear stays unpredictable.
- *
- * @since 2.15.0
*/
public static final int PAGE_SPAWN_DELAY_JITTER = 2;
@@ -72,16 +68,12 @@ public record GameConfig(
* The page then has to come back on the spot it was found on. Without the delay it would be
* collectible again right away, letting a survivor pick up page after page on the same spot.
*
- *
- * @since 2.15.0
*/
public static final int PAGE_RESPAWN_DELAY = 15;
/**
* How many seconds {@link #PAGE_RESPAWN_DELAY} may randomly shift up or down, re-rolled for every
* hidden page, so waiting next to the spot does not pay off.
- *
- * @since 2.15.0
*/
public static final int PAGE_RESPAWN_DELAY_JITTER = 5;
@@ -140,6 +132,13 @@ public record Round(int minPlayers, int maxPlayers, int lobbyTime, int gameTime)
}
}
+ /**
+ * Returns the settings of the creek, the figure that walks the map next to the slender.
+ *
+ * @return the creek settings, never {@code null}
+ */
+ CreekConfig creek();
+
/**
* The sizes of the teams.
*
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigBuilder.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigBuilder.java
new file mode 100644
index 00000000..e69de29b
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
index d9bc692b..2c62243e 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
@@ -46,6 +46,7 @@
* slenderStaticFranticInterval
* slenderStaticMinVolume
* slenderStaticMaxVolume
+ * creek.* (see {@link CreekConfig})
*
*
* If a property can not be found in the file, the default value will be used.
@@ -66,6 +67,7 @@ public final class GameConfigReader {
private static final String PAGE_PROXIMITY_SOUND_KEY = "pageProximitySound";
private static final String DAMAGE_SOUND_KEY = "damageSound";
private static final String SLENDER_STATIC_SOUND_KEY = "slenderStaticSound";
+ private static final String CREEK_PREFIX = "creek.";
private final Path path;
@@ -186,6 +188,82 @@ private float getFloat(Properties properties, String key, float defaultValue) {
}
}
+ /**
+ * Reads a decimal from the properties with double precision. The creek's speeds and weights
+ * are compared against each other, and a float's rounding would let 0.6 read as 0.6000000238.
+ *
+ * @param properties the loaded properties
+ * @param key the key to read
+ * @param defaultValue the value to use when the key is absent or unreadable
+ * @return the parsed value
+ */
+ private double getDouble(Properties properties, String key, double defaultValue) {
+ String value = properties.getProperty(key);
+ if (value == null) {
+ return defaultValue;
+ }
+ try {
+ return Double.parseDouble(value.trim());
+ } catch (NumberFormatException _) {
+ CONFIG_LOGGER.warn("Failed to parse decimal config value for key '{}': '{}'. Falling back to default: {}", key, value, defaultValue);
+ return defaultValue;
+ }
+ }
+
+ /**
+ * Reads the creek settings, every key prefixed with {@value #CREEK_PREFIX}.
+ *
+ * The values are checked against each other only once all of them are read, so a combination
+ * that does not fit together cannot be patched per key. It falls back to the defaults as a
+ * whole instead: a creek running half on the operator's values and half on ours would be
+ * harder to reason about than one running entirely on ours.
+ *
+ *
+ * @param properties the loaded properties
+ * @return the creek settings, never {@code null}
+ */
+ private CreekConfig getCreek(Properties properties) {
+ CreekConfig d = CreekConfig.DEFAULT;
+ try {
+ return new CreekConfig(
+ getBoolean(properties, CREEK_PREFIX + "enabled", d.enabled()),
+ getBoolean(properties, CREEK_PREFIX + "activeWithLastSurvivor", d.activeWithLastSurvivor()),
+ getInt(properties, CREEK_PREFIX + "sightRange", d.sightRange()),
+ getInt(properties, CREEK_PREFIX + "sightViewAngle", d.sightViewAngle()),
+ getInt(properties, CREEK_PREFIX + "wanderPauseMillis", d.wanderPauseMillis()),
+ getDouble(properties, CREEK_PREFIX + "wanderSpeed", d.wanderSpeed()),
+ getDouble(properties, CREEK_PREFIX + "huntSpeed", d.huntSpeed()),
+ getDouble(properties, CREEK_PREFIX + "stalkThreshold", d.stalkThreshold()),
+ getDouble(properties, CREEK_PREFIX + "huntThreshold", d.huntThreshold()),
+ getInt(properties, CREEK_PREFIX + "stalkMinDistance", d.stalkMinDistance()),
+ getInt(properties, CREEK_PREFIX + "stalkMaxDistance", d.stalkMaxDistance()),
+ getInt(properties, CREEK_PREFIX + "stalkMinAngle", d.stalkMinAngle()),
+ getInt(properties, CREEK_PREFIX + "stalkMaxAngle", d.stalkMaxAngle()),
+ getInt(properties, CREEK_PREFIX + "stalkRevealMillis", d.stalkRevealMillis()),
+ getInt(properties, CREEK_PREFIX + "stalkMinSeconds", d.stalkMinSeconds()),
+ getInt(properties, CREEK_PREFIX + "stalkMaxSeconds", d.stalkMaxSeconds()),
+ getInt(properties, CREEK_PREFIX + "huntMaxSeconds", d.huntMaxSeconds()),
+ getDouble(properties, CREEK_PREFIX + "catchDistance", d.catchDistance()),
+ getInt(properties, CREEK_PREFIX + "vanishMinSeconds", d.vanishMinSeconds()),
+ getInt(properties, CREEK_PREFIX + "vanishMaxSeconds", d.vanishMaxSeconds()),
+ getInt(properties, CREEK_PREFIX + "respawnMinDistance", d.respawnMinDistance()),
+ getInt(properties, CREEK_PREFIX + "personalSpace", d.personalSpace()),
+ getInt(properties, CREEK_PREFIX + "stuckMillis", d.stuckMillis()),
+ getDouble(properties, CREEK_PREFIX + "dreadPageWeight", d.dreadPageWeight()),
+ getDouble(properties, CREEK_PREFIX + "dreadTimeWeight", d.dreadTimeWeight()),
+ getDouble(properties, CREEK_PREFIX + "dreadIsolationWeight", d.dreadIsolationWeight()),
+ getInt(properties, CREEK_PREFIX + "isolationRadius", d.isolationRadius()),
+ getInt(properties, CREEK_PREFIX + "betrayalCatchCount", d.betrayalCatchCount()),
+ getDouble(properties, CREEK_PREFIX + "betrayalChance", d.betrayalChance()),
+ getInt(properties, CREEK_PREFIX + "betrayalGlowSeconds", d.betrayalGlowSeconds()),
+ getInt(properties, CREEK_PREFIX + "slownessSeconds", d.slownessSeconds())
+ );
+ } catch (IllegalArgumentException exception) {
+ CONFIG_LOGGER.warn("The creek settings do not fit together: {}. Falling back to the creek defaults", exception.getMessage());
+ return d;
+ }
+ }
+
/**
* Reads a flag from the properties. Anything other than {@code true} or {@code false} is not a
* decision the operator made on purpose, so it falls back to the default instead of silently
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/InternalGameConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/InternalGameConfig.java
new file mode 100644
index 00000000..e69de29b
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..67c630bc 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,39 @@ 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
+ """);
+
+ 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());
+ }
+
+ @Test
+ void testCreekFallsBackWhenValuesDoNotFit(@TempDir Path tempDir) throws IOException {
+ Files.writeString(tempDir.resolve("config.properties"), """
+ minPlayers=2
+ creek.enabled=false
+ creek.stalkThreshold=0.9
+ """);
+
+ assertEquals(CreekConfig.DEFAULT, new GameConfigReader(tempDir).getConfig().creek());
+ }
}
From afc86ea7d4702c2cab52daeebc29891cca02176a Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:38:14 +0200
Subject: [PATCH 03/92] chore(page): expose page count
---
.../cygnus/common/page/PageProvider.java | 9 +++++++++
.../cygnus/common/page/PageProviderTest.java | 19 +++++++++++++++++++
2 files changed, 28 insertions(+)
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..6e74b0c1 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 how many pages the survivors have found so far this round.
+ *
+ * @return the number of found pages, never negative
+ */
+ public int foundPageCount() {
+ return this.currentFoundedPageCount.get();
+ }
+
/**
* Returns the max page amount.
*
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..50a1386e 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,23 @@ private static void runConcurrently(List tasks) throws Exception {
executor.shutdown();
}
}
+
+ @Test
+ void testFoundPageCount(@NotNull Env env) throws Exception {
+ Instance instance = env.createFlatInstance();
+ PageProvider pageProvider = new PageProvider();
+ pageProvider.loadPageData(Set.of(new PageResource(Pos.ZERO, Direction.NORTH)));
+ pageProvider.setMaxPageAmount(2);
+
+ assertEquals(0, pageProvider.foundPageCount());
+
+ PageEntity pageEntity = new PageEntity(instance, Pos.ZERO, 1);
+ seedActivePages(pageProvider, pageEntity);
+ Player player = env.createPlayer(instance);
+
+ pageProvider.triggerPageFound(player, pageEntity.getHitBoxUUID());
+
+ assertEquals(1, pageProvider.foundPageCount());
+ env.destroyInstance(instance, true);
+ }
}
From f7ce872c75843734bf2a1a938c1b1a872dd22905 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:42:06 +0200
Subject: [PATCH 04/92] feat(creek): add entity structure class
---
.../onelitefeather/cygnus/creek/Creek.java | 144 ++++++++++++++++++
1 file changed, 144 insertions(+)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java
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..99bd1d77
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java
@@ -0,0 +1,144 @@
+package net.onelitefeather.cygnus.creek;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.cygnus.common.config.CreekConfig;
+import net.onelitefeather.cygnus.creek.body.CreekBody;
+import net.onelitefeather.cygnus.creek.consequence.CatchConsequence;
+import net.onelitefeather.cygnus.creek.dread.DreadSource;
+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.CreekSight;
+import net.onelitefeather.cygnus.creek.world.RouteProvider;
+import net.onelitefeather.cygnus.creek.world.SpotFinder;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.random.RandomGenerator;
+
+/**
+ * Connects the states to the server. Each step it turns the players into survivor snapshots and
+ * runs the current state.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+final class Creek {
+
+ private final CreekBody body;
+ private final CreekSight sight;
+ private final DreadSource dread;
+ private final RouteProvider route;
+ private final SpotFinder spots;
+ private final CatchConsequence consequence;
+ private final CreekConfig config;
+ private final RandomGenerator random;
+ private CreekState state;
+ private List lastViews = List.of();
+ private boolean entered;
+
+ Creek(CreekBody body, CreekSight sight, DreadSource dread, RouteProvider route, SpotFinder spots,
+ CatchConsequence consequence, CreekConfig config, RandomGenerator random, CreekState initial) {
+ this.body = body;
+ this.sight = sight;
+ this.dread = dread;
+ this.route = route;
+ this.spots = spots;
+ this.consequence = consequence;
+ this.config = config;
+ this.random = random;
+ this.state = initial;
+ }
+
+ /**
+ * Runs one step.
+ *
+ * @param survivors the survivors of the round
+ * @param now the current time in milliseconds
+ */
+ void tick(Collection survivors, long now) {
+ Map players = new HashMap<>();
+ for (Player survivor : survivors) {
+ players.put(survivor.getUuid(), survivor);
+ }
+ this.lastViews = this.views(survivors);
+ CreekContext ctx = new CreekContext(now, this.lastViews, this.body, this.route, this.spots,
+ id -> {
+ Player caught = players.get(id);
+ if (caught != null) this.consequence.apply(caught);
+ },
+ this.config, this.random);
+
+ if (!this.entered) {
+ this.state.enter(ctx);
+ this.entered = true;
+ }
+ if (!this.config.activeWithLastSurvivor() && ctx.survivors().size() <= 1) {
+ if (!(this.state instanceof VanishState vanish && vanish.isForever())) {
+ this.switchTo(VanishState.forever(), ctx);
+ }
+ return;
+ }
+
+ CreekState next = this.state.tick(ctx);
+ if (next != this.state) this.switchTo(next, ctx);
+ }
+
+ /**
+ * Creates a snapshot of every survivor for the states.
+ *
+ * A survivor only counts as seeing the creek if it is visible to them. During a stalk, the
+ * other survivors cannot see it, so they must not scare it off.
+ *
+ *
+ * @param survivors the survivors of the round
+ * @return one view per survivor
+ */
+ List views(Collection survivors) {
+ List views = new ArrayList<>(survivors.size());
+ for (Player survivor : survivors) {
+ Pos position = survivor.getPosition();
+ List others = new ArrayList<>();
+ for (Player other : survivors) {
+ if (other != survivor) others.add(other.getPosition());
+ }
+ boolean sees = this.body.isVisibleTo(survivor.getUuid()) && this.sight.sees(survivor, this.body.entity());
+ views.add(new SurvivorView(survivor.getUuid(), position,
+ this.dread.dreadOf(survivor.getUuid(), position, others), sees));
+ }
+ return views;
+ }
+
+ /**
+ * Returns 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;
+ }
+
+ void remove() {
+ this.body.remove();
+ }
+
+ private void switchTo(CreekState next, CreekContext ctx) {
+ this.state = next;
+ next.enter(ctx);
+ }
+}
From f5afd13af29f45c032ab612f32995f3441a4ed8d Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:42:18 +0200
Subject: [PATCH 05/92] feat(creek): add body package
---
.../cygnus/creek/body/CreakingBody.java | 138 ++++++++++++++++++
.../cygnus/creek/body/CreekBody.java | 96 ++++++++++++
.../cygnus/creek/body/package-info.java | 7 +
3 files changed, 241 insertions(+)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/body/CreekBody.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/body/package-info.java
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..bcdafb1a
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java
@@ -0,0 +1,138 @@
+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;
+
+/**
+ * Shows the creek as a vanilla creaking.
+ *
+ * An enderman would not work: the slender player is already shown as one, so both would look
+ * the same. Creakings only spawn from a creaking heart, and Cygnus has none, so the resource
+ * pack can retexture them freely.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreakingBody implements CreekBody {
+
+ /** How far the goal has to move before a new path is calculated, 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;
+ }
+
+ /**
+ * Spawns a creaking. Nobody can see it until {@link #showTo(Set)} is called.
+ *
+ * @param instance the instance of the round
+ * @param position the spawn position
+ * @return the body
+ */
+ public static CreakingBody spawn(Instance instance, Pos position) {
+ CreakingBody body = new CreakingBody(new EntityCreature(EntityType.CREAKING));
+ 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 walking survivor moves a little between almost every two steps. Recalculating the
+ // path each time would be expensive, so small moves are ignored.
+ Pos current = this.goal;
+ if (current != null && current.distance(goal) <= REPATH_DISTANCE) return;
+ // Only remember goals the navigator accepted. A rejected goal (too close, same block,
+ // unloaded chunk) or a reached one must not block the next goal.
+ 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;
+ }
+
+ /**
+ * The navigator's default arrival distance: from the center of the bounding box to a corner.
+ */
+ 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..d9107fe2
--- /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;
+
+/**
+ * The creek's appearance in the world: the entity that is drawn and moved.
+ *
+ * The states only use this interface. That way the vanilla creaking can later be replaced by a
+ * custom model without changing the behavior.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public interface CreekBody {
+
+ /**
+ * Returns the position of the creek's feet.
+ *
+ * @return the position
+ */
+ Pos position();
+
+ /**
+ * Walks towards a goal.
+ *
+ * @param goal where to go
+ * @param speed the movement speed, in blocks per tick
+ */
+ void moveTo(Pos goal, double speed);
+
+ /**
+ * Stops walking.
+ */
+ void stop();
+
+ /**
+ * Moves the creek to a position instantly.
+ *
+ * @param position the new position
+ */
+ void teleport(Pos position);
+
+ /**
+ * Turns the creek's head towards a point.
+ *
+ * @param point what to look at
+ */
+ void lookAt(Pos point);
+
+ /**
+ * Makes the creek visible to exactly these players.
+ *
+ * @param viewers the players who can see the creek; an empty set hides it from everyone
+ */
+ void showTo(Set viewers);
+
+ /**
+ * Returns whether a player can currently see the creek.
+ *
+ * @param viewer the player's id
+ * @return {@code true} if the creek is visible to that player
+ */
+ boolean isVisibleTo(UUID viewer);
+
+ /**
+ * Switches the hunting look on or off (for the creaking: 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 a survivor looks at the creek during a hunt
+ */
+ void setFrozen(boolean frozen);
+
+ /**
+ * Returns the entity behind the body. Used for line-of-sight checks.
+ *
+ * @return the entity
+ */
+ Entity entity();
+
+ /**
+ * Removes the creek from the world.
+ */
+ void remove();
+}
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;
From f8c9f46833de06c67cccd14934202d71cfd04706 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:42:30 +0200
Subject: [PATCH 06/92] feat(creek): add catch logic
---
.../creek/consequence/CatchConsequence.java | 27 ++++
.../creek/consequence/CatchEffects.java | 79 +++++++++++
.../cygnus/creek/consequence/GlowReveal.java | 128 ++++++++++++++++++
.../creek/consequence/SlenderReveal.java | 28 ++++
.../consequence/StagedCatchConsequence.java | 84 ++++++++++++
.../creek/consequence/package-info.java | 7 +
6 files changed, 353 insertions(+)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchConsequence.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/consequence/GlowReveal.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SlenderReveal.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequence.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/consequence/package-info.java
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..805cbc50
--- /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 when the creek catches a survivor.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+@FunctionalInterface
+public interface CatchConsequence {
+
+ /**
+ * Applies the consequence.
+ *
+ * @param survivor the survivor who was caught
+ */
+ void apply(Player survivor);
+
+ /**
+ * Removes any effects that are still active. Called at the end of a round.
+ */
+ 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..e2d9aae4
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java
@@ -0,0 +1,79 @@
+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.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+/**
+ * The normal punishment for being caught: a jump scare, empty stamina and slowness.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CatchEffects implements CatchConsequence {
+
+ /** Sound of the fallback scare, used when no corpse exists yet. */
+ static final Key SCARE_SOUND = Key.key("entity.creaking.activate");
+
+ /** Duration of the fallback scare's darkness, in ticks. */
+ static final int SCARE_DARKNESS_TICKS = 40;
+
+ private final Predicate jumpScare;
+ private final Function foodBars;
+ private final int slownessTicks;
+ private final Set slowed = ConcurrentHashMap.newKeySet();
+
+ /**
+ * Creates the effects.
+ *
+ * @param jumpScare plays the jump scare and returns {@code false} if it failed
+ * @param foodBars returns a survivor's stamina bar, or {@code null} if there is 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 it does nothing before the first death.
+ // The fallback makes sure the catch is still noticeable.
+ if (!this.jumpScare.test(survivor)) {
+ scareWithoutCorpse(survivor);
+ }
+ FoodBar bar = this.foodBars.apply(survivor);
+ if (bar != null) {
+ bar.drain();
+ }
+ if (this.slownessTicks > 0) {
+ survivor.addEffect(new Potion(PotionEffect.SLOWNESS, 0, this.slownessTicks));
+ this.slowed.add(survivor);
+ }
+ }
+
+ @Override
+ public void cleanUp() {
+ for (Player player : List.copyOf(this.slowed)) {
+ player.removeEffect(PotionEffect.SLOWNESS);
+ }
+ this.slowed.clear();
+ }
+
+ private static void scareWithoutCorpse(Player survivor) {
+ survivor.playSound(Sound.sound(SCARE_SOUND, Sound.Source.HOSTILE, 1.0F, 0.6F));
+ survivor.addEffect(new Potion(PotionEffect.DARKNESS, 0, SCARE_DARKNESS_TICKS));
+ }
+}
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..8b0e1841
--- /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;
+
+/**
+ * Makes a survivor glow for a few seconds, visible only to the slender.
+ *
+ * Glowing is a metadata flag, and Minestom sends metadata to all viewers. So the flag is never
+ * set on the survivor. Instead, a packet with the flag is sent to the slender only.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class GlowReveal implements SlenderReveal {
+
+ /** How often the glow packet is resent, 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();
+
+ /**
+ * Creates 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());
+ // Warn the survivor too. Being revealed 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;
+ }
+ // Any metadata change of the survivor (sprinting, sneaking) sends the real flags to the
+ // slender and removes the glow. Resending it 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 and the glow bit set or cleared.
+ *
+ * The flags are always included. Otherwise the client would keep 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/SlenderReveal.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SlenderReveal.java
new file mode 100644
index 00000000..ccd2f6da
--- /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;
+
+/**
+ * Reveals a survivor's position 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 reveal
+ * @param slender the slender
+ */
+ void reveal(Player survivor, Player slender);
+
+ /**
+ * Ends all running reveals.
+ */
+ 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..5ebe9779
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequence.java
@@ -0,0 +1,84 @@
+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. Sometimes it also reveals the survivor to the slender: always from the
+ * {@code betrayalCatchCount}-th catch on, and before that with a {@code betrayalChance}.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class StagedCatchConsequence implements CatchConsequence {
+
+ /** How often a survivor was caught since their last reveal. */
+ 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();
+
+ /**
+ * Creates the consequence.
+ *
+ * @param effects the punishment for every catch
+ * @param reveal reveals a survivor to the slender
+ * @param slender supplies the slender, or {@code null} while there is none
+ * @param config the reveal settings
+ * @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/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/package-info.java
new file mode 100644
index 00000000..19ed81c3
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * What happens when the creek catches a survivor.
+ */
+@NotNullByDefault
+package net.onelitefeather.cygnus.creek.consequence;
+
+import org.jetbrains.annotations.NotNullByDefault;
From 3d45635d08577875fde42703b6b0abe77f9fdcf9 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:42:49 +0200
Subject: [PATCH 07/92] feat(creek): add dread count handling
---
.../cygnus/creek/dread/DreadSource.java | 31 ++++++++
.../cygnus/creek/dread/PageProgressDread.java | 71 +++++++++++++++++++
2 files changed, 102 insertions(+)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/dread/DreadSource.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/dread/PageProgressDread.java
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..c93e940e
--- /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 stalked or hunted.
+ *
+ * The creek only uses this interface. A later sanity system can replace the rating without
+ * changing the creek.
+ *
+ *
+ * @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 the survivor's position
+ * @param others the positions of all other survivors
+ * @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..99db010f
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/PageProgressDread.java
@@ -0,0 +1,71 @@
+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 found pages, the elapsed time and whether a survivor is alone.
+ *
+ * Pages and time are the same for everyone. Being alone is what makes the difference, so the
+ * creek prefers survivors who leave 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;
+
+ /**
+ * Creates the rating.
+ *
+ * @param foundPages supplies the number of found pages
+ * @param maxPages supplies the total number of pages
+ * @param elapsedMillis supplies the elapsed round time
+ * @param gameTimeSeconds the maximum round length
+ * @param config the weights and the isolation radius
+ */
+ 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);
+ }
+}
From e1ea0da766fdac1f6e9eef75f069c088ef9b6fdc Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:43:06 +0200
Subject: [PATCH 08/92] feat(creek): add state handling
---
.../cygnus/creek/state/CreekContext.java | 104 +++++++++++
.../cygnus/creek/state/CreekState.java | 26 +++
.../cygnus/creek/state/HuntState.java | 125 +++++++++++++
.../cygnus/creek/state/StalkState.java | 111 ++++++++++++
.../cygnus/creek/state/SurvivorView.java | 31 ++++
.../cygnus/creek/state/VanishState.java | 98 ++++++++++
.../cygnus/creek/state/WanderState.java | 170 ++++++++++++++++++
7 files changed, 665 insertions(+)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekContext.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekState.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/state/VanishState.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/state/WanderState.java
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..dcada857
--- /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.function.Consumer;
+import java.util.random.RandomGenerator;
+
+/**
+ * Everything a state needs for one step.
+ *
+ * A new 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 picks where the creek walks to
+ * @param spots finds places where nobody sees the creek
+ * @param onCatch called with the id of a caught 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,
+ Consumer onCatch,
+ CreekConfig config,
+ RandomGenerator random
+) {
+
+ /**
+ * Finds a survivor by id.
+ *
+ * @param id the id to look for
+ * @return the survivor, or empty if they left the round
+ */
+ public Optional survivor(UUID id) {
+ for (SurvivorView view : this.survivors) {
+ if (view.id().equals(id)) return Optional.of(view);
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Returns 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);
+ }
+
+ /**
+ * Returns the eyes of every survivor.
+ *
+ * @return the eye positions
+ */
+ public List observerEyes() {
+ return this.survivors.stream().map(SurvivorView::eyes).toList();
+ }
+
+ /**
+ * Returns 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 minimum distance, 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..e77fdc04
--- /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 behavior, such as wandering or hunting.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public interface CreekState {
+
+ /**
+ * Prepares the body 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 stay, or the next state
+ */
+ CreekState tick(CreekContext ctx);
+}
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..6db634df
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java
@@ -0,0 +1,125 @@
+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;
+
+/**
+ * The creek chases one survivor, but only moves while that survivor is not looking at it.
+ *
+ * When the survivor looks, the creek freezes. When it gets close enough, the survivor is caught.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class HuntState implements CreekState {
+
+ /** Minimum distance to the target after a shortcut, in blocks. */
+ static final double NEAR_MIN = 6.0D;
+
+ /** Maximum distance to the target after a shortcut, in blocks. */
+ static final double NEAR_MAX = 10.0D;
+
+ /** How much closer the creek has to get to count as progress, 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;
+
+ /**
+ * Creates the state.
+ *
+ * @param target the hunted survivor
+ * @param endsAt when the hunt ends, in milliseconds
+ */
+ public HuntState(UUID target, long endsAt) {
+ this.target = target;
+ this.endsAt = endsAt;
+ }
+
+ /**
+ * Starts a hunt that ends after {@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);
+ }
+
+ /**
+ * Returns the hunted survivor.
+ *
+ * @return the survivor's id
+ */
+ public UUID target() {
+ return this.target;
+ }
+
+ @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 VanishState.after(ctx);
+
+ 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.onCatch().accept(this.target);
+ return VanishState.after(ctx);
+ }
+
+ 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;
+ }
+
+ /**
+ * Teleports the creek near the target, out of its view, when walking there does not work.
+ */
+ 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/StalkState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java
new file mode 100644
index 00000000..16ea776a
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java
@@ -0,0 +1,111 @@
+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;
+
+/**
+ * The creek follows one survivor from a distance. Only that survivor can see it.
+ *
+ * When the survivor looks at the creek, it waits a moment and then teleports out of view.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class StalkState implements CreekState {
+
+ private final UUID target;
+ private final long endsAt;
+ private long seenSince = -1L;
+
+ /**
+ * Creates the state.
+ *
+ * @param target the stalked survivor
+ * @param endsAt when the stalk ends, in milliseconds
+ */
+ public StalkState(UUID target, long endsAt) {
+ this.target = target;
+ this.endsAt = endsAt;
+ }
+
+ /**
+ * Starts a stalk with a random length between {@code stalkMinSeconds} and {@code stalkMaxSeconds}.
+ *
+ * @param target the survivor to stalk
+ * @param ctx the current step
+ * @return the state
+ */
+ static StalkState starting(UUID target, CreekContext ctx) {
+ CreekConfig config = ctx.config();
+ long seconds = ctx.random().nextLong(config.stalkMinSeconds(), config.stalkMaxSeconds() + 1L);
+ return new StalkState(target, ctx.now() + seconds * 1000L);
+ }
+
+ /**
+ * Returns the stalked survivor.
+ *
+ * @return the survivor's id
+ */
+ public UUID target() {
+ return this.target;
+ }
+
+ @Override
+ public void enter(CreekContext ctx) {
+ 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 VanishState.after(ctx);
+
+ 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() && relocate(ctx, view)) {
+ this.seenSince = -1L;
+ }
+ return this;
+ }
+ this.seenSince = -1L;
+
+ double distance = body.position().distance(view.position());
+ if (distance < config.stalkMinDistance() || distance > config.stalkMaxDistance()) {
+ relocate(ctx, view);
+ }
+ return this;
+ }
+
+ /**
+ * Teleports the creek to a new spot at the edge of the target's view.
+ *
+ * @return {@code false} if no free spot was found in this step
+ */
+ static boolean relocate(CreekContext ctx, SurvivorView view) {
+ CreekConfig config = ctx.config();
+ Optional spot = ctx.spots().beside(view.position(), config.stalkMinDistance(),
+ config.stalkMaxDistance(), config.stalkMinAngle(), config.stalkMaxAngle(),
+ List.of(view.eyes()), config.personalSpace(), ctx.random());
+ spot.ifPresent(ctx.body()::teleport);
+ return spot.isPresent();
+ }
+}
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..f963612c
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java
@@ -0,0 +1,31 @@
+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 the survivor's feet, including the view direction
+ * @param dread how likely the survivor is to be stalked or hunted, from 0 to 1
+ * @param seesCreek whether the survivor sees 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) {
+
+ /** Eye height of a standing player, in blocks. */
+ public static final double EYE_HEIGHT = 1.62D;
+
+ /**
+ * Returns the survivor's eye position.
+ *
+ * @return the eye position, with the survivor's view direction
+ */
+ public Pos eyes() {
+ return this.position.add(0, EYE_HEIGHT, 0);
+ }
+}
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..243f0480
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/VanishState.java
@@ -0,0 +1,98 @@
+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;
+
+/**
+ * The creek is invisible for a while. Afterwards it reappears somewhere nobody is looking.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class VanishState implements CreekState {
+
+ private final long until;
+
+ /**
+ * Creates 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;
+ }
+
+ /**
+ * Starts a vanish. The higher the dread of the survivors, the shorter it is.
+ *
+ * @param ctx the current step
+ * @return the state
+ */
+ public static VanishState after(CreekContext ctx) {
+ return new VanishState(ctx.now() + cooldownMillis(ctx.config(), ctx.highestDread()));
+ }
+
+ /**
+ * Starts a vanish that never ends. Used when the creek should sit out the rest of the round.
+ *
+ * @return the state
+ */
+ public static VanishState forever() {
+ return new VanishState(Long.MAX_VALUE);
+ }
+
+ /**
+ * Returns whether this vanish never ends.
+ *
+ * @return {@code true} for {@link #forever()}
+ */
+ public boolean isForever() {
+ return this.until == Long.MAX_VALUE;
+ }
+
+ /**
+ * Calculates how long the creek stays away: {@code vanishMaxSeconds} at no dread,
+ * {@code vanishMinSeconds} at full dread.
+ *
+ * @param config the settings
+ * @param dread the highest dread among the survivors
+ * @return the cooldown 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);
+ }
+
+ @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();
+ Optional spot = ctx.route()
+ .next(ctx.body().position(),
+ point -> ctx.spots().hiddenSpotAt(point, observers, distance).isPresent(),
+ ctx.random())
+ .flatMap(point -> ctx.spots().hiddenSpotAt(point, observers, distance));
+ if (spot.isEmpty()) return this;
+
+ ctx.body().teleport(spot.get());
+ return new WanderState(ctx.now());
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/WanderState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/WanderState.java
new file mode 100644
index 00000000..dae86111
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/WanderState.java
@@ -0,0 +1,170 @@
+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 org.jetbrains.annotations.Nullable;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * The creek walks around the map. Every survivor can see it.
+ *
+ * When someone looks at it, it stops for a moment and looks back. When a survivor's dread is
+ * high enough, it switches to stalking them.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class WanderState implements CreekState {
+
+ /** Distance at which a waypoint counts as reached, in blocks. */
+ static final double ARRIVED = 2.0D;
+
+ /** How far the creek has to move to count as progress, in blocks. */
+ static final double PROGRESS = 0.5D;
+
+ private final long stalkAllowedAt;
+ private @Nullable Pos goal;
+ private boolean watched;
+ private long pausedUntil;
+ private Pos progressAt = Pos.ZERO;
+ private long progressSince;
+ private Set shownTo = Set.of();
+
+ /**
+ * Creates the state.
+ *
+ * @param stalkAllowedAt the earliest time the creek may start stalking, in milliseconds
+ */
+ public WanderState(long stalkAllowedAt) {
+ this.stalkAllowedAt = stalkAllowedAt;
+ }
+
+ @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 (!ctx.farFromAll(here, config.personalSpace())) return VanishState.after(ctx);
+
+ if (ctx.now() >= this.stalkAllowedAt) {
+ Optional stalk = pickTarget(ctx).flatMap(target -> startStalking(ctx, target));
+ if (stalk.isPresent()) return stalk.get();
+ }
+
+ 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 (this.needsNewGoal(ctx, here)) {
+ this.goal = ctx.route()
+ .next(here, point -> ctx.farFromAll(point, config.personalSpace()), ctx.random())
+ .orElse(null);
+ this.markProgress(ctx.now(), here);
+ }
+ if (this.goal == null) {
+ body.stop();
+ return this;
+ }
+ body.moveTo(this.goal, config.wanderSpeed());
+ return this;
+ }
+
+ /**
+ * Stops and looks back for a moment when someone spots the creek.
+ *
+ * @return {@code true} while the pause lasts
+ */
+ 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());
+ this.goal = null;
+ return true;
+ }
+
+ private boolean needsNewGoal(CreekContext ctx, Pos here) {
+ if (this.goal == null || here.distance(this.goal) < ARRIVED) return true;
+ if (!ctx.farFromAll(this.goal, ctx.config().personalSpace())) return true;
+ if (here.distance(this.progressAt) >= PROGRESS) {
+ this.markProgress(ctx.now(), here);
+ return false;
+ }
+ return ctx.now() - this.progressSince >= ctx.config().stuckMillis();
+ }
+
+ /**
+ * Makes the creek visible to exactly the current survivors. A player who is no longer a
+ * survivor (dead, or now the slender) stops seeing 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;
+ }
+
+ /**
+ * Picks the survivor to stalk: the one with the highest dread above the threshold. On a tie,
+ * the one farthest from the others.
+ */
+ static Optional pickTarget(CreekContext ctx) {
+ double threshold = ctx.config().stalkThreshold();
+ return ctx.survivors().stream()
+ .filter(view -> view.dread() >= threshold)
+ .max(Comparator.comparingDouble(SurvivorView::dread)
+ .thenComparingDouble(view -> nearestOther(ctx, view)));
+ }
+
+ private static double nearestOther(CreekContext ctx, SurvivorView view) {
+ double nearest = Double.MAX_VALUE;
+ for (SurvivorView other : ctx.survivors()) {
+ if (other.id().equals(view.id())) continue;
+ nearest = Math.min(nearest, other.position().distance(view.position()));
+ }
+ return nearest;
+ }
+
+ private static Optional startStalking(CreekContext ctx, SurvivorView target) {
+ CreekConfig config = ctx.config();
+ return ctx.spots()
+ .beside(target.position(), config.stalkMinDistance(), config.stalkMaxDistance(),
+ config.stalkMinAngle(), config.stalkMaxAngle(), List.of(target.eyes()),
+ config.personalSpace(), ctx.random())
+ .map(spot -> {
+ ctx.body().teleport(spot);
+ return StalkState.starting(target.id(), ctx);
+ });
+ }
+}
From ef13848ccc7c87983e41c574aaa106c4b5a2fa3a Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:43:31 +0200
Subject: [PATCH 09/92] feat(creek): add world integration
---
.../cygnus/creek/world/CreekSight.java | 65 +++++++++++++
.../cygnus/creek/world/Ground.java | 24 +++++
.../cygnus/creek/world/InstanceGround.java | 60 ++++++++++++
.../cygnus/creek/world/RandomPointRoute.java | 55 +++++++++++
.../cygnus/creek/world/RouteProvider.java | 32 ++++++
.../cygnus/creek/world/SpotFinder.java | 97 +++++++++++++++++++
.../cygnus/creek/world/package-info.java | 7 ++
7 files changed, 340 insertions(+)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekSight.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/world/Ground.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/world/InstanceGround.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/world/RandomPointRoute.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteProvider.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/world/SpotFinder.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/world/package-info.java
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..8250c803
--- /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;
+
+/**
+ * Checks whether a survivor sees the creek.
+ *
+ * It uses the same view cone as {@link SlenderGaze}: within a range and an angle of the
+ * survivor's view direction. 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;
+
+ /**
+ * Creates a sight check with the given cone.
+ *
+ * @param range how far away the creek can be noticed, in blocks
+ * @param viewAngle the maximum angle from the view center, in degrees
+ */
+ public CreekSight(int range, int viewAngle) {
+ // SlenderGaze needs a close distance below its range. Only "inside the cone or not"
+ // matters here, and zero keeps the full range.
+ this.cone = new SlenderGaze(range, 0, viewAngle);
+ }
+
+ /**
+ * Checks whether a point is inside an observer's view cone. Blocks in between are ignored.
+ *
+ * @param observer the observer's eye position, including yaw and pitch
+ * @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 sees the creek: it is inside the 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 sees 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..26ee82b3
--- /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;
+
+/**
+ * Moves 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..40e3af2b
--- /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;
+
+ /** 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;
+
+ /**
+ * Creates 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/RandomPointRoute.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RandomPointRoute.java
new file mode 100644
index 00000000..7e03ba49
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RandomPointRoute.java
@@ -0,0 +1,55 @@
+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.function.Supplier;
+import java.util.random.RandomGenerator;
+
+/**
+ * Picks random waypoints from the map's page positions and survivor spawns.
+ *
+ * The points are fetched on every step, because pages move during a round.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class RandomPointRoute implements RouteProvider {
+
+ /** Points closer than this are skipped, in blocks. */
+ static final double MIN_STEP = 4.0D;
+
+ private final Supplier> points;
+
+ /**
+ * Creates the route.
+ *
+ * @param points supplies the waypoints
+ */
+ public RandomPointRoute(Supplier> points) {
+ this.points = points;
+ }
+
+ /**
+ * Returns the current waypoints.
+ *
+ * @return the points, possibly empty
+ */
+ public List points() {
+ return this.points.get();
+ }
+
+ @Override
+ public Optional next(Pos current, Predicate allowed, RandomGenerator random) {
+ List candidates = this.points.get().stream()
+ .filter(point -> point.distance(current) >= MIN_STEP)
+ .filter(allowed)
+ .toList();
+ if (candidates.isEmpty()) return Optional.empty();
+ return Optional.of(candidates.get(random.nextInt(candidates.size())));
+ }
+}
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..63cad7c6
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteProvider.java
@@ -0,0 +1,32 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.random.RandomGenerator;
+
+/**
+ * Decides where the creek walks next.
+ *
+ * For now the waypoints come from the map's pages and spawns. Hand-placed waypoints from the
+ * setup can be added later as another implementation.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+@FunctionalInterface
+public interface RouteProvider {
+
+ /**
+ * Picks the next point.
+ *
+ * @param current the creek's position
+ * @param allowed filters the points the creek may go to
+ * @param random the random source
+ * @return the next point, or empty if none is allowed
+ */
+ Optional next(Pos current, Predicate allowed, RandomGenerator random);
+}
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..9084f55e
--- /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;
+
+ /** Height of the middle of the creek's body. This is the point a survivor would see. */
+ public static final double BODY_CENTRE = 1.4D;
+
+ private final CreekSight sight;
+ private final Ground ground;
+
+ /**
+ * Creates the spot finder.
+ *
+ * @param sight the view check a spot must pass
+ * @param ground moves 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 the position of the creek's feet
+ * @param observerEyes the eye positions of everyone who could see the creek
+ * @param personalSpace the minimum distance to every observer
+ * @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;
+ }
+
+ /**
+ * Moves 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 eye positions of everyone who could see the creek
+ * @param personalSpace the minimum distance to every observer
+ * @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 to the side of a target's view.
+ *
+ * @param target the target's feet; its yaw is the view direction
+ * @param minDistance the minimum distance to the target
+ * @param maxDistance the maximum distance to the target
+ * @param minAngle the minimum angle from the target's view direction, in degrees
+ * @param maxAngle the maximum angle from the target's view direction, in degrees
+ * @param observerEyes the eye positions of everyone who could see the creek
+ * @param personalSpace the minimum distance to every observer
+ * @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;
From 1405f1982d81492c4747247b3101abbbd22bb502 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:43:42 +0200
Subject: [PATCH 10/92] feat(creek): add debug part
---
.../cygnus/creek/debug/CreekDebug.java | 157 ++++++++++++++++++
.../cygnus/creek/debug/package-info.java | 7 +
2 files changed, 164 insertions(+)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/debug/CreekDebug.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/debug/package-info.java
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..498e2859
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/debug/CreekDebug.java
@@ -0,0 +1,157 @@
+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.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.WanderState;
+
+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 doing in the action bar of players who turned it on. Meant for
+ * playtests.
+ *
+ * Watchers see everything, whatever their role. This is a tuning tool, not something for
+ * players in a real round.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekDebug {
+
+ /** Shown to watchers while no round is running. */
+ public static final Component INACTIVE = Component.text("Creek inactive", NamedTextColor.GRAY);
+
+ /** Marks a survivor who currently sees the creek. The default Minecraft font has no emoji. */
+ static final String SEEN = "◉";
+
+ private final Set watchers = ConcurrentHashMap.newKeySet();
+ private volatile boolean active;
+
+ /**
+ * Turns 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;
+ }
+
+ /**
+ * Returns whether anyone has the debug line turned on.
+ *
+ * @return {@code true} if at least one player watches
+ */
+ public boolean hasWatchers() {
+ return !this.watchers.isEmpty();
+ }
+
+ /**
+ * Returns whether the creek is currently in a round.
+ *
+ * @return {@code true} while the creek is in the world
+ */
+ public boolean isActive() {
+ return this.active;
+ }
+
+ /**
+ * Sets whether the creek is in a round. When 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 online watcher.
+ *
+ * @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 the creek's position
+ * @param views the survivors in that step
+ * @param names turns a player id into a name
+ * @return the line
+ */
+ public static Component line(CreekState state, Pos creek, List views, Function names) {
+ TextComponent.Builder builder = Component.text();
+ builder.append(Component.text(label(state), NamedTextColor.RED));
+
+ 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)));
+ });
+
+ 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();
+ }
+
+ /**
+ * Returns the display name of a state.
+ *
+ * @param state the state
+ * @return the name, for example {@code HUNT}
+ */
+ public static String label(CreekState state) {
+ return switch (state) {
+ case WanderState _ -> "WANDER";
+ case StalkState _ -> "STALK";
+ case HuntState _ -> "HUNT";
+ case VanishState _ -> "VANISH";
+ 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());
+ 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..ec9147eb
--- /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;
From 7756db2f97a7041290f193416a107f1badf7f920 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:44:01 +0200
Subject: [PATCH 11/92] feat(creek): add management layer
---
.../cygnus/creek/CreekService.java | 175 ++++++++++++++++++
.../cygnus/creek/RoundClock.java | 62 +++++++
.../cygnus/creek/dread/package-info.java | 7 +
.../cygnus/creek/package-info.java | 7 +
.../cygnus/creek/state/package-info.java | 7 +
5 files changed, 258 insertions(+)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/RoundClock.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/dread/package-info.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/package-info.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/state/package-info.java
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..9536d0ef
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java
@@ -0,0 +1,175 @@
+package net.onelitefeather.cygnus.creek;
+
+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.creek.body.CreekBody;
+import net.onelitefeather.cygnus.creek.consequence.CatchConsequence;
+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.VanishState;
+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.RandomPointRoute;
+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.Supplier;
+import java.util.random.RandomGenerator;
+
+/**
+ * Adds the creek to a round and removes it at the end.
+ *
+ * Like {@code SlenderGazeService}, it listens for the start and end of a round itself. There is
+ * no listener for deaths or disconnects: those players drop out of the survivor list on the next
+ * step, so a creek whose target is gone vanishes 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);
+
+ /** Time between two steps, 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 RandomPointRoute route;
+ private final RepeatingTask task = new RepeatingTask(this::tick);
+ private volatile @Nullable Creek creek;
+
+ /**
+ * Creates 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 routePoints supplies the waypoints
+ * @param bodies spawns the creek's body
+ * @param dread rates the survivors
+ * @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> routePoints,
+ 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.route = new RandomPointRoute(routePoints);
+ }
+
+ /**
+ * Registers the listeners for the start and end of a round.
+ *
+ * @param node the node to register on
+ */
+ public void registerListener(EventNode node) {
+ node.addListener(GameStartEvent.class, _ -> this.start());
+ node.addListener(GameFinishEvent.class, _ -> this.stop());
+ }
+
+ /**
+ * Spawns the creek (invisible at first) and starts the step task.
+ */
+ void start() {
+ if (this.creek != null) return;
+
+ Instance world = this.instance.get();
+ List points = this.route.points();
+ if (world == null || points.isEmpty()) {
+ LOGGER.warn("The map offers no page positions or spawns to walk between, so the creek stays away this round");
+ return;
+ }
+
+ 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 invisible. The creek shows up once the survivors had time to spread out, at a
+ // spot far away from all of them.
+ CreekState initial = new VanishState(this.clock.now() + this.config.vanishMinSeconds() * 1000L);
+ this.creek = new Creek(body, this.sight, this.dread, this.route, this.spots, this.consequence,
+ this.config, this.random, initial);
+ this.debug.setActive(true);
+ this.task.start(TICK_MILLIS, ChronoUnit.MILLIS);
+ }
+
+ /**
+ * Runs one step of the creek.
+ */
+ void tick() {
+ Creek current = this.creek;
+ if (current == null) return;
+ Set players = this.survivors.get();
+ current.tick(players, this.clock.now());
+ if (this.debug.hasWatchers()) {
+ this.debug.show(CreekDebug.line(current.state(), current.body().position(), current.lastViews(), id -> nameOf(players, id)));
+ }
+ }
+
+ private static String nameOf(Set players, UUID id) {
+ for (Player player : players) {
+ if (player.getUuid().equals(id)) return player.getUsername();
+ }
+ return id.toString().substring(0, 8);
+ }
+
+ /**
+ * Removes the creek and clears any remaining catch effects.
+ */
+ void stop() {
+ this.task.stop();
+ Creek current = this.creek;
+ this.creek = null;
+ this.clock.reset();
+ if (current != null) current.remove();
+ this.consequence.cleanUp();
+ this.debug.setActive(false);
+ }
+
+ @Nullable Creek creek() {
+ return this.creek;
+ }
+}
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..b89e4db9
--- /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;
+
+/**
+ * Tracks how long the current round has been running.
+ *
+ * The service starts and resets it, and the dread rating reads it. Sharing one clock avoids a
+ * dependency between the two.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class RoundClock {
+
+ private final LongSupplier clock;
+ private volatile long startedAt = -1L;
+
+ /**
+ * Creates the clock.
+ *
+ * @param clock supplies the current time in milliseconds
+ */
+ public RoundClock(LongSupplier clock) {
+ this.clock = clock;
+ }
+
+ /**
+ * Returns 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 ends.
+ */
+ public void reset() {
+ this.startedAt = -1L;
+ }
+
+ /**
+ * Returns how long the round has been running.
+ *
+ * @return the elapsed 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/dread/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/package-info.java
new file mode 100644
index 00000000..fd62c5a7
--- /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 stalked or hunted.
+ */
+@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/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/package-info.java
new file mode 100644
index 00000000..9c08b467
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * The creek's behavior, split into states: wandering, stalking, hunting and vanishing.
+ */
+@NotNullByDefault
+package net.onelitefeather.cygnus.creek.state;
+
+import org.jetbrains.annotations.NotNullByDefault;
From d2a16721dd692a4ce335c39bd534f4801f9140ba Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:44:27 +0200
Subject: [PATCH 12/92] test(creek): add a bunch of cases
---
.../cygnus/creek/CreekIntegrationTest.java | 123 ++++++++++++
.../creek/CreekServiceIntegrationTest.java | 139 ++++++++++++++
.../body/CreakingBodyIntegrationTest.java | 86 +++++++++
.../CatchEffectsIntegrationTest.java | 59 ++++++
.../GlowRevealIntegrationTest.java | 104 ++++++++++
...StagedCatchConsequenceIntegrationTest.java | 139 ++++++++++++++
.../cygnus/creek/debug/CreekDebugTest.java | 71 +++++++
.../creek/dread/PageProgressDreadTest.java | 70 +++++++
.../cygnus/creek/state/Contexts.java | 40 ++++
.../cygnus/creek/state/CreekContextTest.java | 59 ++++++
.../cygnus/creek/state/HuntStateTest.java | 109 +++++++++++
.../cygnus/creek/state/RecordingBody.java | 91 +++++++++
.../cygnus/creek/state/StalkStateTest.java | 102 ++++++++++
.../cygnus/creek/state/VanishStateTest.java | 90 +++++++++
.../cygnus/creek/state/WanderStateTest.java | 181 ++++++++++++++++++
.../world/CreekSightIntegrationTest.java | 38 ++++
.../cygnus/creek/world/CreekSightTest.java | 41 ++++
.../world/InstanceGroundIntegrationTest.java | 35 ++++
.../creek/world/RandomPointRouteTest.java | 43 +++++
.../cygnus/creek/world/SpotFinderTest.java | 58 ++++++
.../cygnus/stamina/FoodBarTest.java | 18 ++
21 files changed, 1696 insertions(+)
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/consequence/CatchEffectsIntegrationTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/consequence/GlowRevealIntegrationTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequenceIntegrationTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/debug/CreekDebugTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/dread/PageProgressDreadTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/state/CreekContextTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/state/RecordingBody.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/state/StalkStateTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/state/WanderStateTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightIntegrationTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekSightTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/world/InstanceGroundIntegrationTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/world/RandomPointRouteTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/world/SpotFinderTest.java
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..4ed01597
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java
@@ -0,0 +1,123 @@
+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.body.CreekBody;
+import net.onelitefeather.cygnus.creek.state.Contexts;
+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.WanderState;
+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 static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+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());
+ return new Creek(body, sight, (_, _, _) -> 0.0D, Contexts.route(), new SpotFinder(sight, Optional::of),
+ _ -> {}, config, new Random(3), initial);
+ }
+
+ @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 WanderState(Long.MAX_VALUE)).tick(List.of(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 WanderState(Long.MAX_VALUE));
+ creek.tick(List.of(survivor), 0L);
+
+ assertTrue(creek.views(List.of(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(List.of(target, other), 0L);
+
+ SurvivorView otherView = creek.views(List.of(target, other)).stream()
+ .filter(view -> view.id().equals(other.getUuid()))
+ .findFirst().orElseThrow();
+ assertFalse(otherView.seesCreek());
+ }
+
+ @Test
+ @DisplayName("When the target leaves mid-hunt he vanishes")
+ 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(List.of(target, other), 0L);
+
+ creek.tick(List.of(other), 100L);
+
+ assertInstanceOf(VanishState.class, creek.state());
+ assertTrue(body.entity().getViewers().isEmpty());
+ }
+
+ @Test
+ @DisplayName("With the last survivor and the setting off he is gone for good")
+ void dormantWithTheLastSurvivor(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, withoutLastSurvivor(), new WanderState(Long.MAX_VALUE));
+
+ creek.tick(List.of(survivor), 0L);
+
+ assertInstanceOf(VanishState.class, creek.state());
+ assertTrue(((VanishState) creek.state()).isForever());
+ }
+
+ private static CreekConfig withoutLastSurvivor() {
+ CreekConfig d = CreekConfig.DEFAULT;
+ return new CreekConfig(
+ d.enabled(), false, 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());
+ }
+}
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..b8dc1a84
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java
@@ -0,0 +1,139 @@
+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.network.packet.server.play.ActionBarPacket;
+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.creek.body.CreakingBody;
+import net.onelitefeather.cygnus.creek.consequence.CatchConsequence;
+import net.onelitefeather.cygnus.creek.debug.CreekDebug;
+import net.onelitefeather.cygnus.creek.state.VanishState;
+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 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.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 CreekService service(Instance instance, Set survivors, List points, AtomicLong clock) {
+ return new CreekService(CreekConfig.DEFAULT, () -> survivors, () -> instance, () -> points,
+ 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(new Pos(10, 40, 10)), 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("At the end he is removed and every consequence is cleaned up")
+ void stopRemovesHimAndCleansUp(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 Pos(10, 40, 10)), new AtomicLong());
+ service.start();
+ Creek creek = service.creek();
+ assertNotNull(creek);
+
+ service.stop();
+
+ assertTrue(creek.body().entity().isRemoved());
+ assertNull(service.creek());
+ assertEquals(1, cleanUps.get());
+ }
+
+ @Test
+ @DisplayName("Without any point to walk between he stays away")
+ void staysAwayWithoutPoints(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(new Pos(10, 40, 10)), 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(new Pos(10, 40, 10)), 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();
+ }
+}
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..d7c738a1
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java
@@ -0,0 +1,86 @@
+package net.onelitefeather.cygnus.creek.body;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.EntityCreature;
+import net.minestom.server.entity.Player;
+import net.minestom.server.entity.metadata.monster.CreakingMeta;
+import net.minestom.server.instance.Instance;
+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.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());
+ }
+}
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/StagedCatchConsequenceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequenceIntegrationTest.java
new file mode 100644
index 00000000..6782ad19
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequenceIntegrationTest.java
@@ -0,0 +1,139 @@
+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 org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.random.RandomGenerator;
+
+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, always(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);
+ }
+
+ private static RandomGenerator always(double value) {
+ return new RandomGenerator() {
+ @Override
+ public long nextLong() {
+ return 0L;
+ }
+
+ @Override
+ public double nextDouble() {
+ return value;
+ }
+ };
+ }
+}
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..ecc99c3f
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/debug/CreekDebugTest.java
@@ -0,0 +1,71 @@
+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.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.WanderState;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+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("Wandering there is no target, only the dread of everyone")
+ void wanderLine() {
+ List views = List.of(new SurvivorView(STEVE, new Pos(0, 40, 30), 0.1D, false));
+
+ Component line = CreekDebug.line(new WanderState(0L), Pos.ZERO, views, NAMES::get);
+
+ assertEquals("WANDER · 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()));
+ }
+
+ @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());
+ }
+}
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..a3bf682d
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java
@@ -0,0 +1,40 @@
+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.SpotFinder;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.Random;
+import java.util.UUID;
+
+/**
+ * Builds contexts for the state tests: the design's settings, ground everywhere, fixed chance.
+ */
+public final class Contexts {
+
+ static final CreekConfig CONFIG = CreekConfig.DEFAULT;
+ static final CreekSight SIGHT = new CreekSight(CONFIG.sightRange(), CONFIG.sightViewAngle());
+ static final SpotFinder SPOTS = new SpotFinder(SIGHT, Optional::of);
+
+ static CreekContext context(long now, CreekBody body, RouteProvider route, List caught,
+ SurvivorView... survivors) {
+ return new CreekContext(now, List.of(survivors), body, route, SPOTS, caught::add, CONFIG, new Random(7));
+ }
+
+ /** A route that offers the first allowed point at least a block away. */
+ public static RouteProvider route(Pos... points) {
+ return (current, allowed, _) -> Arrays.stream(points)
+ .filter(point -> point.distance(current) >= 1.0D)
+ .filter(allowed)
+ .findFirst();
+ }
+
+ 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..15235187
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateTest.java
@@ -0,0 +1,109 @@
+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.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 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);
+ assertInstanceOf(VanishState.class, 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));
+ assertInstanceOf(VanishState.class, 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/RecordingBody.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/RecordingBody.java
new file mode 100644
index 00000000..1f2c9c4b
--- /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;
+
+ 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..34aa1ea2
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/StalkStateTest.java
@@ -0,0 +1,102 @@
+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 vanishes")
+ void vanishesWithoutTheTarget() {
+ RecordingBody body = new RecordingBody(IN_THE_BAND);
+ assertInstanceOf(VanishState.class, new StalkState(TARGET, 60_000L).tick(at(0L, body)));
+ }
+
+ @Test
+ @DisplayName("When the time is up he vanishes")
+ void vanishesWhenTheTimeIsUp() {
+ RecordingBody body = new RecordingBody(IN_THE_BAND);
+ assertInstanceOf(VanishState.class, 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);
+ }
+}
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..e3c50299
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java
@@ -0,0 +1,90 @@
+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.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(WanderState.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("The cooldown shrinks as the dread grows")
+ void cooldownFollowsDread() {
+ assertEquals(40_000L, VanishState.cooldownMillis(Contexts.CONFIG, 0.0D));
+ assertEquals(30_000L, VanishState.cooldownMillis(Contexts.CONFIG, 0.5D));
+ assertEquals(20_000L, VanishState.cooldownMillis(Contexts.CONFIG, 1.0D));
+ }
+
+ @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)));
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/WanderStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/WanderStateTest.java
new file mode 100644
index 00000000..8ac5f241
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/WanderStateTest.java
@@ -0,0 +1,181 @@
+package net.onelitefeather.cygnus.creek.state;
+
+import net.minestom.server.coordinate.Pos;
+import net.onelitefeather.cygnus.creek.world.RouteProvider;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+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 WanderStateTest {
+
+ 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);
+ }
+
+ @Test
+ @DisplayName("Entering shows him to every survivor")
+ void enterShowsHimToEveryone() {
+ RecordingBody body = new RecordingBody(Pos.ZERO);
+ new WanderState(0L).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));
+ WanderState state = new WanderState(Long.MAX_VALUE);
+ 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, he stops and looks back for a moment")
+ void pausesWhenSpotted() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ WanderState state = new WanderState(Long.MAX_VALUE);
+ 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("A survivor walking up to him makes him vanish")
+ void vanishesWhenApproached() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ WanderState state = new WanderState(Long.MAX_VALUE);
+ CreekContext ctx = Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(),
+ new SurvivorView(FIRST, new Pos(0, 40, 10), 0.0D, false));
+ state.enter(ctx);
+
+ assertInstanceOf(VanishState.class, state.tick(ctx));
+ }
+
+ @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(calls.getAndIncrement() % 2 == 0 ? A : B);
+ WanderState state = new WanderState(Long.MAX_VALUE);
+ 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));
+ WanderState state = new WanderState(Long.MAX_VALUE);
+ 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("Above the stalk threshold he starts stalking")
+ void startsStalking() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ WanderState state = new WanderState(0L);
+ CreekContext ctx = Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.3D, false));
+ state.enter(ctx);
+
+ CreekState next = state.tick(ctx);
+
+ assertInstanceOf(StalkState.class, next);
+ assertEquals(FIRST, ((StalkState) next).target());
+ assertEquals(1, body.teleports.size());
+ }
+
+ @Test
+ @DisplayName("Before the cooldown he keeps wandering")
+ void waitsForTheCooldown() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ WanderState state = new WanderState(5000L);
+ CreekContext ctx = Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.3D, false));
+ state.enter(ctx);
+
+ assertSame(state, state.tick(ctx));
+ }
+
+ @Test
+ @DisplayName("Calm survivors are left alone")
+ void leavesCalmSurvivorsAlone() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ WanderState state = new WanderState(0L);
+ CreekContext ctx = Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.1D, false));
+ state.enter(ctx);
+
+ assertSame(state, state.tick(ctx));
+ }
+
+ @Test
+ @DisplayName("On a tie he picks the one who strayed from the group")
+ void prefersTheLonelyOne() {
+ UUID third = UUID.randomUUID();
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ WanderState state = new WanderState(0L);
+ CreekContext ctx = Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(),
+ new SurvivorView(FIRST, new Pos(0, 40, -60, 0, 0), 0.3D, false),
+ new SurvivorView(SECOND, new Pos(5, 40, -60, 0, 0), 0.3D, false),
+ new SurvivorView(third, new Pos(0, 40, -160, 0, 0), 0.3D, false));
+ state.enter(ctx);
+
+ assertEquals(third, ((StalkState) state.tick(ctx)).target());
+ }
+
+ @Test
+ @DisplayName("Someone who leaves the survivors stops seeing him while he wanders")
+ void dropsViewersWhoLeaveTheSurvivors() {
+ RecordingBody body = new RecordingBody(new Pos(0, 40, 0));
+ WanderState state = new WanderState(Long.MAX_VALUE);
+ 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, "a survivor turned slender or spectator must lose sight of him");
+ }
+}
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/RandomPointRouteTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/RandomPointRouteTest.java
new file mode 100644
index 00000000..dc99be3b
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/RandomPointRouteTest.java
@@ -0,0 +1,43 @@
+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.Random;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class RandomPointRouteTest {
+
+ private static final Pos A = new Pos(10, 40, 0);
+ private static final Pos B = new Pos(-10, 40, 0);
+ private static final Pos NEAR = new Pos(1, 40, 0);
+
+ @Test
+ @DisplayName("Only allowed points are picked")
+ void onlyAllowedPoints() {
+ RandomPointRoute route = new RandomPointRoute(() -> List.of(A, B));
+ for (int i = 0; i < 20; i++) {
+ assertEquals(B, route.next(Pos.ZERO, point -> point.x() < 0, new Random(i)).orElseThrow());
+ }
+ }
+
+ @Test
+ @DisplayName("A point right next to him is no step at all")
+ void skipsPointsNextToHim() {
+ RandomPointRoute route = new RandomPointRoute(() -> List.of(NEAR, A));
+ for (int i = 0; i < 20; i++) {
+ assertEquals(A, route.next(Pos.ZERO, _ -> true, new Random(i)).orElseThrow());
+ }
+ }
+
+ @Test
+ @DisplayName("Nowhere allowed means nowhere to go")
+ void nothingAllowed() {
+ RandomPointRoute route = new RandomPointRoute(() -> List.of(A, B));
+ assertTrue(route.next(Pos.ZERO, _ -> false, new Random(1)).isEmpty());
+ }
+}
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/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);
+ }
}
From 48aeb9bc9ece5c0cef88b642b14802ff1365ca1e Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:44:50 +0200
Subject: [PATCH 13/92] feat(doc): improve documentation
---
.../cygnus/common/config/CreekConfig.java | 79 +++++++++----------
.../common/config/GameConfigReader.java | 13 ++-
.../cygnus/common/page/PageProvider.java | 2 +-
3 files changed, 46 insertions(+), 48 deletions(-)
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java
index 8ef874f8..9b9a84b3 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java
@@ -1,46 +1,45 @@
package net.onelitefeather.cygnus.common.config;
/**
- * Tunes the creek, the figure that walks the map next to the slender.
+ * Settings for the creek, the second figure next to the slender.
*
- * Every number his behavior depends on lives here, so distances and timings can be tuned in a
- * playtest without touching the code. The compact constructor rejects combinations that would
- * break the behavior outright rather than merely make it feel off. a stalk band that runs
- * backwards, or a hiding place inside the cone that counts as being seen the moment he gets there.
+ * All numbers that control the creek's behavior live here, so they can be tuned in a playtest
+ * without changing code. The constructor rejects combinations that would break the behavior,
+ * for example a stalk distance range that runs backwards, or a hiding spot inside the view cone.
*
*
- * @param enabled whether the creek takes part in a round at all
- * @param activeWithLastSurvivor whether he keeps going once only one survivor is left
- * @param sightRange how far away a survivor can notice him, in blocks
- * @param sightViewAngle how far off the center of a survivor's view he may stand and still
- * count as seen, in degrees
- * @param wanderPauseMillis how long he stands still when someone spots him while wandering
- * @param wanderSpeed his movement speed while wandering, in blocks per tick
- * @param huntSpeed his movement speed while hunting, in blocks per tick; a walking
- * survivor covers about 0.216, so this has to stay above that
- * @param stalkThreshold the dread at which he picks a survivor to stalk
+ * @param enabled whether the creek takes part in a round
+ * @param activeWithLastSurvivor whether the creek keeps going when only one survivor is left
+ * @param sightRange how far away a survivor can notice the creek, in blocks
+ * @param sightViewAngle the maximum angle from a survivor's view center at which the
+ * creek counts as seen, in degrees
+ * @param wanderPauseMillis how long the creek stops when spotted while wandering
+ * @param wanderSpeed movement speed while wandering, in blocks per tick
+ * @param huntSpeed movement speed while hunting, in blocks per tick; must be above
+ * a walking survivor's 0.216
+ * @param stalkThreshold the dread at which the creek starts stalking a survivor
* @param huntThreshold the dread at which a stalk turns into a hunt
- * @param stalkMinDistance the closest he stands to the survivor he stalks, in blocks
- * @param stalkMaxDistance the farthest he stands from the survivor he stalks, in blocks
- * @param stalkMinAngle the smallest angle off the survivor's view he places himself at
- * @param stalkMaxAngle the largest angle off the survivor's view he places himself at
- * @param stalkRevealMillis how long he lets himself be seen before he moves on
- * @param stalkMinSeconds the shortest a stalk lasts
- * @param stalkMaxSeconds the longest a stalk lasts
- * @param huntMaxSeconds the longest a hunt lasts before he gives up
- * @param catchDistance how close he has to come to catch a survivor, in blocks
- * @param vanishMinSeconds the shortest he stays away, reached at full dread
- * @param vanishMaxSeconds the longest he stays away, reached at no dread
- * @param respawnMinDistance how far from every survivor he reappears, in blocks
- * @param personalSpace how close he lets a survivor come outside a hunt, in blocks
- * @param stuckMillis how long he may make no progress before he takes a shortcut
- * @param dreadPageWeight the share of the dread that comes from the pages found
- * @param dreadTimeWeight the share of the dread that comes from the elapsed round time
- * @param dreadIsolationWeight the share of the dread that comes from being alone
- * @param isolationRadius how far the nearest other survivor has to be to count as alone
- * @param betrayalCatchCount from which catch on he always gives a survivor away
- * @param betrayalChance the chance he gives a survivor away before that
- * @param betrayalGlowSeconds how long the slender sees a betrayed survivor glow
+ * @param stalkMinDistance minimum distance to the stalked survivor, in blocks
+ * @param stalkMaxDistance maximum distance to the stalked survivor, in blocks
+ * @param stalkMinAngle minimum angle from the stalked survivor's view direction, in degrees
+ * @param stalkMaxAngle maximum angle from the stalked survivor's view direction, in degrees
+ * @param stalkRevealMillis how long the creek stays visible before it teleports away
+ * @param stalkMinSeconds minimum length of a stalk
+ * @param stalkMaxSeconds maximum length of a stalk
+ * @param huntMaxSeconds maximum length of a hunt
+ * @param catchDistance distance at which the creek catches a survivor, in blocks
+ * @param vanishMinSeconds shortest vanish time, used at full dread
+ * @param vanishMaxSeconds longest vanish time, used at no dread
+ * @param respawnMinDistance minimum distance to every survivor when reappearing, in blocks
+ * @param personalSpace how close a survivor may come outside a hunt, in blocks
+ * @param stuckMillis how long the creek may make no progress before taking a shortcut
+ * @param dreadPageWeight dread share from found pages
+ * @param dreadTimeWeight dread share from elapsed round time
+ * @param dreadIsolationWeight dread share from being alone
+ * @param isolationRadius distance to the nearest survivor at which someone counts as alone
+ * @param betrayalCatchCount from this catch on, a survivor is always revealed to the slender
+ * @param betrayalChance chance of a reveal on earlier catches
+ * @param betrayalGlowSeconds how long a revealed survivor glows for the slender
* @param slownessSeconds how long a caught survivor is slowed
* @author theEvilReaper
* @version 1.0.0
@@ -81,7 +80,7 @@ public record CreekConfig(
) {
/**
- * The values from the design: on, and tuned for a round of about fifteen minutes.
+ * The default settings: enabled, and tuned for a round of about fifteen minutes.
*/
public static final CreekConfig DEFAULT = new CreekConfig(
true, true,
@@ -96,9 +95,9 @@ public record CreekConfig(
);
/**
- * Rejects values that would break the creek's behavior.
+ * Checks the values.
*
- * @throws IllegalArgumentException if a value is out of range or two values do not fit together
+ * @throws IllegalArgumentException if a value is out of range or two values contradict each other
*/
public CreekConfig {
atLeast("sightRange", sightRange, 1);
@@ -112,7 +111,7 @@ public record CreekConfig(
atLeast("personalSpace", personalSpace, 1);
below("personalSpace", personalSpace, "stalkMinDistance", stalkMinDistance);
below("stalkMinDistance", stalkMinDistance, "stalkMaxDistance", stalkMaxDistance);
- // A hiding place inside the cone would count as being seen the moment he got there.
+ // A hiding spot inside the view cone would count as seen right away.
below("sightViewAngle", sightViewAngle, "stalkMinAngle", stalkMinAngle);
below("stalkMinAngle", stalkMinAngle, "stalkMaxAngle", stalkMaxAngle);
between("stalkMaxAngle", stalkMaxAngle, 1, 180);
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
index 2c62243e..25cfa8ce 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
@@ -189,8 +189,8 @@ private float getFloat(Properties properties, String key, float defaultValue) {
}
/**
- * Reads a decimal from the properties with double precision. The creek's speeds and weights
- * are compared against each other, and a float's rounding would let 0.6 read as 0.6000000238.
+ * Reads a decimal with double precision. The creek's values are compared with each other,
+ * and float rounding would turn 0.6 into 0.6000000238.
*
* @param properties the loaded properties
* @param key the key to read
@@ -211,12 +211,11 @@ private double getDouble(Properties properties, String key, double defaultValue)
}
/**
- * Reads the creek settings, every key prefixed with {@value #CREEK_PREFIX}.
+ * Reads the creek settings. All keys start with {@value #CREEK_PREFIX}.
*
- * The values are checked against each other only once all of them are read, so a combination
- * that does not fit together cannot be patched per key. It falls back to the defaults as a
- * whole instead: a creek running half on the operator's values and half on ours would be
- * harder to reason about than one running entirely on ours.
+ * The values are checked together after reading. If they contradict each other, all creek
+ * settings fall back to the defaults, not just the broken key. A mix of custom and default
+ * values would be hard to reason about.
*
*
* @param properties the loaded properties
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 6e74b0c1..303ae981 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
@@ -210,7 +210,7 @@ public Component getPageStatus() {
}
/**
- * Returns how many pages the survivors have found so far this round.
+ * Returns the number of pages found in this round.
*
* @return the number of found pages, never negative
*/
From 47d88577108c9f53093122061d5b58e995b61d0c Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:45:14 +0200
Subject: [PATCH 14/92] chore(stamina): add drain method
---
.../onelitefeather/cygnus/stamina/FoodBar.java | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
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;
+ }
}
From 41f53fe8d5b67dc53d29707f197f1f30a45b6cd7 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:45:23 +0200
Subject: [PATCH 15/92] feat(creek): add debug command
---
.../cygnus/command/CreekCommand.java | 59 +++++++++++++++++++
1 file changed, 59 insertions(+)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/command/CreekCommand.java
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);
+ }
+}
From 74ea9ebc1a28791678ae9641535d181a34ed0270 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:45:36 +0200
Subject: [PATCH 16/92] chore(game): wire creek usage
---
.../net/onelitefeather/cygnus/Cygnus.java | 66 +++++++++++++++++++
1 file changed, 66 insertions(+)
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index eaddf023..e8fb0d26 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -39,6 +39,7 @@
import net.minestom.server.listener.EntityActionListener;
import net.minestom.server.listener.common.SettingsListener;
import net.minestom.server.network.packet.client.common.ClientSettingsPacket;
+import net.minestom.server.coordinate.Pos;
import net.minestom.server.network.packet.client.play.ClientEntityActionPacket;
import net.onelitefeather.cygnus.ambient.AmbientProvider;
import net.onelitefeather.cygnus.page.PageProximityService;
@@ -46,13 +47,16 @@
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.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,9 @@
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
import java.util.function.Supplier;
/**
@@ -133,6 +148,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 +209,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::creekRoutePoints,
+ CreakingBody::spawn,
+ new PageProgressDread(
+ this.pageProvider::foundPageCount,
+ this.pageProvider::getMaxPageAmount,
+ roundClock::elapsedMillis,
+ this.gameConfig.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 +248,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 +317,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 +364,21 @@ private void initPhases() {
this.linearPhaseSeries.add(new RestartPhase());
}
+ /**
+ * Collects the points the creek wanders between: where the pages are right now, and where
+ * the survivors started.
+ *
+ * @return the points, possibly empty
+ */
+ private List creekRoutePoints() {
+ List points = new ArrayList<>(this.pageProvider.interactablePagePositions());
+ GameMap map = this.mapProvider.getGameMap();
+ if (map != null) {
+ points.addAll(map.getSurvivorSpawns());
+ }
+ return points;
+ }
+
private void finishGame() {
this.pageProvider.cleanUp();
this.staminaService.cleanUp();
From d3bc5db3701a064f498aa3c73d9570fb9ed2d163 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Thu, 24 Sep 2026 22:45:50 +0200
Subject: [PATCH 17/92] chore(config): add creek configuration example
---
config.properties.example | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/config.properties.example b/config.properties.example
index 7ff986a8..6f1a6e46 100644
--- a/config.properties.example
+++ b/config.properties.example
@@ -196,3 +196,23 @@ 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.
+# Every key below maps to a field of CreekConfig; values that do not fit
+# together make the whole creek section fall back to its defaults.
+#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
From 8cf566d3b4ce5ead8de05915c0e05a6e67a3ed20 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 11:04:51 +0200
Subject: [PATCH 18/92] chore(config): update Creek config usage
---
.../cygnus/common/config/CreekConfig.java | 2 +-
.../cygnus/common/config/GameConfig.java | 10 +--
.../common/config/GameConfigBuilder.java | 0
.../common/config/GameConfigReader.java | 78 +++++++++----------
.../common/config/InternalGameConfig.java | 0
.../cygnus/common/page/PageProvider.java | 2 +-
.../common/config/GameConfigReaderTest.java | 16 +++-
.../cygnus/common/config/GameConfigTest.java | 2 +-
.../cygnus/common/page/PageProviderTest.java | 13 +---
config.properties.example | 4 +-
.../net/onelitefeather/cygnus/Cygnus.java | 5 +-
11 files changed, 66 insertions(+), 66 deletions(-)
delete mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigBuilder.java
delete mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/config/InternalGameConfig.java
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java
index 9b9a84b3..a42854a9 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java
@@ -101,7 +101,7 @@ public record CreekConfig(
*/
public CreekConfig {
atLeast("sightRange", sightRange, 1);
- between("sightViewAngle", sightViewAngle, 1, GameConfig.MAX_GLITCH_VIEW_ANGLE);
+ between("sightViewAngle", sightViewAngle, 1, GameConfig.Glitch.MAX_VIEW_ANGLE);
atLeast("wanderPauseMillis", wanderPauseMillis, 0);
positive("wanderSpeed", wanderSpeed);
positive("huntSpeed", huntSpeed);
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java
index b8393c7d..1769cda0 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java
@@ -21,6 +21,7 @@
* @param damageSound the sound a player hears when hit
* @param glitch how the sight of the slender tears a survivor's view
* @param slenderStatic the static the slender hears while pages are found
+ * @param creek the creek, the second figure next to the slender
* @param lobbyAtmosphereShare how far the lobby's atmosphere is taken towards the map's own: {@code 0}
* leaves the vanilla overworld, {@code 1} is exactly the map's atmosphere
* @author theEvilReaper
@@ -36,6 +37,7 @@ public record GameConfig(
DamageSound damageSound,
Glitch glitch,
SlenderStatic slenderStatic,
+ CreekConfig creek,
float lobbyAtmosphereShare
) {
@@ -104,6 +106,7 @@ public record GameConfig(
DamageSound.DEFAULT,
Glitch.DEFAULT,
SlenderStatic.DEFAULT,
+ CreekConfig.DEFAULT,
DEFAULT_LOBBY_ATMOSPHERE_SHARE
);
@@ -132,13 +135,6 @@ public record Round(int minPlayers, int maxPlayers, int lobbyTime, int gameTime)
}
}
- /**
- * Returns the settings of the creek, the figure that walks the map next to the slender.
- *
- * @return the creek settings, never {@code null}
- */
- CreekConfig creek();
-
/**
* The sizes of the teams.
*
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigBuilder.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigBuilder.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
index 25cfa8ce..4adb0c51 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
@@ -149,6 +149,7 @@ public GameConfig getConfig() {
getFloat(properties, "slenderStaticMinVolume", slenderStatic.minVolume()),
getFloat(properties, "slenderStaticMaxVolume", slenderStatic.maxVolume())
),
+ getCreek(properties),
getFloat(properties, "lobbyAtmosphereShare", GameConfig.DEFAULT.lobbyAtmosphereShare())
);
}
@@ -213,54 +214,49 @@ private double getDouble(Properties properties, String key, double defaultValue)
/**
* Reads the creek settings. All keys start with {@value #CREEK_PREFIX}.
*
- * The values are checked together after reading. If they contradict each other, all creek
- * settings fall back to the defaults, not just the broken key. A mix of custom and default
- * values would be hard to reason about.
+ * Like every other group, an unreadable value falls back to its default, while values that
+ * contradict each other are rejected by {@link CreekConfig}.
*
*
* @param properties the loaded properties
* @return the creek settings, never {@code null}
+ * @throws IllegalArgumentException if the values do not fit together
*/
private CreekConfig getCreek(Properties properties) {
CreekConfig d = CreekConfig.DEFAULT;
- try {
- return new CreekConfig(
- getBoolean(properties, CREEK_PREFIX + "enabled", d.enabled()),
- getBoolean(properties, CREEK_PREFIX + "activeWithLastSurvivor", d.activeWithLastSurvivor()),
- getInt(properties, CREEK_PREFIX + "sightRange", d.sightRange()),
- getInt(properties, CREEK_PREFIX + "sightViewAngle", d.sightViewAngle()),
- getInt(properties, CREEK_PREFIX + "wanderPauseMillis", d.wanderPauseMillis()),
- getDouble(properties, CREEK_PREFIX + "wanderSpeed", d.wanderSpeed()),
- getDouble(properties, CREEK_PREFIX + "huntSpeed", d.huntSpeed()),
- getDouble(properties, CREEK_PREFIX + "stalkThreshold", d.stalkThreshold()),
- getDouble(properties, CREEK_PREFIX + "huntThreshold", d.huntThreshold()),
- getInt(properties, CREEK_PREFIX + "stalkMinDistance", d.stalkMinDistance()),
- getInt(properties, CREEK_PREFIX + "stalkMaxDistance", d.stalkMaxDistance()),
- getInt(properties, CREEK_PREFIX + "stalkMinAngle", d.stalkMinAngle()),
- getInt(properties, CREEK_PREFIX + "stalkMaxAngle", d.stalkMaxAngle()),
- getInt(properties, CREEK_PREFIX + "stalkRevealMillis", d.stalkRevealMillis()),
- getInt(properties, CREEK_PREFIX + "stalkMinSeconds", d.stalkMinSeconds()),
- getInt(properties, CREEK_PREFIX + "stalkMaxSeconds", d.stalkMaxSeconds()),
- getInt(properties, CREEK_PREFIX + "huntMaxSeconds", d.huntMaxSeconds()),
- getDouble(properties, CREEK_PREFIX + "catchDistance", d.catchDistance()),
- getInt(properties, CREEK_PREFIX + "vanishMinSeconds", d.vanishMinSeconds()),
- getInt(properties, CREEK_PREFIX + "vanishMaxSeconds", d.vanishMaxSeconds()),
- getInt(properties, CREEK_PREFIX + "respawnMinDistance", d.respawnMinDistance()),
- getInt(properties, CREEK_PREFIX + "personalSpace", d.personalSpace()),
- getInt(properties, CREEK_PREFIX + "stuckMillis", d.stuckMillis()),
- getDouble(properties, CREEK_PREFIX + "dreadPageWeight", d.dreadPageWeight()),
- getDouble(properties, CREEK_PREFIX + "dreadTimeWeight", d.dreadTimeWeight()),
- getDouble(properties, CREEK_PREFIX + "dreadIsolationWeight", d.dreadIsolationWeight()),
- getInt(properties, CREEK_PREFIX + "isolationRadius", d.isolationRadius()),
- getInt(properties, CREEK_PREFIX + "betrayalCatchCount", d.betrayalCatchCount()),
- getDouble(properties, CREEK_PREFIX + "betrayalChance", d.betrayalChance()),
- getInt(properties, CREEK_PREFIX + "betrayalGlowSeconds", d.betrayalGlowSeconds()),
- getInt(properties, CREEK_PREFIX + "slownessSeconds", d.slownessSeconds())
- );
- } catch (IllegalArgumentException exception) {
- CONFIG_LOGGER.warn("The creek settings do not fit together: {}. Falling back to the creek defaults", exception.getMessage());
- return d;
- }
+ return new CreekConfig(
+ getBoolean(properties, CREEK_PREFIX + "enabled", d.enabled()),
+ getBoolean(properties, CREEK_PREFIX + "activeWithLastSurvivor", d.activeWithLastSurvivor()),
+ getInt(properties, CREEK_PREFIX + "sightRange", d.sightRange()),
+ getInt(properties, CREEK_PREFIX + "sightViewAngle", d.sightViewAngle()),
+ getInt(properties, CREEK_PREFIX + "wanderPauseMillis", d.wanderPauseMillis()),
+ getDouble(properties, CREEK_PREFIX + "wanderSpeed", d.wanderSpeed()),
+ getDouble(properties, CREEK_PREFIX + "huntSpeed", d.huntSpeed()),
+ getDouble(properties, CREEK_PREFIX + "stalkThreshold", d.stalkThreshold()),
+ getDouble(properties, CREEK_PREFIX + "huntThreshold", d.huntThreshold()),
+ getInt(properties, CREEK_PREFIX + "stalkMinDistance", d.stalkMinDistance()),
+ getInt(properties, CREEK_PREFIX + "stalkMaxDistance", d.stalkMaxDistance()),
+ getInt(properties, CREEK_PREFIX + "stalkMinAngle", d.stalkMinAngle()),
+ getInt(properties, CREEK_PREFIX + "stalkMaxAngle", d.stalkMaxAngle()),
+ getInt(properties, CREEK_PREFIX + "stalkRevealMillis", d.stalkRevealMillis()),
+ getInt(properties, CREEK_PREFIX + "stalkMinSeconds", d.stalkMinSeconds()),
+ getInt(properties, CREEK_PREFIX + "stalkMaxSeconds", d.stalkMaxSeconds()),
+ getInt(properties, CREEK_PREFIX + "huntMaxSeconds", d.huntMaxSeconds()),
+ getDouble(properties, CREEK_PREFIX + "catchDistance", d.catchDistance()),
+ getInt(properties, CREEK_PREFIX + "vanishMinSeconds", d.vanishMinSeconds()),
+ getInt(properties, CREEK_PREFIX + "vanishMaxSeconds", d.vanishMaxSeconds()),
+ getInt(properties, CREEK_PREFIX + "respawnMinDistance", d.respawnMinDistance()),
+ getInt(properties, CREEK_PREFIX + "personalSpace", d.personalSpace()),
+ getInt(properties, CREEK_PREFIX + "stuckMillis", d.stuckMillis()),
+ getDouble(properties, CREEK_PREFIX + "dreadPageWeight", d.dreadPageWeight()),
+ getDouble(properties, CREEK_PREFIX + "dreadTimeWeight", d.dreadTimeWeight()),
+ getDouble(properties, CREEK_PREFIX + "dreadIsolationWeight", d.dreadIsolationWeight()),
+ getInt(properties, CREEK_PREFIX + "isolationRadius", d.isolationRadius()),
+ getInt(properties, CREEK_PREFIX + "betrayalCatchCount", d.betrayalCatchCount()),
+ getDouble(properties, CREEK_PREFIX + "betrayalChance", d.betrayalChance()),
+ getInt(properties, CREEK_PREFIX + "betrayalGlowSeconds", d.betrayalGlowSeconds()),
+ getInt(properties, CREEK_PREFIX + "slownessSeconds", d.slownessSeconds())
+ );
}
/**
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/InternalGameConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/InternalGameConfig.java
deleted file mode 100644
index e69de29b..00000000
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 303ae981..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
@@ -215,7 +215,7 @@ public Component getPageStatus() {
* @return the number of found pages, never negative
*/
public int foundPageCount() {
- return this.currentFoundedPageCount.get();
+ return this.foundPages.get();
}
/**
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 67c630bc..3da773c6 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
@@ -381,13 +381,25 @@ void testCreekValuesAreRead(@TempDir Path tempDir) throws IOException {
}
@Test
- void testCreekFallsBackWhenValuesDoNotFit(@TempDir Path tempDir) throws IOException {
+ 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, new GameConfigReader(tempDir).getConfig().creek());
+ 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/page/PageProviderTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/page/PageProviderTest.java
index 50a1386e..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
@@ -361,19 +361,14 @@ private static void runConcurrently(List tasks) throws Exception {
}
@Test
- void testFoundPageCount(@NotNull Env env) throws Exception {
+ void testFoundPageCount(@NotNull Env env) {
Instance instance = env.createFlatInstance();
- PageProvider pageProvider = new PageProvider();
- pageProvider.loadPageData(Set.of(new PageResource(Pos.ZERO, Direction.NORTH)));
- pageProvider.setMaxPageAmount(2);
+ PageProvider pageProvider = spawnedProvider(instance, MIN_ACTIVE_PAGE_COUNT + 1);
+ Player player = env.createPlayer(instance);
assertEquals(0, pageProvider.foundPageCount());
- PageEntity pageEntity = new PageEntity(instance, Pos.ZERO, 1);
- seedActivePages(pageProvider, pageEntity);
- Player player = env.createPlayer(instance);
-
- pageProvider.triggerPageFound(player, pageEntity.getHitBoxUUID());
+ 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 6f1a6e46..85e472e4 100644
--- a/config.properties.example
+++ b/config.properties.example
@@ -201,8 +201,8 @@ survivorTeamSize=12
# 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.
-# Every key below maps to a field of CreekConfig; values that do not fit
-# together make the whole creek section fall back to its defaults.
+# 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
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index e8fb0d26..4cfd9941 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -226,7 +226,7 @@ public Cygnus() {
this.pageProvider::foundPageCount,
this.pageProvider::getMaxPageAmount,
roundClock::elapsedMillis,
- this.gameConfig.gameTime(),
+ this.gameConfig.round().gameTime(),
creekConfig),
new StagedCatchConsequence(
new CatchEffects(this.jumpscareManager::force, this.staminaService::getFoodBar, creekConfig.slownessSeconds()),
@@ -371,7 +371,8 @@ private void initPhases() {
* @return the points, possibly empty
*/
private List creekRoutePoints() {
- List points = new ArrayList<>(this.pageProvider.interactablePagePositions());
+ List points = new ArrayList<>();
+ this.pageProvider.interactablePages().forEach(page -> points.add(page.getPosition()));
GameMap map = this.mapProvider.getGameMap();
if (map != null) {
points.addAll(map.getSurvivorSpawns());
From 9e062c299af197276e396bd4d648d6ce9760e3b5 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:11:16 +0200
Subject: [PATCH 19/92] feat(map): add CreekRoute structure
---
.../cygnus/common/creek/CreekRoute.java | 30 ++++
.../cygnus/common/creek/CreekRoutesFile.java | 143 ++++++++++++++++++
.../cygnus/common/creek/package-info.java | 7 +
.../cygnus/common/map/GameMap.java | 48 ++++++
.../cygnus/common/map/GameMapBuilder.java | 92 ++++++++++-
5 files changed, 319 insertions(+), 1 deletion(-)
create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoute.java
create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFile.java
create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/creek/package-info.java
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoute.java b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoute.java
new file mode 100644
index 00000000..466eb7c5
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoute.java
@@ -0,0 +1,30 @@
+package net.onelitefeather.cygnus.common.creek;
+
+import net.minestom.server.coordinate.Vec;
+
+import java.util.List;
+
+/**
+ * A named path for the creek. The first point is the start, the last one the end.
+ *
+ * @param name the route's name, used in logs and in the debug line
+ * @param points the waypoints in walking order; each one is where the creek's feet stand
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public record CreekRoute(String name, List points) {
+
+ /** A route needs at least a start and an end. */
+ public static final int MIN_POINTS = 2;
+
+ /**
+ * Fills in what a hand-written file may leave out, so a broken route can be reported by name
+ * instead of breaking the whole file.
+ */
+ @SuppressWarnings("ConstantValue")
+ public CreekRoute {
+ name = name == null ? "" : name.trim();
+ points = points == null ? List.of() : List.copyOf(points);
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFile.java b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFile.java
new file mode 100644
index 00000000..b3bf1c33
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFile.java
@@ -0,0 +1,143 @@
+package net.onelitefeather.cygnus.common.creek;
+
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.reflect.TypeToken;
+import net.onelitefeather.cygnus.common.map.GameMap;
+import net.onelitefeather.cygnus.common.util.GsonHelper;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.lang.reflect.Type;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Reads and writes the {@code creek.json} next to a map's {@code map.json}.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekRoutesFile {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(CreekRoutesFile.class);
+ private static final String FILE_NAME = "creek.json";
+ private static final Type ROUTES_TYPE = TypeToken.getParameterized(List.class, CreekRoute.class).getType();
+
+ private CreekRoutesFile() {
+ // Nothing to do here
+ }
+
+ /**
+ * Returns the {@code creek.json} path that belongs to a map file.
+ *
+ * @param mapFile the map file
+ * @return the sibling path, whether it exists or not
+ */
+ public static Path resolve(Path mapFile) {
+ return mapFile.resolveSibling(FILE_NAME);
+ }
+
+ /**
+ * Loads the routes the game can use. Invalid routes are skipped with a warning that names them.
+ *
+ * @param mapFile the map file the routes belong to
+ * @return the valid routes, empty if the file does not exist
+ */
+ public static List load(Path mapFile) {
+ Path file = resolve(mapFile);
+ return valid(read(file), file);
+ }
+
+ /**
+ * Loads every route as written, including unfinished ones. Meant for the setup, which has to
+ * show routes that are still being built.
+ *
+ * @param mapFile the map file the routes belong to
+ * @return all routes, empty if the file does not exist
+ */
+ public static List loadUnchecked(Path mapFile) {
+ return read(resolve(mapFile));
+ }
+
+ /**
+ * Writes the routes to the sibling {@code creek.json}.
+ *
+ * @param mapFile the map file the routes belong to
+ * @param routes the routes to write
+ */
+ public static void save(Path mapFile, List routes) {
+ Path file = resolve(mapFile);
+ try {
+ if (file.getParent() != null) {
+ Files.createDirectories(file.getParent());
+ }
+ try (var writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
+ GsonHelper.GSON.toJson(routes, ROUTES_TYPE, writer);
+ }
+ } catch (IOException exception) {
+ throw new UncheckedIOException("Failed to save creek routes to " + file, exception);
+ }
+ }
+
+ /**
+ * Returns a copy of the map carrying the valid routes from the sibling {@code creek.json}.
+ *
+ * @param mapFile the map file {@code map} was loaded from
+ * @param map the map
+ * @return the map with its routes
+ */
+ public static GameMap loadInto(Path mapFile, GameMap map) {
+ return map.withCreekRoutes(load(mapFile));
+ }
+
+ private static List read(Path file) {
+ if (!Files.exists(file)) {
+ return List.of();
+ }
+ try (var reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
+ List routes;
+ try {
+ routes = GsonHelper.GSON.fromJson(reader, ROUTES_TYPE);
+ } catch (JsonSyntaxException exception) {
+ throw new JsonSyntaxException("Failed to parse creek routes from " + file + ": " + exception.getMessage(), exception);
+ }
+ if (routes == null) return List.of();
+ return routes.stream().filter(route -> route != null).toList();
+ } catch (IOException exception) {
+ throw new UncheckedIOException("Failed to load creek routes from " + file, exception);
+ }
+ }
+
+ private static List valid(List routes, Path file) {
+ Set names = new HashSet<>();
+ List valid = new ArrayList<>(routes.size());
+ for (CreekRoute route : routes) {
+ String problem = problemOf(route, names);
+ if (problem != null) {
+ LOGGER.warn("Skipping creek route '{}' in {}: {}", route.name(), file, problem);
+ continue;
+ }
+ names.add(route.name());
+ valid.add(route);
+ }
+ return List.copyOf(valid);
+ }
+
+ private static @Nullable String problemOf(CreekRoute route, Set names) {
+ if (route.name().isEmpty()) return "the name is empty";
+ if (route.points().size() < CreekRoute.MIN_POINTS) {
+ return "it needs at least " + CreekRoute.MIN_POINTS + " points";
+ }
+ if (names.contains(route.name())) return "another route already uses this name";
+ return null;
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/creek/package-info.java b/common/src/main/java/net/onelitefeather/cygnus/common/creek/package-info.java
new file mode 100644
index 00000000..b340af0a
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * The creek routes of a map: named paths the creek walks along, stored next to the map file.
+ */
+@NotNullByDefault
+package net.onelitefeather.cygnus.common.creek;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMap.java b/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMap.java
index 9f6a870f..9e466a13 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMap.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMap.java
@@ -1,5 +1,6 @@
package net.onelitefeather.cygnus.common.map;
+import net.onelitefeather.cygnus.common.creek.CreekRoute;
import net.onelitefeather.cygnus.common.dimension.MapAtmosphere;
import net.theevilreaper.aves.map.BaseMap;
import net.minestom.server.coordinate.Pos;
@@ -26,6 +27,8 @@ public final class GameMap extends BaseMap {
private final transient Set pageFaces;
private final Set survivorSpawns;
private final @Nullable MapAtmosphere atmosphere;
+ // transient like pageFaces: the routes live in a sibling creek.json (see CreekRoutesFile)
+ private final transient List creekRoutes;
/**
* Creates a new instance from the {@link GameMap} with the given values.
@@ -46,12 +49,38 @@ public GameMap(
Set survivorSpawns,
List builders,
@Nullable MapAtmosphere atmosphere
+ ) {
+ this(name, spawn, slenderSpawn, pageFaces, survivorSpawns, builders, atmosphere, List.of());
+ }
+
+ /**
+ * Creates a map that also carries creek routes.
+ *
+ * @param name the name of the map
+ * @param spawn the spawn position for the players
+ * @param slenderSpawn the spawn position for the slender
+ * @param pageFaces the faces for the pages
+ * @param survivorSpawns the spawn positions for the survivors
+ * @param builders the builders for the map
+ * @param atmosphere the fog and sky look of the map, or {@code null} for the overworld default
+ * @param creekRoutes the routes the creek walks along
+ */
+ public GameMap(
+ String name,
+ Pos spawn,
+ Pos slenderSpawn,
+ Set pageFaces,
+ Set survivorSpawns,
+ List builders,
+ @Nullable MapAtmosphere atmosphere,
+ List creekRoutes
) {
super(name, spawn, builders);
this.slenderSpawn = slenderSpawn;
this.pageFaces = Set.copyOf(pageFaces);
this.survivorSpawns = Set.copyOf(survivorSpawns);
this.atmosphere = atmosphere;
+ this.creekRoutes = List.copyOf(creekRoutes);
}
/**
@@ -92,4 +121,23 @@ public Set getSurvivorSpawns() {
public @Nullable MapAtmosphere getAtmosphere() {
return atmosphere;
}
+
+ /**
+ * Returns the routes the creek walks along.
+ *
+ * @return the routes, empty if the map has none
+ */
+ public List getCreekRoutes() {
+ return creekRoutes;
+ }
+
+ /**
+ * Returns a copy of this map with other creek routes.
+ *
+ * @param creekRoutes the new routes
+ * @return the copy
+ */
+ public GameMap withCreekRoutes(List creekRoutes) {
+ return new GameMap(name(), spawn(), slenderSpawn, pageFaces, survivorSpawns, builders(), atmosphere, creekRoutes);
+ }
}
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMapBuilder.java b/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMapBuilder.java
index f6d9ffc2..b3e025e7 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMapBuilder.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/map/GameMapBuilder.java
@@ -3,13 +3,18 @@
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.dimension.MapAtmosphere;
import net.onelitefeather.cygnus.common.page.PageResource;
import net.theevilreaper.aves.map.BaseMapBuilder;
import org.jetbrains.annotations.Nullable;
+import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
@@ -19,6 +24,7 @@ public final class GameMapBuilder extends BaseMapBuilder {
private @Nullable MapAtmosphere atmosphere;
private final Set pageFaces;
private final Set survivorSpawns;
+ private final Map> creekRoutes;
/**
* Creates a new instance of the builder class
@@ -31,6 +37,7 @@ public GameMapBuilder() {
.thenComparingInt(Pos::blockY)
.thenComparingInt(Pos::blockZ)
);
+ this.creekRoutes = new LinkedHashMap<>();
}
/**
@@ -49,6 +56,8 @@ public GameMapBuilder(GameMap gameMap) {
this.survivorSpawns.addAll(gameMap.getSurvivorSpawns());
this.pageFaces = new HashSet<>(gameMap.getPageFaces());
this.atmosphere = gameMap.getAtmosphere();
+ this.creekRoutes = new LinkedHashMap<>();
+ gameMap.getCreekRoutes().forEach(route -> this.creekRoutes.put(route.name(), new ArrayList<>(route.points())));
}
/**
@@ -91,7 +100,7 @@ public void setSlenderSpawn(@Nullable Pos slenderSpawn) {
*/
@Override
public GameMap build() {
- return new GameMap(name, spawn, slenderSpawn, pageFaces, survivorSpawns, builders, atmosphere);
+ return new GameMap(name, spawn, slenderSpawn, pageFaces, survivorSpawns, builders, atmosphere, getCreekRoutes());
}
/**
@@ -168,4 +177,85 @@ public void setAtmosphere(@Nullable MapAtmosphere atmosphere) {
public @Nullable MapAtmosphere getAtmosphere() {
return atmosphere;
}
+
+ /**
+ * Adds an empty creek route.
+ *
+ * @param name the route's name
+ * @return {@code false} if the name is empty or already taken
+ */
+ public boolean addCreekRoute(String name) {
+ String trimmed = name.trim();
+ if (trimmed.isEmpty() || this.creekRoutes.containsKey(trimmed)) return false;
+ this.creekRoutes.put(trimmed, new ArrayList<>());
+ return true;
+ }
+
+ /**
+ * Removes a creek route.
+ *
+ * @param name the route's name
+ * @return {@code false} if there was no such route
+ */
+ public boolean removeCreekRoute(String name) {
+ return this.creekRoutes.remove(name) != null;
+ }
+
+ /**
+ * Returns whether a creek route with this name exists.
+ *
+ * @param name the route's name
+ * @return {@code true} if it exists
+ */
+ public boolean hasCreekRoute(String name) {
+ return this.creekRoutes.containsKey(name);
+ }
+
+ /**
+ * Appends a point to a creek route.
+ *
+ * @param name the route's name
+ * @param point the new last point
+ * @return {@code false} if there was no such route
+ */
+ public boolean addCreekPoint(String name, Vec point) {
+ List points = this.creekRoutes.get(name);
+ if (points == null) return false;
+ points.add(point);
+ return true;
+ }
+
+ /**
+ * Removes the last point of a creek route.
+ *
+ * @param name the route's name
+ * @return {@code false} if there was no such route or it had no points
+ */
+ public boolean removeLastCreekPoint(String name) {
+ List points = this.creekRoutes.get(name);
+ if (points == null || points.isEmpty()) return false;
+ points.removeLast();
+ return true;
+ }
+
+ /**
+ * Returns the points of a creek route.
+ *
+ * @param name the route's name
+ * @return the points, empty if there is no such route
+ */
+ public List getCreekRoutePoints(String name) {
+ return List.copyOf(this.creekRoutes.getOrDefault(name, List.of()));
+ }
+
+ /**
+ * Returns all creek routes in the order they were added.
+ *
+ * @return the routes
+ */
+ public List getCreekRoutes() {
+ return this.creekRoutes.entrySet().stream()
+ .map(entry -> new CreekRoute(entry.getKey(), entry.getValue()))
+ .toList();
+ }
}
From ce6ae9b55346fb54009531bda009f49452a336ec Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:11:57 +0200
Subject: [PATCH 20/92] chore(page): update map creation context
---
.../net/onelitefeather/cygnus/common/page/PageFacesFile.java | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFacesFile.java b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFacesFile.java
index 0b682d77..f471caf6 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFacesFile.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFacesFile.java
@@ -104,7 +104,8 @@ public static GameMap loadInto(Path mapFile, GameMap map) {
merged,
map.getSurvivorSpawns(),
map.builders(),
- map.getAtmosphere()
+ map.getAtmosphere(),
+ map.getCreekRoutes()
);
}
}
From 11b32b993d01553788d9d0b6cdc2095a9377b760 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:12:46 +0200
Subject: [PATCH 21/92] feat(setup): add preview for creek routes
---
.../cygnus/setup/creek/CreekRoutePreview.java | 190 ++++++++++++++++++
.../cygnus/setup/creek/package-info.java | 7 +
2 files changed, 197 insertions(+)
create mode 100644 setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java
create mode 100644 setup/src/main/java/net/onelitefeather/cygnus/setup/creek/package-info.java
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..bf515976
--- /dev/null
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java
@@ -0,0 +1,190 @@
+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.CreekRoute;
+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 routes to the player editing them: particles at every point and along the
+ * route, the route's name above its start, and a marker on ends that link to another route.
+ *
+ * @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 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 two ends have to be to count as linked, 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 names after routes changed. Does nothing while the preview is hidden.
+ */
+ public void refreshLabels() {
+ this.labels.forEach(Entity::remove);
+ this.labels.clear();
+ Instance instance = this.player.getInstance();
+ if (instance == null) return;
+ String activeName = this.active.get();
+ for (CreekRoute route : this.routes.get()) {
+ if (route.points().isEmpty()) continue;
+ Vec start = route.points().getFirst();
+ Entity label = new Entity(EntityType.TEXT_DISPLAY);
+ TextDisplayMeta meta = (TextDisplayMeta) label.getEntityMeta();
+ meta.setText(Component.text(route.name(),
+ route.name().equals(activeName) ? NamedTextColor.GREEN : NamedTextColor.YELLOW));
+ 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 current = this.routes.get();
+ String activeName = this.active.get();
+ for (CreekRoute route : current) {
+ boolean isActive = route.name().equals(activeName);
+ Particle point = isActive ? Particle.FLAME : Particle.END_ROD;
+ Particle path = isActive ? Particle.CRIT : Particle.ELECTRIC_SPARK;
+ List points = route.points();
+ for (int i = 0; i < points.size(); i++) {
+ this.particle(point, points.get(i));
+ if (i + 1 < points.size()) {
+ line(points.get(i), points.get(i + 1), LINE_STEP).forEach(spot -> this.particle(path, spot));
+ }
+ }
+ }
+ linkedEnds(current, this.linkDistance).forEach(end -> this.particle(Particle.HAPPY_VILLAGER, end.add(0, 0.5, 0)));
+ }
+
+ 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 route ends that are at most {@code distance} away from an end of another route.
+ *
+ * @param routes the routes
+ * @param distance the link distance, in blocks
+ * @return the linked ends
+ */
+ static Set linkedEnds(List routes, double distance) {
+ Set linked = new HashSet<>();
+ for (int a = 0; a < routes.size(); a++) {
+ for (int b = a + 1; b < routes.size(); b++) {
+ for (Vec first : ends(routes.get(a))) {
+ for (Vec second : ends(routes.get(b))) {
+ if (first.distance(second) <= distance) {
+ linked.add(first);
+ linked.add(second);
+ }
+ }
+ }
+ }
+ }
+ return linked;
+ }
+
+ private static List ends(CreekRoute route) {
+ if (route.points().isEmpty()) return List.of();
+ return List.of(route.points().getFirst(), route.points().getLast());
+ }
+}
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;
From a956f9d39f596646e202629e929c1393a75c0a79 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:13:04 +0200
Subject: [PATCH 22/92] feat(setup): add CreekRouteDialogs
---
.../setup/dialog/CreekRouteDialogs.java | 53 +++++++++++++++++++
1 file changed, 53 insertions(+)
create mode 100644 setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/CreekRouteDialogs.java
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..6d291640
--- /dev/null
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/CreekRouteDialogs.java
@@ -0,0 +1,53 @@
+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.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");
+
+ /** Long enough for a descriptive name, short enough for the debug line. */
+ private static final int MAX_NAME_LENGTH = 48;
+
+ /**
+ * 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);
+ }
+
+ private CreekRouteDialogs() {
+ }
+}
From 8eb392ae128379551c6d3d43f7402eaa3ceb1bfe Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:13:26 +0200
Subject: [PATCH 23/92] chore(setup): add new setup part item
---
.../cygnus/setup/item/SetupItemId.java | 5 ++
.../cygnus/setup/item/SetupItems.java | 48 ++++++++++++++++++-
2 files changed, 52 insertions(+), 1 deletion(-)
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..0a022a16 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,11 @@ 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;
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..b051e519 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,7 @@ public final class SetupItems {
private static final HotBarLayout gameSetupLayout;
private static final HotBarLayout pageLayout;
private static final HotBarLayout survivorSpawnLayout;
+ private static final HotBarLayout creekRouteLayout;
static {
DECORATION_PANE = ItemStack.builder(Material.BLACK_STAINED_GLASS_PANE)
@@ -94,6 +95,41 @@ public final class SetupItems {
.build()
);
gameSetupLayout.set(7, saveItem);
+ gameSetupLayout.set(6, 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()
+ );
+
+ creekRouteLayout = new HotBarLayout();
+ creekRouteLayout.set(1, 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()
+ );
+ creekRouteLayout.set(3, 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 active route", NamedTextColor.WHITE))))
+ .set(Tags.ITEM_TAG, SetupItemId.CREEK_UNDO)
+ .build()
+ );
+ creekRouteLayout.set(5, ItemStack.builder(Material.LEAD)
+ .customName(Component.text("Routes", NamedTextColor.AQUA))
+ .lore(getLore(SPACE_SEPARATOR.append(Component.text("Select or delete a route", NamedTextColor.WHITE))))
+ .set(Tags.ITEM_TAG, SetupItemId.CREEK_LIST)
+ .build()
+ );
+ creekRouteLayout.set(7, 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()
+ );
pageLayout = new HotBarLayout();
pageLayout.set(2, ItemStack.builder(Material.COMPASS)
@@ -198,4 +234,14 @@ public static void setPageItems(Player player) {
private SetupItems() {
}
-}
\ No newline at end of file
+
+ /**
+ * Sets the items for the creek route setup.
+ *
+ * @param player the player
+ */
+ public static void setCreekRouteItems(Player player) {
+ creekRouteLayout.apply(player);
+ player.setHeldItemSlot(DEFAULT);
+ }
+}
From 35cff2dabc9cff7665e0fa822bff72202f13a65a Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:14:27 +0200
Subject: [PATCH 24/92] feat(data): wire new inventory
---
.../cygnus/setup/data/GameData.java | 211 +++++++++++++++++-
.../cygnus/setup/data/GameDataTest.java | 76 +++++++
2 files changed, 286 insertions(+), 1 deletion(-)
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..e7b58222 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,20 @@
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.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.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 +39,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 +50,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.
@@ -54,10 +68,60 @@ public class GameData extends InstanceSetupData {
public GameData(Player player, MapEntry mapEntry) {
super(player.getUuid(), 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();
+ }
+
+ 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.name(), route.points().size(),
+ route.name().equals(this.activeCreekRoute), this::selectFromOverview, this::deleteFromOverview);
+ this.creekRouteSlots.add(slot);
+ this.creekRouteInventory.add(slot);
+ }
+ if (this.creekRoutePreview != null && this.hasCreekRouteMode()) {
+ this.creekRoutePreview.refreshLabels();
+ }
+ }
+
+ private void selectFromOverview(Player player, String name) {
+ if (this.selectCreekRoute(name)) {
+ player.sendMessage(SetupMessages.getCreekRouteSelected(name));
+ }
+ }
+
+ private void deleteFromOverview(Player player, String name) {
+ if (this.deleteCreekRoute(name)) {
+ player.sendMessage(SetupMessages.getCreekRouteDeleted(name));
+ }
}
/**
@@ -130,6 +194,106 @@ 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 active = this.activeCreekRoute;
+ if (active == null || !this.gameMapBuilder.addCreekPoint(active, point)) return false;
+ this.refreshCreekRoutes();
+ return true;
+ }
+
+ /**
+ * Removes the last point of the active creek route.
+ *
+ * @return {@code false} without an active route or without points
+ */
+ public boolean removeLastCreekPoint() {
+ String active = this.activeCreekRoute;
+ if (active == null || !this.gameMapBuilder.removeLastCreekPoint(active)) return false;
+ 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 active = this.activeCreekRoute;
+ return active == null ? 0 : this.gameMapBuilder.getCreekRoutePoints(active).size();
+ }
+
/**
* {@inheritDoc}
*/
@@ -258,6 +422,47 @@ public void handleItemInteraction(Player player, byte tagValue) {
return;
}
+ if (SetupItemId.CREEK_ROUTES == tagValue) {
+ this.swapCreekRouteMode();
+ if (hasCreekRouteMode()) {
+ player.sendMessage(SetupMessages.CREEK_MODE_ENABLED);
+ player.sendMessage(SetupMessages.getModeInform("creek route"));
+ SetupItems.setCreekRouteItems(player);
+ this.creekRoutePreview.show();
+ } else {
+ this.creekRoutePreview.hide();
+ player.sendMessage(SetupMessages.CREEK_MODE_DISABLED);
+ SetupItems.setGameLayout(player);
+ }
+ return;
+ }
+ if (SetupItemId.CREEK_LEAVE == tagValue) {
+ if (hasCreekRouteMode()) {
+ this.swapCreekRouteMode();
+ }
+ 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 active = this.activeCreekRoute;
+ if (active == null || !this.removeLastCreekPoint()) {
+ player.sendMessage(SetupMessages.NO_CREEK_POINT_TO_REMOVE);
+ } else {
+ player.sendMessage(SetupMessages.getCreekPointRemoved(active, this.activeCreekPointCount()));
+ }
+ return;
+ }
+ if (SetupItemId.CREEK_LIST == tagValue) {
+ this.creekRouteInventory.open();
+ return;
+ }
+
super.handleItemInteraction(player, tagValue);
}
@@ -312,6 +517,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);
}
@@ -333,6 +539,8 @@ public void reset() {
this.survivorInventory.unregister();
this.inventory.unregister();
this.pageInventory.unregister();
+ this.creekRouteInventory.unregister();
+ this.creekRoutePreview.hide();
}
/**
@@ -344,6 +552,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/test/java/net/onelitefeather/cygnus/setup/data/GameDataTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/GameDataTest.java
index 9ffe1307..f2366ec7 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,11 @@
import net.minestom.server.utils.Direction;
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.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 +23,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 +158,75 @@ 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(new CreekRoute("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);
+ }
}
From df4a43634c22b8a39bb7123e5ba6c89296b7ca43 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:14:56 +0200
Subject: [PATCH 25/92] feat(setup): add creek route listener
---
.../setup/listener/CreekRouteListener.java | 54 +++++++++++++++++++
1 file changed, 54 insertions(+)
create mode 100644 setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java
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..28186d77
--- /dev/null
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java
@@ -0,0 +1,54 @@
+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.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 java.util.function.Consumer;
+
+/**
+ * Appends a point to the active creek route when a block is left-clicked in the creek route mode.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekRouteListener implements Consumer {
+
+ private final SetupDataService dataService;
+
+ public CreekRouteListener(SetupDataService dataService) {
+ this.dataService = dataService;
+ }
+
+ @Override
+ public void accept(PlayerBlockBreakEvent event) {
+ Player player = event.getPlayer();
+ if (!player.hasTag(SetupTags.SETUP_ID_TAG)) return;
+ if (!(this.dataService.get(player.getUuid()).orElse(null) instanceof GameData gameData)) return;
+ if (!gameData.hasCreekRouteMode()) return;
+ event.setCancelled(true);
+
+ String active = gameData.activeCreekRoute();
+ if (active == null || !gameData.addCreekPoint(pointOnTop(event.getBlockPosition()))) {
+ player.sendMessage(SetupMessages.NO_ACTIVE_CREEK_ROUTE);
+ return;
+ }
+ player.sendMessage(SetupMessages.getCreekPointAdded(active, gameData.activeCreekPointCount()));
+ }
+
+ /**
+ * Returns where the creek's feet stand on a block: in the middle, on top of it.
+ *
+ * @param block the clicked block
+ * @return the point
+ */
+ static Vec pointOnTop(Point block) {
+ return new Vec(block.blockX() + 0.5D, block.blockY() + 1.0D, block.blockZ() + 0.5D);
+ }
+}
From 4ea7b804caaed2193fecff9642cbf4c20cdb3885 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:15:12 +0200
Subject: [PATCH 26/92] chore(setup): add creek dialog handling
---
.../onelitefeather/cygnus/setup/event/dialog/DialogTarget.java | 1 +
.../cygnus/setup/listener/dialog/DialogPayloadListener.java | 3 +++
.../cygnus/setup/listener/dialog/DialogRequestListener.java | 2 ++
3 files changed, 6 insertions(+)
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..8952f813 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,5 @@ public enum DialogTarget {
UPDATE_NAME,
ATMOSPHERE_PRESET,
ATMOSPHERE_VALUES,
+ CREEK_ROUTE_NAME,
}
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..0893e004 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,9 @@
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.AtmosphereAdjustHandler;
import net.onelitefeather.cygnus.setup.dialog.handler.AtmosphereConfirmHandler;
import net.onelitefeather.cygnus.setup.dialog.handler.AtmospherePresetHandler;
@@ -29,6 +31,7 @@ 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(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..3b866e51 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,7 @@ public void accept(DialogRequestEvent event) {
switch (target) {
case CREATE_NAME -> MapDialogs.openNameCreateDialog(player);
+ case CREEK_ROUTE_NAME -> CreekRouteDialogs.openNameDialog(player);
case UPDATE_NAME -> {
if (context instanceof DialogContext.NameContext(String name)) {
MapDialogs.openNameUpdateDialog(player, name);
From cfbbf9950d1ab8016cd79f6629c27b1d49a5d5f7 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:15:26 +0200
Subject: [PATCH 27/92] chore(reader): update creek config reader handling
---
.../onelitefeather/cygnus/common/config/GameConfigReader.java | 3 ++-
.../cygnus/common/config/GameConfigReaderTest.java | 2 ++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
index 4adb0c51..4970cc3a 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java
@@ -255,7 +255,8 @@ private CreekConfig getCreek(Properties properties) {
getInt(properties, CREEK_PREFIX + "betrayalCatchCount", d.betrayalCatchCount()),
getDouble(properties, CREEK_PREFIX + "betrayalChance", d.betrayalChance()),
getInt(properties, CREEK_PREFIX + "betrayalGlowSeconds", d.betrayalGlowSeconds()),
- getInt(properties, CREEK_PREFIX + "slownessSeconds", d.slownessSeconds())
+ getInt(properties, CREEK_PREFIX + "slownessSeconds", d.slownessSeconds()),
+ getDouble(properties, CREEK_PREFIX + "routeLinkDistance", d.routeLinkDistance())
);
}
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 3da773c6..4a545912 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
@@ -370,6 +370,7 @@ void testCreekValuesAreRead(@TempDir Path tempDir) throws IOException {
creek.enabled=false
creek.huntThreshold=0.7
creek.stalkMaxDistance=40
+ creek.routeLinkDistance=4.5
""");
CreekConfig creek = new GameConfigReader(tempDir).getConfig().creek();
@@ -378,6 +379,7 @@ void testCreekValuesAreRead(@TempDir Path tempDir) throws IOException {
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);
}
@Test
From de4f16caf4a724345fc876644c3c7bf47b901de2 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:16:20 +0200
Subject: [PATCH 28/92] chore(creek): add new config value
---
.../cygnus/common/config/CreekConfig.java | 8 ++++++--
.../cygnus/common/config/CreekConfigTest.java | 19 ++++++++++++++++++-
2 files changed, 24 insertions(+), 3 deletions(-)
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java
index a42854a9..9a086d6e 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/CreekConfig.java
@@ -41,6 +41,7 @@
* @param betrayalChance chance of a reveal on earlier catches
* @param betrayalGlowSeconds how long a revealed survivor glows for the slender
* @param slownessSeconds how long a caught survivor is slowed
+ * @param routeLinkDistance how close two route ends have to be to count as linked, in blocks
* @author theEvilReaper
* @version 1.0.0
* @since 2.15.0
@@ -76,7 +77,8 @@ public record CreekConfig(
int betrayalCatchCount,
double betrayalChance,
int betrayalGlowSeconds,
- int slownessSeconds
+ int slownessSeconds,
+ double routeLinkDistance
) {
/**
@@ -91,7 +93,8 @@ public record CreekConfig(
30, 1.5D,
20, 40, 30, 15, 3000,
0.6D, 0.3D, 0.1D, 25,
- 2, 0.15D, 6, 4
+ 2, 0.15D, 6, 4,
+ 3.0D
);
/**
@@ -132,6 +135,7 @@ public record CreekConfig(
between("betrayalChance", betrayalChance, 0.0D, 1.0D);
atLeast("betrayalGlowSeconds", betrayalGlowSeconds, 1);
atLeast("slownessSeconds", slownessSeconds, 0);
+ positive("routeLinkDistance", routeLinkDistance);
}
private static void atLeast(String name, double value, double minimum) {
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
index ad8a8a07..b4426bba 100644
--- a/common/src/test/java/net/onelitefeather/cygnus/common/config/CreekConfigTest.java
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/CreekConfigTest.java
@@ -27,6 +27,23 @@ void defaultsMatchTheDesign() {
assertEquals(15, config.personalSpace());
assertEquals(2, config.betrayalCatchCount());
assertEquals(0.15D, config.betrayalChance());
+ assertEquals(3.0D, config.routeLinkDistance());
+ }
+
+ @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));
+ assertTrue(exception.getMessage().contains("routeLinkDistance"));
}
@Test
@@ -64,7 +81,7 @@ private static CreekConfig with(double stalkThreshold, double huntThreshold, int
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.betrayalChance(), d.betrayalGlowSeconds(), d.slownessSeconds(), d.routeLinkDistance());
}
@Test
From 7eca65538acc1b92ee02d7eb162b7b9cdbdf16c6 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:16:38 +0200
Subject: [PATCH 29/92] chore(creek): update state handling
---
.../cygnus/creek/state/VanishState.java | 15 +++++++-------
.../cygnus/creek/state/VanishStateTest.java | 20 +++++++++++++++++++
2 files changed, 28 insertions(+), 7 deletions(-)
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
index 243f0480..90f06a1d 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/VanishState.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/VanishState.java
@@ -85,14 +85,15 @@ public CreekState tick(CreekContext ctx) {
List observers = ctx.observerEyes();
double distance = ctx.config().respawnMinDistance();
- Optional spot = ctx.route()
- .next(ctx.body().position(),
- point -> ctx.spots().hiddenSpotAt(point, observers, distance).isPresent(),
- ctx.random())
- .flatMap(point -> ctx.spots().hiddenSpotAt(point, observers, distance));
- if (spot.isEmpty()) return this;
+ // Any point of the route will do. Asking the route for its next point would only offer the
+ // neighbours of where the creek vanished, which is often right next to a survivor.
+ List spots = ctx.route().points().stream()
+ .map(point -> ctx.spots().hiddenSpotAt(point, observers, distance))
+ .flatMap(Optional::stream)
+ .toList();
+ if (spots.isEmpty()) return this;
- ctx.body().teleport(spot.get());
+ ctx.body().teleport(spots.get(ctx.random().nextInt(spots.size())));
return new WanderState(ctx.now());
}
}
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
index e3c50299..aa6c43f8 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java
@@ -1,6 +1,11 @@
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 org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -87,4 +92,19 @@ void foreverNeverEnds() {
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(new CreekRoute("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(WanderState.class, next);
+ assertEquals(List.of(new Pos(0, 40, -40)), body.teleports);
+ }
}
From 101cbdb15041e81aa3067bba2b1b7e861d78ad0b Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:16:51 +0200
Subject: [PATCH 30/92] chore(map): add file loading for routes
---
.../cygnus/map/GameMapProvider.java | 7 +++++--
.../map/GameMapProviderIntegrationTest.java | 18 ++++++++++++++++++
2 files changed, 23 insertions(+), 2 deletions(-)
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/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java
index 4a674a43..04bd2309 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 = new CreekRoute("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.
*
From 5e95745842a24342b22ef270f9836acdeb06bd85 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:17:14 +0200
Subject: [PATCH 31/92] chore(creek): update service and debug part
---
.../cygnus/creek/CreekService.java | 25 ++++++++++--
.../cygnus/creek/debug/CreekDebug.java | 18 +++++++++
.../cygnus/creek/CreekIntegrationTest.java | 2 +-
.../creek/CreekServiceIntegrationTest.java | 40 +++++++++++++++++--
.../cygnus/creek/debug/CreekDebugTest.java | 18 +++++++++
5 files changed, 95 insertions(+), 8 deletions(-)
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java
index 9536d0ef..8943d471 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java
@@ -6,16 +6,20 @@
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.debug.CreekDebug;
import net.onelitefeather.cygnus.creek.dread.DreadSource;
import net.onelitefeather.cygnus.creek.state.CreekState;
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.RandomPointRoute;
+import net.onelitefeather.cygnus.creek.world.RouteProvider;
import net.onelitefeather.cygnus.creek.world.SpotFinder;
import net.onelitefeather.cygnus.event.GameFinishEvent;
import net.onelitefeather.cygnus.event.GameStartEvent;
@@ -63,7 +67,9 @@ public final class CreekService {
private final CreekSight sight;
private final Ground ground;
private final SpotFinder spots;
- private final RandomPointRoute route;
+ private final Supplier> routes;
+ private final RandomPointRoute fallback;
+ private RouteProvider route;
private final RepeatingTask task = new RepeatingTask(this::tick);
private volatile @Nullable Creek creek;
@@ -74,6 +80,7 @@ public final class CreekService {
* @param survivors supplies the survivors of the round
* @param instance supplies the instance of the round, or {@code null} while there is none
* @param routePoints supplies the waypoints
+ * @param routes supplies the creek routes of the current map
* @param bodies spawns the creek's body
* @param dread rates the survivors
* @param consequence what happens on a catch
@@ -83,6 +90,7 @@ public final class CreekService {
*/
public CreekService(CreekConfig config, Supplier> survivors,
Supplier extends @Nullable Instance> instance, Supplier> routePoints,
+ Supplier> routes,
BiFunction bodies, DreadSource dread,
CatchConsequence consequence, RoundClock clock, RandomGenerator random,
CreekDebug debug) {
@@ -98,7 +106,9 @@ public CreekService(CreekConfig config, Supplier> survivors,
this.sight = new CreekSight(config.sightRange(), config.sightViewAngle());
this.ground = new InstanceGround(instance);
this.spots = new SpotFinder(this.sight, this.ground);
- this.route = new RandomPointRoute(routePoints);
+ this.routes = routes;
+ this.fallback = new RandomPointRoute(routePoints);
+ this.route = this.fallback;
}
/**
@@ -118,9 +128,11 @@ void start() {
if (this.creek != null) return;
Instance world = this.instance.get();
+ CreekPaths paths = CreekPaths.of(this.routes.get(), this.config.routeLinkDistance());
+ this.route = paths.isEmpty() ? this.fallback : new PathRoute(paths);
List points = this.route.points();
if (world == null || points.isEmpty()) {
- LOGGER.warn("The map offers no page positions or spawns to walk between, so the creek stays away this round");
+ LOGGER.warn("The map offers no creek routes, page positions or spawns to walk between, so the creek stays away this round");
return;
}
@@ -145,7 +157,8 @@ void tick() {
Set players = this.survivors.get();
current.tick(players, this.clock.now());
if (this.debug.hasWatchers()) {
- this.debug.show(CreekDebug.line(current.state(), current.body().position(), current.lastViews(), id -> nameOf(players, id)));
+ this.debug.show(CreekDebug.line(current.state(), current.body().position(), current.lastViews(),
+ id -> nameOf(players, id), this.route.describe()));
}
}
@@ -172,4 +185,8 @@ void stop() {
@Nullable Creek creek() {
return this.creek;
}
+
+ RouteProvider route() {
+ return this.route;
+ }
}
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
index 498e2859..fd2b0a7e 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/creek/debug/CreekDebug.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/debug/CreekDebug.java
@@ -106,8 +106,26 @@ public void show(Component line) {
* @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, including where the creek is on its route.
+ *
+ * @param state the creek's state
+ * @param creek the creek's position
+ * @param views the survivors in that step
+ * @param names turns a player id into a name
+ * @param route the route description, or an empty string without a route
+ * @return the line
+ */
+ public static Component line(CreekState state, Pos creek, List views, Function names,
+ String route) {
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));
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java
index 4ed01597..02fdf2a8 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java
@@ -118,6 +118,6 @@ private static CreekConfig withoutLastSurvivor() {
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.betrayalChance(), d.betrayalGlowSeconds(), d.slownessSeconds(), d.routeLinkDistance());
}
}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java
index b8dc1a84..a9a339a5 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java
@@ -1,6 +1,7 @@
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.network.packet.server.play.ActionBarPacket;
@@ -9,10 +10,13 @@
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.VanishState;
+import net.onelitefeather.cygnus.creek.world.PathRoute;
+import net.onelitefeather.cygnus.creek.world.RandomPointRoute;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -45,7 +49,7 @@ public void cleanUp() {
};
private CreekService service(Instance instance, Set survivors, List points, AtomicLong clock) {
- return new CreekService(CreekConfig.DEFAULT, () -> survivors, () -> instance, () -> points,
+ return new CreekService(CreekConfig.DEFAULT, () -> survivors, () -> instance, () -> points, () -> List.of(),
CreakingBody::spawn, (_, _, _) -> 0.0D, consequence, new RoundClock(clock::get), new Random(3), new CreekDebug());
}
@@ -103,7 +107,7 @@ void roundTimeCountsFromTheStart(Env env) {
AtomicLong clock = new AtomicLong(1000L);
RoundClock roundClock = new RoundClock(clock::get);
CreekService service = new CreekService(CreekConfig.DEFAULT, () -> Set.of(survivor), () -> instance,
- () -> List.of(new Pos(10, 40, 10)), CreakingBody::spawn, (_, _, _) -> 0.0D, consequence, roundClock, new Random(3), new CreekDebug());
+ () -> List.of(new Pos(10, 40, 10)), () -> List.of(), CreakingBody::spawn, (_, _, _) -> 0.0D, consequence, roundClock, new Random(3), new CreekDebug());
service.start();
clock.set(4000L);
@@ -124,7 +128,7 @@ void debugGoesToWatchersOnly(Env env) {
CreekDebug debug = new CreekDebug();
debug.toggle(watcher.getUuid());
CreekService service = new CreekService(CreekConfig.DEFAULT, () -> Set.of(watcher, other), () -> instance,
- () -> List.of(new Pos(10, 40, 10)), CreakingBody::spawn, (_, _, _) -> 0.0D, consequence,
+ () -> List.of(new Pos(10, 40, 10)), () -> List.of(), 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);
@@ -136,4 +140,34 @@ void debugGoesToWatchersOnly(Env env) {
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));
+ CreekRoute route = new CreekRoute("Weg", List.of(new Vec(10, 40, 10), new Vec(20, 40, 10)));
+ CreekService service = new CreekService(CreekConfig.DEFAULT, () -> Set.of(survivor), () -> instance,
+ List::of, () -> List.of(route), CreakingBody::spawn, (_, _, _) -> 0.0D, consequence,
+ new RoundClock(new AtomicLong()::get), new Random(3), new CreekDebug());
+
+ service.start();
+
+ assertInstanceOf(PathRoute.class, service.route());
+ assertNotNull(service.creek());
+ service.stop();
+ }
+
+ @Test
+ @DisplayName("Without valid routes the creek falls back to pages and spawns")
+ void fallsBackWithoutRoutes(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 Pos(10, 40, 10)), new AtomicLong());
+
+ service.start();
+
+ assertInstanceOf(RandomPointRoute.class, service.route());
+ service.stop();
+ }
}
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
index ecc99c3f..682c4884 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/creek/debug/CreekDebugTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/debug/CreekDebugTest.java
@@ -68,4 +68,22 @@ void toggle() {
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 WanderState(0L), Pos.ZERO, views, NAMES::get, "Waldweg Nord 3/7 →");
+
+ assertEquals("WANDER · 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("WANDER · Steve 0.10", plain(CreekDebug.line(new WanderState(0L), Pos.ZERO, views, NAMES::get, "")));
+ }
}
From 490974b3977892e638868d20cd391db6e9a61893 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:17:37 +0200
Subject: [PATCH 32/92] test(creek): add context class
---
.../cygnus/creek/state/Contexts.java | 19 +++++++++++++++----
1 file changed, 15 insertions(+), 4 deletions(-)
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
index a3bf682d..085bee91 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java
@@ -29,10 +29,21 @@ static CreekContext context(long now, CreekBody body, RouteProvider route, List<
/** A route that offers the first allowed point at least a block away. */
public static RouteProvider route(Pos... points) {
- return (current, allowed, _) -> Arrays.stream(points)
- .filter(point -> point.distance(current) >= 1.0D)
- .filter(allowed)
- .findFirst();
+ return new RouteProvider() {
+ @Override
+ public Optional next(Pos current, java.util.function.Predicate allowed,
+ java.util.random.RandomGenerator random) {
+ return Arrays.stream(points)
+ .filter(point -> point.distance(current) >= 1.0D)
+ .filter(allowed)
+ .findFirst();
+ }
+
+ @Override
+ public List points() {
+ return List.of(points);
+ }
+ };
}
private Contexts() {
From 5e3a085ad00da3f9175314f0c21a110fedcf6dea Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:17:48 +0200
Subject: [PATCH 33/92] chore(setup): new constants
---
.../cygnus/setup/util/SetupMessages.java | 82 +++++++++++++++++++
1 file changed, 82 insertions(+)
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..36bff908 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
@@ -35,6 +35,18 @@ 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 = Messages.withPrefix(
+ Component.text("The creek route mode has been", NamedTextColor.GRAY)
+ .append(Component.space())
+ .append(Component.text("enabled", NamedTextColor.GREEN)));
+ public static final Component CREEK_MODE_DISABLED = Messages.withPrefix(
+ Component.text("The creek route mode has been", NamedTextColor.GRAY)
+ .append(Component.space())
+ .append(Component.text("disabled", NamedTextColor.RED)));
+ public static final Component NO_ACTIVE_CREEK_ROUTE = Messages.withPrefix(
+ Component.text("Create or select a creek route first", NamedTextColor.RED));
+ public static final Component NO_CREEK_POINT_TO_REMOVE = Messages.withPrefix(
+ Component.text("The active creek route has no point to remove", NamedTextColor.RED));
static {
SPACE_SEPARATOR = Component.text("» ", NamedTextColor.GRAY);
@@ -121,6 +133,76 @@ private SetupMessages() {
return Messages.withMiniPrefix("Don't forget to disable mode", modeTag);
}
+ /**
+ * Creates the message shown after a creek route was created.
+ *
+ * @param name the route's name
+ * @return the created component
+ */
+ public static Component getCreekRouteCreated(String name) {
+ return Messages.withPrefix(Component.text("Created creek route ", NamedTextColor.GRAY)
+ .append(Component.text(name, NamedTextColor.GREEN)));
+ }
+
+ /**
+ * Creates the message shown when a creek route name is already taken.
+ *
+ * @param name the route's name
+ * @return the created component
+ */
+ public static Component getDuplicateCreekRoute(String name) {
+ return Messages.withPrefix(Component.text("A creek route named ", NamedTextColor.RED)
+ .append(Component.text(name, NamedTextColor.GOLD))
+ .append(Component.text(" already exists", NamedTextColor.RED)));
+ }
+
+ /**
+ * Creates the message shown after a point was added to a creek route.
+ *
+ * @param name the route's name
+ * @param count the number of the new point
+ * @return the created component
+ */
+ public static Component getCreekPointAdded(String name, int count) {
+ return Messages.withPrefix(Component.text("Added point " + count + " to ", NamedTextColor.GRAY)
+ .append(Component.text(name, NamedTextColor.GREEN)));
+ }
+
+ /**
+ * Creates the message shown after the last point of a creek route was removed.
+ *
+ * @param name the route's name
+ * @param count the number of points left
+ * @return the created component
+ */
+ public static Component getCreekPointRemoved(String name, int count) {
+ return Messages.withPrefix(Component.text("Removed a point, ", NamedTextColor.GRAY)
+ .append(Component.text(name, NamedTextColor.GREEN))
+ .append(Component.text(" now has " + count, NamedTextColor.GRAY)));
+ }
+
+ /**
+ * Creates the message shown after a creek route was selected.
+ *
+ * @param name the route's name
+ * @return the created component
+ */
+ public static Component getCreekRouteSelected(String name) {
+ return Messages.withPrefix(Component.text("Now editing creek route ", NamedTextColor.GRAY)
+ .append(Component.text(name, NamedTextColor.GREEN)));
+ }
+
+ /**
+ * Creates the message shown after a creek route was deleted.
+ *
+ * @param name the route's name
+ * @return the created component
+ */
+ public static Component getCreekRouteDeleted(String name) {
+ return Messages.withPrefix(Component.text("Deleted creek route ", NamedTextColor.GRAY)
+ .append(Component.text(name, NamedTextColor.RED)));
+ }
+
/**
* Returns a list that contains the given components which some additional empty components
*
From 95088d7c0e01cc773c8ccd36ecee535aba4af1b4 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:17:57 +0200
Subject: [PATCH 34/92] chore(setup): register new listener
---
.../java/net/onelitefeather/cygnus/setup/SetupExtension.java | 5 +++++
1 file changed, 5 insertions(+)
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);
From 9c2e62c6d105da360335e8c9710d8064ce15763e Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:18:37 +0200
Subject: [PATCH 35/92] chore(creek): improve path route logic
---
.../cygnus/creek/world/CreekPaths.java | 161 ++++++++++++++++++
.../cygnus/creek/world/PathRoute.java | 132 ++++++++++++++
.../cygnus/creek/world/RandomPointRoute.java | 1 +
.../cygnus/creek/world/RouteProvider.java | 23 ++-
4 files changed, 315 insertions(+), 2 deletions(-)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java
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..897f2d95
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java
@@ -0,0 +1,161 @@
+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 java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The creek routes of a map, with the links between their ends worked out once.
+ *
+ * Two ends are linked when they are at most the link distance apart. The two ends of one route
+ * never link to each other.
+ *
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekPaths {
+
+ /**
+ * One end of a route.
+ *
+ * @param route the route's index
+ * @param atStart {@code true} for the first point, {@code false} for the last
+ */
+ public record End(int route, boolean atStart) {
+ }
+
+ private static final boolean[] BOTH_ENDS = {true, false};
+
+ private final List routes;
+ private final Map> links;
+
+ private CreekPaths(List routes, Map> links) {
+ this.routes = routes;
+ this.links = links;
+ }
+
+ /**
+ * Builds the paths and works out the links.
+ *
+ * @param routes the valid routes of the map
+ * @param linkDistance how close two ends have to be to count as linked, in blocks
+ * @return the paths
+ */
+ public static CreekPaths of(List routes, double linkDistance) {
+ List copy = List.copyOf(routes);
+ Map> links = new HashMap<>();
+ for (int a = 0; a < copy.size(); a++) {
+ for (int b = a + 1; b < copy.size(); b++) {
+ for (boolean aStart : BOTH_ENDS) {
+ for (boolean bStart : BOTH_ENDS) {
+ End first = new End(a, aStart);
+ End second = new End(b, bStart);
+ if (position(copy, first).distance(position(copy, second)) <= linkDistance) {
+ links.computeIfAbsent(first, _ -> new ArrayList<>()).add(second);
+ links.computeIfAbsent(second, _ -> new ArrayList<>()).add(first);
+ }
+ }
+ }
+ }
+ }
+ return new CreekPaths(copy, links);
+ }
+
+ /**
+ * Returns whether there are no routes.
+ *
+ * @return {@code true} without routes
+ */
+ public boolean isEmpty() {
+ return this.routes.isEmpty();
+ }
+
+ /**
+ * Returns the number of routes.
+ *
+ * @return the number of routes
+ */
+ public int size() {
+ return this.routes.size();
+ }
+
+ /**
+ * Returns a route.
+ *
+ * @param index the route's index
+ * @return the route
+ */
+ public CreekRoute route(int index) {
+ return this.routes.get(index);
+ }
+
+ /**
+ * Returns 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();
+ }
+
+ /**
+ * Returns 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));
+ }
+
+ /**
+ * Returns where an end of a route is.
+ *
+ * @param end the end
+ * @return the first or last point of the route
+ */
+ public Pos position(End end) {
+ return position(this.routes, end);
+ }
+
+ /**
+ * Returns the ends linked to an end, in a fixed order.
+ *
+ * @param end the end
+ * @return the linked ends, empty if there are none
+ */
+ public List links(End end) {
+ return List.copyOf(this.links.getOrDefault(end, List.of()));
+ }
+
+ /**
+ * Returns every point of every route.
+ *
+ * @return the points
+ */
+ public List allPoints() {
+ List points = new ArrayList<>();
+ for (CreekRoute route : this.routes) {
+ route.points().forEach(point -> points.add(toPos(point)));
+ }
+ return points;
+ }
+
+ private static Pos position(List routes, End end) {
+ List points = routes.get(end.route()).points();
+ return toPos(end.atStart() ? points.getFirst() : points.getLast());
+ }
+
+ 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/PathRoute.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java
new file mode 100644
index 00000000..cf0640a9
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java
@@ -0,0 +1,132 @@
+package net.onelitefeather.cygnus.creek.world;
+
+import net.minestom.server.coordinate.Pos;
+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 every route linked to that end. If the creek is far from its last
+ * point, for example after a teleport, it rejoins at the nearest point.
+ *
+ *
+ * @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 was moved and rejoins, in blocks. */
+ static final double REJOIN_DISTANCE = 8.0D;
+
+ private final CreekPaths paths;
+ private @Nullable Cursor cursor;
+
+ /**
+ * Creates 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 at = this.cursor;
+ if (at == null || current.distance(this.point(at)) > REJOIN_DISTANCE) {
+ return this.rejoin(current, allowed, random);
+ }
+ Cursor ahead = this.advance(at, random);
+ if (allowed.test(this.point(ahead))) return this.moveTo(ahead);
+
+ Cursor back = this.behind(at);
+ if (back != null && allowed.test(this.point(back))) return this.moveTo(back);
+ return Optional.empty();
+ }
+
+ @Override
+ public String describe() {
+ Cursor at = this.cursor;
+ if (at == null) return "";
+ return this.paths.route(at.route()).name() + " " + (at.index() + 1) + "/" + this.paths.pointCount(at.route())
+ + " " + (at.direction() > 0 ? "→" : "←");
+ }
+
+ private Optional rejoin(Pos current, Predicate allowed, RandomGenerator random) {
+ Cursor 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 Cursor(route, index, 1);
+ }
+ }
+ }
+ if (best == null) return Optional.empty();
+
+ int last = this.paths.pointCount(best.route()) - 1;
+ // at an end there is only one way into the route
+ int direction = best.index() == 0 ? 1 : best.index() == last ? -1 : (random.nextBoolean() ? 1 : -1);
+ return this.moveTo(new Cursor(best.route(), best.index(), direction));
+ }
+
+ private Cursor advance(Cursor at, RandomGenerator random) {
+ int nextIndex = at.index() + at.direction();
+ if (nextIndex >= 0 && nextIndex < this.paths.pointCount(at.route())) {
+ return new Cursor(at.route(), nextIndex, at.direction());
+ }
+ // walking backwards ends at the start, walking forwards at the end
+ List links = this.paths.links(new CreekPaths.End(at.route(), at.direction() < 0));
+ int choice = random.nextInt(links.size() + 1);
+ if (choice == links.size()) {
+ Cursor back = this.behind(at);
+ return back != null ? back : at;
+ }
+ CreekPaths.End link = links.get(choice);
+ return link.atStart()
+ ? new Cursor(link.route(), 0, 1)
+ : new Cursor(link.route(), this.paths.pointCount(link.route()) - 1, -1);
+ }
+
+ private @Nullable Cursor behind(Cursor at) {
+ int direction = -at.direction();
+ int index = at.index() + direction;
+ if (index < 0 || index >= this.paths.pointCount(at.route())) return null;
+ return new Cursor(at.route(), index, direction);
+ }
+
+ private Optional moveTo(Cursor next) {
+ this.cursor = next;
+ return Optional.of(this.point(next));
+ }
+
+ private Pos point(Cursor at) {
+ return this.paths.point(at.route(), at.index());
+ }
+
+ /**
+ * Where the creek is on the routes.
+ *
+ * @param route the route's index
+ * @param index the index of the waypoint handed out last
+ * @param direction {@code 1} towards the end, {@code -1} towards the start
+ */
+ private record Cursor(int route, int index, int direction) {
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/RandomPointRoute.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RandomPointRoute.java
index 7e03ba49..7c9ad665 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/RandomPointRoute.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RandomPointRoute.java
@@ -39,6 +39,7 @@ public RandomPointRoute(Supplier> points) {
*
* @return the points, possibly empty
*/
+ @Override
public List points() {
return this.points.get();
}
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
index 63cad7c6..52f41bb9 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteProvider.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteProvider.java
@@ -2,6 +2,7 @@
import net.minestom.server.coordinate.Pos;
+import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.random.RandomGenerator;
@@ -9,8 +10,8 @@
/**
* Decides where the creek walks next.
*
- * For now the waypoints come from the map's pages and spawns. Hand-placed waypoints from the
- * setup can be added later as another implementation.
+ * {@link PathRoute} walks the hand-placed routes of a map; {@link RandomPointRoute} falls back
+ * to the map's pages and spawns.
*
*
* @author theEvilReaper
@@ -29,4 +30,22 @@ public interface RouteProvider {
* @return the next point, or empty if none is allowed
*/
Optional next(Pos current, Predicate allowed, RandomGenerator random);
+
+ /**
+ * Returns every point this route can hand out. Used to pick a spawn or respawn spot.
+ *
+ * @return the points, possibly empty
+ */
+ 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 "";
+ }
}
From 6864992c2b650781f8b9b101b749600708dee612 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:18:55 +0200
Subject: [PATCH 36/92] chore(game): add new method
---
.../main/java/net/onelitefeather/cygnus/Cygnus.java | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index 4cfd9941..63e81e67 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -54,6 +54,7 @@
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;
@@ -221,6 +222,7 @@ public Cygnus() {
() -> TeamHelper.survivorsOf(this.teamService),
this.mapProvider.getActiveInstance(),
this::creekRoutePoints,
+ this::creekRoutes,
CreakingBody::spawn,
new PageProgressDread(
this.pageProvider::foundPageCount,
@@ -380,6 +382,16 @@ private List creekRoutePoints() {
return points;
}
+ /**
+ * 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();
From 05c10aba093355000fe31c81415e1acc50c20dba Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:19:12 +0200
Subject: [PATCH 37/92] test(creek): add more route tests
---
.../common/creek/CreekRoutesFileTest.java | 118 ++++++++++++++++++
.../common/map/GameMapCreekRoutesTest.java | 83 ++++++++++++
.../cygnus/creek/world/CreekPathsTest.java | 58 +++++++++
.../listener/game/GameFinishListenerTest.java | 74 +++++++++++
.../setup/creek/CreekRoutePreviewTest.java | 33 +++++
.../inventory/slot/CreekRouteSlotTest.java | 24 ++++
.../listener/CreekRouteListenerTest.java | 16 +++
7 files changed, 406 insertions(+)
create mode 100644 common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFileTest.java
create mode 100644 common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapCreekRoutesTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/listener/game/GameFinishListenerTest.java
create mode 100644 setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java
create mode 100644 setup/src/test/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlotTest.java
create mode 100644 setup/src/test/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListenerTest.java
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..cb0a50e6
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFileTest.java
@@ -0,0 +1,118 @@
+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 = new CreekRoute("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 = new CreekRoute("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": [ { "x": 0, "y": 80, "z": 0 }, { "x": 5, "y": 80, "z": 0 } ] },
+ { "name": "Stub", "points": [ { "x": 0, "y": 80, "z": 0 } ] },
+ { "name": "Waldweg Süd", "points": [ { "x": 0.5, "y": 80, "z": 0.5 }, { "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": [ { "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 = new CreekRoute("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());
+ }
+}
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..528027d2
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapCreekRoutesTest.java
@@ -0,0 +1,83 @@
+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.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 = new CreekRoute("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());
+ }
+}
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..c29c4a6a
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java
@@ -0,0 +1,58 @@
+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 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 new CreekRoute(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() {
+ CreekPaths paths = CreekPaths.of(ROUTES, 3.0D);
+
+ assertEquals(List.of(new CreekPaths.End(1, true), new CreekPaths.End(2, true)),
+ paths.links(new CreekPaths.End(0, false)));
+ assertEquals(List.of(new CreekPaths.End(0, false)), paths.links(new CreekPaths.End(1, true)));
+ assertTrue(paths.links(new CreekPaths.End(3, true)).isEmpty());
+ }
+
+ @Test
+ @DisplayName("The two ends of one route never link to each other")
+ void ownEndsNeverLink() {
+ CreekPaths paths = CreekPaths.of(List.of(route("Short", new Vec(0, 0, 0), new Vec(1, 0, 0))), 3.0D);
+
+ assertTrue(paths.links(new CreekPaths.End(0, true)).isEmpty());
+ }
+
+ @Test
+ @DisplayName("Points and positions come out as Pos")
+ void pointsAndPositions() {
+ 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.position(new CreekPaths.End(1, false)));
+ assertEquals(8, paths.allPoints().size());
+ assertTrue(CreekPaths.of(List.of(), 3.0D).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/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..f1d56b3b
--- /dev/null
+++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java
@@ -0,0 +1,33 @@
+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("Only route ends close to another route's end are marked as linked")
+ void linkedEndsFollowTheDistance() {
+ List routes = List.of(
+ new CreekRoute("A", List.of(new Vec(0, 80, 0), new Vec(10, 80, 0))),
+ new CreekRoute("B", List.of(new Vec(12, 80, 0), new Vec(20, 80, 0))),
+ new CreekRoute("C", List.of(new Vec(50, 80, 0), new Vec(60, 80, 0))));
+
+ assertEquals(Set.of(new Vec(10, 80, 0), new Vec(12, 80, 0)), CreekRoutePreview.linkedEnds(routes, 3.0D));
+ }
+}
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..25e0c874
--- /dev/null
+++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlotTest.java
@@ -0,0 +1,24 @@
+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.item.ItemStack;
+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.assertTrue;
+
+class CreekRouteSlotTest {
+
+ @Test
+ @DisplayName("The item shows the route's name and point count")
+ void itemShowsNameAndPoints() {
+ ItemStack item = new CreekRouteSlot("Waldweg", 4, true, (_, _) -> {}, (_, _) -> {}).getItem();
+
+ assertEquals("Waldweg", PlainTextComponentSerializer.plainText().serialize(item.get(DataComponents.CUSTOM_NAME)));
+ assertTrue(item.get(DataComponents.LORE).stream()
+ .map(line -> PlainTextComponentSerializer.plainText().serialize(line))
+ .anyMatch(line -> line.equals("Points: 4")));
+ }
+}
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..14062226
--- /dev/null
+++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListenerTest.java
@@ -0,0 +1,16 @@
+package net.onelitefeather.cygnus.setup.listener;
+
+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.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)));
+ }
+}
From 272c5eca7931677625d477ddea3e0e1a96d455c2 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:19:28 +0200
Subject: [PATCH 38/92] chore(setup): add route dialog handler and slot
implementation
---
.../dialog/handler/CreekRouteNameHandler.java | 44 ++++++++++++
.../setup/inventory/slot/CreekRouteSlot.java | 69 +++++++++++++++++++
2 files changed, 113 insertions(+)
create mode 100644 setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRouteNameHandler.java
create mode 100644 setup/src/main/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlot.java
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..6f46eb01
--- /dev/null
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRouteNameHandler.java
@@ -0,0 +1,44 @@
+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.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));
+ });
+ }
+}
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..c412bcf7
--- /dev/null
+++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlot.java
@@ -0,0 +1,69 @@
+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.theevilreaper.aves.inventory.click.ClickHolder;
+import net.theevilreaper.aves.inventory.slot.Slot;
+
+import java.util.List;
+import java.util.function.BiConsumer;
+
+/**
+ * One creek route in the route overview. Left click selects it, shift click deletes it.
+ *
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class CreekRouteSlot extends Slot {
+
+ private final String name;
+ private final int points;
+ private final boolean active;
+
+ /**
+ * Creates the slot.
+ *
+ * @param name the route's name
+ * @param points how many points the route has
+ * @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 shift click
+ */
+ public CreekRouteSlot(String name, int points, boolean active, BiConsumer select,
+ BiConsumer delete) {
+ this.name = name;
+ this.points = points;
+ this.active = active;
+ this.setClick((player, _, click, _, result) -> {
+ result.accept(ClickHolder.cancelClick());
+ switch (click) {
+ case Click.Left _ -> {
+ player.closeInventory();
+ select.accept(player, name);
+ }
+ case Click.LeftShift _ -> delete.accept(player, name);
+ default -> {
+ // Nothing to do here
+ }
+ }
+ });
+ }
+
+ @Override
+ public ItemStack getItem() {
+ return ItemStack.builder(Material.LEAD)
+ .customName(Component.text(this.name, this.active ? NamedTextColor.GREEN : NamedTextColor.YELLOW))
+ .lore(List.of(
+ Component.empty(),
+ Component.text("Points: " + this.points, NamedTextColor.GRAY),
+ Component.empty(),
+ Component.text("Left click: edit this route", NamedTextColor.WHITE),
+ Component.text("Shift click: delete this route", NamedTextColor.RED)))
+ .build();
+ }
+}
From a769eed02b514b887b35ce51a189fae2114af825 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:19:40 +0200
Subject: [PATCH 39/92] test(path): add small path test
---
.../cygnus/creek/world/PathRouteTest.java | 155 ++++++++++++++++++
1 file changed, 155 insertions(+)
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java
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..a6188dac
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java
@@ -0,0 +1,155 @@
+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 org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+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.assertTrue;
+
+class PathRouteTest {
+
+ private static final Predicate ANYWHERE = _ -> true;
+
+ private static final CreekRoute A = new CreekRoute("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 = new CreekRoute("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 Pos next(PathRoute route, Pos current, RandomGenerator random) {
+ return route.next(current, ANYWHERE, random).orElseThrow();
+ }
+
+ @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));
+
+ 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());
+ }
+}
From b14affb0d65e984246b040c64182ad85d50361b1 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:19:55 +0200
Subject: [PATCH 40/92] chore(config): update example
---
config.properties.example | 3 +++
1 file changed, 3 insertions(+)
diff --git a/config.properties.example b/config.properties.example
index 85e472e4..4ff97bff 100644
--- a/config.properties.example
+++ b/config.properties.example
@@ -216,3 +216,6 @@ survivorTeamSize=12
#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
From 4b3dcd0991738a44202794122512ea4b7f45565d Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Fri, 25 Sep 2026 18:26:45 +0200
Subject: [PATCH 41/92] chore(message): update component creation
---
.../cygnus/setup/util/SetupMessages.java | 122 +++++++++++-------
1 file changed, 76 insertions(+), 46 deletions(-)
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 36bff908..8a0b97cb 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
@@ -35,18 +35,11 @@ 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 = Messages.withPrefix(
- Component.text("The creek route mode has been", NamedTextColor.GRAY)
- .append(Component.space())
- .append(Component.text("enabled", NamedTextColor.GREEN)));
- public static final Component CREEK_MODE_DISABLED = Messages.withPrefix(
- Component.text("The creek route mode has been", NamedTextColor.GRAY)
- .append(Component.space())
- .append(Component.text("disabled", NamedTextColor.RED)));
- public static final Component NO_ACTIVE_CREEK_ROUTE = Messages.withPrefix(
- Component.text("Create or select a creek route first", NamedTextColor.RED));
- public static final Component NO_CREEK_POINT_TO_REMOVE = Messages.withPrefix(
- Component.text("The active creek route has no point to remove", NamedTextColor.RED));
+ 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;
static {
SPACE_SEPARATOR = Component.text("» ", NamedTextColor.GRAY);
@@ -62,6 +55,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))
@@ -101,6 +104,22 @@ 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)
+ );
}
private SetupMessages() {
@@ -134,73 +153,84 @@ private SetupMessages() {
}
/**
- * Creates the message shown after a creek route was created.
+ * Creates a new {@link Component} instance which confirms that a creek route was created.
*
- * @param name the route's name
+ * @param name of the created route
* @return the created component
*/
- public static Component getCreekRouteCreated(String name) {
- return Messages.withPrefix(Component.text("Created creek route ", NamedTextColor.GRAY)
- .append(Component.text(name, NamedTextColor.GREEN)));
+ @Contract(value = "_ -> new", pure = true)
+ public static @NotNull Component getCreekRouteCreated(@NotNull String name) {
+ return Messages.withMiniPrefix("Created creek route ", nameTag(name));
}
/**
- * Creates the message shown when a creek route name is already taken.
+ * Creates a new {@link Component} instance which informs the player that the route name is taken.
*
- * @param name the route's name
+ * @param name which is already taken
* @return the created component
*/
- public static Component getDuplicateCreekRoute(String name) {
- return Messages.withPrefix(Component.text("A creek route named ", NamedTextColor.RED)
- .append(Component.text(name, NamedTextColor.GOLD))
- .append(Component.text(" already exists", NamedTextColor.RED)));
+ @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 the message shown after a point was added to a creek route.
+ * Creates a new {@link Component} instance which confirms that a point was added to a creek route.
*
- * @param name the route's name
- * @param count the number of the new point
+ * @param name of the route
+ * @param count of points the route has now
* @return the created component
*/
- public static Component getCreekPointAdded(String name, int count) {
- return Messages.withPrefix(Component.text("Added point " + count + " to ", NamedTextColor.GRAY)
- .append(Component.text(name, NamedTextColor.GREEN)));
+ @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 the message shown after the last point of a creek route was removed.
+ * Creates a new {@link Component} instance which confirms that the last point of a creek route was removed.
*
- * @param name the route's name
- * @param count the number of points left
+ * @param name of the route
+ * @param count of points the route has left
* @return the created component
*/
- public static Component getCreekPointRemoved(String name, int count) {
- return Messages.withPrefix(Component.text("Removed a point, ", NamedTextColor.GRAY)
- .append(Component.text(name, NamedTextColor.GREEN))
- .append(Component.text(" now has " + count, NamedTextColor.GRAY)));
+ @Contract(value = "_, _ -> new", pure = true)
+ public static @NotNull Component getCreekPointRemoved(@NotNull String name, int count) {
+ return Messages.withMiniPrefix("