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 instance; + + /** + * Creates the floor search. + * + * @param instance supplies the round's instance, or {@code null} while there is none + */ + public InstanceGround(Supplier 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 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 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 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("Removed the last point of ( left)", + nameTag(name), countTag(count)); } /** - * Creates the message shown after a creek route was selected. + * Creates a new {@link Component} instance which confirms that a creek route is now being edited. * - * @param name the route's name + * @param name of the selected route * @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))); + @Contract(value = "_ -> new", pure = true) + public static @NotNull Component getCreekRouteSelected(@NotNull String name) { + return Messages.withMiniPrefix("Now editing creek route ", nameTag(name)); } /** - * Creates the message shown after a creek route was deleted. + * Creates a new {@link Component} instance which confirms that a creek route was deleted. * - * @param name the route's name + * @param name of the deleted route * @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))); + @Contract(value = "_ -> new", pure = true) + public static @NotNull Component getCreekRouteDeleted(@NotNull String name) { + return Messages.withMiniPrefix("Deleted creek route ", nameTag(name)); + } + + /** + * Route names come from player input, so they are inserted as plain text instead of being parsed. + */ + private static TagResolver nameTag(String name) { + return TagResolver.builder().tag("name", Tag.inserting(Component.text(name))).build(); + } + + private static TagResolver countTag(int count) { + return TagResolver.builder().tag("count", Tag.inserting(Component.text(count))).build(); } /** From c3a8f39f01b26abfdc4b2c68e56a612b6e19f476 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 18:29:02 +0200 Subject: [PATCH 42/92] chore: improve variable naming --- .../cygnus/creek/world/CreekPaths.java | 18 +++---- .../cygnus/creek/world/PathRoute.java | 48 +++++++++---------- .../cygnus/setup/creek/CreekRoutePreview.java | 14 +++--- .../setup/listener/CreekRouteListener.java | 27 +++++++---- 4 files changed, 58 insertions(+), 49 deletions(-) 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 index 897f2d95..49fa0793 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java @@ -49,15 +49,15 @@ private CreekPaths(List routes, Map> links) { * @return the paths */ public static CreekPaths of(List routes, double linkDistance) { - List copy = List.copyOf(routes); + List frozenRoutes = 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) { + for (int firstRoute = 0; firstRoute < frozenRoutes.size(); firstRoute++) { + for (int secondRoute = firstRoute + 1; secondRoute < frozenRoutes.size(); secondRoute++) { + for (boolean firstAtStart : BOTH_ENDS) { + for (boolean secondAtStart : BOTH_ENDS) { + End first = new End(firstRoute, firstAtStart); + End second = new End(secondRoute, secondAtStart); + if (position(frozenRoutes, first).distance(position(frozenRoutes, second)) <= linkDistance) { links.computeIfAbsent(first, _ -> new ArrayList<>()).add(second); links.computeIfAbsent(second, _ -> new ArrayList<>()).add(first); } @@ -65,7 +65,7 @@ public static CreekPaths of(List routes, double linkDistance) { } } } - return new CreekPaths(copy, links); + return new CreekPaths(frozenRoutes, links); } /** 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 index cf0640a9..7c09ee5b 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java @@ -44,24 +44,24 @@ public List points() { @Override public Optional next(Pos current, Predicate allowed, RandomGenerator random) { - Cursor at = this.cursor; - if (at == null || current.distance(this.point(at)) > REJOIN_DISTANCE) { + Cursor last = this.cursor; + if (last == null || current.distance(this.point(last)) > REJOIN_DISTANCE) { return this.rejoin(current, allowed, random); } - Cursor ahead = this.advance(at, random); + Cursor ahead = this.advance(last, random); if (allowed.test(this.point(ahead))) return this.moveTo(ahead); - Cursor back = this.behind(at); + Cursor back = this.behind(last); if (back != null && allowed.test(this.point(back))) return this.moveTo(back); return Optional.empty(); } @Override public String describe() { - Cursor 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 ? "→" : "←"); + Cursor last = this.cursor; + if (last == null) return ""; + return this.paths.route(last.route()).name() + " " + (last.index() + 1) + "/" + this.paths.pointCount(last.route()) + + " " + (last.direction() > 0 ? "→" : "←"); } private Optional rejoin(Pos current, Predicate allowed, RandomGenerator random) { @@ -80,23 +80,23 @@ private Optional rejoin(Pos current, Predicate allowed, RandomGenerato } if (best == null) return Optional.empty(); - int last = this.paths.pointCount(best.route()) - 1; + int lastIndex = 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); + int direction = best.index() == 0 ? 1 : best.index() == lastIndex ? -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()); + private Cursor advance(Cursor from, RandomGenerator random) { + int nextIndex = from.index() + from.direction(); + if (nextIndex >= 0 && nextIndex < this.paths.pointCount(from.route())) { + return new Cursor(from.route(), nextIndex, from.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)); + List links = this.paths.links(new CreekPaths.End(from.route(), from.direction() < 0)); int choice = random.nextInt(links.size() + 1); if (choice == links.size()) { - Cursor back = this.behind(at); - return back != null ? back : at; + Cursor back = this.behind(from); + return back != null ? back : from; } CreekPaths.End link = links.get(choice); return link.atStart() @@ -104,11 +104,11 @@ private Cursor advance(Cursor at, RandomGenerator random) { : 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 @Nullable Cursor behind(Cursor from) { + int direction = -from.direction(); + int index = from.index() + direction; + if (index < 0 || index >= this.paths.pointCount(from.route())) return null; + return new Cursor(from.route(), index, direction); } private Optional moveTo(Cursor next) { @@ -116,8 +116,8 @@ private Optional moveTo(Cursor next) { return Optional.of(this.point(next)); } - private Pos point(Cursor at) { - return this.paths.point(at.route(), at.index()); + private Pos point(Cursor cursor) { + return this.paths.point(cursor.route(), cursor.index()); } /** 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 index bf515976..ae942c7f 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java @@ -119,21 +119,21 @@ private void draw() { this.hide(); return; } - List current = this.routes.get(); + List routes = this.routes.get(); String activeName = this.active.get(); - for (CreekRoute route : current) { + for (CreekRoute route : routes) { boolean isActive = route.name().equals(activeName); - Particle point = isActive ? Particle.FLAME : Particle.END_ROD; - Particle path = isActive ? Particle.CRIT : Particle.ELECTRIC_SPARK; + Particle pointParticle = isActive ? Particle.FLAME : Particle.END_ROD; + Particle lineParticle = isActive ? Particle.CRIT : Particle.ELECTRIC_SPARK; List points = route.points(); for (int i = 0; i < points.size(); i++) { - this.particle(point, points.get(i)); + this.particle(pointParticle, points.get(i)); if (i + 1 < points.size()) { - line(points.get(i), points.get(i + 1), LINE_STEP).forEach(spot -> this.particle(path, spot)); + line(points.get(i), points.get(i + 1), LINE_STEP).forEach(spot -> this.particle(lineParticle, spot)); } } } - linkedEnds(current, this.linkDistance).forEach(end -> this.particle(Particle.HAPPY_VILLAGER, end.add(0, 0.5, 0))); + linkedEnds(routes, this.linkDistance).forEach(end -> this.particle(Particle.HAPPY_VILLAGER, end.add(0, 0.5, 0))); } private void particle(Particle particle, Vec spot) { 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 index 28186d77..7424ac26 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java @@ -8,6 +8,7 @@ import net.onelitefeather.cygnus.setup.util.SetupMessages; import net.onelitefeather.cygnus.setup.util.SetupTags; import net.onelitefeather.guira.SetupDataService; +import net.onelitefeather.guira.data.SetupData; import java.util.function.Consumer; @@ -20,26 +21,34 @@ */ public final class CreekRouteListener implements Consumer { - private final SetupDataService dataService; + private final SetupDataService setupService; - public CreekRouteListener(SetupDataService dataService) { - this.dataService = dataService; + public CreekRouteListener(SetupDataService setupService) { + this.setupService = setupService; } @Override public void accept(PlayerBlockBreakEvent event) { + event.setCancelled(true); + Player player = event.getPlayer(); + if (!player.hasTag(SetupTags.SETUP_ID_TAG)) return; - 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()))) { + SetupData setupData = this.setupService.get(player.getUuid()).orElse(null); + + if (setupData == null) return; + + if (!(setupData instanceof GameData gameData) || !gameData.hasCreekRouteMode()) return; + + String activeRoute = gameData.activeCreekRoute(); + if (activeRoute == null) { player.sendMessage(SetupMessages.NO_ACTIVE_CREEK_ROUTE); return; } - player.sendMessage(SetupMessages.getCreekPointAdded(active, gameData.activeCreekPointCount())); + + gameData.addCreekPoint(pointOnTop(event.getBlockPosition())); + player.sendMessage(SetupMessages.getCreekPointAdded(activeRoute, gameData.activeCreekPointCount())); } /** From b560fc3da09ea083409887b9cdb9081e83188b71 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:43:37 +0200 Subject: [PATCH 43/92] chore(item): update used items during the creek setup --- .../cygnus/setup/item/SetupItemId.java | 2 + .../cygnus/setup/item/SetupItems.java | 61 ++++++++++++++----- 2 files changed, 47 insertions(+), 16 deletions(-) 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 0a022a16..2e199caf 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItemId.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItemId.java @@ -23,6 +23,8 @@ public final class SetupItemId { public static final byte CREEK_UNDO = (byte) 0x0B; public static final byte CREEK_LIST = (byte) 0x0C; public static final byte CREEK_LEAVE = (byte) 0x0D; + public static final byte CREEK_PAUSE = (byte) 0x0E; + public static final byte CREEK_FINISH = (byte) 0x0F; private SetupItemId() { // Nothing to do here diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItems.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItems.java index b051e519..15fc2513 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,7 +29,8 @@ public final class SetupItems { private static final HotBarLayout gameSetupLayout; private static final HotBarLayout pageLayout; private static final HotBarLayout survivorSpawnLayout; - private static final HotBarLayout creekRouteLayout; + private static final HotBarLayout creekRouteOverviewLayout; + private static final HotBarLayout creekRouteEditLayout; static { DECORATION_PANE = ItemStack.builder(Material.BLACK_STAINED_GLASS_PANE) @@ -105,31 +106,49 @@ public final class SetupItems { .build() ); - creekRouteLayout = new HotBarLayout(); - creekRouteLayout.set(1, ItemStack.builder(Material.NAME_TAG) + ItemStack leaveCreekMode = ItemStack.builder(Material.BARRIER) + .customName(Component.text("Leave mode", NamedTextColor.RED)) + .lore(getLore(SPACE_SEPARATOR.append(Component.text("Exit the current mode", NamedTextColor.WHITE)))) + .set(Tags.ITEM_TAG, SetupItemId.CREEK_LEAVE) + .build(); + + creekRouteOverviewLayout = new HotBarLayout(); + creekRouteOverviewLayout.set(0, ItemStack.builder(Material.NAME_TAG) .customName(Component.text("New route", NamedTextColor.GREEN)) .lore(getLore(SPACE_SEPARATOR.append(Component.text("Create a route and name it", NamedTextColor.WHITE)))) .set(Tags.ITEM_TAG, SetupItemId.CREEK_NEW) .build() ); - creekRouteLayout.set(3, ItemStack.builder(Material.FEATHER) + creekRouteOverviewLayout.set(4, ItemStack.builder(Material.LEAD) + .customName(Component.text("Routes", NamedTextColor.AQUA)) + .lore(getLore(SPACE_SEPARATOR.append(Component.text("Edit or delete a route", NamedTextColor.WHITE)))) + .set(Tags.ITEM_TAG, SetupItemId.CREEK_LIST) + .build() + ); + creekRouteOverviewLayout.set(8, leaveCreekMode); + + creekRouteEditLayout = new HotBarLayout(); + creekRouteEditLayout.set(0, ItemStack.builder(Material.FEATHER) .customName(Component.text("Remove last point", NamedTextColor.YELLOW)) - .lore(getLore(SPACE_SEPARATOR.append(Component.text("Undo the last point of the active route", NamedTextColor.WHITE)))) + .lore(getLore( + SPACE_SEPARATOR.append(Component.text("Undo the last point of the route", NamedTextColor.WHITE)), + SPACE_SEPARATOR.append(Component.text("Break a block to add or remove a point", NamedTextColor.WHITE)))) .set(Tags.ITEM_TAG, SetupItemId.CREEK_UNDO) .build() ); - 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) + creekRouteEditLayout.set(2, ItemStack.builder(Material.CLOCK) + .customName(Component.text("Pauses", NamedTextColor.GOLD)) + .lore(getLore(SPACE_SEPARATOR.append(Component.text("Set how long the creek waits at the ends", NamedTextColor.WHITE)))) + .set(Tags.ITEM_TAG, SetupItemId.CREEK_PAUSE) .build() ); - 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) + creekRouteEditLayout.set(6, ItemStack.builder(Material.EMERALD) + .customName(Component.text("Finish route", NamedTextColor.GREEN)) + .lore(getLore(SPACE_SEPARATOR.append(Component.text("Stop editing and go back to the routes", NamedTextColor.WHITE)))) + .set(Tags.ITEM_TAG, SetupItemId.CREEK_FINISH) .build() ); + creekRouteEditLayout.set(8, leaveCreekMode); pageLayout = new HotBarLayout(); pageLayout.set(2, ItemStack.builder(Material.COMPASS) @@ -236,12 +255,22 @@ private SetupItems() { } /** - * Sets the items for the creek route setup. + * Sets the items for the creek route setup while no route is being edited. + * + * @param player the player + */ + public static void setCreekRouteOverviewItems(Player player) { + creekRouteOverviewLayout.apply(player); + player.setHeldItemSlot(DEFAULT); + } + + /** + * Sets the items for editing a creek route. * * @param player the player */ - public static void setCreekRouteItems(Player player) { - creekRouteLayout.apply(player); + public static void setCreekRouteEditItems(Player player) { + creekRouteEditLayout.apply(player); player.setHeldItemSlot(DEFAULT); } } From fd89cdb3cda38081e48b94ff8fa85212376e3e75 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:44:04 +0200 Subject: [PATCH 44/92] chore(config): add new creek values --- .../cygnus/common/config/CreekConfig.java | 14 ++++++-- .../cygnus/common/config/CreekConfigTest.java | 34 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 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 9a086d6e..dd6bb4b9 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 @@ -42,6 +42,9 @@ * @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 + * @param randomStopChance chance that the creek stops for a moment after reaching a waypoint + * @param randomStopMinMillis shortest random stop, in milliseconds + * @param randomStopMaxMillis longest random stop, in milliseconds * @author theEvilReaper * @version 1.0.0 * @since 2.15.0 @@ -78,7 +81,10 @@ public record CreekConfig( double betrayalChance, int betrayalGlowSeconds, int slownessSeconds, - double routeLinkDistance + double routeLinkDistance, + double randomStopChance, + int randomStopMinMillis, + int randomStopMaxMillis ) { /** @@ -94,7 +100,8 @@ public record CreekConfig( 20, 40, 30, 15, 3000, 0.6D, 0.3D, 0.1D, 25, 2, 0.15D, 6, 4, - 3.0D + 3.0D, + 0.15D, 1500, 4000 ); /** @@ -136,6 +143,9 @@ public record CreekConfig( atLeast("betrayalGlowSeconds", betrayalGlowSeconds, 1); atLeast("slownessSeconds", slownessSeconds, 0); positive("routeLinkDistance", routeLinkDistance); + between("randomStopChance", randomStopChance, 0.0D, 1.0D); + atLeast("randomStopMinMillis", randomStopMinMillis, 0); + notAbove("randomStopMinMillis", randomStopMinMillis, "randomStopMaxMillis", randomStopMaxMillis); } 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 b4426bba..4aeee8b4 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 @@ -28,6 +28,9 @@ void defaultsMatchTheDesign() { assertEquals(2, config.betrayalCatchCount()); assertEquals(0.15D, config.betrayalChance()); assertEquals(3.0D, config.routeLinkDistance()); + assertEquals(0.15D, config.randomStopChance()); + assertEquals(1500, config.randomStopMinMillis()); + assertEquals(4000, config.randomStopMaxMillis()); } @Test @@ -42,7 +45,8 @@ void linkDistanceAboveZero() { config.catchDistance(), config.vanishMinSeconds(), config.vanishMaxSeconds(), config.respawnMinDistance(), config.personalSpace(), config.stuckMillis(), config.dreadPageWeight(), config.dreadTimeWeight(), config.dreadIsolationWeight(), config.isolationRadius(), config.betrayalCatchCount(), - config.betrayalChance(), config.betrayalGlowSeconds(), config.slownessSeconds(), 0.0D)); + config.betrayalChance(), config.betrayalGlowSeconds(), config.slownessSeconds(), 0.0D, + config.randomStopChance(), config.randomStopMinMillis(), config.randomStopMaxMillis())); assertTrue(exception.getMessage().contains("routeLinkDistance")); } @@ -81,7 +85,8 @@ 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.routeLinkDistance()); + d.betrayalChance(), d.betrayalGlowSeconds(), d.slownessSeconds(), d.routeLinkDistance(), + d.randomStopChance(), d.randomStopMinMillis(), d.randomStopMaxMillis()); } @Test @@ -90,4 +95,29 @@ void huntIsFasterThanWalking() { // A walking player covers about 0.216 blocks per tick; the speed attribute is blocks per tick. assertTrue(CreekConfig.DEFAULT.huntSpeed() > 0.216D); } + + private static CreekConfig withRandomStops(double chance, int minMillis, int maxMillis) { + CreekConfig d = CreekConfig.DEFAULT; + return new CreekConfig( + d.enabled(), d.activeWithLastSurvivor(), d.sightRange(), d.sightViewAngle(), + d.wanderPauseMillis(), d.wanderSpeed(), d.huntSpeed(), d.stalkThreshold(), d.huntThreshold(), + d.stalkMinDistance(), d.stalkMaxDistance(), d.stalkMinAngle(), d.stalkMaxAngle(), + d.stalkRevealMillis(), d.stalkMinSeconds(), d.stalkMaxSeconds(), d.huntMaxSeconds(), + d.catchDistance(), d.vanishMinSeconds(), d.vanishMaxSeconds(), d.respawnMinDistance(), + d.personalSpace(), d.stuckMillis(), d.dreadPageWeight(), d.dreadTimeWeight(), + d.dreadIsolationWeight(), d.isolationRadius(), d.betrayalCatchCount(), + d.betrayalChance(), d.betrayalGlowSeconds(), d.slownessSeconds(), d.routeLinkDistance(), + chance, minMillis, maxMillis); + } + + @Test + @DisplayName("Random stops need a chance between 0 and 1 and a range that does not run backwards") + void randomStopsAreChecked() { + assertThrows(IllegalArgumentException.class, () -> withRandomStops(1.5D, 1500, 4000)); + assertThrows(IllegalArgumentException.class, () -> withRandomStops(0.15D, -1, 4000)); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> withRandomStops(0.15D, 5000, 4000)); + assertTrue(exception.getMessage().contains("randomStopMinMillis")); + withRandomStops(0.0D, 0, 0); + } } From ae9f96a8c75a56610287136b1c501aca76b38b58 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:44:24 +0200 Subject: [PATCH 45/92] chore(message): add new message --- .../cygnus/setup/util/SetupMessages.java | 78 +++++++++++++++++++ 1 file changed, 78 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 8a0b97cb..ef87975f 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/util/SetupMessages.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/util/SetupMessages.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Locale; /** * The class contains method and static constant values to handle specific messages during the setup @@ -40,6 +41,10 @@ public final class SetupMessages { public static final Component NO_ACTIVE_CREEK_ROUTE; public static final Component NO_CREEK_POINT_TO_REMOVE; public static final Component SELECT_CLICK; + public static final Component CREEK_PAUSE_NEEDS_POINT; + public static final Component CREEK_END_PAUSE_NEEDS_TWO_POINTS; + public static final Component CREEK_ROUTE_NOT_USABLE; + public static final Component CREEK_POINT_HINT; static { SPACE_SEPARATOR = Component.text("» ", NamedTextColor.GRAY); @@ -120,6 +125,18 @@ public final class SetupMessages { NO_CREEK_POINT_TO_REMOVE = Messages.withPrefix( Component.text("The active creek route has no point to remove", NamedTextColor.RED) ); + CREEK_PAUSE_NEEDS_POINT = Messages.withPrefix( + Component.text("Add a point to the creek route before setting its pauses", NamedTextColor.RED) + ); + CREEK_END_PAUSE_NEEDS_TWO_POINTS = Messages.withPrefix( + Component.text("The end pause needs a creek route with at least two points", NamedTextColor.RED) + ); + CREEK_ROUTE_NOT_USABLE = Messages.withPrefix( + Component.text("The creek route needs at least two points before the game uses it", NamedTextColor.RED) + ); + CREEK_POINT_HINT = Messages.withPrefix( + Component.text("Break a block to add a point on top of it, break it again to remove the point", NamedTextColor.GRAY) + ); } private SetupMessages() { @@ -200,6 +217,21 @@ private SetupMessages() { nameTag(name), countTag(count)); } + /** + * Creates a new {@link Component} instance which confirms that a certain point of a creek route was removed. + * + * @param name of the route + * @param number of the removed point, counting from 1 + * @param count of points the route has left + * @return the created component + */ + @Contract(value = "_, _, _ -> new", pure = true) + public static @NotNull Component getCreekPointRemovedAt(@NotNull String name, int number, int count) { + return Messages.withMiniPrefix("Removed point of ( left)", + nameTag(name), countTag(count), + TagResolver.builder().tag("number", Tag.inserting(Component.text(number))).build()); + } + /** * Creates a new {@link Component} instance which confirms that a creek route is now being edited. * @@ -222,6 +254,47 @@ private SetupMessages() { return Messages.withMiniPrefix("Deleted creek route ", nameTag(name)); } + /** + * Creates a new {@link Component} instance which confirms that a creek route is no longer edited. + * + * @param name of the route + * @param count of points the route has + * @return the created component + */ + @Contract(value = "_, _ -> new", pure = true) + public static @NotNull Component getCreekRouteFinished(@NotNull String name, int count) { + return Messages.withMiniPrefix("Finished creek route ( points)", + nameTag(name), countTag(count)); + } + + /** + * Creates a new {@link Component} instance which confirms the pauses of a creek route. + * + * @param name of the route + * @param startMillis pause at the start, in milliseconds + * @param endMillis pause at the end, in milliseconds + * @return the created component + */ + @Contract(value = "_, _, _ -> new", pure = true) + public static @NotNull Component getCreekPausesSet(@NotNull String name, int startMillis, int endMillis) { + return Messages.withMiniPrefix( + "Set the pauses of : start s, end s", + nameTag(name), secondsTag("start", startMillis), secondsTag("end", endMillis)); + } + + /** + * Creates a new {@link Component} instance which confirms the start pause of a creek route. + * + * @param name of the route + * @param startMillis pause at the start, in milliseconds + * @return the created component + */ + @Contract(value = "_, _ -> new", pure = true) + public static @NotNull Component getCreekStartPauseSet(@NotNull String name, int startMillis) { + return Messages.withMiniPrefix("Set the start pause of to s", + nameTag(name), secondsTag("start", startMillis)); + } + /** * Route names come from player input, so they are inserted as plain text instead of being parsed. */ @@ -233,6 +306,11 @@ private static TagResolver countTag(int count) { return TagResolver.builder().tag("count", Tag.inserting(Component.text(count))).build(); } + private static TagResolver secondsTag(String key, int millis) { + String seconds = String.format(Locale.ROOT, "%.1f", millis / 1000.0D); + return TagResolver.builder().tag(key, Tag.inserting(Component.text(seconds))).build(); + } + /** * Returns a list that contains the given components which some additional empty components * From e95e7caa9078187c73490f5f55e9b46b85047dec Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:45:03 +0200 Subject: [PATCH 46/92] chore(creek): add preview particles --- .../cygnus/setup/creek/CreekRoutePreview.java | 93 ++++++++++--------- 1 file changed, 50 insertions(+), 43 deletions(-) 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 index ae942c7f..0e9e58fe 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java @@ -16,6 +16,7 @@ import net.minestom.server.timer.Task; import net.minestom.server.timer.TaskSchedule; import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; @@ -25,8 +26,9 @@ 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. + * Shows the creek route being edited to the player editing it: particles at every point and along + * the route, the route's name above its start, and a marker on ends that link to another route. + * Without a route being edited it shows nothing. * * @author theEvilReaper * @version 1.0.0 @@ -43,6 +45,10 @@ public final class CreekRoutePreview { /** How far above the start point the name floats, in blocks. */ static final double LABEL_HEIGHT = 2.5D; + private static final Particle POINT_PARTICLE = Particle.FLAME; + private static final Particle LINE_PARTICLE = Particle.CRIT; + private static final Particle LINK_PARTICLE = Particle.HAPPY_VILLAGER; + private final Player player; private final Supplier> routes; private final Supplier<@Nullable String> active; @@ -91,27 +97,25 @@ public void hide() { } /** - * Rebuilds the floating names after routes changed. Does nothing while the preview is hidden. + * Rebuilds the floating name after the edited route changed. Without a route being edited the + * name goes away. */ public void refreshLabels() { this.labels.forEach(Entity::remove); this.labels.clear(); Instance instance = this.player.getInstance(); - 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); - } + CreekRoute route = this.activeRoute(this.routes.get()); + if (instance == null || route == null || route.points().isEmpty()) return; + + Vec start = route.points().getFirst().position(); + Entity label = new Entity(EntityType.TEXT_DISPLAY); + TextDisplayMeta meta = (TextDisplayMeta) label.getEntityMeta(); + meta.setText(Component.text(route.name(), NamedTextColor.GREEN)); + meta.setBillboardRenderConstraints(AbstractDisplayMeta.BillboardConstraints.CENTER); + label.setAutoViewable(false); + label.setInstance(instance, new Pos(start.x(), start.y() + LABEL_HEIGHT, start.z())) + .thenRun(() -> label.addViewer(this.player)); + this.labels.add(label); } private void draw() { @@ -120,20 +124,26 @@ private void draw() { return; } List routes = this.routes.get(); + CreekRoute route = this.activeRoute(routes); + if (route == null) return; + + List points = route.points().stream().map(CreekWaypoint::position).toList(); + for (int i = 0; i < points.size(); i++) { + this.particle(POINT_PARTICLE, points.get(i)); + if (i + 1 < points.size()) { + line(points.get(i), points.get(i + 1), LINE_STEP).forEach(spot -> this.particle(LINE_PARTICLE, spot)); + } + } + linkedEnds(route, routes, this.linkDistance).forEach(end -> this.particle(LINK_PARTICLE, end.add(0, 0.5, 0))); + } + + private @Nullable CreekRoute activeRoute(List routes) { String activeName = this.active.get(); + if (activeName == null) return null; for (CreekRoute route : routes) { - boolean isActive = route.name().equals(activeName); - Particle pointParticle = isActive ? Particle.FLAME : Particle.END_ROD; - Particle lineParticle = isActive ? Particle.CRIT : Particle.ELECTRIC_SPARK; - List points = route.points(); - for (int i = 0; i < points.size(); i++) { - this.particle(pointParticle, points.get(i)); - if (i + 1 < points.size()) { - line(points.get(i), points.get(i + 1), LINE_STEP).forEach(spot -> this.particle(lineParticle, spot)); - } - } + if (route.name().equals(activeName)) return route; } - linkedEnds(routes, this.linkDistance).forEach(end -> this.particle(Particle.HAPPY_VILLAGER, end.add(0, 0.5, 0))); + return null; } private void particle(Particle particle, Vec spot) { @@ -160,23 +170,20 @@ static List line(Vec from, Vec to, double step) { } /** - * Returns the route ends that are at most {@code distance} away from an end of another route. + * Returns the ends of a route that are at most {@code distance} away from an end of another route. * - * @param routes the routes + * @param route the route whose ends are checked + * @param routes all routes of the map, the checked one included * @param distance the link distance, in blocks - * @return the linked ends + * @return the linked ends of {@code route} */ - static Set linkedEnds(List routes, double distance) { + static Set linkedEnds(CreekRoute route, 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); - } - } + for (Vec end : ends(route)) { + for (CreekRoute other : routes) { + if (other.name().equals(route.name())) continue; + for (Vec otherEnd : ends(other)) { + if (end.distance(otherEnd) <= distance) linked.add(end); } } } @@ -185,6 +192,6 @@ static Set linkedEnds(List routes, double distance) { private static List ends(CreekRoute route) { if (route.points().isEmpty()) return List.of(); - return List.of(route.points().getFirst(), route.points().getLast()); + return List.of(route.points().getFirst().position(), route.points().getLast().position()); } } From ce1daae0283d4a6f0ba1a3ad5acc265934f175f0 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:45:35 +0200 Subject: [PATCH 47/92] chore(setup): add new dialog things --- .../cygnus/setup/event/dialog/DialogContext.java | 15 ++++++++++++++- .../cygnus/setup/event/dialog/DialogTarget.java | 1 + .../listener/dialog/DialogPayloadListener.java | 2 ++ .../listener/dialog/DialogRequestListener.java | 5 +++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogContext.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogContext.java index bcd9768c..8935d17a 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogContext.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogContext.java @@ -18,7 +18,8 @@ public sealed interface DialogContext permits DialogContext.PositionContent, DialogContext.AuthorAmount, DialogContext.PageContent, - DialogContext.AtmosphereContext { + DialogContext.AtmosphereContext, + DialogContext.CreekPauseContext { /** * Specific context for the update or deletion of a name. @@ -65,4 +66,16 @@ record PageContent(PageResource resource) implements DialogContext { record AtmosphereContext(MapAtmosphere atmosphere) implements DialogContext { } + + + /** + * Carries the creek route whose pauses are edited, so the dialog can pre-fill its sliders. + * + * @param route the route's name + * @param startMillis the current pause at the start, in milliseconds + * @param endMillis the current pause at the end, in milliseconds + */ + record CreekPauseContext(String route, int startMillis, int endMillis) implements DialogContext { + + } } diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogTarget.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogTarget.java index 8952f813..dcb74bf1 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogTarget.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/event/dialog/DialogTarget.java @@ -16,4 +16,5 @@ public enum DialogTarget { ATMOSPHERE_PRESET, ATMOSPHERE_VALUES, CREEK_ROUTE_NAME, + CREEK_ROUTE_PAUSE, } 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 0893e004..13a2b308 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogPayloadListener.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogPayloadListener.java @@ -9,6 +9,7 @@ import net.onelitefeather.cygnus.setup.dialog.CreekRouteDialogs; import net.onelitefeather.cygnus.setup.dialog.MapDialogs; import net.onelitefeather.cygnus.setup.dialog.handler.CreekRouteNameHandler; +import net.onelitefeather.cygnus.setup.dialog.handler.CreekRoutePauseHandler; import net.onelitefeather.cygnus.setup.dialog.handler.AtmosphereAdjustHandler; import net.onelitefeather.cygnus.setup.dialog.handler.AtmosphereConfirmHandler; import net.onelitefeather.cygnus.setup.dialog.handler.AtmospherePresetHandler; @@ -32,6 +33,7 @@ public DialogPayloadListener(SetupDataService dataService, AtmospherePreviewServ this.handlers = Map.ofEntries( Map.entry(MapDialogs.MAP_KEY, new MapNameHandler(dataService)), Map.entry(CreekRouteDialogs.ROUTE_NAME_KEY, new CreekRouteNameHandler(dataService)), + Map.entry(CreekRouteDialogs.PAUSE_KEY, new CreekRoutePauseHandler(dataService)), Map.entry(MapDialogs.AUTHOR_AMOUNT_KEY, new AuthorAmountHandler()), Map.entry(MapDialogs.AUTHOR_INPUT_ENTRY_KEY, new AuthorInputHandler(dataService)), Map.entry(MapDialogs.NON_DYNAMIC_DELETE_KEY, new NonDynamicDataHandler(dataService)), diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogRequestListener.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogRequestListener.java index 3b866e51..82c27013 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogRequestListener.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/dialog/DialogRequestListener.java @@ -22,6 +22,11 @@ public void accept(DialogRequestEvent event) { switch (target) { case CREATE_NAME -> MapDialogs.openNameCreateDialog(player); case CREEK_ROUTE_NAME -> CreekRouteDialogs.openNameDialog(player); + case CREEK_ROUTE_PAUSE -> { + if (context instanceof DialogContext.CreekPauseContext pauseContext) { + CreekRouteDialogs.openPauseDialog(player, pauseContext); + } + } case UPDATE_NAME -> { if (context instanceof DialogContext.NameContext(String name)) { MapDialogs.openNameUpdateDialog(player, name); From bd2898f6e9c255e022a06a89377d4d90b63f95b9 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:45:50 +0200 Subject: [PATCH 48/92] chore(config): update reader cases --- .../cygnus/common/config/GameConfigReader.java | 5 ++++- .../cygnus/common/config/GameConfigReaderTest.java | 6 ++++++ 2 files changed, 10 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 4970cc3a..85998cf9 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 @@ -256,7 +256,10 @@ private CreekConfig getCreek(Properties properties) { getDouble(properties, CREEK_PREFIX + "betrayalChance", d.betrayalChance()), getInt(properties, CREEK_PREFIX + "betrayalGlowSeconds", d.betrayalGlowSeconds()), getInt(properties, CREEK_PREFIX + "slownessSeconds", d.slownessSeconds()), - getDouble(properties, CREEK_PREFIX + "routeLinkDistance", d.routeLinkDistance()) + getDouble(properties, CREEK_PREFIX + "routeLinkDistance", d.routeLinkDistance()), + getDouble(properties, CREEK_PREFIX + "randomStopChance", d.randomStopChance()), + getInt(properties, CREEK_PREFIX + "randomStopMinMillis", d.randomStopMinMillis()), + getInt(properties, CREEK_PREFIX + "randomStopMaxMillis", d.randomStopMaxMillis()) ); } 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 4a545912..4464da05 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java @@ -371,6 +371,9 @@ void testCreekValuesAreRead(@TempDir Path tempDir) throws IOException { creek.huntThreshold=0.7 creek.stalkMaxDistance=40 creek.routeLinkDistance=4.5 + creek.randomStopChance=0.4 + creek.randomStopMinMillis=500 + creek.randomStopMaxMillis=900 """); CreekConfig creek = new GameConfigReader(tempDir).getConfig().creek(); @@ -380,6 +383,9 @@ void testCreekValuesAreRead(@TempDir Path tempDir) throws IOException { assertEquals(40, creek.stalkMaxDistance()); assertEquals(CreekConfig.DEFAULT.stalkMinDistance(), creek.stalkMinDistance()); assertEquals(4.5D, creek.routeLinkDistance(), 1.0E-9); + assertEquals(0.4D, creek.randomStopChance(), 1.0E-9); + assertEquals(500, creek.randomStopMinMillis()); + assertEquals(900, creek.randomStopMaxMillis()); } @Test From 0902b1ee9650d8f9cc8ba150012f2f7c0f1fa55c Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:46:51 +0200 Subject: [PATCH 49/92] chore(map): improve map creation --- .../cygnus/common/map/GameMap.java | 10 ++ .../cygnus/common/map/GameMapBuilder.java | 75 +++++++++-- .../cygnus/common/page/PageFacesFile.java | 11 +- .../common/map/GameMapCreekRoutesTest.java | 119 +++++++++++++++++- .../cygnus/common/map/GameMapTest.java | 14 +++ .../map/GameMapProviderIntegrationTest.java | 2 +- 6 files changed, 211 insertions(+), 20 deletions(-) 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 9e466a13..3b24a5a4 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 @@ -131,6 +131,16 @@ public List getCreekRoutes() { return creekRoutes; } + /** + * Returns a copy of this map with other page faces. + * + * @param pageFaces the new page faces + * @return the copy + */ + public GameMap withPageFaces(Set pageFaces) { + return new GameMap(name(), spawn(), slenderSpawn, pageFaces, survivorSpawns, builders(), atmosphere, creekRoutes); + } + /** * Returns a copy of this map with other creek routes. * 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 b3e025e7..18e18d56 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 @@ -4,6 +4,7 @@ import net.minestom.server.coordinate.Vec; import net.minestom.server.utils.Direction; import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import net.onelitefeather.cygnus.common.dimension.MapAtmosphere; import net.onelitefeather.cygnus.common.page.PageResource; import net.theevilreaper.aves.map.BaseMapBuilder; @@ -24,7 +25,7 @@ public final class GameMapBuilder extends BaseMapBuilder { private @Nullable MapAtmosphere atmosphere; private final Set pageFaces; private final Set survivorSpawns; - private final Map> creekRoutes; + private final Map> creekRoutes; /** * Creates a new instance of the builder class @@ -212,29 +213,87 @@ public boolean hasCreekRoute(String name) { } /** - * Appends a point to a creek route. + * Appends a point to a creek route. Once the route has an end, its pause moves to the new end. * * @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); + List points = this.creekRoutes.get(name); if (points == null) return false; - points.add(point); + CreekWaypoint added = CreekWaypoint.of(point); + if (points.size() >= CreekRoute.MIN_POINTS) { + CreekWaypoint oldEnd = points.getLast(); + points.set(points.size() - 1, oldEnd.withPause(0)); + added = added.withPause(oldEnd.pauseMillis()); + } + points.add(added); return true; } /** - * Removes the last point of a creek route. + * Removes the last point of a creek route. If the route still has an end, the pause moves to it. * * @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); + List points = this.creekRoutes.get(name); if (points == null || points.isEmpty()) return false; - points.removeLast(); + CreekWaypoint removed = points.removeLast(); + if (points.size() >= CreekRoute.MIN_POINTS) { + int lastIndex = points.size() - 1; + points.set(lastIndex, points.get(lastIndex).withPause(removed.pauseMillis())); + } + return true; + } + + /** + * Removes the point of a creek route that sits at the given position. If several do, the one + * added last goes. The pauses of the start and the end stay with the new start and end. + * + * @param name the route's name + * @param point the position of the point to remove + * @return the number of the removed point, counting from 1, or {@code -1} if there was none + */ + public int removeCreekPoint(String name, Vec point) { + List points = this.creekRoutes.get(name); + if (points == null) return -1; + int index = -1; + for (int i = points.size() - 1; i >= 0; i--) { + if (points.get(i).position().equals(point)) { + index = i; + break; + } + } + if (index < 0) return -1; + if (index == points.size() - 1) { + this.removeLastCreekPoint(name); + return index + 1; + } + CreekWaypoint removed = points.remove(index); + if (index == 0) { + points.set(0, points.getFirst().withPause(removed.pauseMillis())); + } + return index + 1; + } + + /** + * Sets how long the creek waits at the start or the end of a route. + * + * @param name the route's name + * @param atStart {@code true} for the first point, {@code false} for the last + * @param millis the pause, in milliseconds + * @return {@code false} without such a route, with a negative pause, without a point at the + * start, or with fewer than two points for the end + */ + public boolean setCreekEndPause(String name, boolean atStart, int millis) { + List points = this.creekRoutes.get(name); + if (points == null || millis < 0) return false; + if (atStart ? points.isEmpty() : points.size() < CreekRoute.MIN_POINTS) return false; + int index = atStart ? 0 : points.size() - 1; + points.set(index, points.get(index).withPause(millis)); return true; } @@ -244,7 +303,7 @@ public boolean removeLastCreekPoint(String name) { * @param name the route's name * @return the points, empty if there is no such route */ - public List getCreekRoutePoints(String name) { + public List getCreekRoutePoints(String name) { return List.copyOf(this.creekRoutes.getOrDefault(name, List.of())); } 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 f471caf6..c33cbbb5 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 @@ -97,15 +97,6 @@ public static GameMap loadInto(Path mapFile, GameMap map) { Set merged = new HashSet<>(map.getPageFaces()); merged.addAll(load(mapFile)); - return new GameMap( - map.name(), - map.spawn(), - map.getSlenderSpawn(), - merged, - map.getSurvivorSpawns(), - map.builders(), - map.getAtmosphere(), - map.getCreekRoutes() - ); + return map.withPageFaces(merged); } } 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 index 528027d2..d9cd309f 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapCreekRoutesTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapCreekRoutesTest.java @@ -4,6 +4,7 @@ import net.minestom.server.coordinate.Vec; import net.minestom.server.utils.Direction; import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import net.onelitefeather.cygnus.common.page.PageFacesFile; import net.onelitefeather.cygnus.common.page.PageResource; import org.junit.jupiter.api.DisplayName; @@ -20,7 +21,7 @@ class GameMapCreekRoutesTest { - private static final CreekRoute ROUTE = new CreekRoute("Waldweg", List.of(new Vec(0, 80, 0), new Vec(5, 80, 0))); + private static final CreekRoute ROUTE = CreekRoute.ofPositions("Waldweg", List.of(new Vec(0, 80, 0), new Vec(5, 80, 0))); private static GameMap map() { return new GameMap("Forest", Pos.ZERO, Pos.ZERO, Set.of(), Set.of(), List.of(), null); @@ -80,4 +81,120 @@ void pageMergeKeepsRoutes(@TempDir Path root) { assertEquals(List.of(ROUTE), merged.getCreekRoutes()); } + + @Test + @DisplayName("Pauses can be set on the start and the end") + void setsEndPauses() { + GameMapBuilder builder = new GameMapBuilder(); + builder.addCreekRoute("Waldweg"); + + assertFalse(builder.setCreekEndPause("Waldweg", true, 1000), "no point yet"); + builder.addCreekPoint("Waldweg", new Vec(0, 80, 0)); + assertTrue(builder.setCreekEndPause("Waldweg", true, 1000)); + assertFalse(builder.setCreekEndPause("Waldweg", false, 2000), "the end needs two points"); + builder.addCreekPoint("Waldweg", new Vec(5, 80, 0)); + assertTrue(builder.setCreekEndPause("Waldweg", false, 2000)); + assertFalse(builder.setCreekEndPause("Waldweg", false, -1), "no negative pauses"); + assertFalse(builder.setCreekEndPause("Unknown", true, 1000)); + + List points = builder.getCreekRoutePoints("Waldweg"); + assertEquals(1000, points.getFirst().pauseMillis()); + assertEquals(2000, points.getLast().pauseMillis()); + } + + @Test + @DisplayName("The end pause moves with the end") + void endPauseMovesWithTheEnd() { + GameMapBuilder builder = new GameMapBuilder(); + builder.addCreekRoute("Waldweg"); + builder.addCreekPoint("Waldweg", new Vec(0, 80, 0)); + builder.addCreekPoint("Waldweg", new Vec(5, 80, 0)); + builder.setCreekEndPause("Waldweg", false, 2000); + + builder.addCreekPoint("Waldweg", new Vec(9, 80, 0)); + List grown = builder.getCreekRoutePoints("Waldweg"); + assertEquals(List.of(0, 0, 2000), grown.stream().map(CreekWaypoint::pauseMillis).toList()); + + builder.removeLastCreekPoint("Waldweg"); + List shrunk = builder.getCreekRoutePoints("Waldweg"); + assertEquals(List.of(0, 2000), shrunk.stream().map(CreekWaypoint::pauseMillis).toList()); + } + + @Test + @DisplayName("With one point left the start keeps its own pause") + void startKeepsItsPause() { + GameMapBuilder builder = new GameMapBuilder(); + builder.addCreekRoute("Waldweg"); + builder.addCreekPoint("Waldweg", new Vec(0, 80, 0)); + builder.setCreekEndPause("Waldweg", true, 1000); + builder.addCreekPoint("Waldweg", new Vec(5, 80, 0)); + builder.setCreekEndPause("Waldweg", false, 2000); + + builder.removeLastCreekPoint("Waldweg"); + + assertEquals(List.of(1000), builder.getCreekRoutePoints("Waldweg").stream().map(CreekWaypoint::pauseMillis).toList()); + } + + /** A route from (0,80,0) over (5,80,0) to (9,80,0), paused 1000 ms at the start and 2000 ms at the end. */ + private static GameMapBuilder threePointRoute() { + GameMapBuilder builder = new GameMapBuilder(); + builder.addCreekRoute("Waldweg"); + builder.addCreekPoint("Waldweg", new Vec(0, 80, 0)); + builder.addCreekPoint("Waldweg", new Vec(5, 80, 0)); + builder.addCreekPoint("Waldweg", new Vec(9, 80, 0)); + builder.setCreekEndPause("Waldweg", true, 1000); + builder.setCreekEndPause("Waldweg", false, 2000); + return builder; + } + + private static List positions(GameMapBuilder builder) { + return builder.getCreekRoutePoints("Waldweg").stream().map(CreekWaypoint::position).toList(); + } + + private static List pauses(GameMapBuilder builder) { + return builder.getCreekRoutePoints("Waldweg").stream().map(CreekWaypoint::pauseMillis).toList(); + } + + @Test + @DisplayName("A point in the middle can be removed") + void removesAMiddlePoint() { + GameMapBuilder builder = threePointRoute(); + + assertEquals(2, builder.removeCreekPoint("Waldweg", new Vec(5, 80, 0))); + + assertEquals(List.of(new Vec(0, 80, 0), new Vec(9, 80, 0)), positions(builder)); + assertEquals(List.of(1000, 2000), pauses(builder)); + } + + @Test + @DisplayName("Removing the first point hands its pause to the new first point") + void removesTheFirstPoint() { + GameMapBuilder builder = threePointRoute(); + + assertEquals(1, builder.removeCreekPoint("Waldweg", new Vec(0, 80, 0))); + + assertEquals(List.of(new Vec(5, 80, 0), new Vec(9, 80, 0)), positions(builder)); + assertEquals(List.of(1000, 2000), pauses(builder)); + } + + @Test + @DisplayName("Removing the last point hands its pause to the new last point") + void removesTheLastPointByPosition() { + GameMapBuilder builder = threePointRoute(); + + assertEquals(3, builder.removeCreekPoint("Waldweg", new Vec(9, 80, 0))); + + assertEquals(List.of(new Vec(0, 80, 0), new Vec(5, 80, 0)), positions(builder)); + assertEquals(List.of(1000, 2000), pauses(builder)); + } + + @Test + @DisplayName("Without a point at that spot nothing is removed") + void nothingToRemove() { + GameMapBuilder builder = threePointRoute(); + + assertEquals(-1, builder.removeCreekPoint("Waldweg", new Vec(3, 80, 0))); + assertEquals(-1, builder.removeCreekPoint("Unknown", new Vec(0, 80, 0))); + assertEquals(3, positions(builder).size()); + } } diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapTest.java index b582126b..2a660fac 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/map/GameMapTest.java @@ -130,4 +130,18 @@ void testGameMapCopyWithEmptyCollections() { assertNotNull(builder.getPageFaces()); assertTrue(builder.getPageFaces().isEmpty()); } + + @Test + void testWithPageFacesKeepsTheRest() { + GameMap map = new GameMap("Forest", Pos.ZERO, new Pos(1, 2, 3), Set.of(), Set.of(new Pos(4, 5, 6)), List.of("Builder"), null); + PageResource page = new PageResource(Vec.ZERO, Direction.NORTH); + + GameMap copy = map.withPageFaces(Set.of(page)); + + assertEquals(Set.of(page), copy.getPageFaces()); + assertEquals("Forest", copy.name()); + assertEquals(new Pos(1, 2, 3), copy.getSlenderSpawn()); + assertEquals(Set.of(new Pos(4, 5, 6)), copy.getSurvivorSpawns()); + assertEquals(List.of("Builder"), copy.builders()); + } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java index 04bd2309..245a5a88 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java @@ -218,7 +218,7 @@ void loadsPageFacesFromTheSiblingPagesJson(Env env, @TempDir Path root) throws I 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))); + CreekRoute route = CreekRoute.ofPositions("Waldweg", List.of(new Vec(0, 80, 0), new Vec(5, 80, 0))); CreekRoutesFile.save(mapFile, List.of(route)); provider.loadGameMap(); From 7e5fb360c4ce8da26cfa7b86f94c81bdc4c47a1e Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:47:25 +0200 Subject: [PATCH 50/92] chore(creek): improve route listener --- .../cygnus/setup/listener/CreekRouteListener.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 index 7424ac26..7ada2129 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java @@ -13,7 +13,8 @@ import java.util.function.Consumer; /** - * Appends a point to the active creek route when a block is left-clicked in the creek route mode. + * Appends a point to the active creek route when a block is broken in the creek route mode, or + * removes the point that already sits on that block. * * @author theEvilReaper * @version 1.0.0 @@ -47,7 +48,14 @@ public void accept(PlayerBlockBreakEvent event) { return; } - gameData.addCreekPoint(pointOnTop(event.getBlockPosition())); + Vec point = pointOnTop(event.getBlockPosition()); + int removed = gameData.removeCreekPointAt(point); + if (removed > 0) { + player.sendMessage(SetupMessages.getCreekPointRemovedAt(activeRoute, removed, gameData.activeCreekPointCount())); + return; + } + + gameData.addCreekPoint(point); player.sendMessage(SetupMessages.getCreekPointAdded(activeRoute, gameData.activeCreekPointCount())); } From e67b0d25e47e4b3f620e356e47ab530c792e9883 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:48:05 +0200 Subject: [PATCH 51/92] chore(setup): improve route usage --- .../cygnus/setup/data/GameData.java | 123 ++++++++++++++++-- .../cygnus/setup/data/GameDataTest.java | 107 ++++++++++++++- 2 files changed, 216 insertions(+), 14 deletions(-) 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 e7b58222..86a33cbb 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 @@ -13,10 +13,12 @@ import net.onelitefeather.cygnus.common.config.CreekConfig; import net.onelitefeather.cygnus.common.creek.CreekRoute; import net.onelitefeather.cygnus.common.creek.CreekRoutesFile; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import net.onelitefeather.cygnus.common.map.GameMap; import net.onelitefeather.cygnus.common.map.GameMapBuilder; import net.onelitefeather.cygnus.common.util.GsonHelper; import net.onelitefeather.cygnus.setup.creek.CreekRoutePreview; +import net.onelitefeather.cygnus.setup.event.dialog.DialogContext; import net.onelitefeather.cygnus.setup.event.dialog.DialogRequestEvent; import net.onelitefeather.cygnus.setup.event.dialog.DialogTarget; import net.onelitefeather.cygnus.setup.inventory.page.PageHeaderFormatter; @@ -78,6 +80,12 @@ public GameData(Player player, MapEntry mapEntry) { this.refreshCreekRoutes(); } + /** + * Creates the {@link PageableInventory} to display the creek routes of the map. + * + * @param player who owns the inventory + * @return the created inventory + */ private PageableInventory createCreekRouteInventory(Player player) { InventoryLayout layout = InventoryLayout.fromType(InventoryType.CHEST_6_ROW); layout.setItems(LayoutCalculator.fillRow(InventoryType.CHEST_1_ROW), SetupItems.DECORATION_PANE); @@ -102,12 +110,12 @@ 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); + CreekRouteSlot slot = new CreekRouteSlot(route, route.name().equals(this.activeCreekRoute), + this::selectFromOverview, this::deleteFromOverview); this.creekRouteSlots.add(slot); this.creekRouteInventory.add(slot); } - if (this.creekRoutePreview != null && this.hasCreekRouteMode()) { + if (this.hasCreekRouteMode()) { this.creekRoutePreview.refreshLabels(); } } @@ -115,6 +123,8 @@ private void refreshCreekRoutes() { private void selectFromOverview(Player player, String name) { if (this.selectCreekRoute(name)) { player.sendMessage(SetupMessages.getCreekRouteSelected(name)); + player.sendMessage(SetupMessages.CREEK_POINT_HINT); + SetupItems.setCreekRouteEditItems(player); } } @@ -257,20 +267,74 @@ public boolean deleteCreekRoute(String name) { * @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; + String activeRoute = this.activeCreekRoute; + if (activeRoute == null || !this.gameMapBuilder.addCreekPoint(activeRoute, point)) return false; this.refreshCreekRoutes(); return true; } + /** + * Removes the point of the active creek route that sits at the given position. + * + * @param point the position of the point to remove + * @return the number of the removed point, counting from 1, or {@code -1} without an active + * route or without a point there + */ + public int removeCreekPointAt(Vec point) { + String activeRoute = this.activeCreekRoute; + if (activeRoute == null) return -1; + int removed = this.gameMapBuilder.removeCreekPoint(activeRoute, point); + if (removed > 0) this.refreshCreekRoutes(); + return removed; + } + /** * Removes the last point of the active creek route. * * @return {@code false} without an active route or without points */ public boolean removeLastCreekPoint() { - String active = this.activeCreekRoute; - if (active == null || !this.gameMapBuilder.removeLastCreekPoint(active)) return false; + String activeRoute = this.activeCreekRoute; + if (activeRoute == null || !this.gameMapBuilder.removeLastCreekPoint(activeRoute)) return false; + this.refreshCreekRoutes(); + return true; + } + + /** + * Sets how long the creek waits at the start of the active route. + * + * @param millis the pause, in milliseconds + * @return {@code false} without an active route or without a point + */ + public boolean setCreekStartPause(int millis) { + return this.setCreekPause(true, millis); + } + + /** + * Sets how long the creek waits at the end of the active route. + * + * @param millis the pause, in milliseconds + * @return {@code false} without an active route or with fewer than two points + */ + public boolean setCreekEndPause(int millis) { + return this.setCreekPause(false, millis); + } + + private boolean setCreekPause(boolean atStart, int millis) { + String activeRoute = this.activeCreekRoute; + if (activeRoute == null || !this.gameMapBuilder.setCreekEndPause(activeRoute, atStart, millis)) return false; + this.refreshCreekRoutes(); + return true; + } + + /** + * Stops editing the active creek route. The route itself stays. + * + * @return {@code false} if no route was being edited + */ + public boolean finishCreekRoute() { + if (this.activeCreekRoute == null) return false; + this.activeCreekRoute = null; this.refreshCreekRoutes(); return true; } @@ -290,8 +354,8 @@ public boolean removeLastCreekPoint() { * @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(); + String activeRoute = this.activeCreekRoute; + return activeRoute == null ? 0 : this.gameMapBuilder.getCreekRoutePoints(activeRoute).size(); } /** @@ -423,13 +487,18 @@ public void handleItemInteraction(Player player, byte tagValue) { } if (SetupItemId.CREEK_ROUTES == tagValue) { + if (!hasCreekRouteMode() && (hasPageMode() || hasSurvivorMode())) { + player.sendMessage(SetupMessages.getModeInform(hasPageMode() ? "page" : "survivor")); + return; + } this.swapCreekRouteMode(); if (hasCreekRouteMode()) { player.sendMessage(SetupMessages.CREEK_MODE_ENABLED); player.sendMessage(SetupMessages.getModeInform("creek route")); - SetupItems.setCreekRouteItems(player); + SetupItems.setCreekRouteOverviewItems(player); this.creekRoutePreview.show(); } else { + this.finishCreekRoute(); this.creekRoutePreview.hide(); player.sendMessage(SetupMessages.CREEK_MODE_DISABLED); SetupItems.setGameLayout(player); @@ -440,6 +509,7 @@ public void handleItemInteraction(Player player, byte tagValue) { if (hasCreekRouteMode()) { this.swapCreekRouteMode(); } + this.finishCreekRoute(); this.creekRoutePreview.hide(); player.sendMessage(SetupMessages.CREEK_MODE_DISABLED); SetupItems.setGameLayout(player); @@ -450,11 +520,13 @@ public void handleItemInteraction(Player player, byte tagValue) { return; } if (SetupItemId.CREEK_UNDO == tagValue) { - String active = this.activeCreekRoute; - if (active == null || !this.removeLastCreekPoint()) { + String activeRoute = this.activeCreekRoute; + if (activeRoute == null) { + player.sendMessage(SetupMessages.NO_ACTIVE_CREEK_ROUTE); + } else if (!this.removeLastCreekPoint()) { player.sendMessage(SetupMessages.NO_CREEK_POINT_TO_REMOVE); } else { - player.sendMessage(SetupMessages.getCreekPointRemoved(active, this.activeCreekPointCount())); + player.sendMessage(SetupMessages.getCreekPointRemoved(activeRoute, this.activeCreekPointCount())); } return; } @@ -462,6 +534,31 @@ public void handleItemInteraction(Player player, byte tagValue) { this.creekRouteInventory.open(); return; } + if (SetupItemId.CREEK_FINISH == tagValue) { + String activeRoute = this.activeCreekRoute; + int pointCount = this.activeCreekPointCount(); + if (activeRoute != null && this.finishCreekRoute()) { + player.sendMessage(SetupMessages.getCreekRouteFinished(activeRoute, pointCount)); + if (pointCount < CreekRoute.MIN_POINTS) { + player.sendMessage(SetupMessages.CREEK_ROUTE_NOT_USABLE); + } + } + SetupItems.setCreekRouteOverviewItems(player); + return; + } + if (SetupItemId.CREEK_PAUSE == tagValue) { + String activeRoute = this.activeCreekRoute; + if (activeRoute == null) { + player.sendMessage(SetupMessages.NO_ACTIVE_CREEK_ROUTE); + return; + } + List points = this.gameMapBuilder.getCreekRoutePoints(activeRoute); + int startMillis = points.isEmpty() ? 0 : points.getFirst().pauseMillis(); + int endMillis = points.size() < CreekRoute.MIN_POINTS ? 0 : points.getLast().pauseMillis(); + EventDispatcher.call(new DialogRequestEvent(player, DialogTarget.CREEK_ROUTE_PAUSE, + new DialogContext.CreekPauseContext(activeRoute, startMillis, endMillis))); + return; + } super.handleItemInteraction(player, tagValue); } 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 f2366ec7..46c152a9 100644 --- a/setup/src/test/java/net/onelitefeather/cygnus/setup/data/GameDataTest.java +++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/GameDataTest.java @@ -7,8 +7,10 @@ import net.minestom.server.utils.Direction; import net.minestom.testing.Env; import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.cygnus.common.Tags; import net.onelitefeather.cygnus.common.creek.CreekRoute; import net.onelitefeather.cygnus.common.creek.CreekRoutesFile; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import net.onelitefeather.cygnus.common.map.GameMap; import net.onelitefeather.cygnus.common.map.GameMapBuilder; import net.onelitefeather.cygnus.setup.item.SetupItemId; @@ -209,7 +211,7 @@ void saveAndLoadKeepAnUnfinishedCreekRoute(Env env, @TempDir Path root) { gameData.save(); GameData reloaded = new GameData(player, mapEntry); - assertEquals(List.of(new CreekRoute("Stub", List.of(new Vec(1, 80, 1)))), + assertEquals(List.of(CreekRoute.ofPositions("Stub", List.of(new Vec(1, 80, 1)))), ((GameMapBuilder) reloaded.getMapBuilder()).getCreekRoutes()); assertTrue(Files.exists(CreekRoutesFile.resolve(root.resolve("map.json")))); @@ -229,4 +231,107 @@ void testCreekRouteItemTogglesTheMode(Env env) { env.destroyInstance(instance, true); } + + @Test + void testCreekRouteModeStaysOffWhileAnotherModeIsActive(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + gameData.swapPageMode(); + + gameData.handleItemInteraction(player, SetupItemId.CREEK_ROUTES); + + assertFalse(gameData.hasCreekRouteMode()); + env.destroyInstance(instance, true); + } + + @Test + void testCreekRoutePauses(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + + assertFalse(gameData.setCreekStartPause(1000), "no active route"); + gameData.createCreekRoute("Waldweg"); + assertFalse(gameData.setCreekStartPause(1000), "no point yet"); + gameData.addCreekPoint(new Vec(1, 80, 1)); + assertTrue(gameData.setCreekStartPause(1000)); + assertFalse(gameData.setCreekEndPause(2000), "the end needs two points"); + gameData.addCreekPoint(new Vec(5, 80, 1)); + assertTrue(gameData.setCreekEndPause(2000)); + + List points = ((GameMapBuilder) gameData.getMapBuilder()).getCreekRoutePoints("Waldweg"); + assertEquals(1000, points.getFirst().pauseMillis()); + assertEquals(2000, points.getLast().pauseMillis()); + + env.destroyInstance(instance, true); + } + + @Test + void testFinishCreekRoute(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + + assertFalse(gameData.finishCreekRoute(), "nothing to finish yet"); + gameData.createCreekRoute("Waldweg"); + gameData.addCreekPoint(new Vec(1, 80, 1)); + + assertTrue(gameData.finishCreekRoute()); + assertNull(gameData.activeCreekRoute()); + assertTrue(((GameMapBuilder) gameData.getMapBuilder()).hasCreekRoute("Waldweg"), "finishing keeps the route"); + + env.destroyInstance(instance, true); + } + + @Test + void testFinishItemSwitchesBackToTheOverview(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + gameData.handleItemInteraction(player, SetupItemId.CREEK_ROUTES); + gameData.createCreekRoute("Waldweg"); + + gameData.handleItemInteraction(player, SetupItemId.CREEK_FINISH); + + assertNull(gameData.activeCreekRoute()); + assertEquals(SetupItemId.CREEK_NEW, player.getInventory().getItemStack(0).getTag(Tags.ITEM_TAG).byteValue()); + + env.destroyInstance(instance, true); + } + + @Test + void testLeavingTheCreekRouteModeFinishesTheRoute(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + gameData.handleItemInteraction(player, SetupItemId.CREEK_ROUTES); + gameData.createCreekRoute("Waldweg"); + + gameData.handleItemInteraction(player, SetupItemId.CREEK_LEAVE); + + assertFalse(gameData.hasCreekRouteMode()); + assertNull(gameData.activeCreekRoute()); + + env.destroyInstance(instance, true); + } + + @Test + void testRemoveCreekPointAt(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + GameData gameData = new GameData(player, MapEntry.of(Paths.get(""))); + + assertEquals(-1, gameData.removeCreekPointAt(new Vec(5, 80, 1)), "no active route"); + gameData.createCreekRoute("Waldweg"); + gameData.addCreekPoint(new Vec(1, 80, 1)); + gameData.addCreekPoint(new Vec(5, 80, 1)); + gameData.addCreekPoint(new Vec(9, 80, 1)); + + assertEquals(2, gameData.removeCreekPointAt(new Vec(5, 80, 1))); + assertEquals(2, gameData.activeCreekPointCount()); + assertEquals(-1, gameData.removeCreekPointAt(new Vec(5, 80, 1)), "already gone"); + + env.destroyInstance(instance, true); + } } From f72d1c9f44c25032e7aea2a9b20f9b1dda2b171a Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:48:38 +0200 Subject: [PATCH 52/92] chore(creek): improve route path, route and file handling --- .../cygnus/common/creek/CreekRoute.java | 15 +++++- .../cygnus/common/creek/CreekRoutesFile.java | 3 ++ .../common/creek/CreekRoutesFileTest.java | 54 +++++++++++++++++-- .../cygnus/creek/CreekService.java | 33 ++++++------ .../cygnus/creek/world/CreekPaths.java | 20 +++++-- .../creek/CreekServiceIntegrationTest.java | 37 ++++--------- .../cygnus/creek/world/CreekPathsTest.java | 15 +++++- .../setup/dialog/CreekRouteDialogs.java | 49 +++++++++++++++++ .../dialog/handler/CreekRouteNameHandler.java | 3 ++ .../setup/inventory/slot/CreekRouteSlot.java | 45 +++++++++++----- .../setup/creek/CreekRoutePreviewTest.java | 18 ++++--- .../inventory/slot/CreekRouteSlotTest.java | 20 +++++-- 12 files changed, 234 insertions(+), 78 deletions(-) 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 index 466eb7c5..92677a61 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoute.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoute.java @@ -8,12 +8,12 @@ * 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 + * @param points the waypoints in walking order * @author theEvilReaper * @version 1.0.0 * @since 2.15.0 */ -public record CreekRoute(String name, List points) { +public record CreekRoute(String name, List points) { /** A route needs at least a start and an end. */ public static final int MIN_POINTS = 2; @@ -27,4 +27,15 @@ public record CreekRoute(String name, List points) { name = name == null ? "" : name.trim(); points = points == null ? List.of() : List.copyOf(points); } + + /** + * Creates a route whose points have no pauses. + * + * @param name the route's name + * @param positions where the creek's feet stand, in walking order + * @return the route + */ + public static CreekRoute ofPositions(String name, List positions) { + return new CreekRoute(name, positions.stream().map(CreekWaypoint::of).toList()); + } } 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 index b3bf1c33..42fbd4c9 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFile.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFile.java @@ -137,6 +137,9 @@ private static List valid(List routes, Path file) { if (route.points().size() < CreekRoute.MIN_POINTS) { return "it needs at least " + CreekRoute.MIN_POINTS + " points"; } + if (route.points().stream().anyMatch(point -> point.pauseMillis() < 0)) { + return "a point has a negative pause"; + } if (names.contains(route.name())) return "another route already uses this name"; return null; } 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 index cb0a50e6..ca177605 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFileTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFileTest.java @@ -21,9 +21,9 @@ class CreekRoutesFileTest { - private static final CreekRoute NORTH = new CreekRoute("Waldweg Nord", + private static final CreekRoute NORTH = CreekRoute.ofPositions("Waldweg Nord", List.of(new Vec(12.5, 80, 4.5), new Vec(20.5, 80, 9.5), new Vec(31.5, 81, 15.5))); - private static final CreekRoute SOUTH = new CreekRoute("Waldweg Süd", + private static final CreekRoute SOUTH = CreekRoute.ofPositions("Waldweg Süd", List.of(new Vec(0.5, 80, 0.5), new Vec(0.5, 80, 10.5))); private static Path mapFile(Path root) { @@ -95,7 +95,7 @@ void duplicateNamesKeepTheFirst(@TempDir Path 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))); + CreekRoute stub = CreekRoute.ofPositions("Stub", List.of(new Vec(0, 80, 0))); CreekRoutesFile.save(mapFile(root), List.of(stub)); assertEquals(List.of(stub), CreekRoutesFile.loadUnchecked(mapFile(root))); @@ -115,4 +115,52 @@ void loadIntoSetsTheRoutes(@TempDir Path root) { assertEquals(List.of(NORTH), CreekRoutesFile.loadInto(mapFile(root), map).getCreekRoutes()); } + + @Test + @DisplayName("A file written before pauses existed loads with no pauses") + void oldFileHasNoPauses(@TempDir Path root) throws IOException { + Files.writeString(root.resolve("creek.json"), """ + [ { "name": "Alt", "points": [ { "x": 0.5, "y": 80, "z": 0.5 }, { "x": 0.5, "y": 80, "z": 10.5 } ] } ] + """, StandardCharsets.UTF_8); + + List routes = CreekRoutesFile.load(mapFile(root)); + + assertEquals(1, routes.size()); + assertTrue(routes.getFirst().points().stream().allMatch(point -> point.pauseMillis() == 0)); + } + + @Test + @DisplayName("Pauses survive saving and loading") + void pausesAreKept(@TempDir Path root) { + CreekRoute route = new CreekRoute("Pausen", List.of( + CreekWaypoint.of(new Vec(0.5, 80, 0.5)).withPause(2000), + CreekWaypoint.of(new Vec(5.5, 80, 0.5)), + CreekWaypoint.of(new Vec(9.5, 80, 0.5)).withPause(4000))); + CreekRoutesFile.save(mapFile(root), List.of(route)); + + assertEquals(List.of(route), CreekRoutesFile.load(mapFile(root))); + } + + @Test + @DisplayName("A route with a negative pause is skipped") + void negativePauseIsSkipped(@TempDir Path root) throws IOException { + Files.writeString(root.resolve("creek.json"), """ + [ + { "name": "Kaputt", "points": [ { "x": 0, "y": 80, "z": 0, "pauseMillis": -5 }, { "x": 5, "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("A waypoint knows its position") + void waypointPosition() { + CreekWaypoint point = CreekWaypoint.of(new Vec(1.5, 80, -2.5)); + + assertEquals(new Vec(1.5, 80, -2.5), point.position()); + assertEquals(0, point.pauseMillis()); + assertEquals(3000, point.withPause(3000).pauseMillis()); + } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java index 8943d471..8e35cc72 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java @@ -18,7 +18,6 @@ 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; @@ -68,8 +67,7 @@ public final class CreekService { private final Ground ground; private final SpotFinder spots; private final Supplier> routes; - private final RandomPointRoute fallback; - private RouteProvider route; + private volatile @Nullable RouteProvider route; private final RepeatingTask task = new RepeatingTask(this::tick); private volatile @Nullable Creek creek; @@ -79,8 +77,7 @@ public final class CreekService { * @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 routes supplies the creek routes of the current map + * @param routes supplies the creek routes of the current map; without one the creek stays away * @param bodies spawns the creek's body * @param dread rates the survivors * @param consequence what happens on a catch @@ -89,8 +86,7 @@ public final class CreekService { * @param debug the debug line for playtests */ public CreekService(CreekConfig config, Supplier> survivors, - Supplier instance, Supplier> routePoints, - Supplier> routes, + Supplier instance, Supplier> routes, BiFunction bodies, DreadSource dread, CatchConsequence consequence, RoundClock clock, RandomGenerator random, CreekDebug debug) { @@ -107,8 +103,6 @@ public CreekService(CreekConfig config, Supplier> survivors, this.ground = new InstanceGround(instance); this.spots = new SpotFinder(this.sight, this.ground); this.routes = routes; - this.fallback = new RandomPointRoute(routePoints); - this.route = this.fallback; } /** @@ -129,12 +123,13 @@ void start() { 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 creek routes, page positions or spawns to walk between, so the creek stays away this round"); + if (world == null || paths.isEmpty()) { + LOGGER.warn("The map has no usable creek routes, so the creek stays away this round"); return; } + PathRoute pathRoute = new PathRoute(paths); + this.route = pathRoute; + List points = pathRoute.points(); this.clock.start(); Pos point = points.get(this.random.nextInt(points.size())); @@ -142,7 +137,7 @@ void start() { // 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.creek = new Creek(body, this.sight, this.dread, pathRoute, this.spots, this.consequence, this.config, this.random, initial); this.debug.setActive(true); this.task.start(TICK_MILLIS, ChronoUnit.MILLIS); @@ -158,7 +153,7 @@ void tick() { 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.route.describe())); + id -> nameOf(players, id), describeRoute())); } } @@ -176,6 +171,7 @@ void stop() { this.task.stop(); Creek current = this.creek; this.creek = null; + this.route = null; this.clock.reset(); if (current != null) current.remove(); this.consequence.cleanUp(); @@ -186,7 +182,12 @@ void stop() { return this.creek; } - RouteProvider route() { + private String describeRoute() { + RouteProvider current = this.route; + return current == null ? "" : current.describe(); + } + + @Nullable RouteProvider route() { return this.route; } } 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 index 49fa0793..0c2f5ced 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java @@ -3,6 +3,7 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.coordinate.Vec; import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import java.util.ArrayList; import java.util.HashMap; @@ -114,7 +115,18 @@ public int pointCount(int route) { * @return the point */ public Pos point(int route, int index) { - return toPos(this.routes.get(route).points().get(index)); + return toPos(this.routes.get(route).points().get(index).position()); + } + + /** + * Returns how long the creek waits at one point of a route. + * + * @param route the route's index + * @param index the point's index + * @return the pause, in milliseconds + */ + public int pauseMillis(int route, int index) { + return this.routes.get(route).points().get(index).pauseMillis(); } /** @@ -145,14 +157,14 @@ public List links(End end) { public List allPoints() { List points = new ArrayList<>(); for (CreekRoute route : this.routes) { - route.points().forEach(point -> points.add(toPos(point))); + route.points().forEach(point -> points.add(toPos(point.position()))); } 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()); + List points = routes.get(end.route()).points(); + return toPos((end.atStart() ? points.getFirst() : points.getLast()).position()); } private static Pos toPos(Vec point) { 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 a9a339a5..9f5addc5 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java @@ -16,7 +16,6 @@ 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; @@ -48,8 +47,10 @@ public void cleanUp() { } }; - private CreekService service(Instance instance, Set survivors, List points, AtomicLong clock) { - return new CreekService(CreekConfig.DEFAULT, () -> survivors, () -> instance, () -> points, () -> List.of(), + private static final CreekRoute ROUTE = CreekRoute.ofPositions("Test", List.of(new Vec(10, 40, 10), new Vec(20, 40, 10))); + + private CreekService service(Instance instance, Set survivors, List routes, AtomicLong clock) { + return new CreekService(CreekConfig.DEFAULT, () -> survivors, () -> instance, () -> routes, CreakingBody::spawn, (_, _, _) -> 0.0D, consequence, new RoundClock(clock::get), new Random(3), new CreekDebug()); } @@ -58,7 +59,7 @@ private CreekService service(Instance instance, Set survivors, List 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()); + CreekService service = service(instance, Set.of(survivor), List.of(ROUTE), new AtomicLong()); service.start(); service.tick(); @@ -75,7 +76,7 @@ void startsOutOfSight(Env env) { 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()); + CreekService service = service(instance, Set.of(survivor), List.of(ROUTE), new AtomicLong()); service.start(); Creek creek = service.creek(); assertNotNull(creek); @@ -88,8 +89,8 @@ void stopRemovesHimAndCleansUp(Env env) { } @Test - @DisplayName("Without any point to walk between he stays away") - void staysAwayWithoutPoints(Env env) { + @DisplayName("Without a usable route the creek stays away") + void staysAwayWithoutRoutes(Env env) { Instance instance = env.createFlatInstance(); Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); CreekService service = service(instance, Set.of(survivor), List.of(), new AtomicLong()); @@ -107,7 +108,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)), () -> List.of(), CreakingBody::spawn, (_, _, _) -> 0.0D, consequence, roundClock, new Random(3), new CreekDebug()); + () -> List.of(ROUTE), CreakingBody::spawn, (_, _, _) -> 0.0D, consequence, roundClock, new Random(3), new CreekDebug()); service.start(); clock.set(4000L); @@ -128,7 +129,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)), () -> List.of(), CreakingBody::spawn, (_, _, _) -> 0.0D, consequence, + () -> List.of(ROUTE), CreakingBody::spawn, (_, _, _) -> 0.0D, consequence, new RoundClock(new AtomicLong()::get), new Random(3), debug); Collector watched = watcherConnection.trackIncoming(ActionBarPacket.class); Collector unwatched = otherConnection.trackIncoming(ActionBarPacket.class); @@ -146,10 +147,7 @@ void debugGoesToWatchersOnly(Env env) { 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()); + CreekService service = service(instance, Set.of(survivor), List.of(ROUTE), new AtomicLong()); service.start(); @@ -157,17 +155,4 @@ void routesAreUsedWhenThereAreAny(Env env) { 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/world/CreekPathsTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java index c29c4a6a..54649f25 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java @@ -3,6 +3,7 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.coordinate.Vec; import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,7 +15,7 @@ class CreekPathsTest { private static CreekRoute route(String name, Vec... points) { - return new CreekRoute(name, List.of(points)); + return CreekRoute.ofPositions(name, List.of(points)); } /** A ends at (10,0,0); B starts 2 blocks away, C exactly 3, D 3.1. */ @@ -55,4 +56,16 @@ void pointsAndPositions() { assertEquals(8, paths.allPoints().size()); assertTrue(CreekPaths.of(List.of(), 3.0D).isEmpty()); } + + @Test + @DisplayName("Each point reports its pause") + void pausesPerPoint() { + CreekRoute route = new CreekRoute("P", List.of( + CreekWaypoint.of(new Vec(0, 0, 0)).withPause(1000), + CreekWaypoint.of(new Vec(10, 0, 0)))); + CreekPaths paths = CreekPaths.of(List.of(route), 3.0D); + + assertEquals(1000, paths.pauseMillis(0, 0)); + assertEquals(0, paths.pauseMillis(0, 1)); + } } diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/CreekRouteDialogs.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/CreekRouteDialogs.java index 6d291640..d485ffcf 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/CreekRouteDialogs.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/CreekRouteDialogs.java @@ -5,6 +5,7 @@ import net.minestom.server.dialog.DialogAction; import net.minestom.server.dialog.DialogAfterAction; import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.setup.event.dialog.DialogContext; import net.onelitefeather.cygnus.setup.util.DialogBase; import net.onelitefeather.pica.dialog.DialogTemplate; import net.onelitefeather.pica.dialog.type.DialogType; @@ -19,10 +20,16 @@ public final class CreekRouteDialogs extends DialogBase { public static final Key ROUTE_NAME_KEY = create("creek_route_name"); + public static final Key PAUSE_KEY = create("creek_route_pause"); + public static final String START_PAUSE_INPUT = "start"; + public static final String END_PAUSE_INPUT = "end"; /** Long enough for a descriptive name, short enough for the debug line. */ private static final int MAX_NAME_LENGTH = 48; + /** Longest pause the dialog offers, in seconds. */ + private static final float MAX_PAUSE_SECONDS = 30f; + /** * Opens the dialog. * @@ -48,6 +55,48 @@ public static void openNameDialog(Player player) { dialogTemplate.open(player); } + /** + * Opens the dialog for the pauses at the start and the end of a route. + * + * @param player the player + * @param context the route and its current pauses + */ + public static void openPauseDialog(Player player, DialogContext.CreekPauseContext context) { + DialogTemplate dialogTemplate = DialogType.confirm(PAUSE_KEY) + .meta(dialogMeta -> { + dialogMeta.closeWithEscape(true); + dialogMeta.pause(false); + dialogMeta.afterAction(DialogAfterAction.CLOSE); + dialogMeta.title(Component.text("Creek route")); + dialogMeta.emptyMessage(); + dialogMeta.messageBody(template -> + template.contents(Component.text("Pauses of " + context.route()))); + dialogMeta.range(START_PAUSE_INPUT, range -> { + range.label(Component.text("Pause at the start")); + range.width(202); + range.start(0f); + range.end(MAX_PAUSE_SECONDS); + range.step(0.5f); + range.initial(context.startMillis() / 1000f); + range.labelFormat("%s: %s s"); + }); + dialogMeta.range(END_PAUSE_INPUT, range -> { + range.label(Component.text("Pause at the end")); + range.width(202); + range.start(0f); + range.end(MAX_PAUSE_SECONDS); + range.step(0.5f); + range.initial(context.endMillis() / 1000f); + range.labelFormat("%s: %s s"); + }); + }) + .yesButton(button -> button.width(101).label(Component.text("Save")) + .action(new DialogAction.DynamicCustom(PAUSE_KEY, getEmptyPayload()))) + .noButton(button -> button.width(101).label(NO_COMPONENT)) + .build(); + dialogTemplate.open(player); + } + private CreekRouteDialogs() { } } diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRouteNameHandler.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRouteNameHandler.java index 6f46eb01..060634f5 100644 --- 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 @@ -5,6 +5,7 @@ import net.minestom.server.entity.Player; import net.minestom.server.event.player.PlayerCustomClickEvent; import net.onelitefeather.cygnus.setup.data.GameData; +import net.onelitefeather.cygnus.setup.item.SetupItems; import net.onelitefeather.cygnus.setup.util.SetupMessages; import net.onelitefeather.guira.SetupDataService; @@ -39,6 +40,8 @@ public void handle(PlayerCustomClickEvent event, CompoundBinaryTag payload) { return; } player.sendMessage(SetupMessages.getCreekRouteCreated(name)); + player.sendMessage(SetupMessages.CREEK_POINT_HINT); + SetupItems.setCreekRouteEditItems(player); }); } } diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlot.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/inventory/slot/CreekRouteSlot.java index c412bcf7..8a8db4c8 100644 --- 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 @@ -6,14 +6,20 @@ import net.minestom.server.inventory.click.Click; import net.minestom.server.item.ItemStack; import net.minestom.server.item.Material; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import net.theevilreaper.aves.inventory.click.ClickHolder; import net.theevilreaper.aves.inventory.slot.Slot; import java.util.List; +import java.util.Locale; import java.util.function.BiConsumer; +import static net.onelitefeather.cygnus.setup.util.SetupMessages.DELETE_CLICK; +import static net.onelitefeather.cygnus.setup.util.SetupMessages.SELECT_CLICK; + /** - * One creek route in the route overview. Left click selects it, shift click deletes it. + * One creek route in the route overview. Left click selects it, right click deletes it. * * @author theEvilReaper * @version 1.0.0 @@ -21,24 +27,22 @@ */ public final class CreekRouteSlot extends Slot { - private final String name; - private final int points; + private final CreekRoute route; private final boolean active; /** * Creates the slot. * - * @param name the route's name - * @param points how many points the route has + * @param route the route to show * @param active whether the route is being edited * @param select called with the clicking player and the name on a left click - * @param delete called with the clicking player and the name on a shift click + * @param delete called with the clicking player and the name on a right click */ - public CreekRouteSlot(String name, int points, boolean active, BiConsumer select, + public CreekRouteSlot(CreekRoute route, boolean active, BiConsumer select, BiConsumer delete) { - this.name = name; - this.points = points; + this.route = route; this.active = active; + String name = route.name(); this.setClick((player, _, click, _, result) -> { result.accept(ClickHolder.cancelClick()); switch (click) { @@ -46,7 +50,7 @@ public CreekRouteSlot(String name, int points, boolean active, BiConsumer delete.accept(player, name); + case Click.Right _ -> delete.accept(player, name); default -> { // Nothing to do here } @@ -56,14 +60,27 @@ public CreekRouteSlot(String name, int points, boolean active, BiConsumer points = this.route.points(); + int startPause = points.isEmpty() ? 0 : points.getFirst().pauseMillis(); + int endPause = points.size() < CreekRoute.MIN_POINTS ? 0 : points.getLast().pauseMillis(); return ItemStack.builder(Material.LEAD) - .customName(Component.text(this.name, this.active ? NamedTextColor.GREEN : NamedTextColor.YELLOW)) + .customName(Component.text(this.route.name(), this.active ? NamedTextColor.GREEN : NamedTextColor.YELLOW)) .lore(List.of( Component.empty(), - Component.text("Points: " + this.points, NamedTextColor.GRAY), + Component.text("Points: ", NamedTextColor.GRAY) + .append(Component.text(points.size(), NamedTextColor.GOLD)), + Component.text("Pause: start ", NamedTextColor.GRAY) + .append(Component.text(seconds(startPause), NamedTextColor.GOLD)) + .append(Component.text(" · end ", NamedTextColor.GRAY)) + .append(Component.text(seconds(endPause), NamedTextColor.GOLD)), Component.empty(), - Component.text("Left click: edit this route", NamedTextColor.WHITE), - Component.text("Shift click: delete this route", NamedTextColor.RED))) + SELECT_CLICK, + DELETE_CLICK, + Component.empty())) .build(); } + + private static String seconds(int millis) { + return String.format(Locale.ROOT, "%.1f s", millis / 1000.0D); + } } diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java index f1d56b3b..b1f6fddf 100644 --- a/setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java +++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java @@ -21,13 +21,15 @@ void lineSteps() { } @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)); + @DisplayName("Only the ends of the edited route are marked, and only if another route's end is close") + void linkedEndsOfTheEditedRoute() { + CreekRoute a = CreekRoute.ofPositions("A", List.of(new Vec(0, 80, 0), new Vec(10, 80, 0))); + CreekRoute b = CreekRoute.ofPositions("B", List.of(new Vec(12, 80, 0), new Vec(20, 80, 0))); + CreekRoute c = CreekRoute.ofPositions("C", List.of(new Vec(50, 80, 0), new Vec(60, 80, 0))); + List routes = List.of(a, b, c); + + assertEquals(Set.of(new Vec(10, 80, 0)), CreekRoutePreview.linkedEnds(a, routes, 3.0D)); + assertEquals(Set.of(new Vec(12, 80, 0)), CreekRoutePreview.linkedEnds(b, routes, 3.0D)); + assertEquals(Set.of(), CreekRoutePreview.linkedEnds(c, 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 index 25e0c874..32ca30fb 100644 --- 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 @@ -2,23 +2,35 @@ import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.minestom.server.component.DataComponents; +import net.minestom.server.coordinate.Vec; import net.minestom.server.item.ItemStack; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.util.List; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; class CreekRouteSlotTest { @Test - @DisplayName("The item shows the route's name and point count") + @DisplayName("The item shows the route's name, point count and pauses") void itemShowsNameAndPoints() { - ItemStack item = new CreekRouteSlot("Waldweg", 4, true, (_, _) -> {}, (_, _) -> {}).getItem(); + CreekRoute route = new CreekRoute("Waldweg", List.of( + CreekWaypoint.of(new Vec(0, 80, 0)).withPause(2000), + CreekWaypoint.of(new Vec(5, 80, 0)), + CreekWaypoint.of(new Vec(9, 80, 0)), + CreekWaypoint.of(new Vec(12, 80, 0)).withPause(4000))); + ItemStack item = new CreekRouteSlot(route, true, (_, _) -> {}, (_, _) -> {}).getItem(); assertEquals("Waldweg", PlainTextComponentSerializer.plainText().serialize(item.get(DataComponents.CUSTOM_NAME))); - assertTrue(item.get(DataComponents.LORE).stream() + List lore = item.get(DataComponents.LORE).stream() .map(line -> PlainTextComponentSerializer.plainText().serialize(line)) - .anyMatch(line -> line.equals("Points: 4"))); + .toList(); + assertTrue(lore.contains("Points: 4")); + assertTrue(lore.contains("Pause: start 2.0 s · end 4.0 s")); } } From e3d6d3e91360808c33791eb955f313fb240b3ba5 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:48:55 +0200 Subject: [PATCH 53/92] chore: update test cases --- .../cygnus/creek/CreekIntegrationTest.java | 3 +- .../cygnus/creek/state/VanishStateTest.java | 2 +- .../cygnus/creek/world/PathRouteTest.java | 66 +++++++++++++++++-- .../creek/world/RandomPointRouteTest.java | 43 ------------ .../cygnus/setup/item/SetupItemsTest.java | 27 ++++++++ 5 files changed, 92 insertions(+), 49 deletions(-) delete mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/world/RandomPointRouteTest.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 index 02fdf2a8..801ccf8b 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,7 @@ 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.routeLinkDistance()); + d.betrayalChance(), d.betrayalGlowSeconds(), d.slownessSeconds(), d.routeLinkDistance(), + d.randomStopChance(), d.randomStopMinMillis(), d.randomStopMaxMillis()); } } 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 aa6c43f8..b268ad52 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 @@ -97,7 +97,7 @@ void foreverNeverEnds() { @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( + RouteProvider route = new PathRoute(CreekPaths.of(List.of(CreekRoute.ofPositions("Weg", List.of( new Vec(0, 40, 10), new Vec(0, 40, 5), new Vec(0, 40, -5), new Vec(0, 40, -40)))), 3.0D)); route.next(new Pos(0, 40, 5), _ -> true, new java.util.Random(1)); RecordingBody body = new RecordingBody(new Pos(0, 40, 5)); 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 index a6188dac..853b6349 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java @@ -3,9 +3,11 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.coordinate.Vec; import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.function.Predicate; @@ -18,10 +20,10 @@ class PathRouteTest { private static final Predicate ANYWHERE = _ -> true; - private static final CreekRoute A = new CreekRoute("A", + private static final CreekRoute A = CreekRoute.ofPositions("A", List.of(new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0))); /** Starts 2 blocks after the end of A, so the two are linked. */ - private static final CreekRoute B = new CreekRoute("B", + private static final CreekRoute B = CreekRoute.ofPositions("B", List.of(new Vec(22, 40, 0), new Vec(22, 40, 10))); private static PathRoute route(CreekRoute... routes) { @@ -48,10 +50,21 @@ public boolean nextBoolean() { }; } - private static Pos next(PathRoute route, Pos current, RandomGenerator random) { + private static RouteStep step(PathRoute route, Pos current, RandomGenerator random) { return route.next(current, ANYWHERE, random).orElseThrow(); } + private static Pos next(PathRoute route, Pos current, RandomGenerator random) { + return step(route, current, random).target(); + } + + private static CreekRoute paused(String name, int startPause, int endPause, Vec... positions) { + List points = new ArrayList<>(CreekRoute.ofPositions(name, List.of(positions)).points()); + points.set(0, points.getFirst().withPause(startPause)); + points.set(points.size() - 1, points.getLast().withPause(endPause)); + return new CreekRoute(name, points); + } + @Test @DisplayName("Without a position it joins at the nearest point") void joinsAtTheNearestPoint() { @@ -122,7 +135,7 @@ 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)); + Optional next = route.next(at, point -> point.x() < 15, fixed(0)).map(RouteStep::target); assertEquals(Optional.of(new Pos(0, 40, 0)), next); } @@ -152,4 +165,49 @@ void emptyDescriptionBeforeJoining() { assertEquals("", route(A).describe()); assertEquals(5, route(A, B).points().size()); } + + @Test + @DisplayName("Reaching an end in walking direction carries its pause") + void endsInWalkingDirectionPause() { + PathRoute route = route(paused("A", 1000, 2000, new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0))); + + assertEquals(0, step(route, new Pos(0, 40, 0), fixed(0)).pauseMillis(), "it walks away from the start"); + assertEquals(0, step(route, new Pos(0, 40, 0), fixed(0)).pauseMillis()); + RouteStep end = step(route, new Pos(10, 40, 0), fixed(0)); + assertEquals(new RouteStep(new Pos(20, 40, 0), 2000), end); + assertEquals(0, step(route, end.target(), fixed(0)).pauseMillis()); + assertEquals(new RouteStep(new Pos(0, 40, 0), 1000), step(route, new Pos(10, 40, 0), fixed(0))); + } + + @Test + @DisplayName("The start of a linked route is no second stop") + void noPauseAtTheSteppingStone() { + PathRoute route = route( + paused("A", 0, 2000, new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0)), + paused("B", 3000, 0, new Vec(22, 40, 0), new Vec(22, 40, 10))); + next(route, new Pos(10, 40, 0), fixed(0)); + + assertEquals(2000, step(route, new Pos(10, 40, 0), fixed(0)).pauseMillis()); + assertEquals(new RouteStep(new Pos(22, 40, 0), 0), step(route, new Pos(20, 40, 0), fixed(0))); + } + + @Test + @DisplayName("Rejoining at an end does not stop there") + void noPauseWhenRejoiningAtAnEnd() { + PathRoute route = route(paused("A", 1000, 2000, new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0))); + + assertEquals(new RouteStep(new Pos(20, 40, 0), 0), step(route, new Pos(21, 40, 0), fixed(0))); + } + + @Test + @DisplayName("A point in the middle keeps its pause") + void middlePointKeepsItsPause() { + PathRoute route = route(new CreekRoute("M", List.of( + CreekWaypoint.of(new Vec(0, 40, 0)), + CreekWaypoint.of(new Vec(10, 40, 0)).withPause(1500), + CreekWaypoint.of(new Vec(20, 40, 0))))); + next(route, new Pos(0, 40, 0), fixed(0)); + + assertEquals(new RouteStep(new Pos(10, 40, 0), 1500), step(route, new Pos(0, 40, 0), fixed(0))); + } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/RandomPointRouteTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/RandomPointRouteTest.java deleted file mode 100644 index dc99be3b..00000000 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/world/RandomPointRouteTest.java +++ /dev/null @@ -1,43 +0,0 @@ -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/setup/src/test/java/net/onelitefeather/cygnus/setup/item/SetupItemsTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/item/SetupItemsTest.java index 28b4dd0f..8bd64603 100644 --- a/setup/src/test/java/net/onelitefeather/cygnus/setup/item/SetupItemsTest.java +++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/item/SetupItemsTest.java @@ -67,4 +67,31 @@ private void assertItem(@NotNull Player player, int slot, byte itemId) { assertNotEquals(Material.AIR, itemStack.material()); assertEquals(itemId, itemStack.getTag(Tags.ITEM_TAG).byteValue()); } + + @Test + void testCreekRouteOverviewItemSet(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + + SetupItems.setCreekRouteOverviewItems(player); + assertItem(player, 0, SetupItemId.CREEK_NEW); + assertItem(player, 4, SetupItemId.CREEK_LIST); + assertItem(player, 8, SetupItemId.CREEK_LEAVE); + + env.destroyInstance(instance, true); + } + + @Test + void testCreekRouteEditItemSet(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + + SetupItems.setCreekRouteEditItems(player); + assertItem(player, 0, SetupItemId.CREEK_UNDO); + assertItem(player, 2, SetupItemId.CREEK_PAUSE); + assertItem(player, 6, SetupItemId.CREEK_FINISH); + assertItem(player, 8, SetupItemId.CREEK_LEAVE); + + env.destroyInstance(instance, true); + } } From b0d1dfaafc08e0c5ed676379123d141aba15595b Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:49:10 +0200 Subject: [PATCH 54/92] chore(creek): add step handling --- .../cygnus/creek/state/WanderState.java | 44 ++++++- .../cygnus/creek/state/WanderStateTest.java | 119 +++++++++++++++++- 2 files changed, 159 insertions(+), 4 deletions(-) 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 index dae86111..8edc0ab2 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/WanderState.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/WanderState.java @@ -3,6 +3,7 @@ import net.minestom.server.coordinate.Pos; import net.onelitefeather.cygnus.common.config.CreekConfig; import net.onelitefeather.cygnus.creek.body.CreekBody; +import net.onelitefeather.cygnus.creek.world.RouteStep; import org.jetbrains.annotations.Nullable; import java.util.Comparator; @@ -16,6 +17,8 @@ *

    * 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. + * After reaching a waypoint it may rest there: for the pause the route sets, or for a random stop, + * whichever is longer. *

    * * @author theEvilReaper @@ -34,6 +37,8 @@ public final class WanderState implements CreekState { private @Nullable Pos goal; private boolean watched; private long pausedUntil; + private int goalPauseMillis; + private long restingUntil; private Pos progressAt = Pos.ZERO; private long progressSince; private Set shownTo = Set.of(); @@ -77,10 +82,25 @@ public CreekState tick(CreekContext ctx) { return this; } + if (ctx.now() < this.restingUntil) { + body.stop(); + return this; + } + if (this.hasArrived(here)) { + long rest = this.restMillis(ctx); + this.goal = null; + if (rest > 0) { + this.restingUntil = ctx.now() + rest; + body.stop(); + return this; + } + } + if (this.needsNewGoal(ctx, here)) { - this.goal = ctx.route() - .next(here, point -> ctx.farFromAll(point, config.personalSpace()), ctx.random()) - .orElse(null); + Optional step = ctx.route() + .next(here, point -> ctx.farFromAll(point, config.personalSpace()), ctx.random()); + this.goal = step.map(RouteStep::target).orElse(null); + this.goalPauseMillis = step.map(RouteStep::pauseMillis).orElse(0); this.markProgress(ctx.now(), here); } if (this.goal == null) { @@ -109,6 +129,24 @@ private boolean pause(CreekContext ctx, SurvivorView watcher) { return true; } + private boolean hasArrived(Pos here) { + return this.goal != null && here.distance(this.goal) < ARRIVED; + } + + /** + * Works out how long to rest at the point just reached: its pause, or a random stop if that + * is longer. + */ + private long restMillis(CreekContext ctx) { + CreekConfig config = ctx.config(); + int rest = this.goalPauseMillis; + if (config.randomStopChance() > 0.0D && ctx.random().nextDouble() < config.randomStopChance()) { + int spread = config.randomStopMaxMillis() - config.randomStopMinMillis(); + rest = Math.max(rest, config.randomStopMinMillis() + ctx.random().nextInt(spread + 1)); + } + return rest; + } + private boolean needsNewGoal(CreekContext ctx, Pos here) { if (this.goal == null || here.distance(this.goal) < ARRIVED) return true; if (!ctx.farFromAll(this.goal, ctx.config().personalSpace())) return true; 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 index 8ac5f241..9aeb1e5a 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/state/WanderStateTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/WanderStateTest.java @@ -1,7 +1,9 @@ package net.onelitefeather.cygnus.creek.state; import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.config.CreekConfig; import net.onelitefeather.cygnus.creek.world.RouteProvider; +import net.onelitefeather.cygnus.creek.world.RouteStep; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -87,7 +89,7 @@ void vanishesWhenApproached() { 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); + RouteProvider cycling = (_, _, _) -> Optional.of(new RouteStep(calls.getAndIncrement() % 2 == 0 ? A : B, 0)); WanderState state = new WanderState(Long.MAX_VALUE); state.enter(Contexts.context(0L, body, cycling, new ArrayList<>(), far(FIRST, 0.0D, false))); @@ -178,4 +180,119 @@ void dropsViewersWhoLeaveTheSurvivors() { assertEquals(Set.of(FIRST), body.viewers, "a survivor turned slender or spectator must lose sight of him"); } + + /** Hands out A, then B, then A again, each with the given pause. */ + private static RouteProvider alternating(int pauseMillis) { + AtomicInteger calls = new AtomicInteger(); + return (_, _, _) -> Optional.of(new RouteStep(calls.getAndIncrement() % 2 == 0 ? A : B, pauseMillis)); + } + + private static CreekContext at(long now, RecordingBody body, RouteProvider route, CreekConfig config, + SurvivorView... survivors) { + return Contexts.context(now, body, route, config, + survivors.length == 0 ? new SurvivorView[]{far(FIRST, 0.0D, false)} : survivors); + } + + @Test + @DisplayName("Arriving at a point with a pause, it rests there") + void restsAtAPointWithAPause() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = alternating(2000); + WanderState state = new WanderState(Long.MAX_VALUE); + state.enter(at(0L, body, route, Contexts.CONFIG)); + state.tick(at(0L, body, route, Contexts.CONFIG)); + assertEquals(A, body.goal); + + body.position = A; + state.tick(at(100L, body, route, Contexts.CONFIG)); + assertNull(body.goal); + state.tick(at(2099L, body, route, Contexts.CONFIG)); + assertNull(body.goal); + + state.tick(at(2100L, body, route, Contexts.CONFIG)); + assertEquals(B, body.goal); + } + + @Test + @DisplayName("Getting stuck is no reason to rest") + void noRestWhenStuck() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = alternating(5000); + WanderState state = new WanderState(Long.MAX_VALUE); + state.enter(at(0L, body, route, Contexts.CONFIG)); + state.tick(at(0L, body, route, Contexts.CONFIG)); + + state.tick(at(3000L, body, route, Contexts.CONFIG)); + + assertEquals(B, body.goal); + } + + @Test + @DisplayName("A random stop holds it at a point without a pause") + void randomStop() { + CreekConfig config = Contexts.randomStops(1.0D, 1000, 1000); + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = alternating(0); + WanderState state = new WanderState(Long.MAX_VALUE); + state.enter(at(0L, body, route, config)); + state.tick(at(0L, body, route, config)); + + body.position = A; + state.tick(at(100L, body, route, config)); + state.tick(at(1099L, body, route, config)); + assertNull(body.goal); + + state.tick(at(1100L, body, route, config)); + assertEquals(B, body.goal); + } + + @Test + @DisplayName("Pause and random stop do not add up, the longer one wins") + void longerHaltWins() { + CreekConfig config = Contexts.randomStops(1.0D, 1000, 1000); + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = alternating(3000); + WanderState state = new WanderState(Long.MAX_VALUE); + state.enter(at(0L, body, route, config)); + state.tick(at(0L, body, route, config)); + + body.position = A; + state.tick(at(100L, body, route, config)); + state.tick(at(3099L, body, route, config)); + assertNull(body.goal); + + state.tick(at(3100L, body, route, config)); + assertEquals(B, body.goal); + } + + @Test + @DisplayName("Without a chance and without a pause it walks on right away") + void noHaltWithoutChanceOrPause() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = alternating(0); + WanderState state = new WanderState(Long.MAX_VALUE); + state.enter(at(0L, body, route, Contexts.CONFIG)); + state.tick(at(0L, body, route, Contexts.CONFIG)); + + body.position = A; + state.tick(at(100L, body, route, Contexts.CONFIG)); + + assertEquals(B, body.goal); + } + + @Test + @DisplayName("Resting does not keep it from stalking") + void stalksWhileResting() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = alternating(5000); + WanderState state = new WanderState(1000L); + state.enter(at(0L, body, route, Contexts.CONFIG)); + state.tick(at(0L, body, route, Contexts.CONFIG)); + body.position = A; + state.tick(at(100L, body, route, Contexts.CONFIG)); + + CreekState next = state.tick(at(1000L, body, route, Contexts.CONFIG, far(FIRST, 0.3D, false))); + + assertInstanceOf(StalkState.class, next); + } } From 67d275f4b6155fe336f8de184d06ecf1f5d1c204 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:49:30 +0200 Subject: [PATCH 55/92] chore(game): improve creek usage during startup --- .../net/onelitefeather/cygnus/Cygnus.java | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 63e81e67..69a2eef0 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -39,7 +39,6 @@ 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; @@ -114,7 +113,6 @@ 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; @@ -221,7 +219,6 @@ public Cygnus() { creekConfig, () -> TeamHelper.survivorsOf(this.teamService), this.mapProvider.getActiveInstance(), - this::creekRoutePoints, this::creekRoutes, CreakingBody::spawn, new PageProgressDread( @@ -366,22 +363,6 @@ 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.interactablePages().forEach(page -> points.add(page.getPosition())); - GameMap map = this.mapProvider.getGameMap(); - if (map != null) { - points.addAll(map.getSurvivorSpawns()); - } - return points; - } - /** * Returns the creek routes of the current map. * From 31d0d0af572bdc7924e8e0528e0952fceb15e055 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:49:49 +0200 Subject: [PATCH 56/92] fix(dialog): improve atmosphere dialog --- .../cygnus/setup/dialog/AtmosphereDialogs.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/AtmosphereDialogs.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/AtmosphereDialogs.java index f4b6283e..f299b012 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/AtmosphereDialogs.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/AtmosphereDialogs.java @@ -133,7 +133,7 @@ public static void openValueDialog(Player player, MapAtmosphere atmosphere) { range.end(128f); range.step(1f); range.initial(atmosphere.fogStartDistance()); - range.labelFormat("%s: %.0f blocks"); + range.labelFormat("%s: %s blocks"); }); meta.range(FOG_END_INPUT, range -> { range.label(Component.text("Fully fogged at")); @@ -142,7 +142,7 @@ public static void openValueDialog(Player player, MapAtmosphere atmosphere) { range.end(384f); range.step(1f); range.initial(atmosphere.fogEndDistance()); - range.labelFormat("%s: %.0f blocks"); + range.labelFormat("%s: %s blocks"); }); meta.range(SKY_FOG_END_INPUT, range -> { range.label(Component.text("Horizon haze at")); @@ -151,7 +151,7 @@ public static void openValueDialog(Player player, MapAtmosphere atmosphere) { range.end(256f); range.step(1f); range.initial(atmosphere.skyFogEndDistance()); - range.labelFormat("%s: %.0f blocks"); + range.labelFormat("%s: %s blocks"); }); meta.range(SKY_LIGHT_INPUT, range -> { range.label(Component.text("Sky light")); @@ -160,7 +160,7 @@ public static void openValueDialog(Player player, MapAtmosphere atmosphere) { range.end(100f); range.step(1f); range.initial(atmosphere.skyLightFactor() * SKY_LIGHT_SCALE); - range.labelFormat("%s: %.0f"); + range.labelFormat("%s: %s"); }); meta.text(FOG_COLOR_INPUT, text -> text.label(Component.text("Fog color")) From 5e52308b6cdd4fee066825e49310009d5ef726c3 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:50:15 +0200 Subject: [PATCH 57/92] chore(creek): remove old random route and update provider --- .../cygnus/creek/world/PathRoute.java | 22 ++++++-- .../cygnus/creek/world/RandomPointRoute.java | 56 ------------------- .../cygnus/creek/world/RouteProvider.java | 7 +-- 3 files changed, 20 insertions(+), 65 deletions(-) delete mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/world/RandomPointRoute.java 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 index 7c09ee5b..fecc42a3 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java @@ -13,7 +13,8 @@ *

    * 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. + * point, for example after a teleport, it rejoins at the nearest point. Each step carries the pause + * of its point, except for an end the creek walks away from. *

    * * @author theEvilReaper @@ -43,7 +44,7 @@ public List points() { } @Override - public Optional next(Pos current, Predicate allowed, RandomGenerator random) { + public Optional next(Pos current, Predicate allowed, RandomGenerator random) { Cursor last = this.cursor; if (last == null || current.distance(this.point(last)) > REJOIN_DISTANCE) { return this.rejoin(current, allowed, random); @@ -64,7 +65,7 @@ public String describe() { + " " + (last.direction() > 0 ? "→" : "←"); } - private Optional rejoin(Pos current, Predicate allowed, RandomGenerator random) { + 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++) { @@ -111,9 +112,20 @@ private Cursor advance(Cursor from, RandomGenerator random) { return new Cursor(from.route(), index, direction); } - private Optional moveTo(Cursor next) { + private Optional moveTo(Cursor next) { this.cursor = next; - return Optional.of(this.point(next)); + return Optional.of(new RouteStep(this.point(next), this.pauseAt(next))); + } + + /** + * An end the creek walks away from is where it starts, not where it stops. + */ + private int pauseAt(Cursor cursor) { + int lastIndex = this.paths.pointCount(cursor.route()) - 1; + boolean leavingStart = cursor.index() == 0 && cursor.direction() > 0; + boolean leavingEnd = cursor.index() == lastIndex && cursor.direction() < 0; + if (leavingStart || leavingEnd) return 0; + return this.paths.pauseMillis(cursor.route(), cursor.index()); } private Pos point(Cursor cursor) { 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 deleted file mode 100644 index 7c9ad665..00000000 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/RandomPointRoute.java +++ /dev/null @@ -1,56 +0,0 @@ -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 - */ - @Override - 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 index 52f41bb9..d9f6bf82 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 @@ -10,8 +10,7 @@ /** * Decides where the creek walks next. *

    - * {@link PathRoute} walks the hand-placed routes of a map; {@link RandomPointRoute} falls back - * to the map's pages and spawns. + * {@link PathRoute} walks the hand-placed routes of a map. *

    * * @author theEvilReaper @@ -27,9 +26,9 @@ public interface RouteProvider { * @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 + * @return the next point with its pause, or empty if none is allowed */ - Optional next(Pos current, Predicate allowed, RandomGenerator random); + Optional next(Pos current, Predicate allowed, RandomGenerator random); /** * Returns every point this route can hand out. Used to pick a spawn or respawn spot. From 55264924de1b5e95dc677bb3c6c45228fdc70a35 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:50:41 +0200 Subject: [PATCH 58/92] chore(config): update example config --- config.properties.example | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/config.properties.example b/config.properties.example index 4ff97bff..4344fdbf 100644 --- a/config.properties.example +++ b/config.properties.example @@ -201,6 +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. +# The creek only takes part on maps whose creek.json holds at least one usable +# route; everywhere else it stays away, even with creek.enabled=true. # Every key below maps to a field of CreekConfig. An unreadable value falls back # to its default; values that contradict each other are rejected. #creek.enabled=true @@ -219,3 +221,10 @@ survivorTeamSize=12 # How close the ends of two routes in creek.json have to be (in blocks) for the # creek to switch from one route to the other. #creek.routeLinkDistance=3.0 +# After reaching a waypoint the creek sometimes stops for a moment. The chance is +# between 0 and 1 (0 turns it off); the stop lasts between min and max milliseconds. +# A pause set on the route end in creek.json and a random stop do not add up: the +# longer one wins. +#creek.randomStopChance=0.15 +#creek.randomStopMinMillis=1500 +#creek.randomStopMaxMillis=4000 From 2540baeda15747fec77f2ce20cdb32e04af1283c Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:51:04 +0200 Subject: [PATCH 59/92] test(creek): update to RouteStep --- .../cygnus/creek/state/Contexts.java | 31 ++++++++++++++++--- 1 file changed, 27 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 085bee91..905a380f 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 @@ -5,6 +5,7 @@ import net.onelitefeather.cygnus.creek.body.CreekBody; import net.onelitefeather.cygnus.creek.world.CreekSight; import net.onelitefeather.cygnus.creek.world.RouteProvider; +import net.onelitefeather.cygnus.creek.world.RouteStep; import net.onelitefeather.cygnus.creek.world.SpotFinder; import java.util.Arrays; @@ -18,7 +19,8 @@ */ public final class Contexts { - static final CreekConfig CONFIG = CreekConfig.DEFAULT; + /** The defaults, without random stops, so wandering tests do not depend on chance. */ + static final CreekConfig CONFIG = randomStops(0.0D, 0, 0); static final CreekSight SIGHT = new CreekSight(CONFIG.sightRange(), CONFIG.sightViewAngle()); static final SpotFinder SPOTS = new SpotFinder(SIGHT, Optional::of); @@ -27,16 +29,37 @@ static CreekContext context(long now, CreekBody body, RouteProvider route, List< return new CreekContext(now, List.of(survivors), body, route, SPOTS, caught::add, CONFIG, new Random(7)); } + static CreekContext context(long now, CreekBody body, RouteProvider route, CreekConfig config, + SurvivorView... survivors) { + return new CreekContext(now, List.of(survivors), body, route, SPOTS, _ -> {}, config, new Random(7)); + } + + /** The defaults with other random stops. */ + static CreekConfig randomStops(double chance, int minMillis, int maxMillis) { + CreekConfig d = CreekConfig.DEFAULT; + return new CreekConfig( + d.enabled(), d.activeWithLastSurvivor(), d.sightRange(), d.sightViewAngle(), + d.wanderPauseMillis(), d.wanderSpeed(), d.huntSpeed(), d.stalkThreshold(), d.huntThreshold(), + d.stalkMinDistance(), d.stalkMaxDistance(), d.stalkMinAngle(), d.stalkMaxAngle(), + d.stalkRevealMillis(), d.stalkMinSeconds(), d.stalkMaxSeconds(), d.huntMaxSeconds(), + d.catchDistance(), d.vanishMinSeconds(), d.vanishMaxSeconds(), d.respawnMinDistance(), + d.personalSpace(), d.stuckMillis(), d.dreadPageWeight(), d.dreadTimeWeight(), + d.dreadIsolationWeight(), d.isolationRadius(), d.betrayalCatchCount(), + d.betrayalChance(), d.betrayalGlowSeconds(), d.slownessSeconds(), d.routeLinkDistance(), + chance, minMillis, maxMillis); + } + /** A route that offers the first allowed point at least a block away. */ public static RouteProvider route(Pos... points) { return new RouteProvider() { @Override - public Optional next(Pos current, java.util.function.Predicate allowed, - java.util.random.RandomGenerator random) { + 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(); + .findFirst() + .map(point -> new RouteStep(point, 0)); } @Override From f1607ff2f9864aa2c00a0a1a8f914298f550e084 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:51:29 +0200 Subject: [PATCH 60/92] feat(route): add step record --- .../cygnus/creek/world/RouteStep.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java new file mode 100644 index 00000000..3d5cc308 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java @@ -0,0 +1,15 @@ +package net.onelitefeather.cygnus.creek.world; + +import net.minestom.server.coordinate.Pos; + +/** + * The next waypoint of the creek and how long it waits once it gets there. + * + * @param target where the creek walks to + * @param pauseMillis how long it waits after arriving, in milliseconds + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public record RouteStep(Pos target, int pauseMillis) { +} From 34c7db5a335ca1138ad40ed19199c274880cc8e7 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Fri, 25 Sep 2026 22:51:51 +0200 Subject: [PATCH 61/92] feat(creek): add waypoint and pause handler --- .../cygnus/common/creek/CreekWaypoint.java | 50 +++++++++++++ .../handler/CreekRoutePauseHandler.java | 70 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekWaypoint.java create mode 100644 setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRoutePauseHandler.java diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekWaypoint.java b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekWaypoint.java new file mode 100644 index 00000000..14967c0e --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekWaypoint.java @@ -0,0 +1,50 @@ +package net.onelitefeather.cygnus.common.creek; + +import net.minestom.server.coordinate.Vec; + +/** + * One point of a creek route: where the creek's feet stand, and how long it waits there. + *

    + * The fields are flat so the JSON keeps the {@code x}, {@code y}, {@code z} layout of older files. + * A file without {@code pauseMillis} loads with a pause of 0. + *

    + * + * @param x the x coordinate + * @param y the y coordinate + * @param z the z coordinate + * @param pauseMillis how long the creek waits after reaching this point, in milliseconds + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public record CreekWaypoint(double x, double y, double z, int pauseMillis) { + + /** + * Creates a waypoint without a pause. + * + * @param position where the creek's feet stand + * @return the waypoint + */ + public static CreekWaypoint of(Vec position) { + return new CreekWaypoint(position.x(), position.y(), position.z(), 0); + } + + /** + * Returns where the creek's feet stand. + * + * @return the position + */ + public Vec position() { + return new Vec(this.x, this.y, this.z); + } + + /** + * Returns a copy of this waypoint with another pause. + * + * @param pauseMillis the new pause, in milliseconds + * @return the copy + */ + public CreekWaypoint withPause(int pauseMillis) { + return new CreekWaypoint(this.x, this.y, this.z, pauseMillis); + } +} diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRoutePauseHandler.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRoutePauseHandler.java new file mode 100644 index 00000000..d119cf2c --- /dev/null +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/dialog/handler/CreekRoutePauseHandler.java @@ -0,0 +1,70 @@ +package net.onelitefeather.cygnus.setup.dialog.handler; + +import net.kyori.adventure.nbt.BinaryTag; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.nbt.DoubleBinaryTag; +import net.kyori.adventure.nbt.FloatBinaryTag; +import net.kyori.adventure.nbt.IntBinaryTag; +import net.minestom.server.entity.Player; +import net.minestom.server.event.player.PlayerCustomClickEvent; +import net.onelitefeather.cygnus.setup.data.GameData; +import net.onelitefeather.cygnus.setup.dialog.CreekRouteDialogs; +import net.onelitefeather.cygnus.setup.util.SetupMessages; +import net.onelitefeather.guira.SetupDataService; +import org.jetbrains.annotations.Nullable; + +/** + * Applies the pauses from the dialog to the active creek route. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekRoutePauseHandler implements DialogHandler { + + private final SetupDataService dataService; + + public CreekRoutePauseHandler(SetupDataService dataService) { + this.dataService = dataService; + } + + @Override + public void handle(PlayerCustomClickEvent event, CompoundBinaryTag payload) { + int startMillis = millis(payload.get(CreekRouteDialogs.START_PAUSE_INPUT)); + int endMillis = millis(payload.get(CreekRouteDialogs.END_PAUSE_INPUT)); + if (startMillis < 0 || endMillis < 0) return; + Player player = event.getPlayer(); + this.dataService.get(player.getUuid()).ifPresent(data -> { + if (!(data instanceof GameData gameData)) return; + String activeRoute = gameData.activeCreekRoute(); + if (activeRoute == null) { + player.sendMessage(SetupMessages.NO_ACTIVE_CREEK_ROUTE); + return; + } + if (!gameData.setCreekStartPause(startMillis)) { + player.sendMessage(SetupMessages.CREEK_PAUSE_NEEDS_POINT); + return; + } + if (!gameData.setCreekEndPause(endMillis)) { + player.sendMessage(SetupMessages.getCreekStartPauseSet(activeRoute, startMillis)); + player.sendMessage(SetupMessages.CREEK_END_PAUSE_NEEDS_TWO_POINTS); + return; + } + player.sendMessage(SetupMessages.getCreekPausesSet(activeRoute, startMillis, endMillis)); + }); + } + + /** + * Turns a slider value in seconds into milliseconds. + * + * @return the milliseconds, or {@code -1} if the client sent nothing usable + */ + private static int millis(@Nullable BinaryTag tag) { + return switch (tag) { + case FloatBinaryTag value -> Math.round(value.value() * 1000f); + case DoubleBinaryTag value -> (int) Math.round(value.value() * 1000D); + case IntBinaryTag value -> value.value() * 1000; + case null, default -> -1; + }; + } +} From f2c3a61cc4ab5eabe1908753d81912feb8917d5a Mon Sep 17 00:00:00 2001 From: Joltras Date: Sat, 26 Sep 2026 19:17:33 +0200 Subject: [PATCH 62/92] fix(setup): remove bossbar on reset --- .../net/onelitefeather/cygnus/setup/data/GameData.java | 4 ++-- .../cygnus/setup/data/InstanceSetupData.java | 10 +++++----- .../onelitefeather/cygnus/setup/data/LobbyData.java | 3 ++- 3 files changed, 9 insertions(+), 8 deletions(-) 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 86a33cbb..65241b10 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java @@ -68,7 +68,7 @@ public class GameData extends InstanceSetupData { * @param mapEntry the map entry associated with this game data */ public GameData(Player player, MapEntry mapEntry) { - super(player.getUuid(), mapEntry, BossBar.Color.RED); + super(player, mapEntry, BossBar.Color.RED); this.loadData(); this.creekRouteSlots = new ArrayList<>(); this.inventory = new MapDataOverviewInventory(player, this.gameMapBuilder, InventoryMode.GAME); @@ -592,7 +592,6 @@ public void handleDataContextDelete(MapDataCategory category, Point point) { this.gameMapBuilder.removeSurvivorSpawn(pos); this.triggerUpdate(InventoryTarget.SURVIVOR); } else if (category == MapDataCategory.PAGE) { - Player player = MinecraftServer.getConnectionManager().getOnlinePlayerByUuid(this.uuid); PageResource pageResource = null; if (player instanceof SetupPlayer setupPlayer) { pageResource = setupPlayer.getPageResource(); @@ -633,6 +632,7 @@ public void teleport(Player player) { @Override public void reset() { super.reset(); + this.bossBar.removeViewer(player); this.survivorInventory.unregister(); this.inventory.unregister(); this.pageInventory.unregister(); diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java index 6127b7a5..cfc94269 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java @@ -34,7 +34,7 @@ public abstract class InstanceSetupData implements SetupData { protected static final Pos SPAWN_POINT = new Pos(0, 100, 0); - protected UUID uuid; + protected Player player; protected MapEntry mapEntry; protected @Nullable InstanceContainer instance; protected @Nullable FalcoAnvilLoader chunkLoader; @@ -44,12 +44,12 @@ public abstract class InstanceSetupData implements SetupData { /** * Creates a new setup data container. * - * @param uuid unique identifier of this setup session + * @param player who owns the setup * @param mapEntry map entry associated with the setup * @param color boss bar color used for setup feedback */ - protected InstanceSetupData(UUID uuid, MapEntry mapEntry, BossBar.Color color) { - this.uuid = uuid; + protected InstanceSetupData(Player player, MapEntry mapEntry, BossBar.Color color) { + this.player = player; this.mapEntry = mapEntry; this.bossBar = BossBar.bossBar(Component.empty(), 1, color, BossBar.Overlay.PROGRESS); } @@ -170,7 +170,7 @@ public MapEntry getMapEntry() { */ @Override public UUID getId() { - return this.uuid; + return this.player.getUuid(); } /** diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java index 6bb9e6d0..595036b7 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java @@ -28,7 +28,7 @@ public final class LobbyData extends InstanceSetupData { * @param mapEntry that contains the map */ public LobbyData(Player player, MapEntry mapEntry) { - super(player.getUuid(), mapEntry, BossBar.Color.GREEN); + super(player, mapEntry, BossBar.Color.GREEN); this.loadData(); this.viewInventory = new MapDataOverviewInventory(player, this.mapBuilder, InventoryMode.LOBBY); } @@ -120,6 +120,7 @@ public void teleport(Player player) { @Override public void reset() { super.reset(); + this.bossBar.removeViewer(player); this.viewInventory.unregister(); } From 62519f9c3554e1c2d3b80beb41617f177897b157 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sat, 26 Sep 2026 19:17:43 +0200 Subject: [PATCH 63/92] chore(setup): improve item range --- .../onelitefeather/cygnus/setup/item/SetupItems.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 15fc2513..6c99e3e0 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItems.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/item/SetupItems.java @@ -61,13 +61,13 @@ public final class SetupItems { lobbySetupLayout.set(6, saveItem); gameSetupLayout = new HotBarLayout(); - gameSetupLayout.set(1, ItemStack.builder(Material.COMPASS) + gameSetupLayout.set(0, ItemStack.builder(Material.COMPASS) .customName(Component.text("Data", NamedTextColor.AQUA)) .lore(getLore(SPACE_SEPARATOR.append(Component.text("View basic map data", NamedTextColor.WHITE)))) .set(Tags.ITEM_TAG, SetupItemId.DATA) .build() ); - gameSetupLayout.set(3, ItemStack.builder(Material.PAPER) + gameSetupLayout.set(1, ItemStack.builder(Material.PAPER) .customName(Component.text("Page", NamedTextColor.AQUA)) .lore( getLore( @@ -82,7 +82,7 @@ public final class SetupItems { .build() ); - gameSetupLayout.set(5, ItemStack.builder(Material.MINECART) + gameSetupLayout.set(4, ItemStack.builder(Material.MINECART) .customName(Component.text("Survivor", NamedTextColor.GREEN)) .lore( getLore( @@ -95,8 +95,7 @@ public final class SetupItems { .set(Tags.ITEM_TAG, SetupItemId.SURVIVOR) .build() ); - gameSetupLayout.set(7, saveItem); - gameSetupLayout.set(6, ItemStack.builder(Material.LEAD) + gameSetupLayout.set(7, ItemStack.builder(Material.LEAD) .customName(Component.text("Creek routes", NamedTextColor.DARK_GREEN)) .lore(getLore(SPACE_SEPARATOR .append(Component.text("Switch to the ", NamedTextColor.WHITE)) @@ -105,6 +104,7 @@ public final class SetupItems { .set(Tags.ITEM_TAG, SetupItemId.CREEK_ROUTES) .build() ); + gameSetupLayout.set(8, saveItem); ItemStack leaveCreekMode = ItemStack.builder(Material.BARRIER) .customName(Component.text("Leave mode", NamedTextColor.RED)) From abe96da9291ec428e18880981f58fbb058fe5414 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sat, 26 Sep 2026 21:20:12 +0200 Subject: [PATCH 64/92] chore(game): improve entity pathfinding --- .../cygnus/creek/body/CreakingBody.java | 2 + .../creek/body/FoliageGroundGenerator.java | 44 ++++++ .../cygnus/creek/body/StepFollower.java | 127 ++++++++++++++++++ .../body/CreakingBodyIntegrationTest.java | 93 +++++++++++++ .../setup/listener/CreekRouteListener.java | 14 +- .../listener/CreekRouteListenerTest.java | 17 ++- 6 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.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 index bcdafb1a..f7b56c1c 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java @@ -48,6 +48,8 @@ private CreakingBody(EntityCreature entity) { */ public static CreakingBody spawn(Instance instance, Pos position) { CreakingBody body = new CreakingBody(new EntityCreature(EntityType.CREAKING)); + body.entity.getNavigator().setNodeGenerator(FoliageGroundGenerator::new); + body.entity.getNavigator().setNodeFollower(() -> new StepFollower(body.entity)); body.entity.updateViewableRule(player -> body.viewers.contains(player.getUuid())); body.entity.setInstance(instance, position); return body; diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java new file mode 100644 index 00000000..354ae867 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java @@ -0,0 +1,44 @@ +package net.onelitefeather.cygnus.creek.body; + +import net.minestom.server.collision.BoundingBox; +import net.minestom.server.collision.CollisionUtils; +import net.minestom.server.collision.PhysicsResult; +import net.minestom.server.coordinate.Point; +import net.minestom.server.entity.pathfinding.generators.GroundNodeGenerator; +import net.minestom.server.instance.block.Block; + +/** + * Plans paths like {@link GroundNodeGenerator}, but also through grass, ferns and leaf litter. + *

    + * Minestom only lets an entity walk into a block that is air. Any plant on the ground made that + * spot unwalkable, so the creek took detours, jumped or did not move at all. Here only solid + * blocks and liquids stop a step. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +final class FoliageGroundGenerator extends GroundNodeGenerator { + + @Override + public boolean canMoveTowards(Block.Getter getter, Point start, Point end, BoundingBox boundingBox) { + Block target = getter.getBlock(end); + if (target.solid() || target.liquid()) return false; + PhysicsResult result = sweep(getter, boundingBox, start, end); + return !result.collisionX() && !result.collisionY() && !result.collisionZ(); + } + + /** + * Moves the creek's bounding box from {@code start} to {@code end} and reports what it hits. + * + * @param getter the blocks of the world + * @param boundingBox the creek's bounding box + * @param start where the move starts + * @param end where the move ends + * @return the result of the move + */ + private static PhysicsResult sweep(Block.Getter getter, BoundingBox boundingBox, Point start, Point end) { + return CollisionUtils.handlePhysics(getter, boundingBox, start.asPos(), end.sub(start).asVec(), null, false); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java new file mode 100644 index 00000000..fe956827 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java @@ -0,0 +1,127 @@ +package net.onelitefeather.cygnus.creek.body; + +import net.minestom.server.collision.CollisionUtils; +import net.minestom.server.collision.PhysicsResult; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.pathfinding.followers.GroundNodeFollower; +import net.minestom.server.instance.Instance; +import net.minestom.server.utils.position.PositionUtils; +import org.jetbrains.annotations.Nullable; + +/** + * Walks the creek along a path like {@link GroundNodeFollower}, but steps up low blocks the way a + * vanilla mob does instead of jumping onto them. + *

    + * Three things differ from Minestom's follower: + *

    + *
      + *
    • Each step is checked a tiny bit above the feet. Standing exactly on top of a slab, + * Minestom counts the next slab as a wall and the entity would stop at the block edge.
    • + *
    • Blocks up to {@link #STEP_HEIGHT} high, like slabs and dirt paths, are walked up. It + * only jumps when that does not get it any farther.
    • + *
    • A path point counts as reached when the creek is in its column and less than a block + * away. The path puts the floor of a slab one block up, so on a slab the creek + * never stood in the same block as the point and kept jumping at it.
    • + *
    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +final class StepFollower extends GroundNodeFollower { + + /** Highest block the creek walks up without jumping, in blocks. Same as a vanilla mob. */ + static final double STEP_HEIGHT = 0.6D; + + /** How far ahead a blocked step is looked for before jumping, in blocks. */ + private static final double LOOK_AHEAD = 0.3D; + + private final Entity entity; + + StepFollower(Entity entity) { + super(entity); + this.entity = entity; + } + + @Override + public void moveTowards(Point direction, double speed, Point lookAt) { + Instance instance = this.entity.getInstance(); + if (instance == null) return; + Pos position = this.entity.getPosition(); + double dx = direction.x() - position.x(); + double dz = direction.z() - position.z(); + + // Never step past the point. Minestom slows down by the distance including height, so + // above a point one block lower it kept overshooting and never dropped into a narrow gap. + double step = Math.min(speed, Math.hypot(dx, dz)); + + Pos moved = this.walk(instance, position, horizontal(dx, dz, step)); + Point look = lookAt.sub(position); + float yaw = PositionUtils.getLookYaw(look.x(), look.z()); + float pitch = PositionUtils.getLookPitch(look.x(), look.y(), look.z()); + this.entity.refreshPosition(moved.withView(yaw, pitch)); + } + + @Override + public void jump(@Nullable Point point, @Nullable Point target) { + Instance instance = this.entity.getInstance(); + if (instance == null || point == null || !this.entity.isOnGround()) return; + Pos position = this.entity.getPosition(); + Vec ahead = horizontal(point.x() - position.x(), point.z() - position.z(), LOOK_AHEAD); + // Still room to walk, or a step to walk up: no jump needed. + if (!this.blocked(instance, position.add(0, Vec.EPSILON, 0), ahead)) return; + if (!this.blocked(instance, this.lifted(instance, position), ahead)) return; + super.jump(point, target); + } + + @Override + public boolean isAtPoint(Point point) { + Pos position = this.entity.getPosition(); + return position.blockX() == point.blockX() && position.blockZ() == point.blockZ() + && Math.abs(position.y() - point.y()) < 1.0D; + } + + /** + * Moves by {@code step}, walking up a low block if the flat move runs into it. + */ + private Pos walk(Instance instance, Pos from, Vec step) { + PhysicsResult flat = this.sweep(instance, from.add(0, Vec.EPSILON, 0), step); + Pos flatEnd = flat.newPosition().withY(from.y()); + if ((!flat.collisionX() && !flat.collisionZ()) || !this.entity.isOnGround()) return flatEnd; + + PhysicsResult over = this.sweep(instance, this.lifted(instance, from), step); + if (horizontalDistance(from, over.newPosition()) <= horizontalDistance(from, flatEnd)) return flatEnd; + return this.sweep(instance, over.newPosition(), new Vec(0, -STEP_HEIGHT, 0)).newPosition(); + } + + /** + * Returns the position {@link #STEP_HEIGHT} higher, or lower if a ceiling is in the way. + */ + private Pos lifted(Instance instance, Pos from) { + return this.sweep(instance, from.add(0, Vec.EPSILON, 0), new Vec(0, STEP_HEIGHT, 0)).newPosition(); + } + + private boolean blocked(Instance instance, Pos from, Vec step) { + PhysicsResult result = this.sweep(instance, from, step); + return result.collisionX() || result.collisionZ(); + } + + /** + * Moves the creek's bounding box by {@code move} from {@code from} and reports what it hits. + */ + private PhysicsResult sweep(Instance instance, Pos from, Vec move) { + return CollisionUtils.handlePhysics(instance, this.entity.getBoundingBox(), from, move, null, false); + } + + private static Vec horizontal(double dx, double dz, double length) { + double radians = Math.atan2(dz, dx); + return new Vec(Math.cos(radians) * length, 0, Math.sin(radians) * length); + } + + private static double horizontalDistance(Pos from, Pos to) { + return Math.hypot(to.x() - from.x(), to.z() - from.z()); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java index d7c738a1..7f36a8c5 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java @@ -5,10 +5,13 @@ import net.minestom.server.entity.Player; import net.minestom.server.entity.metadata.monster.CreakingMeta; import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; import net.minestom.testing.Env; import net.onelitefeather.cygnus.CygnusPlayerTestBase; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.util.Set; @@ -83,4 +86,94 @@ void rejectedGoalDoesNotBlockTheNext(Env env) { assertEquals(next, ((EntityCreature) body.entity()).getNavigator().getGoalPosition()); } + + @ParameterizedTest + @ValueSource(strings = {"minecraft:leaf_litter", "minecraft:short_grass", "minecraft:fern"}) + @DisplayName("He walks through plants on the ground") + void walksThroughPlants(String plant, Env env) { + Instance instance = env.createFlatInstance(); + env.createConnection().connect(instance, new Pos(0, 40, 0)); + Block cover = Block.fromKey(plant); + for (int x = -2; x <= 14; x++) { + for (int z = 5; z <= 15; z++) instance.setBlock(x, 40, z, cover); + } + CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40, 10.5)); + Pos goal = new Pos(10.5, 40, 10.5); + + walk(env, body, goal); + + assertTrue(body.position().distance(goal) <= 1.0D, "he gets through the " + plant + ", stands at " + body.position()); + } + + @Test + @DisplayName("He walks along a row of slabs without hopping") + void walksOverSlabsWithoutHopping(Env env) { + Instance instance = env.createFlatInstance(); + env.createConnection().connect(instance, new Pos(0, 40, 0)); + for (int x = 0; x <= 12; x++) { + instance.setBlock(x, 40, 10, Block.STONE_SLAB); + } + CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40.5, 10.5)); + Pos goal = new Pos(10.5, 40.5, 10.5); + + double highest = walk(env, body, goal); + + assertTrue(body.position().distance(goal) <= 1.0D, "he reaches the end of the slabs, stands at " + body.position()); + assertTrue(highest < 41.0D, "he does not jump on flat slabs, highest y " + highest); + } + + @Test + @DisplayName("He climbs steps made of slabs") + void climbsSlabSteps(Env env) { + Instance instance = env.createFlatInstance(); + env.createConnection().connect(instance, new Pos(0, 40, 0)); + // floor 40 -> slab 40.5 -> block 41 -> slab 41.5 -> block 42 + // across the whole width, so he cannot walk around them + for (int z = 0; z <= 20; z++) { + instance.setBlock(3, 40, z, Block.STONE_SLAB); + for (int x = 4; x <= 20; x++) instance.setBlock(x, 40, z, Block.STONE); + instance.setBlock(5, 41, z, Block.STONE_SLAB); + for (int x = 6; x <= 20; x++) instance.setBlock(x, 41, z, Block.STONE); + } + CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40, 10.5)); + Pos goal = new Pos(10.5, 42, 10.5); + + walk(env, body, goal); + + assertTrue(body.position().distance(goal) <= 1.0D, "he gets to the top, stands at " + body.position()); + } + + @Test + @DisplayName("He drops into a gap only one block wide") + void dropsIntoANarrowGap(Env env) { + Instance instance = env.createFlatInstance(); + env.createConnection().connect(instance, new Pos(0, 40, 0)); + for (int x = -2; x <= 12; x++) { + for (int z = 5; z <= 15; z++) instance.setBlock(x, 40, z, Block.STONE); + } + instance.setBlock(8, 40, 10, Block.AIR); + // off the block grid, so full steps never land him exactly in the middle of the gap + CreakingBody body = CreakingBody.spawn(instance, new Pos(0.6, 41, 10.37)); + Pos goal = new Pos(8.5, 40, 10.5); + + walk(env, body, goal); + + assertTrue(body.position().distance(goal) <= 1.0D, "he gets down into the gap, stands at " + body.position()); + } + + /** + * Lets him settle, sends him to the goal and ticks until he is there or 10 seconds are over. + * + * @return the highest y he reached on the way + */ + private static double walk(Env env, CreakingBody body, Pos goal) { + for (int i = 0; i < 20; i++) env.tick(); + body.moveTo(goal, 0.25D); + double highest = body.position().y(); + for (int i = 0; i < 200 && body.position().distance(goal) > 1.0D; i++) { + env.tick(); + highest = Math.max(highest, body.position().y()); + } + return highest; + } } diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java index 7ada2129..e581cde1 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListener.java @@ -4,6 +4,7 @@ import net.minestom.server.coordinate.Vec; import net.minestom.server.entity.Player; import net.minestom.server.event.player.PlayerBlockBreakEvent; +import net.minestom.server.instance.block.Block; import net.onelitefeather.cygnus.setup.data.GameData; import net.onelitefeather.cygnus.setup.util.SetupMessages; import net.onelitefeather.cygnus.setup.util.SetupTags; @@ -48,7 +49,7 @@ public void accept(PlayerBlockBreakEvent event) { return; } - Vec point = pointOnTop(event.getBlockPosition()); + Vec point = pointOnTop(event.getBlockPosition(), event.getBlock()); int removed = gameData.removeCreekPointAt(point); if (removed > 0) { player.sendMessage(SetupMessages.getCreekPointRemovedAt(activeRoute, removed, gameData.activeCreekPointCount())); @@ -60,12 +61,15 @@ public void accept(PlayerBlockBreakEvent event) { } /** - * Returns where the creek's feet stand on a block: in the middle, on top of it. + * Returns where the creek's feet stand on a block: in the middle, on top of its collision + * shape. A slab puts the feet half a block up, a plant without collision at its own bottom. * - * @param block the clicked block + * @param position the position of the clicked block + * @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); + static Vec pointOnTop(Point position, Block block) { + double top = block.registry().collisionShape().relativeEnd().y(); + return new Vec(position.blockX() + 0.5D, position.blockY() + top, position.blockZ() + 0.5D); } } diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListenerTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListenerTest.java index 14062226..9c697344 100644 --- a/setup/src/test/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListenerTest.java +++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/listener/CreekRouteListenerTest.java @@ -1,6 +1,7 @@ package net.onelitefeather.cygnus.setup.listener; import net.minestom.server.coordinate.Vec; +import net.minestom.server.instance.block.Block; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -11,6 +12,20 @@ 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))); + assertEquals(new Vec(3.5, 81, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.STONE)); + } + + @Test + @DisplayName("A point sits on the real height of a lower block") + void pointOnTopOfALowerBlock() { + assertEquals(new Vec(3.5, 80.5, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.STONE_SLAB)); + assertEquals(new Vec(3.5, 80.9375, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.DIRT_PATH)); + assertEquals(new Vec(3.5, 80.0625, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.WHITE_CARPET)); + } + + @Test + @DisplayName("A point on a plant sits where the plant grows") + void pointOnAPlant() { + assertEquals(new Vec(3.5, 80, -1.5), CreekRouteListener.pointOnTop(new Vec(3, 80, -2), Block.FERN)); } } From 634514b0402d96041ff0e64aa05afc3690031f84 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sat, 26 Sep 2026 22:54:34 +0200 Subject: [PATCH 65/92] chore(creek): improve documentation --- .../cygnus/creek/body/CreakingBody.java | 25 ++-- .../cygnus/creek/body/CreekBody.java | 30 ++-- .../creek/body/FoliageGroundGenerator.java | 9 +- .../cygnus/creek/body/StepFollower.java | 31 ++-- .../creek/consequence/CatchConsequence.java | 6 +- .../creek/consequence/CatchEffects.java | 16 +-- .../cygnus/creek/consequence/GlowReveal.java | 22 +-- .../consequence/SelectionConsequence.java | 135 ++++++++++++++++++ .../creek/consequence/SlenderReveal.java | 6 +- .../consequence/StagedCatchConsequence.java | 13 +- .../creek/consequence/package-info.java | 2 +- .../cygnus/creek/debug/CreekDebug.java | 92 +++++++++--- .../cygnus/creek/debug/package-info.java | 2 +- .../cygnus/creek/dread/DreadSource.java | 10 +- .../cygnus/creek/dread/PageProgressDread.java | 19 +-- .../cygnus/creek/dread/package-info.java | 2 +- 16 files changed, 306 insertions(+), 114 deletions(-) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.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 index f7b56c1c..017ef83c 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java @@ -15,11 +15,11 @@ import java.util.concurrent.ConcurrentHashMap; /** - * Shows the creek as a vanilla creaking. + * The creek in the world, dressed up 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. + * An enderman was out of the question: the slender player already looks like one, and nobody could + * tell the two apart. Creakings only spawn from a creaking heart, and Cygnus has none, so the + * resource pack is free to give them a new look. *

    * * @author theEvilReaper @@ -28,7 +28,7 @@ */ public final class CreakingBody implements CreekBody { - /** How far the goal has to move before a new path is calculated, in blocks. */ + /** How far the goal has to move before the creek works out a new path, in blocks. */ static final double REPATH_DISTANCE = 1.0D; private final EntityCreature entity; @@ -40,10 +40,10 @@ private CreakingBody(EntityCreature entity) { } /** - * Spawns a creaking. Nobody can see it until {@link #showTo(Set)} is called. + * Puts a creaking into the world. Nobody sees it until {@link #showTo(Set)} says who may. * * @param instance the instance of the round - * @param position the spawn position + * @param position where it appears * @return the body */ public static CreakingBody spawn(Instance instance, Pos position) { @@ -63,12 +63,12 @@ public Pos position() { @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. + // A survivor on the move shifts a little between almost every two steps. Working out a + // new path every time would be expensive, so small shifts are ignored. Pos current = this.goal; if (current != null && current.distance(goal) <= REPATH_DISTANCE) return; - // Only remember goals the navigator accepted. A rejected goal (too close, same block, - // unloaded chunk) or a reached one must not block the next goal. + // Only remember goals the navigator took on. One it turned down (too close, same block, + // unloaded chunk) or one already reached must not stand in the way of the next. boolean started = this.entity.getNavigator().setPathTo(goal, this.arrivalDistance(), () -> this.forget(goal)); this.goal = started ? goal : null; } @@ -78,7 +78,8 @@ private void forget(Pos reached) { } /** - * The navigator's default arrival distance: from the center of the bounding box to a corner. + * How close counts as arrived for Minestom's navigator: from the middle of the bounding box to + * one of its corners. */ private double arrivalDistance() { BoundingBox box = this.entity.getBoundingBox(); 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 index d9107fe2..9669140a 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreekBody.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreekBody.java @@ -7,10 +7,10 @@ import java.util.UUID; /** - * The creek's appearance in the world: the entity that is drawn and moved. + * What the creek looks like in the world: the entity that gets drawn and moved around. *

    - * The states only use this interface. That way the vanilla creaking can later be replaced by a - * custom model without changing the behavior. + * The states only ever talk to this interface. That way the vanilla creaking can later make way + * for a custom model without the behaviour changing at all. *

    * * @author theEvilReaper @@ -20,7 +20,7 @@ public interface CreekBody { /** - * Returns the position of the creek's feet. + * Where the creek's feet are. * * @return the position */ @@ -30,7 +30,7 @@ public interface CreekBody { * Walks towards a goal. * * @param goal where to go - * @param speed the movement speed, in blocks per tick + * @param speed how fast, in blocks per tick */ void moveTo(Pos goal, double speed); @@ -40,9 +40,9 @@ public interface CreekBody { void stop(); /** - * Moves the creek to a position instantly. + * Moves the creek somewhere else in an instant. * - * @param position the new position + * @param position where it ends up */ void teleport(Pos position); @@ -54,22 +54,22 @@ public interface CreekBody { void lookAt(Pos point); /** - * Makes the creek visible to exactly these players. + * Lets exactly these players see the creek. * - * @param viewers the players who can see the creek; an empty set hides it from everyone + * @param viewers who may see it; an empty set hides it from everyone */ void showTo(Set viewers); /** - * Returns whether a player can currently see the creek. + * Tells whether a player can see the creek right now. * * @param viewer the player's id - * @return {@code true} if the creek is visible to that player + * @return {@code true} if the creek is visible to them */ boolean isVisibleTo(UUID viewer); /** - * Switches the hunting look on or off (for the creaking: glowing eyes). + * Switches the hunting look on or off. For the creaking, that means glowing eyes. * * @param aggressive {@code true} for the hunting look */ @@ -78,19 +78,19 @@ public interface CreekBody { /** * Switches the frozen look on or off. * - * @param frozen {@code true} while a survivor looks at the creek during a hunt + * @param frozen {@code true} while the hunted survivor is looking at the creek */ void setFrozen(boolean frozen); /** - * Returns the entity behind the body. Used for line-of-sight checks. + * The entity behind the body, needed to check who can see it. * * @return the entity */ Entity entity(); /** - * Removes the creek from the world. + * Takes the creek out of the world. */ void remove(); } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java index 354ae867..bd6558f3 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/FoliageGroundGenerator.java @@ -8,11 +8,12 @@ import net.minestom.server.instance.block.Block; /** - * Plans paths like {@link GroundNodeGenerator}, but also through grass, ferns and leaf litter. + * Plans paths like {@link GroundNodeGenerator}, but lets the creek walk through grass, ferns + * and leaf litter. *

    - * Minestom only lets an entity walk into a block that is air. Any plant on the ground made that - * spot unwalkable, so the creek took detours, jumped or did not move at all. Here only solid - * blocks and liquids stop a step. + * Minestom only lets an entity step into a block of pure air, so a single tuft of grass made a + * spot unwalkable: the creek took detours, jumped around or did not move at all. Here only solid + * blocks and liquids get in the way. *

    * * @author theEvilReaper diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java index fe956827..afc4112d 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java @@ -12,19 +12,19 @@ import org.jetbrains.annotations.Nullable; /** - * Walks the creek along a path like {@link GroundNodeFollower}, but steps up low blocks the way a - * vanilla mob does instead of jumping onto them. + * Moves the creek along its path like {@link GroundNodeFollower}, but steps up low blocks the + * way a vanilla mob does instead of hopping onto them. *

    - * Three things differ from Minestom's follower: + * It differs from Minestom's follower in three ways: *

    *
      - *
    • Each step is checked a tiny bit above the feet. Standing exactly on top of a slab, - * Minestom counts the next slab as a wall and the entity would stop at the block edge.
    • - *
    • Blocks up to {@link #STEP_HEIGHT} high, like slabs and dirt paths, are walked up. It - * only jumps when that does not get it any farther.
    • - *
    • A path point counts as reached when the creek is in its column and less than a block - * away. The path puts the floor of a slab one block up, so on a slab the creek - * never stood in the same block as the point and kept jumping at it.
    • + *
    • Each step is checked a hair above the feet. Standing right on top of a slab, Minestom + * treats the next slab as a wall, and the creek would get stuck at the edge.
    • + *
    • Anything up to {@link #STEP_HEIGHT} high, like slabs and dirt paths, it simply walks up. + * It only jumps when stepping up gets it nowhere.
    • + *
    • A path point counts as reached once the creek stands in its column and less than a block + * away in height. The path puts the floor of a slab a block too high, so on slabs the creek + * never quite arrived and kept jumping at the point.
    • *
    * * @author theEvilReaper @@ -33,10 +33,10 @@ */ final class StepFollower extends GroundNodeFollower { - /** Highest block the creek walks up without jumping, in blocks. Same as a vanilla mob. */ + /** The highest step the creek takes without jumping, in blocks. The same as a vanilla mob. */ static final double STEP_HEIGHT = 0.6D; - /** How far ahead a blocked step is looked for before jumping, in blocks. */ + /** How far ahead it looks for a blocked step before deciding to jump, in blocks. */ private static final double LOOK_AHEAD = 0.3D; private final Entity entity; @@ -54,8 +54,9 @@ public void moveTowards(Point direction, double speed, Point lookAt) { double dx = direction.x() - position.x(); double dz = direction.z() - position.z(); - // Never step past the point. Minestom slows down by the distance including height, so - // above a point one block lower it kept overshooting and never dropped into a narrow gap. + // Never overshoot the point. Minestom slows down using the distance including height, so + // above a point one block lower the creek kept swinging past it and never dropped into a + // narrow gap. double step = Math.min(speed, Math.hypot(dx, dz)); Pos moved = this.walk(instance, position, horizontal(dx, dz, step)); @@ -71,7 +72,7 @@ public void jump(@Nullable Point point, @Nullable Point target) { if (instance == null || point == null || !this.entity.isOnGround()) return; Pos position = this.entity.getPosition(); Vec ahead = horizontal(point.x() - position.x(), point.z() - position.z(), LOOK_AHEAD); - // Still room to walk, or a step to walk up: no jump needed. + // Still room to walk, or a step it can walk up: no need to jump. if (!this.blocked(instance, position.add(0, Vec.EPSILON, 0), ahead)) return; if (!this.blocked(instance, this.lifted(instance, position), ahead)) return; super.jump(point, target); 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 index 805cbc50..ef952d87 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchConsequence.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchConsequence.java @@ -3,7 +3,7 @@ import net.minestom.server.entity.Player; /** - * What happens when the creek catches a survivor. + * What happens to a survivor the creek catches. * * @author theEvilReaper * @version 1.0.0 @@ -13,14 +13,14 @@ public interface CatchConsequence { /** - * Applies the consequence. + * Lets the catch hit the survivor. * * @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. + * Takes back anything that is still running. Called when the round ends. */ default void cleanUp() { } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java index e2d9aae4..9ca1a092 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java @@ -15,7 +15,7 @@ import java.util.function.Predicate; /** - * The normal punishment for being caught: a jump scare, empty stamina and slowness. + * The usual price for being caught: a jump scare, an empty stamina bar and slowness. * * @author theEvilReaper * @version 1.0.0 @@ -23,10 +23,10 @@ */ public final class CatchEffects implements CatchConsequence { - /** Sound of the fallback scare, used when no corpse exists yet. */ + /** The sound of the stand-in scare, for when there is no corpse to show yet. */ static final Key SCARE_SOUND = Key.key("entity.creaking.activate"); - /** Duration of the fallback scare's darkness, in ticks. */ + /** How long the stand-in scare's darkness lasts, in ticks. */ static final int SCARE_DARKNESS_TICKS = 40; private final Predicate jumpScare; @@ -35,10 +35,10 @@ public final class CatchEffects implements CatchConsequence { private final Set slowed = ConcurrentHashMap.newKeySet(); /** - * Creates the effects. + * Sets up 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 jumpScare plays the jump scare and returns {@code false} if it could not + * @param foodBars returns a survivor's stamina bar, or {@code null} if they have none * @param slownessSeconds how long a caught survivor is slowed */ public CatchEffects(Predicate jumpScare, Function foodBars, int slownessSeconds) { @@ -49,8 +49,8 @@ public CatchEffects(Predicate jumpScare, Function - * 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. + * Glowing is a metadata flag, and Minestom sends metadata to everyone who can see the player. So + * the flag is never set on the survivor itself. Instead, only the slender gets a packet with it. *

    * * @author theEvilReaper @@ -32,7 +32,7 @@ */ public final class GlowReveal implements SlenderReveal { - /** How often the glow packet is resent, in ticks. */ + /** How often the glow packet goes out again, in ticks. */ static final int RESEND_TICKS = 5; /** The glowing bit in the entity flags. */ @@ -46,7 +46,7 @@ public final class GlowReveal implements SlenderReveal { private final Set running = ConcurrentHashMap.newKeySet(); /** - * Creates the reveal. + * Sets up the reveal. * * @param seconds how long the glow lasts */ @@ -58,7 +58,7 @@ public GlowReveal(int seconds) { 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. + // Let the survivor know too. Being given away without any hint would feel unfair. survivor.playSound(Sound.sound(SURVIVOR_SOUND, Sound.Source.HOSTILE, 1.0F, 0.8F)); survivor.sendMessage(WARNING); @@ -78,8 +78,8 @@ private void step(Reveal reveal) { 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. + // Whenever the survivor's metadata changes (sprinting, sneaking), the slender gets the real + // flags and the glow is gone. Sending it again keeps that gap short. reveal.slender.sendPacket(flagsPacket(reveal.survivor, true)); } @@ -99,10 +99,10 @@ private void end(Reveal reveal) { } /** - * Builds a packet with the survivor's current entity flags and the glow bit set or cleared. + * Builds a packet with the survivor's current entity flags, with 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. + * The flags always go along. Otherwise the client would hold on to the last value it got, which + * after a reveal is the glow. *

    */ static EntityMetaDataPacket flagsPacket(Player survivor, boolean glowing) { diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java new file mode 100644 index 00000000..08a8302c --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java @@ -0,0 +1,135 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.kyori.adventure.sound.Sound; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.potion.Potion; +import net.minestom.server.potion.PotionEffect; +import net.onelitefeather.cygnus.creek.world.Ground; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; +import java.util.random.RandomGenerator; + +/** + * What happens when the patrolling creek picks out a survivor who came too close. + *

    + * It is a coin toss: either the survivor freezes on the spot and everyone standing next to them + * goes blind for a moment, or they suddenly find themselves somewhere else on the map. It is a + * scare, not a catch, so it never counts towards giving them away to the slender. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class SelectionConsequence { + + /** How long the survivor stays frozen, in ticks (2 seconds). */ + static final int STUN_TICKS = 40; + + /** Slowness VII: strong enough that the survivor cannot move at all. */ + static final int STUN_AMPLIFIER = 6; + + /** Everyone this close to the frozen survivor goes blind too, in blocks. */ + static final double BLIND_RADIUS = 8.0D; + + /** How long they stay blind, in ticks (2 seconds). */ + static final int BLIND_TICKS = 40; + + /** A teleported survivor lands at least this far away, in blocks. */ + static final double TELEPORT_MIN_DISTANCE = 20.0D; + + /** A teleported survivor lands at most this far away, so they stay in the same part of the map. */ + static final double TELEPORT_MAX_DISTANCE = 40.0D; + + private final Supplier> routePoints; + private final Ground ground; + private final RandomGenerator random; + private final Set slowed = ConcurrentHashMap.newKeySet(); + private final Set blinded = ConcurrentHashMap.newKeySet(); + + /** + * Sets up the consequence for a round. + * + * @param routePoints supplies the points of the map's routes, the places a survivor can be sent to + * @param ground finds the floor at such a place + * @param random tosses the coin between freezing and teleporting, and picks the place + */ + public SelectionConsequence(Supplier> routePoints, Ground ground, RandomGenerator random) { + this.routePoints = routePoints; + this.ground = ground; + this.random = random; + } + + /** + * Freezes the survivor or sends them away. If there is nowhere to send them, they are frozen + * instead, so a selection never goes unnoticed. + * + * @param selected the survivor the creek picked out + * @param survivors every survivor of the round, to find the ones standing next to a frozen one + */ + public void apply(Player selected, Collection survivors) { + if (this.random.nextBoolean() || !this.teleportAway(selected)) { + this.stun(selected, survivors); + } + } + + /** + * Freezes the survivor for a moment and blinds the other survivors standing next to them. + */ + void stun(Player selected, Collection survivors) { + scare(selected); + selected.addEffect(new Potion(PotionEffect.SLOWNESS, STUN_AMPLIFIER, STUN_TICKS)); + this.slowed.add(selected); + Pos center = selected.getPosition(); + for (Player other : survivors) { + if (other == selected || other.getPosition().distance(center) > BLIND_RADIUS) continue; + other.addEffect(new Potion(PotionEffect.BLINDNESS, 0, BLIND_TICKS)); + this.blinded.add(other); + } + } + + /** + * Sends the survivor to a random route point 20 to 40 blocks away. Route points are always + * walkable, so nobody ends up inside a wall. + * + * @return {@code false} if none of the route points in that range has a floor + */ + boolean teleportAway(Player selected) { + Pos from = selected.getPosition(); + List targets = new ArrayList<>(); + for (Pos point : this.routePoints.get()) { + double distance = point.distance(from); + if (distance < TELEPORT_MIN_DISTANCE || distance > TELEPORT_MAX_DISTANCE) continue; + this.ground.settle(point).ifPresent(targets::add); + } + if (targets.isEmpty()) return false; + Pos target = targets.get(this.random.nextInt(targets.size())); + selected.teleport(target.withView(from.yaw(), from.pitch())); + scare(selected); + return true; + } + + /** + * Takes back every slowness and blindness handed out, so nothing lingers after the round. + */ + public void cleanUp() { + for (Player player : List.copyOf(this.slowed)) { + player.removeEffect(PotionEffect.SLOWNESS); + } + for (Player player : List.copyOf(this.blinded)) { + player.removeEffect(PotionEffect.BLINDNESS); + } + this.slowed.clear(); + this.blinded.clear(); + } + + private static void scare(Player player) { + player.playSound(Sound.sound(CatchEffects.SCARE_SOUND, Sound.Source.HOSTILE, 1.0F, 0.6F)); + } +} 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 index ccd2f6da..2659546e 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SlenderReveal.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SlenderReveal.java @@ -3,7 +3,7 @@ import net.minestom.server.entity.Player; /** - * Reveals a survivor's position to the slender. + * Gives a survivor's position away to the slender. * * @author theEvilReaper * @version 1.0.0 @@ -15,13 +15,13 @@ public interface SlenderReveal { /** * Shows the slender where a survivor is. * - * @param survivor the survivor to reveal + * @param survivor the survivor to give away * @param slender the slender */ void reveal(Player survivor, Player slender); /** - * Ends all running reveals. + * Ends every reveal that is still running. */ default void cleanUp() { } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequence.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequence.java index 5ebe9779..329a68f7 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequence.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequence.java @@ -12,8 +12,9 @@ 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}. + * Punishes every catch, and sometimes gives the survivor away to the slender as well: always + * from the {@code betrayalCatchCount}-th catch on, and before that with a chance of + * {@code betrayalChance}. * * @author theEvilReaper * @version 1.0.0 @@ -21,7 +22,7 @@ */ public final class StagedCatchConsequence implements CatchConsequence { - /** How often a survivor was caught since their last reveal. */ + /** How often a survivor has been caught since they were last given away. */ static final Tag CATCHES = Tag.Transient("creekCatches"); private final CatchConsequence effects; @@ -33,12 +34,12 @@ public final class StagedCatchConsequence implements CatchConsequence { private final Set counted = ConcurrentHashMap.newKeySet(); /** - * Creates the consequence. + * Sets up the consequence. * * @param effects the punishment for every catch - * @param reveal reveals a survivor to the slender + * @param reveal gives a survivor away to the slender * @param slender supplies the slender, or {@code null} while there is none - * @param config the reveal settings + * @param config the settings for giving survivors away * @param random the random source */ public StagedCatchConsequence(CatchConsequence effects, SlenderReveal reveal, Supplier<@Nullable Player> slender, 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 index 19ed81c3..aa967e90 100644 --- 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 @@ -1,5 +1,5 @@ /** - * What happens when the creek catches a survivor. + * What happens to a survivor the creek catches or picks out. */ @NotNullByDefault package net.onelitefeather.cygnus.creek.consequence; 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 fd2b0a7e..4f60c13a 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 @@ -7,11 +7,12 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Player; import net.onelitefeather.cygnus.creek.state.CreekState; +import net.onelitefeather.cygnus.creek.state.DoneState; import net.onelitefeather.cygnus.creek.state.HuntState; +import net.onelitefeather.cygnus.creek.state.PatrolState; import net.onelitefeather.cygnus.creek.state.StalkState; import net.onelitefeather.cygnus.creek.state.SurvivorView; import net.onelitefeather.cygnus.creek.state.VanishState; -import net.onelitefeather.cygnus.creek.state.WanderState; import java.util.List; import java.util.Locale; @@ -22,11 +23,11 @@ import java.util.function.Function; /** - * Shows what the creek is doing in the action bar of players who turned it on. Meant for + * Shows what the creek is up to in the action bar of everyone who switched it on. Meant for * playtests. *

    - * Watchers see everything, whatever their role. This is a tuning tool, not something for - * players in a real round. + * Watchers see everything, whatever their role in the round. It is a tuning tool, not something + * players should have during a real game. *

    * * @author theEvilReaper @@ -35,17 +36,17 @@ */ public final class CreekDebug { - /** Shown to watchers while no round is running. */ + /** What watchers see 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. */ + /** Marks a survivor who can see the creek right now. The default Minecraft font has no emoji. */ static final String SEEN = "◉"; private final Set watchers = ConcurrentHashMap.newKeySet(); private volatile boolean active; /** - * Turns the debug line on or off for a player. + * Switches the debug line on or off for a player. * * @param player the player's id * @return {@code true} if the line is now on @@ -57,16 +58,16 @@ public boolean toggle(UUID player) { } /** - * Returns whether anyone has the debug line turned on. + * Tells whether anyone has the debug line switched on. * - * @return {@code true} if at least one player watches + * @return {@code true} if at least one player is watching */ public boolean hasWatchers() { return !this.watchers.isEmpty(); } /** - * Returns whether the creek is currently in a round. + * Tells whether the creek is part of a round right now. * * @return {@code true} while the creek is in the world */ @@ -75,7 +76,8 @@ public boolean isActive() { } /** - * Sets whether the creek is in a round. When it leaves, watchers see "Creek inactive". + * Tells the debug line whether the creek is part of a round. Once it leaves, watchers see + * "Creek inactive". * * @param active {@code true} while the creek is in the world */ @@ -85,7 +87,7 @@ public void setActive(boolean active) { } /** - * Sends a line to every online watcher. + * Sends a line to every watcher who is online. * * @param line the line to show */ @@ -100,9 +102,9 @@ public void show(Component line) { * Builds the debug line for one step. * * @param state the creek's state - * @param creek the creek's position + * @param creek where the creek is * @param views the survivors in that step - * @param names turns a player id into a name + * @param names turns a player's id into a name * @return the line */ public static Component line(CreekState state, Pos creek, List views, Function names) { @@ -110,17 +112,33 @@ public static Component line(CreekState state, Pos creek, List vie } /** - * Builds the debug line for one step, including where the creek is on its route. + * Builds the debug line for one step, together with where the creek is on its route. * * @param state the creek's state - * @param creek the creek's position + * @param creek where the creek is * @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 + * @param names turns a player's id into a name + * @param route where the creek is on its route, or an empty string without a route * @return the line */ public static Component line(CreekState state, Pos creek, List views, Function names, String route) { + return line(state, creek, views, names, route, Long.MAX_VALUE); + } + + /** + * Builds the debug line of the patrolling creek. + * + * @param state the creek's state + * @param creek where the creek is + * @param views every survivor + * @param names turns a survivor's id into a name + * @param route where the creek is on its route, or an empty string + * @param now the current time in milliseconds, to show how long until it picks someone out again + * @return the line + */ + public static Component line(CreekState state, Pos creek, List views, Function names, + String route, long now) { TextComponent.Builder builder = Component.text(); builder.append(Component.text(label(state), NamedTextColor.RED)); if (!route.isEmpty()) { @@ -137,6 +155,11 @@ public static Component line(CreekState state, Pos creek, List vie NamedTextColor.GRAY))); }); + if (state instanceof PatrolState patrol && patrol.selectAllowedAt() > now) { + long seconds = (patrol.selectAllowedAt() - now + 999L) / 1000L; + builder.append(Component.text(" · select " + seconds + "s", NamedTextColor.GRAY)); + } + for (SurvivorView view : views) { builder.append(Component.text(" · ", NamedTextColor.DARK_GRAY)); builder.append(Component.text( @@ -150,17 +173,45 @@ public static Component line(CreekState state, Pos creek, List vie } /** - * Returns the display name of a state. + * Builds the part of the debug line about the variants: how many are running, and for each + * one who it haunts, what it is doing and how many seconds it has left. + * + * @param states the states of the running variants + * @param capacity how many variants may run at once + * @param names turns a survivor's id into a name + * @param now the current time in milliseconds + * @return the segment, starting with a separator + */ + public static Component variants(List states, int capacity, Function names, long now) { + TextComponent.Builder builder = Component.text(); + builder.append(Component.text(" | ", NamedTextColor.DARK_GRAY)); + builder.append(Component.text("variants " + states.size() + "/" + capacity, NamedTextColor.LIGHT_PURPLE)); + for (CreekState state : states) { + long endsAt = switch (state) { + case StalkState stalk -> stalk.endsAt(); + case HuntState hunt -> hunt.endsAt(); + default -> now; + }; + String who = target(state).map(names).orElse("?"); + long seconds = Math.max(0L, (endsAt - now) / 1000L); + builder.append(Component.text(" · " + who + " " + label(state) + " " + seconds + "s", NamedTextColor.GRAY)); + } + return builder.build(); + } + + /** + * The name a state goes by in the debug line. * * @param state the state * @return the name, for example {@code HUNT} */ public static String label(CreekState state) { return switch (state) { - case WanderState _ -> "WANDER"; + case PatrolState _ -> "PATROL"; case StalkState _ -> "STALK"; case HuntState _ -> "HUNT"; case VanishState _ -> "VANISH"; + case DoneState _ -> "DONE"; default -> state.getClass().getSimpleName(); }; } @@ -169,6 +220,7 @@ private static Optional target(CreekState state) { return switch (state) { case StalkState stalk -> Optional.of(stalk.target()); case HuntState hunt -> Optional.of(hunt.target()); + case PatrolState patrol -> patrol.staring(); default -> Optional.empty(); }; } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/debug/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/creek/debug/package-info.java index ec9147eb..4466e163 100644 --- 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 @@ -1,5 +1,5 @@ /** - * A debug line in the action bar for playtests. + * A debug line in the action bar, for playtests. */ @NotNullByDefault package net.onelitefeather.cygnus.creek.debug; 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 index c93e940e..9fe2abd9 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/dread/DreadSource.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/DreadSource.java @@ -6,10 +6,10 @@ import java.util.UUID; /** - * Rates how likely a survivor is to be stalked or hunted. + * Rates how likely a survivor is to be haunted. *

    - * The creek only uses this interface. A later sanity system can replace the rating without - * changing the creek. + * The creek only ever asks this interface. A sanity system can take over the rating later without + * the creek noticing. *

    * * @author theEvilReaper @@ -23,8 +23,8 @@ 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 + * @param position where the survivor is + * @param others where all the other survivors are * @return the dread, between {@code 0} and {@code 1} */ double dreadOf(UUID survivor, Pos position, List others); diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/dread/PageProgressDread.java b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/PageProgressDread.java index 99db010f..e1c56be4 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/dread/PageProgressDread.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/dread/PageProgressDread.java @@ -9,10 +9,11 @@ import java.util.function.LongSupplier; /** - * Rates the dread from the found pages, the elapsed time and whether a survivor is alone. + * Rates the dread from the pages found so far, how long the round has been going and whether a + * survivor is on their own. *

    - * Pages and time are the same for everyone. Being alone is what makes the difference, so the - * creek prefers survivors who leave the group. + * Pages and time are the same for everyone. Being alone is what sets a survivor apart, so the + * creek goes for those who wander off from the group. *

    * * @author theEvilReaper @@ -28,13 +29,13 @@ public final class PageProgressDread implements DreadSource { private final CreekConfig config; /** - * Creates the rating. + * Sets up 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 + * @param foundPages supplies how many pages have been found + * @param maxPages supplies how many pages there are in total + * @param elapsedMillis supplies how long the round has been going + * @param gameTimeSeconds how long a round lasts at most + * @param config the weights and how far apart counts as alone */ public PageProgressDread(IntSupplier foundPages, IntSupplier maxPages, LongSupplier elapsedMillis, int gameTimeSeconds, CreekConfig config) { 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 index fd62c5a7..aca6d5de 100644 --- 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 @@ -1,5 +1,5 @@ /** - * Rates how likely a survivor is to be stalked or hunted. + * Rates how likely a survivor is to be haunted. */ @NotNullByDefault package net.onelitefeather.cygnus.creek.dread; From 4c0b58a702ec10ebeaf40d0ac742f719a81d3310 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sat, 26 Sep 2026 22:55:42 +0200 Subject: [PATCH 66/92] refactor(creek): overhaul state usage to have multiple instances --- .../cygnus/creek/state/CreekContext.java | 25 +- .../cygnus/creek/state/CreekState.java | 6 +- .../cygnus/creek/state/DoneState.java | 35 +++ .../cygnus/creek/state/HuntState.java | 36 ++- .../cygnus/creek/state/PatrolState.java | 243 ++++++++++++++++++ .../cygnus/creek/state/StalkState.java | 76 ++++-- .../cygnus/creek/state/SurvivorView.java | 13 +- .../cygnus/creek/state/VanishState.java | 33 +-- .../cygnus/creek/state/package-info.java | 2 +- 9 files changed, 397 insertions(+), 72 deletions(-) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/state/DoneState.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.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 index dcada857..cb446ddf 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekContext.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekContext.java @@ -17,18 +17,20 @@ /** * 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. + * A fresh context is built for every step. It holds no server objects, so the states can be tested + * without a running server. *

    * * @param now the current time in milliseconds * @param survivors the survivors of the round * @param body the creek's body - * @param route 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 route decides where the creek walks next + * @param spots finds places where nobody can see the creek + * @param onCatch called with the id of a survivor the creek caught + * @param onSelect called with the id of a survivor the patrolling creek picked out * @param config the settings * @param random the random source + * * @author theEvilReaper * @version 1.0.0 * @since 2.15.0 @@ -40,15 +42,16 @@ public record CreekContext( RouteProvider route, SpotFinder spots, Consumer onCatch, + Consumer onSelect, CreekConfig config, RandomGenerator random ) { /** - * Finds a survivor by id. + * Looks up a survivor by id. * * @param id the id to look for - * @return the survivor, or empty if they left the round + * @return the survivor, or empty if they are no longer in the round */ public Optional survivor(UUID id) { for (SurvivorView view : this.survivors) { @@ -58,7 +61,7 @@ public Optional survivor(UUID id) { } /** - * Returns the ids of every survivor. + * The ids of every survivor. * * @return the ids */ @@ -71,7 +74,7 @@ public Set survivorIds() { } /** - * Returns the eyes of every survivor. + * Where every survivor's eyes are. * * @return the eye positions */ @@ -80,7 +83,7 @@ public List observerEyes() { } /** - * Returns the highest dread among the survivors. + * The highest dread among the survivors. * * @return the dread, {@code 0} without survivors */ @@ -92,7 +95,7 @@ public double highestDread() { * 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 + * @param distance the distance to keep, in blocks * @return {@code true} if every survivor is at least that far away */ public boolean farFromAll(Pos point, double distance) { 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 index e77fdc04..a873243f 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekState.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekState.java @@ -1,7 +1,7 @@ package net.onelitefeather.cygnus.creek.state; /** - * One phase of the creek's behavior, such as wandering or hunting. + * One phase of the creek's behaviour, such as patrolling, stalking or hunting. * * @author theEvilReaper * @version 1.0.0 @@ -10,7 +10,7 @@ public interface CreekState { /** - * Prepares the body for this state. Called once when the creek switches to it. + * Gets the body ready for this state. Called once, when the creek switches to it. * * @param ctx the current step */ @@ -20,7 +20,7 @@ public interface CreekState { * Runs one step. * * @param ctx the current step - * @return {@code this} to stay, or the next state + * @return {@code this} to carry on, or the state to switch to */ CreekState tick(CreekContext ctx); } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/DoneState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/DoneState.java new file mode 100644 index 00000000..9660644a --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/DoneState.java @@ -0,0 +1,35 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.onelitefeather.cygnus.creek.body.CreekBody; + +import java.util.Set; + +/** + * A variant whose stalk or hunt is over. It hides the creek and waits to be cleaned up. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class DoneState implements CreekState { + + /** There is nothing to remember in this state, so one instance serves every variant. */ + public static final DoneState INSTANCE = new DoneState(); + + private DoneState() { + } + + @Override + public void enter(CreekContext ctx) { + CreekBody body = ctx.body(); + body.stop(); + body.setAggressive(false); + body.setFrozen(false); + body.showTo(Set.of()); + } + + @Override + public CreekState tick(CreekContext ctx) { + return this; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java index 6db634df..b4a4ab7a 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java @@ -10,9 +10,10 @@ import java.util.UUID; /** - * The creek chases one survivor, but only moves while that survivor is not looking at it. + * The creek chases one survivor, but only moves while they are not looking at it. *

    - * When the survivor looks, the creek freezes. When it gets close enough, the survivor is caught. + * Look at it and it freezes. Let it get close enough and you are caught. The hunt is over after a + * catch, when time runs out or when the survivor is gone. *

    * * @author theEvilReaper @@ -21,13 +22,13 @@ */ public final class HuntState implements CreekState { - /** Minimum distance to the target after a shortcut, in blocks. */ + /** How close to the survivor the creek lands after a shortcut, at the least, in blocks. */ static final double NEAR_MIN = 6.0D; - /** Maximum distance to the target after a shortcut, in blocks. */ + /** How close to the survivor the creek lands after a shortcut, at the most, in blocks. */ static final double NEAR_MAX = 10.0D; - /** How much closer the creek has to get to count as progress, in blocks. */ + /** How much closer the creek has to get to count as getting anywhere, in blocks. */ static final double PROGRESS = 0.5D; private final UUID target; @@ -36,10 +37,10 @@ public final class HuntState implements CreekState { private long progressSince; /** - * Creates the state. + * Sets up the hunt. * - * @param target the hunted survivor - * @param endsAt when the hunt ends, in milliseconds + * @param target the survivor being hunted + * @param endsAt when the hunt is over, in milliseconds */ public HuntState(UUID target, long endsAt) { this.target = target; @@ -47,7 +48,7 @@ public HuntState(UUID target, long endsAt) { } /** - * Starts a hunt that ends after {@code huntMaxSeconds}. + * Starts a hunt that lasts {@code huntMaxSeconds}. * * @param target the survivor to hunt * @param ctx the current step @@ -58,7 +59,7 @@ static HuntState starting(UUID target, CreekContext ctx) { } /** - * Returns the hunted survivor. + * Tells who is being hunted. * * @return the survivor's id */ @@ -66,6 +67,15 @@ public UUID target() { return this.target; } + /** + * Tells when the hunt is over. + * + * @return the time in milliseconds + */ + public long endsAt() { + return this.endsAt; + } + @Override public void enter(CreekContext ctx) { CreekBody body = ctx.body(); @@ -78,7 +88,7 @@ public void enter(CreekContext ctx) { @Override public CreekState tick(CreekContext ctx) { Optional found = ctx.survivor(this.target); - if (found.isEmpty() || ctx.now() >= this.endsAt) return VanishState.after(ctx); + if (found.isEmpty() || ctx.now() >= this.endsAt) return DoneState.INSTANCE; SurvivorView view = found.get(); CreekConfig config = ctx.config(); @@ -95,7 +105,7 @@ public CreekState tick(CreekContext ctx) { double distance = body.position().distance(view.position()); if (distance <= config.catchDistance()) { ctx.onCatch().accept(this.target); - return VanishState.after(ctx); + return DoneState.INSTANCE; } if (distance < this.bestDistance - PROGRESS) { @@ -112,7 +122,7 @@ public CreekState tick(CreekContext ctx) { } /** - * Teleports the creek near the target, out of its view, when walking there does not work. + * When walking there gets the creek nowhere, it jumps close to the survivor instead, out of their view. */ private void shortcut(CreekContext ctx, SurvivorView view) { ctx.spots() diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java new file mode 100644 index 00000000..86ae075e --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java @@ -0,0 +1,243 @@ +package net.onelitefeather.cygnus.creek.state; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.body.CreekBody; +import net.onelitefeather.cygnus.creek.world.RouteStep; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +/** + * The creek on its rounds: it walks its routes, and every survivor can see it. + *

    + * Come within {@link #SELECT_RADIUS} of it and it picks out whoever is closest, stares at them for a + * second and then freezes them or sends them away. After that it simply walks on and leaves + * everyone alone for {@link #SELECT_COOLDOWN_MILLIS}. Spot it from farther away and it stops and + * looks back at you for a moment. At some waypoints it rests for a while, either because the route + * says so or just by chance; whichever takes longer wins. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class PatrolState implements CreekState { + + /** How close a survivor has to come before the creek picks them out, in blocks. */ + static final double SELECT_RADIUS = 4.0D; + + /** How long it stares at the chosen survivor before anything happens, in milliseconds. */ + static final long STARE_MILLIS = 1000L; + + /** How long it leaves everyone alone after picking someone out, in milliseconds. */ + static final long SELECT_COOLDOWN_MILLIS = 10_000L; + + /** How close counts as having reached a waypoint, in blocks. */ + static final double ARRIVED = 2.0D; + + /** How far it has to get to count as moving at all, in blocks. */ + static final double PROGRESS = 0.5D; + + private @Nullable Pos goal; + private boolean watched; + private long pausedUntil; + private int goalPauseMillis; + private long restingUntil; + private Pos progressAt = Pos.ZERO; + private long progressSince; + private Set shownTo = Set.of(); + private @Nullable UUID staring; + private long stareUntil; + private long selectAllowedAt; + + /** + * Tells who the creek is staring at right now. + * + * @return the survivor's id, or empty while it stares at nobody + */ + public Optional staring() { + return Optional.ofNullable(this.staring); + } + + /** + * Tells when the creek may pick someone out again. + * + * @return the time in milliseconds + */ + public long selectAllowedAt() { + return this.selectAllowedAt; + } + + @Override + public void enter(CreekContext ctx) { + CreekBody body = ctx.body(); + body.setAggressive(false); + body.setFrozen(false); + this.showToSurvivors(ctx); + this.markProgress(ctx.now(), body.position()); + } + + @Override + public CreekState tick(CreekContext ctx) { + CreekConfig config = ctx.config(); + CreekBody body = ctx.body(); + Pos here = body.position(); + this.showToSurvivors(ctx); + + if (this.stare(ctx)) return this; + if (ctx.now() >= this.selectAllowedAt && this.select(ctx, here)) return this; + + Optional watcher = ctx.survivors().stream().filter(SurvivorView::seesCreek).findFirst(); + if (watcher.isEmpty()) { + this.watched = false; + } else if (this.pause(ctx, watcher.get())) { + return this; + } + + if (ctx.now() < this.restingUntil) { + body.stop(); + return this; + } + if (this.hasArrived(here)) { + long rest = this.restMillis(ctx); + this.goal = null; + if (rest > 0) { + this.restingUntil = ctx.now() + rest; + body.stop(); + return this; + } + } + + if (this.needsNewGoal(ctx, here)) { + Optional step = ctx.route().next(here, _ -> true, ctx.random()); + this.goal = step.map(RouteStep::target).orElse(null); + this.goalPauseMillis = step.map(RouteStep::pauseMillis).orElse(0); + this.markProgress(ctx.now(), here); + } + if (this.goal == null) { + body.stop(); + return this; + } + body.moveTo(this.goal, config.wanderSpeed()); + return this; + } + + /** + * Keeps staring at the chosen survivor. Once the second is up, the consequence hits them. + * + * @return {@code true} while the stare lasts + */ + private boolean stare(CreekContext ctx) { + UUID target = this.staring; + if (target == null) return false; + Optional view = ctx.survivor(target); + if (view.isEmpty()) { + // They left before the stare was over: nothing happens, and no cooldown starts. + this.staring = null; + return false; + } + CreekBody body = ctx.body(); + if (ctx.now() < this.stareUntil) { + body.stop(); + body.lookAt(view.get().eyes()); + return true; + } + this.staring = null; + this.selectAllowedAt = ctx.now() + SELECT_COOLDOWN_MILLIS; + // They are most likely still looking at it. Walk on rather than stop again to look back. + this.watched = true; + this.markProgress(ctx.now(), body.position()); + ctx.onSelect().accept(target); + return false; + } + + /** + * Picks out the closest survivor within {@link #SELECT_RADIUS} and starts staring at them. + * + * @return {@code true} if someone was picked out + */ + private boolean select(CreekContext ctx, Pos here) { + SurvivorView nearest = null; + double best = SELECT_RADIUS; + for (SurvivorView view : ctx.survivors()) { + double distance = view.position().distance(here); + if (distance <= best) { + best = distance; + nearest = view; + } + } + if (nearest == null) return false; + this.staring = nearest.id(); + this.stareUntil = ctx.now() + STARE_MILLIS; + ctx.body().stop(); + ctx.body().lookAt(nearest.eyes()); + this.markProgress(ctx.now(), here); + return true; + } + + /** + * Stops and looks back for a moment when someone spots the creek from afar. + * + * @return {@code true} while it is looking back + */ + private boolean pause(CreekContext ctx, SurvivorView watcher) { + if (!this.watched) { + this.watched = true; + this.pausedUntil = ctx.now() + ctx.config().wanderPauseMillis(); + } + if (ctx.now() >= this.pausedUntil) return false; + + ctx.body().stop(); + ctx.body().lookAt(watcher.eyes()); + // Keep heading for the same point. Dropping it made the route hand out the next one, so the + // creek skipped points or turned around. Standing still on purpose is not being stuck. + this.markProgress(ctx.now(), ctx.body().position()); + return true; + } + + private boolean hasArrived(Pos here) { + return this.goal != null && here.distance(this.goal) < ARRIVED; + } + + /** + * How long to rest at the point just reached: the route's pause, or a random stop if that + * is longer. + */ + private long restMillis(CreekContext ctx) { + CreekConfig config = ctx.config(); + int rest = this.goalPauseMillis; + if (config.randomStopChance() > 0.0D && ctx.random().nextDouble() < config.randomStopChance()) { + int spread = config.randomStopMaxMillis() - config.randomStopMinMillis(); + rest = Math.max(rest, config.randomStopMinMillis() + ctx.random().nextInt(spread + 1)); + } + return rest; + } + + private boolean needsNewGoal(CreekContext ctx, Pos here) { + if (this.goal == null || here.distance(this.goal) < ARRIVED) return true; + if (here.distance(this.progressAt) >= PROGRESS) { + this.markProgress(ctx.now(), here); + return false; + } + return ctx.now() - this.progressSince >= ctx.config().stuckMillis(); + } + + /** + * Shows the creek to exactly the current survivors. Anyone who stops being one (dead, or now + * the slender) loses sight of it right away. + */ + private void showToSurvivors(CreekContext ctx) { + Set survivors = ctx.survivorIds(); + if (survivors.equals(this.shownTo)) return; + this.shownTo = survivors; + ctx.body().showTo(survivors); + } + + private void markProgress(long now, Pos here) { + this.progressAt = here; + this.progressSince = now; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java index 16ea776a..5fada895 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java @@ -10,9 +10,12 @@ import java.util.UUID; /** - * The creek follows one survivor from a distance. Only that survivor can see it. + * The creek shadows one survivor from a distance, and only that survivor can see it. *

    - * When the survivor looks at the creek, it waits a moment and then teleports out of view. + * Look at it and it holds your gaze for a moment, then it is gone and turns up somewhere else, + * just out of view. The longer the stalk lasts, the closer it creeps: it starts at + * {@code stalkMinDistance} to {@code stalkMaxDistance} away and ends at {@link #END_MIN_DISTANCE} + * to {@link #END_MAX_DISTANCE}. The stalk is over when time runs out or the survivor is gone. *

    * * @author theEvilReaper @@ -21,15 +24,22 @@ */ public final class StalkState implements CreekState { + /** How close the creek gets by the end of a stalk, in blocks. */ + static final double END_MIN_DISTANCE = 8.0D; + + /** How far away it may still be at the end of a stalk, in blocks. */ + static final double END_MAX_DISTANCE = 15.0D; + private final UUID target; private final long endsAt; + private long startedAt; private long seenSince = -1L; /** - * Creates the state. + * Sets up the stalk. * - * @param target the stalked survivor - * @param endsAt when the stalk ends, in milliseconds + * @param target the survivor being stalked + * @param endsAt when the stalk is over, in milliseconds */ public StalkState(UUID target, long endsAt) { this.target = target; @@ -37,7 +47,8 @@ public StalkState(UUID target, long endsAt) { } /** - * Starts a stalk with a random length between {@code stalkMinSeconds} and {@code stalkMaxSeconds}. + * Starts a stalk that lasts somewhere between {@code stalkMinSeconds} and + * {@code stalkMaxSeconds}. * * @param target the survivor to stalk * @param ctx the current step @@ -50,7 +61,7 @@ static StalkState starting(UUID target, CreekContext ctx) { } /** - * Returns the stalked survivor. + * Tells who is being stalked. * * @return the survivor's id */ @@ -58,8 +69,18 @@ public UUID target() { return this.target; } + /** + * Tells when the stalk is over. + * + * @return the time in milliseconds + */ + public long endsAt() { + return this.endsAt; + } + @Override public void enter(CreekContext ctx) { + this.startedAt = ctx.now(); CreekBody body = ctx.body(); body.stop(); body.setAggressive(false); @@ -70,7 +91,7 @@ public void enter(CreekContext ctx) { @Override public CreekState tick(CreekContext ctx) { Optional found = ctx.survivor(this.target); - if (found.isEmpty() || ctx.now() >= this.endsAt) return VanishState.after(ctx); + if (found.isEmpty() || ctx.now() >= this.endsAt) return DoneState.INSTANCE; SurvivorView view = found.get(); CreekConfig config = ctx.config(); @@ -81,7 +102,7 @@ public CreekState tick(CreekContext ctx) { if (view.seesCreek()) { if (this.seenSince < 0) this.seenSince = ctx.now(); - if (ctx.now() - this.seenSince >= config.stalkRevealMillis() && relocate(ctx, view)) { + if (ctx.now() - this.seenSince >= config.stalkRevealMillis() && this.relocate(ctx, view)) { this.seenSince = -1L; } return this; @@ -89,23 +110,44 @@ public CreekState tick(CreekContext ctx) { this.seenSince = -1L; double distance = body.position().distance(view.position()); - if (distance < config.stalkMinDistance() || distance > config.stalkMaxDistance()) { - relocate(ctx, view); + if (distance < this.minDistance(ctx) || distance > this.maxDistance(ctx)) { + this.relocate(ctx, view); } return this; } /** - * Teleports the creek to a new spot at the edge of the target's view. + * Moves the creek to a new spot just outside the survivor's view. * - * @return {@code false} if no free spot was found in this step + * @return {@code false} if there was no free spot in this step */ - static boolean relocate(CreekContext ctx, SurvivorView view) { + private 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()); + double min = this.minDistance(ctx); + // The band's own minimum keeps the creek off the survivor. The fixed personal space would + // find no spot at all once the band comes closer than that. + Optional spot = ctx.spots().beside(view.position(), min, this.maxDistance(ctx), + config.stalkMinAngle(), config.stalkMaxAngle(), List.of(view.eyes()), min, ctx.random()); spot.ifPresent(ctx.body()::teleport); return spot.isPresent(); } + + /** + * How far into the stalk it is: 0 at the start, 1 at the end. + */ + private double progress(long now) { + long length = this.endsAt - this.startedAt; + if (length <= 0) return 1.0D; + return Math.clamp((double) (now - this.startedAt) / length, 0.0D, 1.0D); + } + + private double minDistance(CreekContext ctx) { + double start = ctx.config().stalkMinDistance(); + return start + (END_MIN_DISTANCE - start) * this.progress(ctx.now()); + } + + private double maxDistance(CreekContext ctx) { + double start = ctx.config().stalkMaxDistance(); + return start + (END_MAX_DISTANCE - start) * this.progress(ctx.now()); + } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java index f963612c..be6724b3 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java @@ -8,22 +8,23 @@ * 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 + * @param position where the survivor's feet are, including where they are looking + * @param dread how likely the survivor is to be haunted, from 0 to 1 + * @param seesCreek whether the survivor can see the creek right now + * * @author theEvilReaper * @version 1.0.0 * @since 2.15.0 */ public record SurvivorView(UUID id, Pos position, double dread, boolean seesCreek) { - /** Eye height of a standing player, in blocks. */ + /** How high a standing player's eyes are, in blocks. */ public static final double EYE_HEIGHT = 1.62D; /** - * Returns the survivor's eye position. + * Where the survivor's eyes are. * - * @return the eye position, with the survivor's view direction + * @return the eye position, looking where the survivor looks */ 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 index 90f06a1d..e0412d9d 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 @@ -9,7 +9,8 @@ import java.util.Set; /** - * The creek is invisible for a while. Afterwards it reappears somewhere nobody is looking. + * The creek is gone for a while: at the start of a round, or for good once it sits out the rest + * of it. When it comes back, it turns up somewhere nobody is looking. * * @author theEvilReaper * @version 1.0.0 @@ -20,7 +21,7 @@ public final class VanishState implements CreekState { private final long until; /** - * Creates a vanish that ends at a fixed time. + * Sets up a vanish that ends at a fixed time. * * @param until when the creek may come back, in milliseconds */ @@ -29,17 +30,7 @@ public VanishState(long 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. + * A vanish that never ends, for when the creek sits out the rest of the round. * * @return the state */ @@ -48,7 +39,7 @@ public static VanishState forever() { } /** - * Returns whether this vanish never ends. + * Tells whether this vanish never ends. * * @return {@code true} for {@link #forever()} */ @@ -57,14 +48,14 @@ public boolean isForever() { } /** - * Calculates how long the creek stays away: {@code vanishMaxSeconds} at no dread, - * {@code vanishMinSeconds} at full dread. + * How long a breather lasts: {@code vanishMaxSeconds} for a calm survivor, down to + * {@code vanishMinSeconds} for one who is scared to death. Used for the pause after a variant. * * @param config the settings - * @param dread the highest dread among the survivors - * @return the cooldown in milliseconds + * @param dread the survivor's dread + * @return the breather in milliseconds */ - static long cooldownMillis(CreekConfig config, double dread) { + public 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); @@ -86,7 +77,7 @@ public CreekState tick(CreekContext ctx) { List observers = ctx.observerEyes(); double distance = ctx.config().respawnMinDistance(); // Any point of the route will do. Asking the route for its next point would only offer the - // neighbours of where the creek vanished, which is often right next to a survivor. + // neighbours of where the creek disappeared, and that is often right next to a survivor. List spots = ctx.route().points().stream() .map(point -> ctx.spots().hiddenSpotAt(point, observers, distance)) .flatMap(Optional::stream) @@ -94,6 +85,6 @@ public CreekState tick(CreekContext ctx) { if (spots.isEmpty()) return this; ctx.body().teleport(spots.get(ctx.random().nextInt(spots.size()))); - return new WanderState(ctx.now()); + return new PatrolState(); } } 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 index 9c08b467..6d635664 100644 --- 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 @@ -1,5 +1,5 @@ /** - * The creek's behavior, split into states: wandering, stalking, hunting and vanishing. + * How the creek behaves, one state at a time: patrolling, stalking, hunting, vanishing and done. */ @NotNullByDefault package net.onelitefeather.cygnus.creek.state; From 58d192cf6da5c74bb99185531373c55acc27c574 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sat, 26 Sep 2026 22:57:29 +0200 Subject: [PATCH 67/92] chore(creek): improve path documentation --- .../cygnus/creek/world/CreekPaths.java | 28 +++++++-------- .../cygnus/creek/world/CreekSight.java | 28 +++++++-------- .../cygnus/creek/world/Ground.java | 2 +- .../cygnus/creek/world/InstanceGround.java | 4 +-- .../cygnus/creek/world/PathRoute.java | 18 +++++----- .../cygnus/creek/world/RouteProvider.java | 10 +++--- .../cygnus/creek/world/RouteStep.java | 5 +-- .../cygnus/creek/world/SpotFinder.java | 36 +++++++++---------- 8 files changed, 66 insertions(+), 65 deletions(-) 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 index 0c2f5ced..23772788 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java @@ -11,9 +11,9 @@ import java.util.Map; /** - * The creek routes of a map, with the links between their ends worked out once. + * The creek routes of a map, with the links between their ends worked out once up front. *

    - * Two ends are linked when they are at most the link distance apart. The two ends of one route + * Two ends are linked when they are at most the link distance apart. The two ends of the same route * never link to each other. *

    * @@ -27,7 +27,7 @@ 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 + * @param atStart {@code true} for its first point, {@code false} for its last */ public record End(int route, boolean atStart) { } @@ -43,7 +43,7 @@ private CreekPaths(List routes, Map> links) { } /** - * Builds the paths and works out the links. + * Takes the routes of a map and works out which ends are linked. * * @param routes the valid routes of the map * @param linkDistance how close two ends have to be to count as linked, in blocks @@ -70,7 +70,7 @@ public static CreekPaths of(List routes, double linkDistance) { } /** - * Returns whether there are no routes. + * Tells whether the map has no routes at all. * * @return {@code true} without routes */ @@ -79,7 +79,7 @@ public boolean isEmpty() { } /** - * Returns the number of routes. + * How many routes there are. * * @return the number of routes */ @@ -88,7 +88,7 @@ public int size() { } /** - * Returns a route. + * One of the routes. * * @param index the route's index * @return the route @@ -98,7 +98,7 @@ public CreekRoute route(int index) { } /** - * Returns how many points a route has. + * How many points a route has. * * @param route the route's index * @return the number of points @@ -108,7 +108,7 @@ public int pointCount(int route) { } /** - * Returns one point of a route. + * One point of a route. * * @param route the route's index * @param index the point's index @@ -119,7 +119,7 @@ public Pos point(int route, int index) { } /** - * Returns how long the creek waits at one point of a route. + * How long the creek rests at one point of a route. * * @param route the route's index * @param index the point's index @@ -130,17 +130,17 @@ public int pauseMillis(int route, int index) { } /** - * Returns where an end of a route is. + * Where an end of a route is. * * @param end the end - * @return the first or last point of the route + * @return the route's first or last point */ public Pos position(End end) { return position(this.routes, end); } /** - * Returns the ends linked to an end, in a fixed order. + * The ends linked to an end, always in the same order. * * @param end the end * @return the linked ends, empty if there are none @@ -150,7 +150,7 @@ public List links(End end) { } /** - * Returns every point of every route. + * Every point of every route. * * @return the points */ 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 index 8250c803..2bdb24db 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekSight.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekSight.java @@ -8,11 +8,11 @@ import net.onelitefeather.cygnus.gaze.SlenderGaze; /** - * Checks whether a survivor sees the creek. + * Works out whether a survivor can see 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. + * It uses the same view cone as {@link SlenderGaze}: close enough, and not too far off to the side + * of where the survivor is looking. On top of that it checks the line of sight, because the creek + * is usually far away and often behind trees. *

    * * @author theEvilReaper @@ -24,21 +24,21 @@ public final class CreekSight { private final SlenderGaze cone; /** - * Creates a sight check with the given cone. + * Sets up the 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 + * @param range how far away the creek can still be noticed, in blocks + * @param viewAngle how far off to the side it can still be noticed, in degrees from the view centre */ public CreekSight(int range, int viewAngle) { - // SlenderGaze needs a close distance below its range. Only "inside the cone or not" - // matters here, and zero keeps the full range. + // SlenderGaze wants a close distance below its range. All that matters here is "inside + // the cone or not", and zero keeps the whole range. this.cone = new SlenderGaze(range, 0, viewAngle); } /** - * Checks whether a point is inside an observer's view cone. Blocks in between are ignored. + * Checks whether a point is inside someone's view cone. Blocks in between do not matter here. * - * @param observer the observer's eye position, including yaw and pitch + * @param observer where the observer's eyes are, including where they are looking * @param target the point to check * @return {@code true} if the point is inside the cone */ @@ -47,12 +47,12 @@ public boolean inView(Pos observer, Point target) { } /** - * Checks whether a player sees the creek: it is inside the view cone and no block is in - * the way. + * Checks whether a player can see the creek: it is inside their view cone and no block is in the + * way. * * @param observer the player * @param creek the creek's entity - * @return {@code true} if the player sees the creek + * @return {@code true} if the player can see the creek */ public boolean sees(Player observer, Entity creek) { Instance instance = observer.getInstance(); 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 index 26ee82b3..6ad23409 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/Ground.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/Ground.java @@ -5,7 +5,7 @@ import java.util.Optional; /** - * Moves a candidate spot onto a floor the creek can stand on. + * Puts a candidate spot onto a floor the creek can stand on. * * @author theEvilReaper * @version 1.0.0 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 index 40e3af2b..82cf457f 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/InstanceGround.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/InstanceGround.java @@ -22,13 +22,13 @@ public final class InstanceGround implements Ground { /** 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. */ + /** How many free blocks the creek needs above its feet. A creaking is almost three blocks tall. */ static final int HEADROOM = 3; private final Supplier instance; /** - * Creates the floor search. + * Sets up the floor search. * * @param instance supplies the round's instance, or {@code null} while there is none */ 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 index fecc42a3..c4d31a07 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java @@ -12,9 +12,9 @@ * 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. Each step carries the pause - * of its point, except for an end the creek walks away from. + * between turning around and any route linked to that end. If the creek ends up far from its last + * point, after a teleport for example, it joins back in at the nearest point. Each step brings the + * pause of its point along, except for an end the creek is just leaving. *

    * * @author theEvilReaper @@ -23,14 +23,14 @@ */ public final class PathRoute implements RouteProvider { - /** Farther than this from its last waypoint, the creek was moved and rejoins, in blocks. */ + /** Farther than this from its last waypoint, the creek must have been moved and joins back in, in blocks. */ static final double REJOIN_DISTANCE = 8.0D; private final CreekPaths paths; private @Nullable Cursor cursor; /** - * Creates the route walker. + * Sets up the route walker. * * @param paths the routes of the map */ @@ -82,7 +82,7 @@ private Optional rejoin(Pos current, Predicate allowed, RandomGe if (best == null) return Optional.empty(); int lastIndex = this.paths.pointCount(best.route()) - 1; - // at an end there is only one way into the route + // At an end there is only one way into the route. int direction = best.index() == 0 ? 1 : best.index() == lastIndex ? -1 : (random.nextBoolean() ? 1 : -1); return this.moveTo(new Cursor(best.route(), best.index(), direction)); } @@ -92,7 +92,7 @@ private Cursor advance(Cursor from, RandomGenerator random) { if (nextIndex >= 0 && nextIndex < this.paths.pointCount(from.route())) { return new Cursor(from.route(), nextIndex, from.direction()); } - // walking backwards ends at the start, walking forwards at the end + // Walking backwards ends at the start, walking forwards at the end. List links = this.paths.links(new CreekPaths.End(from.route(), from.direction() < 0)); int choice = random.nextInt(links.size() + 1); if (choice == links.size()) { @@ -118,7 +118,7 @@ private Optional moveTo(Cursor next) { } /** - * An end the creek walks away from is where it starts, not where it stops. + * An end the creek walks away from is where it sets off, not where it rests. */ private int pauseAt(Cursor cursor) { int lastIndex = this.paths.pointCount(cursor.route()) - 1; @@ -136,7 +136,7 @@ private Pos point(Cursor cursor) { * Where the creek is on the routes. * * @param route the route's index - * @param index the index of the waypoint handed out last + * @param index the index of the waypoint it was sent to 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/RouteProvider.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteProvider.java index d9f6bf82..c830cbd3 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 @@ -23,17 +23,17 @@ public interface RouteProvider { /** * Picks the next point. * - * @param current the creek's position - * @param allowed filters the points the creek may go to + * @param current where the creek is + * @param allowed which points the creek may go to * @param random the random source - * @return the next point with its pause, or empty if none is allowed + * @return the next point and its pause, 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. + * Every point this route can hand out. Used to find a place to appear or come back. * - * @return the points, possibly empty + * @return the points, possibly none */ default List points() { return List.of(); diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java index 3d5cc308..5fdb2d84 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java @@ -3,10 +3,11 @@ import net.minestom.server.coordinate.Pos; /** - * The next waypoint of the creek and how long it waits once it gets there. + * The creek's next waypoint and how long it rests once it gets there. * * @param target where the creek walks to - * @param pauseMillis how long it waits after arriving, in milliseconds + * @param pauseMillis how long it rests after arriving, in milliseconds + * * @author theEvilReaper * @version 1.0.0 * @since 2.15.0 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 index 9084f55e..e94634e1 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/SpotFinder.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/SpotFinder.java @@ -18,17 +18,17 @@ 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. */ + /** How high the middle of the creek's body is. That is the part a survivor would notice. */ public static final double BODY_CENTRE = 1.4D; private final CreekSight sight; private final Ground ground; /** - * Creates the spot finder. + * Sets up the spot finder. * - * @param sight the view check a spot must pass - * @param ground moves candidates onto a floor + * @param sight the view check a spot has to pass + * @param ground puts candidates onto a floor */ public SpotFinder(CreekSight sight, Ground ground) { this.sight = sight; @@ -38,9 +38,9 @@ public SpotFinder(CreekSight sight, 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 + * @param spot where the creek's feet would be + * @param observerEyes the eyes of everyone who could see the creek + * @param personalSpace how far away from everyone the spot has to be * @return {@code true} if the spot is far enough away and outside every view cone */ public boolean isHidden(Pos spot, List observerEyes, double personalSpace) { @@ -53,11 +53,11 @@ public boolean isHidden(Pos spot, List observerEyes, double personalSpace) } /** - * Moves a candidate onto the floor and keeps it only if nobody would see the creek there. + * Puts a candidate onto the floor and keeps it only if nobody would see the creek there. * * @param candidate the spot to try - * @param observerEyes the eye positions of everyone who could see the creek - * @param personalSpace the minimum distance to every observer + * @param observerEyes the eyes of everyone who could see the creek + * @param personalSpace how far away from everyone the spot has to be * @return the hidden spot, or empty */ public Optional hiddenSpotAt(Pos candidate, List observerEyes, double personalSpace) { @@ -65,15 +65,15 @@ public Optional hiddenSpotAt(Pos candidate, List observerEyes, double } /** - * Finds a hidden spot to the side of a target's view. + * Finds a hidden spot off to the side of someone'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 target where the target's feet are; their yaw is where they look + * @param minDistance how close to the target the spot may be at the least + * @param maxDistance how far from the target the spot may be at the most + * @param minAngle how far off to the side of their view it has to be at the least, in degrees + * @param maxAngle how far off to the side of their view it may be at the most, in degrees + * @param observerEyes the eyes of everyone who could see the creek + * @param personalSpace how far away from everyone the spot has to be * @param random the random source * @return a hidden spot, or empty if no candidate worked */ From 5aec375fef03c7cf8ff57aac3b5b29176a426a67 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sat, 26 Sep 2026 23:00:27 +0200 Subject: [PATCH 68/92] chore(creek): update creek service and clock part --- .../onelitefeather/cygnus/creek/Creek.java | 48 ++++- .../cygnus/creek/CreekService.java | 75 +++++-- .../cygnus/creek/CreekVariants.java | 204 ++++++++++++++++++ .../cygnus/creek/RoundClock.java | 16 +- 4 files changed, 306 insertions(+), 37 deletions(-) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.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 index 99bd1d77..18af880e 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java @@ -5,6 +5,7 @@ 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.consequence.SelectionConsequence; import net.onelitefeather.cygnus.creek.dread.DreadSource; import net.onelitefeather.cygnus.creek.state.CreekContext; import net.onelitefeather.cygnus.creek.state.CreekState; @@ -19,12 +20,16 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; 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. + * One creek in the round: its body, what it is doing right now, and the step that moves it on. + *

    + * The patrolling creek is one of these, and so is every variant. Each step it takes a snapshot of + * the survivors and lets its current state decide what happens next. + *

    * * @author theEvilReaper * @version 1.0.0 @@ -38,6 +43,7 @@ final class Creek { private final RouteProvider route; private final SpotFinder spots; private final CatchConsequence consequence; + private final SelectionConsequence selection; private final CreekConfig config; private final RandomGenerator random; private CreekState state; @@ -45,13 +51,15 @@ final class Creek { private boolean entered; Creek(CreekBody body, CreekSight sight, DreadSource dread, RouteProvider route, SpotFinder spots, - CatchConsequence consequence, CreekConfig config, RandomGenerator random, CreekState initial) { + CatchConsequence consequence, SelectionConsequence selection, 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.selection = selection; this.config = config; this.random = random; this.state = initial; @@ -64,23 +72,45 @@ final class Creek { * @param now the current time in milliseconds */ void tick(Collection survivors, long now) { + this.tick(survivors, Set.of(), now); + } + + /** + * Runs one step, leaving some survivors out of it. The patrolling creek uses this for everyone + * who is haunted by a variant: they do not see it, and it does not pick them out. + *

    + * The snapshots in {@link #lastViews()} still cover every survivor. + *

    + * + * @param survivors the survivors of the round + * @param ignored the survivors this creek leaves alone in this step + * @param now the current time in milliseconds + */ + void tick(Collection survivors, Set ignored, 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, + List noticed = ignored.isEmpty() ? this.lastViews + : this.lastViews.stream().filter(view -> !ignored.contains(view.id())).toList(); + CreekContext ctx = new CreekContext(now, noticed, this.body, this.route, this.spots, id -> { Player caught = players.get(id); if (caught != null) this.consequence.apply(caught); }, + id -> { + Player selected = players.get(id); + if (selected != null) this.selection.apply(selected, survivors); + }, this.config, this.random); if (!this.entered) { this.state.enter(ctx); this.entered = true; } - if (!this.config.activeWithLastSurvivor() && ctx.survivors().size() <= 1) { + // Count every survivor here: leaving the haunted ones out is not being down to the last one. + if (!this.config.activeWithLastSurvivor() && this.lastViews.size() <= 1) { if (!(this.state instanceof VanishState vanish && vanish.isForever())) { this.switchTo(VanishState.forever(), ctx); } @@ -92,10 +122,10 @@ void tick(Collection survivors, long now) { } /** - * Creates a snapshot of every survivor for the states. + * Takes 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. + * A survivor only counts as seeing the creek if it is shown to them at all. A variant is hidden + * from everyone but its target, so the others must not be able to scare it off. *

    * * @param survivors the survivors of the round @@ -117,7 +147,7 @@ List views(Collection survivors) { } /** - * Returns the survivor snapshots from the last step. + * The survivor snapshots from the last step. * * @return the views, empty before the first step */ 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 8e35cc72..17ba264f 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java @@ -1,5 +1,6 @@ package net.onelitefeather.cygnus.creek; +import net.kyori.adventure.text.Component; import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Player; import net.minestom.server.event.Event; @@ -9,9 +10,11 @@ import net.onelitefeather.cygnus.common.creek.CreekRoute; import net.onelitefeather.cygnus.creek.body.CreekBody; import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; +import net.onelitefeather.cygnus.creek.consequence.SelectionConsequence; import net.onelitefeather.cygnus.creek.debug.CreekDebug; import net.onelitefeather.cygnus.creek.dread.DreadSource; import net.onelitefeather.cygnus.creek.state.CreekState; +import net.onelitefeather.cygnus.creek.state.SurvivorView; import net.onelitefeather.cygnus.creek.state.VanishState; import net.onelitefeather.cygnus.creek.world.CreekPaths; import net.onelitefeather.cygnus.creek.world.CreekSight; @@ -32,15 +35,16 @@ import java.util.Set; import java.util.UUID; import java.util.function.BiFunction; +import java.util.function.Function; import java.util.function.Supplier; import java.util.random.RandomGenerator; /** - * Adds the creek to a round and removes it at the end. + * Brings the creek into a round and takes it out again at the end. *

    - * Like {@code SlenderGazeService}, it listens for the start and end of a round 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. + * Like {@code SlenderGazeService}, it listens for the start and end of a round on its own. It does + * not need to hear about deaths or disconnects: those players are simply missing from the + * survivors on the next step, so a variant whose target is gone ends within 100 ms anyway. *

    * * @author theEvilReaper @@ -51,7 +55,7 @@ public final class CreekService { private static final Logger LOGGER = LoggerFactory.getLogger(CreekService.class); - /** Time between two steps, in milliseconds. */ + /** How long one step lasts, in milliseconds. */ static final int TICK_MILLIS = 100; private final CreekConfig config; @@ -68,18 +72,20 @@ public final class CreekService { private final SpotFinder spots; private final Supplier> routes; private volatile @Nullable RouteProvider route; + private volatile @Nullable SelectionConsequence selection; private final RepeatingTask task = new RepeatingTask(this::tick); private volatile @Nullable Creek creek; + private volatile @Nullable CreekVariants variants; /** - * Creates the service. + * Sets up the service. * * @param config the settings * @param survivors supplies the survivors of the round * @param instance supplies the instance of the round, or {@code null} while there is none - * @param routes supplies the creek routes of the current map; without one the creek stays away - * @param bodies spawns the creek's body - * @param dread rates the survivors + * @param routes supplies the creek routes of the current map; without any, the creek stays away + * @param bodies puts a creek body into the world + * @param dread rates how scared each survivor is * @param consequence what happens on a catch * @param clock the round's clock * @param random the random source @@ -106,9 +112,9 @@ public CreekService(CreekConfig config, Supplier> survivors, } /** - * Registers the listeners for the start and end of a round. + * Listens for the start and end of a round. * - * @param node the node to register on + * @param node the node to listen on */ public void registerListener(EventNode node) { node.addListener(GameStartEvent.class, _ -> this.start()); @@ -116,7 +122,7 @@ public void registerListener(EventNode node) { } /** - * Spawns the creek (invisible at first) and starts the step task. + * Puts the creek into the world, hidden at first, and starts stepping it. */ void start() { if (this.creek != null) return; @@ -129,31 +135,50 @@ void start() { } PathRoute pathRoute = new PathRoute(paths); this.route = pathRoute; + SelectionConsequence selection = new SelectionConsequence(pathRoute::points, this.ground, this.random); + this.selection = selection; List points = pathRoute.points(); 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. + // Start hidden. The creek only shows up once the survivors have had time to spread out, + // somewhere 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, pathRoute, this.spots, this.consequence, + this.creek = new Creek(body, this.sight, this.dread, pathRoute, this.spots, this.consequence, selection, this.config, this.random, initial); + long variantsFrom = this.clock.now() + this.config.vanishMinSeconds() * 1000L; + this.variants = new CreekVariants(this.config, this.spots, this.random, variantsFrom, + (spot, state) -> new Creek(this.bodies.apply(world, spot), this.sight, this.dread, pathRoute, + this.spots, this.consequence, selection, this.config, this.random, state)); this.debug.setActive(true); this.task.start(TICK_MILLIS, ChronoUnit.MILLIS); } /** - * Runs one step of the creek. + * Runs one step: first the patrolling creek, then the variants. Survivors with a variant do not + * see the patrolling creek, and it does not pick them out. */ void tick() { Creek current = this.creek; if (current == null) return; Set players = this.survivors.get(); - current.tick(players, this.clock.now()); + long now = this.clock.now(); + CreekVariants currentVariants = this.variants; + // A haunted survivor already has a creek of their own, so the patrolling one leaves them be. + Set haunted = currentVariants == null ? Set.of() : currentVariants.running().keySet(); + current.tick(players, haunted, now); + if (currentVariants != null) currentVariants.tick(players, current.lastViews(), now); if (this.debug.hasWatchers()) { - this.debug.show(CreekDebug.line(current.state(), current.body().position(), current.lastViews(), - id -> nameOf(players, id), describeRoute())); + Function names = id -> nameOf(players, id); + List views = current.lastViews(); + Component line = CreekDebug.line(current.state(), current.body().position(), views, names, + describeRoute(), now); + if (currentVariants != null) { + List states = currentVariants.running().values().stream().map(Creek::state).toList(); + line = line.append(CreekDebug.variants(states, CreekVariants.capacity(views.size()), names, now)); + } + this.debug.show(line); } } @@ -165,16 +190,22 @@ private static String nameOf(Set players, UUID id) { } /** - * Removes the creek and clears any remaining catch effects. + * Takes the creek and every variant out of the world and clears anything still running on the survivors. */ void stop() { this.task.stop(); + CreekVariants currentVariants = this.variants; + this.variants = null; + if (currentVariants != null) currentVariants.stop(); Creek current = this.creek; this.creek = null; this.route = null; this.clock.reset(); if (current != null) current.remove(); this.consequence.cleanUp(); + SelectionConsequence currentSelection = this.selection; + this.selection = null; + if (currentSelection != null) currentSelection.cleanUp(); this.debug.setActive(false); } @@ -182,6 +213,10 @@ void stop() { return this.creek; } + @Nullable CreekVariants variants() { + return this.variants; + } + private String describeRoute() { RouteProvider current = this.route; return current == null ? "" : current.describe(); diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java new file mode 100644 index 00000000..6c0d1000 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java @@ -0,0 +1,204 @@ +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.state.CreekState; +import net.onelitefeather.cygnus.creek.state.DoneState; +import net.onelitefeather.cygnus.creek.state.StalkState; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import net.onelitefeather.cygnus.creek.state.VanishState; +import net.onelitefeather.cygnus.creek.world.SpotFinder; + +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.BiFunction; +import java.util.function.Predicate; +import java.util.random.RandomGenerator; + +/** + * The creek's variants: a personal creek for each haunted survivor, one that nobody else sees. + *

    + * The survivor with the highest dread gets the next one. It starts by stalking them and is gone + * once the stalk or the hunt is over. The more survivors there are, the more of them can be + * haunted at once, see {@link #capacity(int)}. After a variant, the survivor gets a breather before + * the next one. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +final class CreekVariants { + + /** One variant for every this many survivors. */ + static final int SURVIVORS_PER_VARIANT = 4; + + /** Never more variants than this at once, however big the round. */ + static final int MAX_VARIANTS = 3; + + private final CreekConfig config; + private final SpotFinder spots; + private final RandomGenerator random; + private final long allowedAt; + private final BiFunction spawner; + private final Map running; + private final Map cooldowns; + + /** + * Sets up the variants for a round. + * + * @param config the settings + * @param spots finds a spot next to a survivor, out of their view + * @param random the random source + * @param allowedAt the earliest time a variant may show up, in milliseconds + * @param spawner creates a variant's creek at a spot, starting in the given state + */ + CreekVariants(CreekConfig config, SpotFinder spots, RandomGenerator random, long allowedAt, + BiFunction spawner) { + this.config = config; + this.spots = spots; + this.random = random; + this.allowedAt = allowedAt; + this.spawner = spawner; + this.running = new LinkedHashMap<>(); + this.cooldowns = new HashMap<>(); + } + + /** + * How many survivors can be haunted at once: one per {@link #SURVIVORS_PER_VARIANT} + * survivors, rounded up, and never more than {@link #MAX_VARIANTS}. + * + * @param survivors the number of survivors still alive + * @return how many variants may run at once + */ + static int capacity(int survivors) { + return Math.min(MAX_VARIANTS, (survivors + SURVIVORS_PER_VARIANT - 1) / SURVIVORS_PER_VARIANT); + } + + /** + * Picks who to haunt next: the survivor with the highest dread, as long as it reaches the + * threshold. If two are equally scared, the one who strayed farthest from the others. + * + * @param views every survivor + * @param threshold the lowest dread that counts + * @param eligible whether a survivor may get a variant right now + * @return the survivor, or empty if nobody fits + */ + static Optional pick(List views, double threshold, Predicate eligible) { + return views.stream() + .filter(view -> view.dread() >= threshold && eligible.test(view.id())) + .max(Comparator.comparingDouble(SurvivorView::dread) + .thenComparingDouble(view -> nearestOther(views, view))); + } + + /** + * Runs one step: lets every variant act, clears away the finished ones and starts new ones + * while there is room. + * + * @param survivors the survivors of the round + * @param views a snapshot of every survivor, for their dread and position + * @param now the current time in milliseconds + */ + void tick(Collection survivors, List views, long now) { + if (!this.config.activeWithLastSurvivor() && views.size() <= 1) { + this.stop(); + return; + } + this.tickRunning(survivors, views, now); + if (now < this.allowedAt) return; + // Someone with no free spot next to them should not hold up everyone behind them. + Set noSpot = new HashSet<>(); + int free = capacity(views.size()) - this.running.size(); + while (free > 0) { + Optional target = pick(views, this.config.stalkThreshold(), + id -> this.isFree(id, now) && !noSpot.contains(id)); + if (target.isEmpty()) return; + if (this.start(target.get(), now)) { + free--; + } else { + noSpot.add(target.get().id()); + } + } + } + + /** + * Tells which survivors are haunted right now, and by which creek. + * + * @return a copy, keyed by the haunted survivor + */ + Map running() { + return Map.copyOf(this.running); + } + + /** + * Removes every variant and forgets every breather. + */ + void stop() { + for (Creek variant : this.running.values()) { + variant.remove(); + } + this.running.clear(); + this.cooldowns.clear(); + } + + private void tickRunning(Collection survivors, List views, long now) { + Iterator> iterator = this.running.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + Creek variant = entry.getValue(); + variant.tick(survivors, now); + if (!(variant.state() instanceof DoneState)) continue; + variant.remove(); + iterator.remove(); + long cooldown = VanishState.cooldownMillis(this.config, dreadOf(views, entry.getKey())); + this.cooldowns.put(entry.getKey(), now + cooldown); + } + } + + private boolean isFree(UUID id, long now) { + return !this.running.containsKey(id) && this.cooldowns.getOrDefault(id, Long.MIN_VALUE) <= now; + } + + /** + * Places a new variant next to the survivor, out of their view. + * + * @return {@code false} if there was no free spot next to them in this step + */ + private boolean start(SurvivorView view, long now) { + Optional spot = this.spots.beside(view.position(), config.stalkMinDistance(), config.stalkMaxDistance(), + config.stalkMinAngle(), config.stalkMaxAngle(), List.of(view.eyes()), config.personalSpace(), + this.random); + if (spot.isEmpty()) return false; + + long seconds = this.random.nextLong(config.stalkMinSeconds(), config.stalkMaxSeconds() + 1L); + StalkState stalk = new StalkState(view.id(), now + seconds * 1000L); + this.running.put(view.id(), this.spawner.apply(spot.get(), stalk)); + return true; + } + + private static double dreadOf(List views, UUID id) { + for (SurvivorView view : views) { + if (view.id().equals(id)) return view.dread(); + } + return 0.0D; + } + + private static double nearestOther(List views, SurvivorView view) { + double nearest = Double.MAX_VALUE; + for (SurvivorView other : views) { + if (other.id().equals(view.id())) continue; + nearest = Math.min(nearest, other.position().distance(view.position())); + } + return nearest; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/RoundClock.java b/game/src/main/java/net/onelitefeather/cygnus/creek/RoundClock.java index b89e4db9..c3a5fe91 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/RoundClock.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/RoundClock.java @@ -3,10 +3,10 @@ import java.util.function.LongSupplier; /** - * Tracks how long the current round has been running. + * Keeps track of how long the current round has been going. *

    - * The service starts and resets it, and the dread rating reads it. Sharing one clock avoids a - * dependency between the two. + * The service starts and resets it, and the dread rating reads it. Sharing one clock saves the two + * from depending on each other. *

    * * @author theEvilReaper @@ -19,7 +19,7 @@ public final class RoundClock { private volatile long startedAt = -1L; /** - * Creates the clock. + * Sets up the clock. * * @param clock supplies the current time in milliseconds */ @@ -28,7 +28,7 @@ public RoundClock(LongSupplier clock) { } /** - * Returns the current time. + * The current time. * * @return the time in milliseconds */ @@ -44,16 +44,16 @@ public void start() { } /** - * Resets the clock when the round ends. + * Resets the clock when the round is over. */ public void reset() { this.startedAt = -1L; } /** - * Returns how long the round has been running. + * How long the round has been going. * - * @return the elapsed time in milliseconds, {@code 0} outside a round + * @return the time in milliseconds, {@code 0} outside a round */ public long elapsedMillis() { long start = this.startedAt; From 002b10da360349f0d3e04f194ad0bbea7ee0d66d Mon Sep 17 00:00:00 2001 From: Joltras Date: Sat, 26 Sep 2026 23:00:49 +0200 Subject: [PATCH 69/92] test(creek): update test cases --- .../cygnus/creek/CreekIntegrationTest.java | 90 ++++++- .../creek/CreekServiceIntegrationTest.java | 108 ++++++++ .../creek/CreekVariantsIntegrationTest.java | 247 ++++++++++++++++++ .../cygnus/creek/CreekVariantsTest.java | 72 +++++ .../SelectionConsequenceIntegrationTest.java | 105 ++++++++ .../cygnus/creek/debug/CreekDebugTest.java | 75 +++++- .../cygnus/creek/state/Contexts.java | 12 +- .../cygnus/creek/state/HuntStateTest.java | 5 +- ...derStateTest.java => PatrolStateTest.java} | 239 ++++++++++------- .../cygnus/creek/state/RecordingBody.java | 2 +- .../cygnus/creek/state/StalkStateTest.java | 54 +++- .../cygnus/creek/state/VanishStateTest.java | 4 +- 12 files changed, 899 insertions(+), 114 deletions(-) create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java rename game/src/test/java/net/onelitefeather/cygnus/creek/state/{WanderStateTest.java => PatrolStateTest.java} (55%) 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 801ccf8b..f6b9bf5f 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java @@ -3,18 +3,22 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Player; import net.minestom.server.instance.Instance; +import net.minestom.server.potion.PotionEffect; import net.minestom.testing.Env; import net.onelitefeather.cygnus.CygnusPlayerTestBase; import net.onelitefeather.cygnus.common.config.CreekConfig; import net.onelitefeather.cygnus.creek.body.CreakingBody; import net.onelitefeather.cygnus.creek.body.CreekBody; +import net.onelitefeather.cygnus.creek.consequence.SelectionConsequence; import net.onelitefeather.cygnus.creek.state.Contexts; +import net.onelitefeather.cygnus.creek.state.DoneState; +import net.onelitefeather.cygnus.creek.state.CreekContext; import net.onelitefeather.cygnus.creek.state.CreekState; import net.onelitefeather.cygnus.creek.state.HuntState; import net.onelitefeather.cygnus.creek.state.StalkState; import net.onelitefeather.cygnus.creek.state.SurvivorView; import net.onelitefeather.cygnus.creek.state.VanishState; -import net.onelitefeather.cygnus.creek.state.WanderState; +import net.onelitefeather.cygnus.creek.state.PatrolState; import net.onelitefeather.cygnus.creek.world.CreekSight; import net.onelitefeather.cygnus.creek.world.SpotFinder; import org.junit.jupiter.api.DisplayName; @@ -23,7 +27,9 @@ import java.util.List; import java.util.Optional; import java.util.Random; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -32,8 +38,10 @@ class CreekIntegrationTest extends CygnusPlayerTestBase { private static Creek creek(CreekBody body, CreekConfig config, CreekState initial) { CreekSight sight = new CreekSight(config.sightRange(), config.sightViewAngle()); + // No route points: a selection always ends in the stun. + SelectionConsequence selection = new SelectionConsequence(List::of, Optional::of, new Random(3)); return new Creek(body, sight, (_, _, _) -> 0.0D, Contexts.route(), new SpotFinder(sight, Optional::of), - _ -> {}, config, new Random(3), initial); + _ -> {}, selection, config, new Random(3), initial); } @Test @@ -44,7 +52,7 @@ void wanderingIsPublicButNotForTheSlender(Env env) { 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); + creek(body, CreekConfig.DEFAULT, new PatrolState()).tick(List.of(survivor), 0L); assertTrue(body.entity().getViewers().contains(survivor)); assertFalse(body.entity().getViewers().contains(slender)); @@ -56,7 +64,7 @@ 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 creek = creek(body, CreekConfig.DEFAULT, new PatrolState()); creek.tick(List.of(survivor), 0L); assertTrue(creek.views(List.of(survivor)).getFirst().seesCreek()); @@ -79,7 +87,7 @@ void hiddenFromNonTargets(Env env) { } @Test - @DisplayName("When the target leaves mid-hunt he vanishes") + @DisplayName("When the target leaves mid-hunt he is done") void targetLeavesMidHunt(Env env) { Instance instance = env.createFlatInstance(); Player target = env.createConnection().connect(instance, new Pos(0, 40, 0, 180, 0)); @@ -90,7 +98,7 @@ void targetLeavesMidHunt(Env env) { creek.tick(List.of(other), 100L); - assertInstanceOf(VanishState.class, creek.state()); + assertInstanceOf(DoneState.class, creek.state()); assertTrue(body.entity().getViewers().isEmpty()); } @@ -100,7 +108,7 @@ 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 creek = creek(body, withoutLastSurvivor(), new PatrolState()); creek.tick(List.of(survivor), 0L); @@ -108,6 +116,74 @@ void dormantWithTheLastSurvivor(Env env) { assertTrue(((VanishState) creek.state()).isForever()); } + @Test + @DisplayName("A state selecting a survivor hands them to the selection consequence") + void selectionReachesTheConsequence(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); + CreekState selecting = new CreekState() { + @Override + public void enter(CreekContext ctx) { + } + + @Override + public CreekState tick(CreekContext ctx) { + ctx.onSelect().accept(survivor.getUuid()); + return this; + } + }; + + creek(body, CreekConfig.DEFAULT, selecting).tick(List.of(survivor), 0L); + + assertTrue(survivor.hasEffect(PotionEffect.SLOWNESS)); + } + + @Test + @DisplayName("A haunted survivor does not see the patrolling creek, everyone else does") + void patrolIsHiddenFromTheHaunted(Env env) { + Instance instance = env.createFlatInstance(); + Player haunted = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0)); + Player other = env.createConnection().connect(instance, new Pos(5, 40, 0, 0, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); + + creek(body, CreekConfig.DEFAULT, new PatrolState()) + .tick(List.of(haunted, other), Set.of(haunted.getUuid()), 0L); + + assertFalse(body.isVisibleTo(haunted.getUuid())); + assertTrue(body.isVisibleTo(other.getUuid())); + } + + @Test + @DisplayName("The patrolling creek never picks out a haunted survivor") + void patrolLeavesTheHauntedAlone(Env env) { + Instance instance = env.createFlatInstance(); + Player haunted = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0)); + Player other = env.createConnection().connect(instance, new Pos(0, 40, 60, 0, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 2)); + Creek creek = creek(body, CreekConfig.DEFAULT, new PatrolState()); + + creek.tick(List.of(haunted, other), Set.of(haunted.getUuid()), 0L); + creek.tick(List.of(haunted, other), Set.of(haunted.getUuid()), 1000L); + + assertFalse(haunted.hasEffect(PotionEffect.SLOWNESS)); + assertEquals(2, creek.lastViews().size(), "the snapshots still cover every survivor"); + } + + @Test + @DisplayName("Hiding him from a haunted survivor does not count as being down to the last one") + void hidingIsNotTheLastSurvivor(Env env) { + Instance instance = env.createFlatInstance(); + Player haunted = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player other = env.createConnection().connect(instance, new Pos(5, 40, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 30)); + Creek creek = creek(body, withoutLastSurvivor(), new PatrolState()); + + creek.tick(List.of(haunted, other), Set.of(haunted.getUuid()), 0L); + + assertInstanceOf(PatrolState.class, creek.state()); + } + private static CreekConfig withoutLastSurvivor() { CreekConfig d = CreekConfig.DEFAULT; return new CreekConfig( 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 9f5addc5..56a62ce3 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java @@ -5,6 +5,7 @@ import net.minestom.server.entity.Player; import net.minestom.server.instance.Instance; import net.minestom.server.network.packet.server.play.ActionBarPacket; +import net.minestom.server.potion.PotionEffect; import net.minestom.testing.Collector; import net.minestom.testing.Env; import net.minestom.testing.TestConnection; @@ -14,6 +15,7 @@ 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.PatrolState; import net.onelitefeather.cygnus.creek.state.VanishState; import net.onelitefeather.cygnus.creek.world.PathRoute; import org.junit.jupiter.api.DisplayName; @@ -155,4 +157,110 @@ void routesAreUsedWhenThereAreAny(Env env) { assertNotNull(service.creek()); service.stop(); } + + @Test + @DisplayName("After the start delay he patrols and a scared survivor gets a variant") + void patrolsAndStartsAVariant(Env env) { + Instance instance = env.createFlatInstance(); + // Far from the route points, so the vanish finds a hidden spot to come back at. + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0)); + Player other = env.createConnection().connect(instance, new Pos(0, 40, -160, 0, 0)); + AtomicLong clock = new AtomicLong(); + CreekService service = new CreekService(CreekConfig.DEFAULT, () -> Set.of(survivor, other), () -> instance, + () -> List.of(ROUTE), CreakingBody::spawn, + (id, _, _) -> id.equals(survivor.getUuid()) ? 0.5D : 0.0D, consequence, + new RoundClock(clock::get), new Random(3), new CreekDebug()); + service.start(); + + clock.set(CreekConfig.DEFAULT.vanishMaxSeconds() * 1000L + 1L); + service.tick(); + service.tick(); + + Creek creek = service.creek(); + assertNotNull(creek); + assertInstanceOf(PatrolState.class, creek.state()); + CreekVariants variants = service.variants(); + assertNotNull(variants); + assertTrue(variants.running().containsKey(survivor.getUuid())); + service.stop(); + } + + @Test + @DisplayName("At the end every variant is removed and every consequence is cleaned up") + void stopRemovesVariantsAndCleansUp(Env env) { + Instance instance = env.createFlatInstance(); + // Far from the route points, so the vanish finds a hidden spot to come back at. + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0)); + Player other = env.createConnection().connect(instance, new Pos(0, 40, -160, 0, 0)); + AtomicLong clock = new AtomicLong(); + CreekService service = new CreekService(CreekConfig.DEFAULT, () -> Set.of(survivor, other), () -> instance, + () -> List.of(ROUTE), CreakingBody::spawn, + (id, _, _) -> id.equals(survivor.getUuid()) ? 0.5D : 0.0D, consequence, + new RoundClock(clock::get), new Random(3), new CreekDebug()); + service.start(); + clock.set(CreekConfig.DEFAULT.vanishMaxSeconds() * 1000L + 1L); + service.tick(); + service.tick(); + CreekVariants variants = service.variants(); + assertNotNull(variants); + Creek variant = variants.running().get(survivor.getUuid()); + assertNotNull(variant); + + service.stop(); + + assertTrue(variant.body().entity().isRemoved()); + assertNull(service.variants()); + assertEquals(1, cleanUps.get()); + } + + @Test + @DisplayName("At the end no stun from the patrol is left on a survivor") + void stopClearsTheSelectionEffects(Env env) { + Instance instance = env.createFlatInstance(); + // far from every route point: the teleport has no target, so a selection always stuns + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0)); + AtomicLong clock = new AtomicLong(); + CreekService service = service(instance, Set.of(survivor), List.of(ROUTE), clock); + service.start(); + clock.set(CreekConfig.DEFAULT.vanishMaxSeconds() * 1000L + 1L); + service.tick(); + Creek creek = service.creek(); + assertNotNull(creek); + assertInstanceOf(PatrolState.class, creek.state()); + + creek.body().teleport(new Pos(0, 40, -58)); + service.tick(); + clock.addAndGet(1000L); + service.tick(); + assertTrue(survivor.hasEffect(PotionEffect.SLOWNESS), "the patrol stunned the survivor"); + + service.stop(); + + assertFalse(survivor.hasEffect(PotionEffect.SLOWNESS)); + } + + @Test + @DisplayName("Once a survivor has a variant, the patrolling creek is hidden from them") + void patrolIsHiddenFromTheHaunted(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0)); + Player other = env.createConnection().connect(instance, new Pos(0, 40, -160, 0, 0)); + AtomicLong clock = new AtomicLong(); + CreekService service = new CreekService(CreekConfig.DEFAULT, () -> Set.of(survivor, other), () -> instance, + () -> List.of(ROUTE), CreakingBody::spawn, + (id, _, _) -> id.equals(survivor.getUuid()) ? 0.5D : 0.0D, consequence, + new RoundClock(clock::get), new Random(3), new CreekDebug()); + service.start(); + clock.set(CreekConfig.DEFAULT.vanishMaxSeconds() * 1000L + 1L); + service.tick(); + Creek creek = service.creek(); + assertNotNull(creek); + assertTrue(creek.body().isVisibleTo(survivor.getUuid()), "before the variant everyone sees the patrol"); + + service.tick(); + + assertFalse(creek.body().isVisibleTo(survivor.getUuid())); + assertTrue(creek.body().isVisibleTo(other.getUuid())); + service.stop(); + } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java new file mode 100644 index 00000000..c8dcc1e0 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java @@ -0,0 +1,247 @@ +package net.onelitefeather.cygnus.creek; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.body.CreakingBody; +import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; +import net.onelitefeather.cygnus.creek.consequence.SelectionConsequence; +import net.onelitefeather.cygnus.creek.state.Contexts; +import net.onelitefeather.cygnus.creek.state.CreekState; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import net.onelitefeather.cygnus.creek.world.CreekSight; +import net.onelitefeather.cygnus.creek.world.Ground; +import net.onelitefeather.cygnus.creek.world.SpotFinder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiFunction; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekVariantsIntegrationTest extends CygnusPlayerTestBase { + + private static final CreekConfig CONFIG = CreekConfig.DEFAULT; + private static final CreekSight SIGHT = new CreekSight(CONFIG.sightRange(), CONFIG.sightViewAngle()); + private static final CatchConsequence NO_CATCH = new CatchConsequence() { + @Override + public void apply(Player survivor) { + } + + @Override + public void cleanUp() { + } + }; + private static final SelectionConsequence SELECTION = new SelectionConsequence(List::of, Optional::of, new Random(1)); + + private static BiFunction spawner(Instance instance, CreekConfig config, SpotFinder spots) { + return (spot, initial) -> new Creek(CreakingBody.spawn(instance, spot), SIGHT, (_, _, _) -> 0.0D, + Contexts.route(), spots, NO_CATCH, SELECTION, config, new Random(3), initial); + } + + private static CreekVariants variants(Instance instance, CreekConfig config, Ground ground, long allowedAt) { + SpotFinder spots = new SpotFinder(SIGHT, ground); + return new CreekVariants(config, spots, new Random(5), allowedAt, spawner(instance, config, spots)); + } + + private static SurvivorView view(Player player, double dread) { + return new SurvivorView(player.getUuid(), player.getPosition(), dread, false); + } + + private static Player join(Env env, Instance instance, double x) { + return env.createConnection().connect(instance, new Pos(x, 40, 0, 0, 0)); + } + + @Test + @DisplayName("A variant starts for the survivor with the highest dread and only they see it") + void startsForTheHighestDread(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + Player second = join(env, instance, 10); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); + List survivors = List.of(first, second); + List views = List.of(view(first, 0.5D), view(second, 0.3D)); + + variants.tick(survivors, views, 0L); + variants.tick(survivors, views, 100L); + + assertEquals(1, variants.running().size(), "two survivors allow one variant"); + Creek variant = variants.running().get(first.getUuid()); + assertTrue(variant.body().isVisibleTo(first.getUuid())); + assertFalse(variant.body().isVisibleTo(second.getUuid())); + } + + @Test + @DisplayName("Five survivors allow two variants, one per survivor") + void twoVariantsForFiveSurvivors(Env env) { + Instance instance = env.createFlatInstance(); + List survivors = List.of(join(env, instance, 0), join(env, instance, 100), join(env, instance, 200), + join(env, instance, 300), join(env, instance, 400)); + List views = List.of(view(survivors.get(0), 0.5D), view(survivors.get(1), 0.4D), + view(survivors.get(2), 0.3D), view(survivors.get(3), 0.0D), view(survivors.get(4), 0.0D)); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); + + variants.tick(survivors, views, 0L); + variants.tick(survivors, views, 100L); + + assertEquals(2, variants.running().size()); + assertTrue(variants.running().containsKey(survivors.get(0).getUuid())); + assertTrue(variants.running().containsKey(survivors.get(1).getUuid())); + } + + @Test + @DisplayName("Nothing starts before the allowed time") + void waitsForTheAllowedTime(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 1000L); + + variants.tick(List.of(first), List.of(view(first, 0.9D)), 999L); + + assertTrue(variants.running().isEmpty()); + } + + @Test + @DisplayName("After a variant ends, its target has a cooldown") + void cooldownAfterTheEnd(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + Player second = join(env, instance, 10); + List survivors = List.of(first, second); + List views = List.of(view(first, 0.5D), view(second, 0.0D)); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); + variants.tick(survivors, views, 0L); + Creek variant = variants.running().get(first.getUuid()); + + // the stalk lasts at most stalkMaxSeconds (90 s) + variants.tick(survivors, views, 100_000L); + assertTrue(variants.running().isEmpty()); + assertTrue(variant.body().entity().isRemoved()); + + // dread 0.5 gives a 30 s cooldown with the default vanish times + variants.tick(survivors, views, 129_999L); + assertTrue(variants.running().isEmpty()); + + variants.tick(survivors, views, 130_000L); + assertEquals(1, variants.running().size()); + } + + @Test + @DisplayName("A variant ends when its target leaves") + void endsWhenTheTargetLeaves(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + Player second = join(env, instance, 10); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); + variants.tick(List.of(first, second), List.of(view(first, 0.5D), view(second, 0.0D)), 0L); + Creek variant = variants.running().get(first.getUuid()); + + variants.tick(List.of(second), List.of(view(second, 0.0D)), 100L); + + assertTrue(variants.running().isEmpty()); + assertTrue(variant.body().entity().isRemoved()); + } + + @Test + @DisplayName("A dropping capacity does not cut a running variant short") + void droppingCapacityKeepsRunningVariants(Env env) { + Instance instance = env.createFlatInstance(); + List survivors = List.of(join(env, instance, 0), join(env, instance, 100), join(env, instance, 200), + join(env, instance, 300), join(env, instance, 400)); + List views = List.of(view(survivors.get(0), 0.5D), view(survivors.get(1), 0.4D), + view(survivors.get(2), 0.3D), view(survivors.get(3), 0.0D), view(survivors.get(4), 0.0D)); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); + variants.tick(survivors, views, 0L); + + variants.tick(survivors.subList(0, 4), views.subList(0, 4), 100L); + + assertEquals(2, variants.running().size()); + } + + @Test + @DisplayName("Without a spot nothing starts, the next step tries again") + void retriesWithoutASpot(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + AtomicBoolean floor = new AtomicBoolean(false); + CreekVariants variants = variants(instance, CONFIG, + candidate -> floor.get() ? Optional.of(candidate) : Optional.empty(), 0L); + + variants.tick(List.of(first), List.of(view(first, 0.5D)), 0L); + assertTrue(variants.running().isEmpty()); + + floor.set(true); + variants.tick(List.of(first), List.of(view(first, 0.5D)), 100L); + assertEquals(1, variants.running().size()); + } + + @Test + @DisplayName("A survivor without a free spot does not block the next one in line") + void skipsASurvivorWithoutASpot(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + Player second = join(env, instance, 200); + // no floor near the first survivor, floor everywhere near the second + CreekVariants variants = variants(instance, CONFIG, + candidate -> candidate.x() > 100 ? Optional.of(candidate) : Optional.empty(), 0L); + + variants.tick(List.of(first, second), List.of(view(first, 0.5D), view(second, 0.3D)), 0L); + + assertEquals(List.of(second.getUuid()), List.copyOf(variants.running().keySet())); + } + + @Test + @DisplayName("With the last survivor and the setting off, every variant ends") + void endsWithTheLastSurvivor(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + Player second = join(env, instance, 10); + CreekConfig config = withoutLastSurvivor(); + CreekVariants variants = variants(instance, config, Optional::of, 0L); + variants.tick(List.of(first, second), List.of(view(first, 0.5D), view(second, 0.0D)), 0L); + Creek variant = variants.running().get(first.getUuid()); + + variants.tick(List.of(first), List.of(view(first, 0.5D)), 100L); + + assertTrue(variants.running().isEmpty()); + assertTrue(variant.body().entity().isRemoved()); + } + + @Test + @DisplayName("Stopping removes every variant") + void stopRemovesEveryVariant(Env env) { + Instance instance = env.createFlatInstance(); + Player first = join(env, instance, 0); + CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); + variants.tick(List.of(first), List.of(view(first, 0.5D)), 0L); + Creek variant = variants.running().get(first.getUuid()); + + variants.stop(); + + assertTrue(variants.running().isEmpty()); + assertTrue(variant.body().entity().isRemoved()); + } + + 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(), d.routeLinkDistance(), + d.randomStopChance(), d.randomStopMinMillis(), d.randomStopMaxMillis()); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java new file mode 100644 index 00000000..d1e9eba1 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java @@ -0,0 +1,72 @@ +package net.onelitefeather.cygnus.creek; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CreekVariantsTest { + + private static final UUID FIRST = UUID.randomUUID(); + private static final UUID SECOND = UUID.randomUUID(); + private static final UUID THIRD = UUID.randomUUID(); + + @Test + @DisplayName("One variant per four survivors, at most three") + void capacity() { + assertEquals(0, CreekVariants.capacity(0)); + assertEquals(1, CreekVariants.capacity(1)); + assertEquals(1, CreekVariants.capacity(4)); + assertEquals(2, CreekVariants.capacity(5)); + assertEquals(2, CreekVariants.capacity(8)); + assertEquals(3, CreekVariants.capacity(9)); + assertEquals(3, CreekVariants.capacity(12)); + assertEquals(3, CreekVariants.capacity(40)); + } + + @Test + @DisplayName("The survivor with the highest dread above the threshold is picked") + void picksTheHighestDread() { + List views = List.of( + new SurvivorView(FIRST, new Pos(0, 40, 0), 0.3D, false), + new SurvivorView(SECOND, new Pos(10, 40, 0), 0.5D, false), + new SurvivorView(THIRD, new Pos(20, 40, 0), 0.1D, false)); + + assertEquals(Optional.of(views.get(1)), CreekVariants.pick(views, 0.25D, _ -> true)); + } + + @Test + @DisplayName("Nobody above the threshold, nobody is picked") + void picksNobodyBelowTheThreshold() { + List views = List.of(new SurvivorView(FIRST, new Pos(0, 40, 0), 0.2D, false)); + + assertEquals(Optional.empty(), CreekVariants.pick(views, 0.25D, _ -> true)); + } + + @Test + @DisplayName("Survivors that are not eligible are skipped") + void skipsIneligibleSurvivors() { + List views = List.of( + new SurvivorView(FIRST, new Pos(0, 40, 0), 0.9D, false), + new SurvivorView(SECOND, new Pos(10, 40, 0), 0.5D, false)); + + assertEquals(Optional.of(views.get(1)), CreekVariants.pick(views, 0.25D, id -> !id.equals(FIRST))); + } + + @Test + @DisplayName("On a tie the one who strayed from the group is picked") + void prefersTheLonelyOne() { + List views = List.of( + new SurvivorView(FIRST, new Pos(0, 40, -60), 0.3D, false), + new SurvivorView(SECOND, new Pos(5, 40, -60), 0.3D, false), + new SurvivorView(THIRD, new Pos(0, 40, -160), 0.3D, false)); + + assertEquals(THIRD, CreekVariants.pick(views, 0.25D, _ -> true).orElseThrow().id()); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java new file mode 100644 index 00000000..00216fc8 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java @@ -0,0 +1,105 @@ +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 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.assertTrue; + +class SelectionConsequenceIntegrationTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A stun slows the selected survivor and does not count as a catch") + void stunSlowsTheSelected(Env env) { + Instance instance = env.createFlatInstance(); + Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); + SelectionConsequence selection = new SelectionConsequence(List::of, Optional::of, new Random(1)); + + selection.stun(selected, List.of(selected)); + + assertTrue(selected.hasEffect(PotionEffect.SLOWNESS)); + assertFalse(selected.hasEffect(PotionEffect.BLINDNESS)); + assertFalse(selected.hasTag(StagedCatchConsequence.CATCHES)); + } + + @Test + @DisplayName("A stun blinds other survivors nearby, nobody else") + void stunBlindsOtherSurvivorsNearby(Env env) { + Instance instance = env.createFlatInstance(); + Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); + Player far = env.createConnection().connect(instance, new Pos(20, 40, 0)); + Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0)); + SelectionConsequence selection = new SelectionConsequence(List::of, Optional::of, new Random(1)); + + selection.stun(selected, List.of(selected, near, far)); + + assertTrue(near.hasEffect(PotionEffect.BLINDNESS)); + assertFalse(far.hasEffect(PotionEffect.BLINDNESS)); + assertFalse(slender.hasEffect(PotionEffect.BLINDNESS), "only survivors go blind"); + assertFalse(selected.hasEffect(PotionEffect.BLINDNESS)); + } + + @Test + @DisplayName("A teleport lands on a route point 20 to 40 blocks away") + void teleportLandsInRange(Env env) { + Instance instance = env.createFlatInstance(); + Player selected = env.createConnection().connect(instance, new Pos(0.5, 40, 0.5)); + List points = List.of(new Pos(10.5, 40, 0.5), new Pos(30.5, 40, 0.5), new Pos(60.5, 40, 0.5)); + SelectionConsequence selection = new SelectionConsequence(() -> points, Optional::of, new Random(1)); + + assertTrue(selection.teleportAway(selected)); + env.tick(); + + assertTrue(selected.getPosition().sameBlock(new Pos(30.5, 40, 0.5)), "stands at " + selected.getPosition()); + } + + @Test + @DisplayName("Without a route point in range there is no teleport") + void noTeleportWithoutAPointInRange(Env env) { + Instance instance = env.createFlatInstance(); + Player selected = env.createConnection().connect(instance, new Pos(0.5, 40, 0.5)); + SelectionConsequence selection = new SelectionConsequence(() -> List.of(new Pos(5.5, 40, 0.5)), Optional::of, + new Random(1)); + + assertFalse(selection.teleportAway(selected)); + } + + @Test + @DisplayName("When the teleport finds no point, the stun is applied instead") + void fallsBackToTheStunWithoutAFittingPoint(Env env) { + Instance instance = env.createFlatInstance(); + Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); + // nextBoolean() is false for 0, so apply() tries the teleport first + SelectionConsequence selection = new SelectionConsequence(List::of, Optional::of, () -> 0L); + + selection.apply(selected, List.of(selected)); + + assertTrue(selected.hasEffect(PotionEffect.SLOWNESS)); + } + + @Test + @DisplayName("Cleaning up removes every slowness and blindness it applied") + void cleanUpRemovesTheEffects(Env env) { + Instance instance = env.createFlatInstance(); + Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); + SelectionConsequence selection = new SelectionConsequence(List::of, Optional::of, new Random(1)); + selection.stun(selected, List.of(selected, near)); + + selection.cleanUp(); + + assertFalse(selected.hasEffect(PotionEffect.SLOWNESS)); + assertFalse(near.hasEffect(PotionEffect.BLINDNESS)); + } +} 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 682c4884..0b377b95 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 @@ -3,16 +3,26 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.state.Contexts; +import net.onelitefeather.cygnus.creek.state.CreekContext; +import net.onelitefeather.cygnus.creek.state.CreekState; +import net.onelitefeather.cygnus.creek.state.DoneState; import net.onelitefeather.cygnus.creek.state.HuntState; import net.onelitefeather.cygnus.creek.state.StalkState; import net.onelitefeather.cygnus.creek.state.SurvivorView; import net.onelitefeather.cygnus.creek.state.VanishState; -import net.onelitefeather.cygnus.creek.state.WanderState; +import net.onelitefeather.cygnus.creek.state.PatrolState; +import net.onelitefeather.cygnus.creek.state.RecordingBody; +import net.onelitefeather.cygnus.creek.world.CreekSight; +import net.onelitefeather.cygnus.creek.world.SpotFinder; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Random; import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -42,13 +52,13 @@ void huntLine() { } @Test - @DisplayName("Wandering there is no target, only the dread of everyone") + @DisplayName("Patrolling 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); + Component line = CreekDebug.line(new PatrolState(), Pos.ZERO, views, NAMES::get); - assertEquals("WANDER · Steve 0.10", plain(line)); + assertEquals("PATROL · Steve 0.10", plain(line)); } @Test @@ -56,6 +66,8 @@ void wanderLine() { void labels() { assertEquals("STALK", CreekDebug.label(new StalkState(STEVE, 0L))); assertEquals("VANISH", CreekDebug.label(VanishState.forever())); + assertEquals("PATROL", CreekDebug.label(new PatrolState())); + assertEquals("DONE", CreekDebug.label(DoneState.INSTANCE)); } @Test @@ -74,9 +86,9 @@ void toggle() { 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 →"); + Component line = CreekDebug.line(new PatrolState(), Pos.ZERO, views, NAMES::get, "Waldweg Nord 3/7 →"); - assertEquals("WANDER · Waldweg Nord 3/7 → · Steve 0.10", plain(line)); + assertEquals("PATROL · Waldweg Nord 3/7 → · Steve 0.10", plain(line)); } @Test @@ -84,6 +96,55 @@ void routeIsShown() { 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, ""))); + assertEquals("PATROL · Steve 0.10", plain(CreekDebug.line(new PatrolState(), Pos.ZERO, views, NAMES::get, ""))); + } + + private static final Pos HERE = new Pos(0, 40, 0); + private static final SpotFinder SPOTS = new SpotFinder(new CreekSight(48, 35), Optional::of); + + @Test + @DisplayName("While staring the patrol names the selected survivor") + void patrolStareIsShown() { + PatrolState patrol = new PatrolState(); + List views = List.of(new SurvivorView(STEVE, new Pos(0, 40, 2), 0.1D, false)); + CreekContext ctx = new CreekContext(0L, views, new RecordingBody(HERE), Contexts.route(new Pos(30, 40, 0)), + SPOTS, _ -> {}, _ -> {}, CreekConfig.DEFAULT, new Random(1)); + patrol.enter(ctx); + patrol.tick(ctx); + + assertEquals("PATROL → Steve · 2.0 m · Steve 0.10", + plain(CreekDebug.line(patrol, HERE, views, NAMES::get, "", 0L))); + } + + @Test + @DisplayName("The variants show how many run and who they haunt") + void variantsSegment() { + List states = List.of(new StalkState(STEVE, 34_000L), new HuntState(ALEX, 12_000L)); + + assertEquals(" | variants 2/2 · Steve STALK 34s · Alex HUNT 12s", + plain(CreekDebug.variants(states, 2, NAMES::get, 0L))); + } + + @Test + @DisplayName("Without variants only the count is shown") + void noVariants() { + assertEquals(" | variants 0/1", plain(CreekDebug.variants(List.of(), 1, NAMES::get, 0L))); + } + + @Test + @DisplayName("During the selection cooldown the patrol shows the seconds left") + void selectCooldownIsShown() { + PatrolState patrol = new PatrolState(); + List near = List.of(new SurvivorView(STEVE, new Pos(0, 40, 2), 0.1D, false)); + RecordingBody body = new RecordingBody(HERE); + CreekContext start = new CreekContext(0L, near, body, Contexts.route(new Pos(30, 40, 0)), SPOTS, + _ -> {}, _ -> {}, CreekConfig.DEFAULT, new Random(1)); + patrol.enter(start); + patrol.tick(start); + patrol.tick(new CreekContext(1000L, near, body, Contexts.route(new Pos(30, 40, 0)), SPOTS, + _ -> {}, _ -> {}, CreekConfig.DEFAULT, new Random(1))); + + assertEquals("PATROL · select 7s · Steve 0.10", + plain(CreekDebug.line(patrol, HERE, near, NAMES::get, "", 4000L))); } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java index 905a380f..2e20b74b 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 @@ -26,12 +26,20 @@ public final class Contexts { 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)); + return new CreekContext(now, List.of(survivors), body, route, SPOTS, caught::add, _ -> {}, CONFIG, + new Random(7)); } static CreekContext context(long now, CreekBody body, RouteProvider route, CreekConfig config, SurvivorView... survivors) { - return new CreekContext(now, List.of(survivors), body, route, SPOTS, _ -> {}, config, new Random(7)); + return new CreekContext(now, List.of(survivors), body, route, SPOTS, _ -> {}, _ -> {}, config, new Random(7)); + } + + /** A context that records every survivor the state selects. */ + static CreekContext selecting(long now, CreekBody body, RouteProvider route, List selected, + SurvivorView... survivors) { + return new CreekContext(now, List.of(survivors), body, route, SPOTS, _ -> {}, selected::add, CONFIG, + new Random(7)); } /** The defaults with other random stops. */ 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 index 15235187..9c7ba153 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/HuntStateTest.java @@ -11,7 +11,6 @@ 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; @@ -71,7 +70,7 @@ void catchesTheTarget() { CreekState next = new HuntState(TARGET, 30_000L).tick(at(0L, body, caught, false)); assertEquals(List.of(TARGET), caught); - assertInstanceOf(VanishState.class, next); + assertSame(DoneState.INSTANCE, next); } @Test @@ -89,7 +88,7 @@ void noCatchWhileSeen() { @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))); + assertSame(DoneState.INSTANCE, new HuntState(TARGET, 30_000L).tick(at(30_000L, body, new ArrayList<>(), false))); } @Test diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/WanderStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java similarity index 55% rename from game/src/test/java/net/onelitefeather/cygnus/creek/state/WanderStateTest.java rename to game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java index 9aeb1e5a..54e20a9b 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/state/WanderStateTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java @@ -1,25 +1,29 @@ package net.onelitefeather.cygnus.creek.state; import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.common.creek.CreekRoute; +import net.onelitefeather.cygnus.creek.world.CreekPaths; +import net.onelitefeather.cygnus.creek.world.PathRoute; import net.onelitefeather.cygnus.creek.world.RouteProvider; import net.onelitefeather.cygnus.creek.world.RouteStep; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; -class WanderStateTest { +class PatrolStateTest { private static final UUID FIRST = UUID.randomUUID(); private static final UUID SECOND = UUID.randomUUID(); @@ -30,11 +34,17 @@ private static SurvivorView far(UUID id, double dread, boolean sees) { return new SurvivorView(id, new Pos(0, 40, -60, 0, 0), dread, sees); } + private static SurvivorView at(UUID id, double z) { + return new SurvivorView(id, new Pos(0, 40, z), 0.0D, false); + } + + // ---- walking, carried over from the wander behaviour ---- + @Test @DisplayName("Entering shows him to every survivor") void enterShowsHimToEveryone() { RecordingBody body = new RecordingBody(Pos.ZERO); - new WanderState(0L).enter(Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), + new PatrolState().enter(Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.0D, false), new SurvivorView(SECOND, new Pos(40, 40, 40), 0.0D, false))); assertEquals(Set.of(FIRST, SECOND), body.viewers); @@ -44,7 +54,7 @@ void enterShowsHimToEveryone() { @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); + PatrolState state = new PatrolState(); CreekContext ctx = Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.0D, false)); state.enter(ctx); @@ -54,10 +64,10 @@ void walksToTheNextPoint() { } @Test - @DisplayName("Spotted, he stops and looks back for a moment") + @DisplayName("Spotted from afar, 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); + PatrolState state = new PatrolState(); SurvivorView watcher = far(FIRST, 0.0D, true); state.enter(Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), watcher)); @@ -73,15 +83,27 @@ void pausesWhenSpotted() { } @Test - @DisplayName("A survivor walking up to him makes him vanish") - void vanishesWhenApproached() { + @DisplayName("A survivor close by does not make him vanish") + void doesNotVanishWhenApproached() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + CreekContext ctx = Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), at(FIRST, 10)); + state.enter(ctx); + + assertSame(state, state.tick(ctx)); + assertEquals(A, body.goal); + } + + @Test + @DisplayName("A high dread does not make him stalk") + void neverStalks() { 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)); + PatrolState state = new PatrolState(); + CreekContext ctx = Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.9D, false)); state.enter(ctx); - assertInstanceOf(VanishState.class, state.tick(ctx)); + assertSame(state, state.tick(ctx)); + assertTrue(body.teleports.isEmpty()); } @Test @@ -90,7 +112,7 @@ void picksAnotherPointWhenStuck() { RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); AtomicInteger calls = new AtomicInteger(); RouteProvider cycling = (_, _, _) -> Optional.of(new RouteStep(calls.getAndIncrement() % 2 == 0 ? A : B, 0)); - WanderState state = new WanderState(Long.MAX_VALUE); + PatrolState state = new PatrolState(); state.enter(Contexts.context(0L, body, cycling, new ArrayList<>(), far(FIRST, 0.0D, false))); state.tick(Contexts.context(0L, body, cycling, new ArrayList<>(), far(FIRST, 0.0D, false))); @@ -107,7 +129,7 @@ void picksAnotherPointWhenStuck() { @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); + PatrolState state = new PatrolState(); CreekContext ctx = Contexts.context(0L, body, Contexts.route(), new ArrayList<>(), far(FIRST, 0.0D, false)); state.enter(ctx); @@ -117,68 +139,16 @@ void standsStillWithoutAPoint() { } @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") + @DisplayName("Someone who leaves the survivors stops seeing him") void dropsViewersWhoLeaveTheSurvivors() { RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); - WanderState state = new WanderState(Long.MAX_VALUE); + PatrolState state = new PatrolState(); SurvivorView second = new SurvivorView(SECOND, new Pos(40, 40, 40), 0.0D, false); state.enter(Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.0D, false), second)); state.tick(Contexts.context(100L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.0D, false))); - assertEquals(Set.of(FIRST), body.viewers, "a survivor turned slender or spectator must lose sight of him"); + assertEquals(Set.of(FIRST), body.viewers); } /** Hands out A, then B, then A again, each with the given pause. */ @@ -187,10 +157,8 @@ private static RouteProvider alternating(int pauseMillis) { return (_, _, _) -> Optional.of(new RouteStep(calls.getAndIncrement() % 2 == 0 ? A : B, pauseMillis)); } - private static CreekContext at(long now, RecordingBody body, RouteProvider route, CreekConfig config, - SurvivorView... survivors) { - return Contexts.context(now, body, route, config, - survivors.length == 0 ? new SurvivorView[]{far(FIRST, 0.0D, false)} : survivors); + private static CreekContext at(long now, RecordingBody body, RouteProvider route, CreekConfig config) { + return Contexts.context(now, body, route, config, far(FIRST, 0.0D, false)); } @Test @@ -198,7 +166,7 @@ private static CreekContext at(long now, RecordingBody body, RouteProvider route void restsAtAPointWithAPause() { RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); RouteProvider route = alternating(2000); - WanderState state = new WanderState(Long.MAX_VALUE); + PatrolState state = new PatrolState(); state.enter(at(0L, body, route, Contexts.CONFIG)); state.tick(at(0L, body, route, Contexts.CONFIG)); assertEquals(A, body.goal); @@ -218,7 +186,7 @@ void restsAtAPointWithAPause() { void noRestWhenStuck() { RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); RouteProvider route = alternating(5000); - WanderState state = new WanderState(Long.MAX_VALUE); + PatrolState state = new PatrolState(); state.enter(at(0L, body, route, Contexts.CONFIG)); state.tick(at(0L, body, route, Contexts.CONFIG)); @@ -233,7 +201,7 @@ void randomStop() { CreekConfig config = Contexts.randomStops(1.0D, 1000, 1000); RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); RouteProvider route = alternating(0); - WanderState state = new WanderState(Long.MAX_VALUE); + PatrolState state = new PatrolState(); state.enter(at(0L, body, route, config)); state.tick(at(0L, body, route, config)); @@ -252,7 +220,7 @@ void longerHaltWins() { CreekConfig config = Contexts.randomStops(1.0D, 1000, 1000); RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); RouteProvider route = alternating(3000); - WanderState state = new WanderState(Long.MAX_VALUE); + PatrolState state = new PatrolState(); state.enter(at(0L, body, route, config)); state.tick(at(0L, body, route, config)); @@ -270,7 +238,7 @@ void longerHaltWins() { void noHaltWithoutChanceOrPause() { RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); RouteProvider route = alternating(0); - WanderState state = new WanderState(Long.MAX_VALUE); + PatrolState state = new PatrolState(); state.enter(at(0L, body, route, Contexts.CONFIG)); state.tick(at(0L, body, route, Contexts.CONFIG)); @@ -280,19 +248,114 @@ void noHaltWithoutChanceOrPause() { assertEquals(B, body.goal); } + // ---- selecting ---- + @Test - @DisplayName("Resting does not keep it from stalking") - void stalksWhileResting() { + @DisplayName("Within the radius he selects the nearest survivor and stares at them") + void selectsTheNearestWithinTheRadius() { RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); - RouteProvider route = alternating(5000); - WanderState state = new WanderState(1000L); - state.enter(at(0L, body, route, Contexts.CONFIG)); - state.tick(at(0L, body, route, Contexts.CONFIG)); - body.position = A; - state.tick(at(100L, body, route, Contexts.CONFIG)); + PatrolState state = new PatrolState(); + List selected = new ArrayList<>(); + SurvivorView first = at(FIRST, 3); + SurvivorView second = at(SECOND, 2); + state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, first, second)); + + state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, first, second)); + + assertEquals(Optional.of(SECOND), state.staring()); + assertEquals(second.eyes(), body.lookedAt); + assertNull(body.goal); + assertTrue(selected.isEmpty(), "the consequence waits for the end of the stare"); + } + + @Test + @DisplayName("Nobody within the radius, nobody is selected") + void selectsNobodyOutsideTheRadius() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + List selected = new ArrayList<>(); + state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, at(FIRST, 5))); + + state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, at(FIRST, 5))); + + assertEquals(Optional.empty(), state.staring()); + assertEquals(A, body.goal); + } - CreekState next = state.tick(at(1000L, body, route, Contexts.CONFIG, far(FIRST, 0.3D, false))); + @Test + @DisplayName("After staring for a second he applies the consequence and walks on") + void staresThenAppliesAndWalksOn() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + List selected = new ArrayList<>(); + state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2))); + state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2))); + + state.tick(Contexts.selecting(999L, body, Contexts.route(A), selected, at(SECOND, 2))); + assertTrue(selected.isEmpty()); + assertNull(body.goal); + + state.tick(Contexts.selecting(1000L, body, Contexts.route(A), selected, at(SECOND, 2))); + assertEquals(List.of(SECOND), selected); + assertEquals(A, body.goal); + assertEquals(Optional.empty(), state.staring()); + assertEquals(11_000L, state.selectAllowedAt()); + } + + @Test + @DisplayName("During the cooldown he selects nobody") + void selectsNobodyDuringTheCooldown() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + List selected = new ArrayList<>(); + state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2))); + state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2))); + state.tick(Contexts.selecting(1000L, body, Contexts.route(A), selected, at(SECOND, 2))); + + state.tick(Contexts.selecting(10_999L, body, Contexts.route(A), selected, at(SECOND, 2))); + assertEquals(Optional.empty(), state.staring()); + assertEquals(A, body.goal); + + state.tick(Contexts.selecting(11_000L, body, Contexts.route(A), selected, at(SECOND, 2))); + assertEquals(Optional.of(SECOND), state.staring()); + } + + @Test + @DisplayName("If the selected survivor leaves during the stare, nothing happens and no cooldown starts") + void dropsTheSelectionWhenTheSurvivorLeaves() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + List selected = new ArrayList<>(); + state.enter(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2), far(FIRST, 0.0D, false))); + state.tick(Contexts.selecting(0L, body, Contexts.route(A), selected, at(SECOND, 2), far(FIRST, 0.0D, false))); + + state.tick(Contexts.selecting(500L, body, Contexts.route(A), selected, far(FIRST, 0.0D, false))); + assertTrue(selected.isEmpty()); + assertEquals(Optional.empty(), state.staring()); + + state.tick(Contexts.selecting(600L, body, Contexts.route(A), selected, at(SECOND, 2), far(FIRST, 0.0D, false))); + assertEquals(Optional.of(SECOND), state.staring()); + } + + @Test + @DisplayName("After looking back he walks on to the point he was heading for") + void keepsHisPointAfterAPause() { + PathRoute route = new PathRoute(CreekPaths.of(List.of(CreekRoute.ofPositions("R", + List.of(new Vec(0, 40, 0), new Vec(20, 40, 0), new Vec(40, 40, 0)))), 3.0D)); + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + PatrolState state = new PatrolState(); + SurvivorView calm = far(FIRST, 0.0D, false); + state.enter(Contexts.context(0L, body, route, new ArrayList<>(), calm)); + state.tick(Contexts.context(0L, body, route, new ArrayList<>(), calm)); + state.tick(Contexts.context(100L, body, route, new ArrayList<>(), calm)); + assertEquals(new Pos(20, 40, 0), body.goal); + + body.position = new Pos(10, 40, 0); + SurvivorView watcher = far(FIRST, 0.0D, true); + state.tick(Contexts.context(200L, body, route, new ArrayList<>(), watcher)); + assertNull(body.goal, "he stops to look back"); - assertInstanceOf(StalkState.class, next); + state.tick(Contexts.context(1700L, body, route, new ArrayList<>(), watcher)); + assertEquals(new Pos(20, 40, 0), body.goal, "he goes on to the point he was heading for"); } } 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 index 1f2c9c4b..750308de 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/state/RecordingBody.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/RecordingBody.java @@ -26,7 +26,7 @@ public final class RecordingBody implements CreekBody { @Nullable Pos lookedAt; boolean removed; - RecordingBody(Pos position) { + public RecordingBody(Pos position) { this.position = position; } 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 index 34aa1ea2..b8a41356 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/state/StalkStateTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/StalkStateTest.java @@ -41,17 +41,17 @@ void enterShowsOnlyTheTarget() { } @Test - @DisplayName("Without the target he vanishes") + @DisplayName("Without the target he is done") void vanishesWithoutTheTarget() { RecordingBody body = new RecordingBody(IN_THE_BAND); - assertInstanceOf(VanishState.class, new StalkState(TARGET, 60_000L).tick(at(0L, body))); + assertSame(DoneState.INSTANCE, new StalkState(TARGET, 60_000L).tick(at(0L, body))); } @Test - @DisplayName("When the time is up he vanishes") + @DisplayName("When the time is up he is done") void vanishesWhenTheTimeIsUp() { RecordingBody body = new RecordingBody(IN_THE_BAND); - assertInstanceOf(VanishState.class, new StalkState(TARGET, 1000L).tick(at(1000L, body, target(0.3D, false)))); + assertSame(DoneState.INSTANCE, new StalkState(TARGET, 1000L).tick(at(1000L, body, target(0.3D, false)))); } @Test @@ -99,4 +99,50 @@ void staresInsideTheBand() { assertTrue(body.teleports.isEmpty()); assertEquals(target(0.3D, false).eyes(), body.lookedAt); } + + private static double distanceToTarget(RecordingBody body) { + return body.position.distance(target(0.3D, false).position()); + } + + @Test + @DisplayName("Halfway through the stalk the band has shrunk to about 14 to 25 blocks") + void bandShrinksByHalfway() { + RecordingBody body = new RecordingBody(new Pos(0, 40, -17)); + StalkState state = new StalkState(TARGET, 60_000L); + state.enter(at(0L, body, target(0.3D, false))); + + state.tick(at(30_000L, body, target(0.3D, false))); + + assertTrue(body.teleports.isEmpty(), "17 blocks is inside the band halfway through"); + } + + @Test + @DisplayName("Late in the stalk he closes in when he is too far away") + void closesInLate() { + RecordingBody body = new RecordingBody(new Pos(0, 40, -18)); + StalkState state = new StalkState(TARGET, 60_000L); + state.enter(at(0L, body, target(0.3D, false))); + + state.tick(at(59_000L, body, target(0.3D, false))); + + assertEquals(1, body.teleports.size()); + double distance = distanceToTarget(body); + assertTrue(distance >= 8.0D - 1.0E-6 && distance <= 15.5D, "distance was " + distance); + } + + @Test + @DisplayName("Seen late in the stalk he comes back closer") + void comesBackCloserWhenSeenLate() { + RecordingBody body = new RecordingBody(new Pos(0, 40, -12)); + StalkState state = new StalkState(TARGET, 60_000L); + state.enter(at(0L, body, target(0.3D, false))); + + state.tick(at(58_000L, body, target(0.3D, true))); + assertTrue(body.teleports.isEmpty(), "12 blocks is inside the late band"); + state.tick(at(58_700L, body, target(0.3D, true))); + + assertEquals(1, body.teleports.size()); + double distance = distanceToTarget(body); + assertTrue(distance >= 8.0D - 1.0E-6 && distance <= 15.5D, "distance was " + distance); + } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java index b268ad52..7d97adfe 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 @@ -62,7 +62,7 @@ void comesBackHidden() { CreekState next = state.tick(Contexts.context(1000L, body, Contexts.route(AHEAD, BEHIND), new ArrayList<>(), WATCHER)); - assertInstanceOf(WanderState.class, next); + assertInstanceOf(PatrolState.class, next); assertEquals(List.of(BEHIND), body.teleports); } @@ -104,7 +104,7 @@ void comesBackAnywhereOnTheRoute() { CreekState next = new VanishState(0L).tick(Contexts.context(0L, body, route, new ArrayList<>(), WATCHER)); - assertInstanceOf(WanderState.class, next); + assertInstanceOf(PatrolState.class, next); assertEquals(List.of(new Pos(0, 40, -40)), body.teleports); } } From 13fd7b128e23441ad364a752ecc862d201df89d3 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sat, 26 Sep 2026 23:01:08 +0200 Subject: [PATCH 70/92] docs: update documentation --- .../cygnus/common/dimension/MapAtmosphere.java | 3 ++- .../net/onelitefeather/cygnus/common/page/PageFactory.java | 2 +- .../net/onelitefeather/cygnus/common/util/ColorUtil.java | 5 +++-- .../onelitefeather/cygnus/noise/SlenderStaticService.java | 4 ++-- .../net/onelitefeather/cygnus/page/PageProximityService.java | 1 - 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/dimension/MapAtmosphere.java b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/MapAtmosphere.java index d8343cef..9209aa7f 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/dimension/MapAtmosphere.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/MapAtmosphere.java @@ -15,7 +15,8 @@ * for every one of them. *

    *

    - * The components are declared as {@link Color} rather than {@link RGBLike} on purpose. Gson cannot + * The components are declared as {@link Color} rather than + * {@link net.kyori.adventure.util.RGBLike RGBLike} on purpose. Gson cannot * deserialize an interface without being told which implementation to pick, and a map file that * fails to parse would take the whole service down with it. *

    diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFactory.java b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFactory.java index 107a82a1..7cbc95a0 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFactory.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageFactory.java @@ -7,7 +7,7 @@ /** * The factory should be used to create references from a {@link PageEntity}. * It's not possible to create a new instance from the entity class directly because the constructor of it is only visible to the package level. - * @see PageEntity#PageEntity(Instance, Pos, int) + * @see PageEntity#PageEntity(PageResource, int) * @author theEvilReaper * @since 1.0.0 * @version 1.0.0 diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java index 0d409333..df38817e 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java @@ -11,8 +11,9 @@ * good together, but picking colors as variations on one hue almost always * does. HSL lets us say "same hue, just a bit darker" or "same hue, more * washed out" instead of guessing at RGB numbers by hand. That's exactly what - * {@link SeededDimensionPreset} relies on to turn a single random seed into a - * fog/sky/light color set that actually feels like it belongs together. + * {@link net.onelitefeather.cygnus.common.dimension.SeedDimensionPreset SeedDimensionPreset} + * relies on to turn a single random seed into a fog/sky/light color set that + * actually feels like it belongs together. *

    * If you're not familiar with HSL: hue is the color itself (0 = red, 120 = * green, 240 = blue, and it loops back around at 360), saturation is how diff --git a/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java b/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java index 547e4909..230e37a0 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java @@ -24,8 +24,8 @@ * left, the way the static in Slender: The Eight Pages tightens with every page.

    * *

    Two things carry it. A carpet runs for as long as the round does and closes the gap between - * its bursts from {@link GameConfig#slenderStaticQuietInterval()} down to - * {@link GameConfig#slenderStaticFranticInterval()} as the pages disappear. On top of it, every + * its bursts from {@link GameConfig.SlenderStatic#quietInterval()} down to + * {@link GameConfig.SlenderStatic#franticInterval()} as the pages disappear. On top of it, every * single find lands as a burst of its own, so he knows a page went the moment it went instead of * on the next beat of the carpet.

    * diff --git a/game/src/main/java/net/onelitefeather/cygnus/page/PageProximityService.java b/game/src/main/java/net/onelitefeather/cygnus/page/PageProximityService.java index 2af9c028..4dbdf674 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/page/PageProximityService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/page/PageProximityService.java @@ -29,7 +29,6 @@ *
  • Phase 2 (20% < TTL ≤ 50%): Warning chime every 3 seconds (60 ticks) with standard pitch.
  • *
  • Phase 3 (TTL ≤ 20%): Critical rapid chime every 1 second (20 ticks) with lower pitch and quieter volume.
  • * - *

    * * @author TheMeinerLP * @author theEvilReaper From 27ca0fe94061f6fb265a9cf017024af4fbf7f5e8 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sat, 26 Sep 2026 23:01:22 +0200 Subject: [PATCH 71/92] chore(creek): remove WanderState class --- .../cygnus/creek/state/WanderState.java | 208 ------------------ 1 file changed, 208 deletions(-) delete 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/WanderState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/WanderState.java deleted file mode 100644 index 8edc0ab2..00000000 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/WanderState.java +++ /dev/null @@ -1,208 +0,0 @@ -package net.onelitefeather.cygnus.creek.state; - -import net.minestom.server.coordinate.Pos; -import net.onelitefeather.cygnus.common.config.CreekConfig; -import net.onelitefeather.cygnus.creek.body.CreekBody; -import net.onelitefeather.cygnus.creek.world.RouteStep; -import org.jetbrains.annotations.Nullable; - -import java.util.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. - * After reaching a waypoint it may rest there: for the pause the route sets, or for a random stop, - * whichever is longer. - *

    - * - * @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 int goalPauseMillis; - private long restingUntil; - 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 (ctx.now() < this.restingUntil) { - body.stop(); - return this; - } - if (this.hasArrived(here)) { - long rest = this.restMillis(ctx); - this.goal = null; - if (rest > 0) { - this.restingUntil = ctx.now() + rest; - body.stop(); - return this; - } - } - - if (this.needsNewGoal(ctx, here)) { - Optional step = ctx.route() - .next(here, point -> ctx.farFromAll(point, config.personalSpace()), ctx.random()); - this.goal = step.map(RouteStep::target).orElse(null); - this.goalPauseMillis = step.map(RouteStep::pauseMillis).orElse(0); - 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 hasArrived(Pos here) { - return this.goal != null && here.distance(this.goal) < ARRIVED; - } - - /** - * Works out how long to rest at the point just reached: its pause, or a random stop if that - * is longer. - */ - private long restMillis(CreekContext ctx) { - CreekConfig config = ctx.config(); - int rest = this.goalPauseMillis; - if (config.randomStopChance() > 0.0D && ctx.random().nextDouble() < config.randomStopChance()) { - int spread = config.randomStopMaxMillis() - config.randomStopMinMillis(); - rest = Math.max(rest, config.randomStopMinMillis() + ctx.random().nextInt(spread + 1)); - } - return rest; - } - - private boolean needsNewGoal(CreekContext ctx, Pos here) { - 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 69a72d88a813bd6d3d341e40d4461df056266a88 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 12:31:41 +0200 Subject: [PATCH 72/92] chore(creek): move count logic --- .../cygnus/creek/CreekService.java | 18 ++++++++++++---- .../cygnus/creek/CreekVariants.java | 21 +++++++++++++------ 2 files changed, 29 insertions(+), 10 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 17ba264f..436234cd 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java @@ -158,6 +158,10 @@ void start() { /** * Runs one step: first the patrolling creek, then the variants. Survivors with a variant do not * see the patrolling creek, and it does not pick them out. + *

    + * Once only one survivor is left and {@code activeWithLastSurvivor} is off, the patrolling creek + * is gone for good and every variant ends. + *

    */ void tick() { Creek current = this.creek; @@ -165,10 +169,16 @@ void tick() { Set players = this.survivors.get(); long now = this.clock.now(); CreekVariants currentVariants = this.variants; - // A haunted survivor already has a creek of their own, so the patrolling one leaves them be. - Set haunted = currentVariants == null ? Set.of() : currentVariants.running().keySet(); - current.tick(players, haunted, now); - if (currentVariants != null) currentVariants.tick(players, current.lastViews(), now); + // Count every survivor here: hiding the patrol from the haunted is not being down to the last one. + if (!this.config.activeWithLastSurvivor() && players.size() <= 1) { + current.vanishForGood(players, now); + if (currentVariants != null) currentVariants.stop(); + } else { + // A haunted survivor already has a creek of their own, so the patrolling one leaves them be. + Set haunted = currentVariants == null ? Set.of() : currentVariants.running().keySet(); + current.tick(players, haunted, now); + if (currentVariants != null) currentVariants.tick(players, current.lastViews(), now); + } if (this.debug.hasWatchers()) { Function names = id -> nameOf(players, id); List views = current.lastViews(); diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java index 6c0d1000..626d0a92 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java @@ -7,7 +7,6 @@ import net.onelitefeather.cygnus.creek.state.DoneState; import net.onelitefeather.cygnus.creek.state.StalkState; import net.onelitefeather.cygnus.creek.state.SurvivorView; -import net.onelitefeather.cygnus.creek.state.VanishState; import net.onelitefeather.cygnus.creek.world.SpotFinder; import java.util.Collection; @@ -85,6 +84,20 @@ static int capacity(int survivors) { return Math.min(MAX_VARIANTS, (survivors + SURVIVORS_PER_VARIANT - 1) / SURVIVORS_PER_VARIANT); } + /** + * How long a survivor's breather after a variant lasts: {@code vanishMaxSeconds} for a calm + * survivor, down to {@code vanishMinSeconds} for one who is scared to death. + * + * @param config the settings + * @param dread the survivor's dread + * @return the breather in milliseconds + */ + static long cooldownMillis(CreekConfig config, double dread) { + double span = config.vanishMaxSeconds() - config.vanishMinSeconds(); + double seconds = config.vanishMaxSeconds() - span * Math.clamp(dread, 0.0D, 1.0D); + return Math.round(seconds * 1000.0D); + } + /** * Picks who to haunt next: the survivor with the highest dread, as long as it reaches the * threshold. If two are equally scared, the one who strayed farthest from the others. @@ -110,10 +123,6 @@ static Optional pick(List views, double threshold, P * @param now the current time in milliseconds */ void tick(Collection survivors, List views, long now) { - if (!this.config.activeWithLastSurvivor() && views.size() <= 1) { - this.stop(); - return; - } this.tickRunning(survivors, views, now); if (now < this.allowedAt) return; // Someone with no free spot next to them should not hold up everyone behind them. @@ -160,7 +169,7 @@ private void tickRunning(Collection survivors, List views, if (!(variant.state() instanceof DoneState)) continue; variant.remove(); iterator.remove(); - long cooldown = VanishState.cooldownMillis(this.config, dreadOf(views, entry.getKey())); + long cooldown = cooldownMillis(this.config, dreadOf(views, entry.getKey())); this.cooldowns.put(entry.getKey(), now + cooldown); } } From 85a6abad9d2d50409356fddf260b6fcf9efcd3f5 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 12:32:11 +0200 Subject: [PATCH 73/92] chore(creek): remove config usage from the state handling --- .../cygnus/creek/state/VanishState.java | 15 --------------- .../cygnus/creek/state/VanishStateTest.java | 8 -------- 2 files changed, 23 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 e0412d9d..fa9de40f 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 @@ -1,7 +1,6 @@ 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; @@ -47,20 +46,6 @@ public boolean isForever() { return this.until == Long.MAX_VALUE; } - /** - * How long a breather lasts: {@code vanishMaxSeconds} for a calm survivor, down to - * {@code vanishMinSeconds} for one who is scared to death. Used for the pause after a variant. - * - * @param config the settings - * @param dread the survivor's dread - * @return the breather in milliseconds - */ - public 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(); 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 7d97adfe..f1be7efd 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 @@ -75,14 +75,6 @@ void staysAwayWithoutAPoint() { 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() { From 74464fa129f4f0f18f5c044946757ac82b5313f9 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 12:32:34 +0200 Subject: [PATCH 74/92] chore(creek): add new method to handle vanish changes --- .../onelitefeather/cygnus/creek/Creek.java | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java index 18af880e..6c39ba71 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java @@ -87,6 +87,33 @@ void tick(Collection survivors, long now) { * @param now the current time in milliseconds */ void tick(Collection survivors, Set ignored, long now) { + CreekContext ctx = this.context(survivors, ignored, now); + if (!this.entered) { + this.state.enter(ctx); + this.entered = true; + } + CreekState next = this.state.tick(ctx); + if (next != this.state) this.switchTo(next, ctx); + } + + /** + * Sends the creek away for the rest of the round. Calling it again changes nothing. + * + * @param survivors the survivors of the round + * @param now the current time in milliseconds + */ + void vanishForGood(Collection survivors, long now) { + CreekContext ctx = this.context(survivors, Set.of(), now); + if (this.state instanceof VanishState vanish && vanish.isForever()) return; + // The state it had so far is never entered now, and never needs to be. + this.entered = true; + this.switchTo(VanishState.forever(), ctx); + } + + /** + * Takes the snapshots for this step and builds the context for the states. + */ + private CreekContext context(Collection survivors, Set ignored, long now) { Map players = new HashMap<>(); for (Player survivor : survivors) { players.put(survivor.getUuid(), survivor); @@ -94,7 +121,7 @@ void tick(Collection survivors, Set ignored, long now) { this.lastViews = this.views(survivors); List noticed = ignored.isEmpty() ? this.lastViews : this.lastViews.stream().filter(view -> !ignored.contains(view.id())).toList(); - CreekContext ctx = new CreekContext(now, noticed, this.body, this.route, this.spots, + return new CreekContext(now, noticed, this.body, this.route, this.spots, id -> { Player caught = players.get(id); if (caught != null) this.consequence.apply(caught); @@ -104,21 +131,6 @@ void tick(Collection survivors, Set ignored, long now) { if (selected != null) this.selection.apply(selected, survivors); }, this.config, this.random); - - if (!this.entered) { - this.state.enter(ctx); - this.entered = true; - } - // Count every survivor here: leaving the haunted ones out is not being down to the last one. - if (!this.config.activeWithLastSurvivor() && this.lastViews.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); } /** From 23fb098c7e57d8bb2dba44a6dfdbce8cb46a914d Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 12:33:02 +0200 Subject: [PATCH 75/92] test(creek): combine test cases and refactor others --- .../cygnus/creek/CreekIntegrationTest.java | 45 ++--- .../creek/CreekServiceIntegrationTest.java | 155 +++++++++--------- .../creek/CreekVariantsIntegrationTest.java | 31 ---- .../cygnus/creek/CreekVariantsTest.java | 9 + .../SelectionConsequenceIntegrationTest.java | 20 +-- .../cygnus/creek/debug/CreekDebugTest.java | 2 +- .../cygnus/creek/state/PatrolStateTest.java | 24 --- 7 files changed, 107 insertions(+), 179 deletions(-) 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 f6b9bf5f..0ced59f4 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java @@ -32,6 +32,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; class CreekIntegrationTest extends CygnusPlayerTestBase { @@ -103,17 +104,21 @@ void targetLeavesMidHunt(Env env) { } @Test - @DisplayName("With the last survivor and the setting off he is gone for good") - void dormantWithTheLastSurvivor(Env env) { + @DisplayName("Vanishing for good hides him from everyone for the rest of the round") + void vanishForGood(Env env) { Instance instance = env.createFlatInstance(); Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 30)); - Creek creek = creek(body, withoutLastSurvivor(), new PatrolState()); - + Creek creek = creek(body, CreekConfig.DEFAULT, new PatrolState()); creek.tick(List.of(survivor), 0L); - assertInstanceOf(VanishState.class, creek.state()); - assertTrue(((VanishState) creek.state()).isForever()); + creek.vanishForGood(List.of(survivor), 100L); + CreekState vanished = creek.state(); + creek.vanishForGood(List.of(survivor), 200L); + + assertTrue(((VanishState) vanished).isForever()); + assertSame(vanished, creek.state(), "calling it again changes nothing"); + assertFalse(body.isVisibleTo(survivor.getUuid())); } @Test @@ -169,32 +174,4 @@ void patrolLeavesTheHauntedAlone(Env env) { assertFalse(haunted.hasEffect(PotionEffect.SLOWNESS)); assertEquals(2, creek.lastViews().size(), "the snapshots still cover every survivor"); } - - @Test - @DisplayName("Hiding him from a haunted survivor does not count as being down to the last one") - void hidingIsNotTheLastSurvivor(Env env) { - Instance instance = env.createFlatInstance(); - Player haunted = env.createConnection().connect(instance, new Pos(0, 40, 0)); - Player other = env.createConnection().connect(instance, new Pos(5, 40, 0)); - CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 30)); - Creek creek = creek(body, withoutLastSurvivor(), new PatrolState()); - - creek.tick(List.of(haunted, other), Set.of(haunted.getUuid()), 0L); - - assertInstanceOf(PatrolState.class, creek.state()); - } - - 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(), d.routeLinkDistance(), - d.randomStopChance(), d.randomStopMinMillis(), d.randomStopMaxMillis()); - } } 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 56a62ce3..60516f65 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java @@ -26,6 +26,7 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -73,23 +74,6 @@ void startsOutOfSight(Env env) { 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(ROUTE), 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 a usable route the creek stays away") void staysAwayWithoutRoutes(Env env) { @@ -159,57 +143,22 @@ void routesAreUsedWhenThereAreAny(Env env) { } @Test - @DisplayName("After the start delay he patrols and a scared survivor gets a variant") - void patrolsAndStartsAVariant(Env env) { - Instance instance = env.createFlatInstance(); - // Far from the route points, so the vanish finds a hidden spot to come back at. - Player survivor = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0)); - Player other = env.createConnection().connect(instance, new Pos(0, 40, -160, 0, 0)); - AtomicLong clock = new AtomicLong(); - CreekService service = new CreekService(CreekConfig.DEFAULT, () -> Set.of(survivor, other), () -> instance, - () -> List.of(ROUTE), CreakingBody::spawn, - (id, _, _) -> id.equals(survivor.getUuid()) ? 0.5D : 0.0D, consequence, - new RoundClock(clock::get), new Random(3), new CreekDebug()); - service.start(); - - clock.set(CreekConfig.DEFAULT.vanishMaxSeconds() * 1000L + 1L); - service.tick(); - service.tick(); - - Creek creek = service.creek(); + @DisplayName("At the end he and every variant are removed and every consequence is cleaned up") + void stopRemovesEverythingAndCleansUp(Env env) { + Round round = this.roundWithVariant(env, CreekConfig.DEFAULT); + Creek creek = round.service().creek(); + CreekVariants variants = round.service().variants(); assertNotNull(creek); - assertInstanceOf(PatrolState.class, creek.state()); - CreekVariants variants = service.variants(); - assertNotNull(variants); - assertTrue(variants.running().containsKey(survivor.getUuid())); - service.stop(); - } - - @Test - @DisplayName("At the end every variant is removed and every consequence is cleaned up") - void stopRemovesVariantsAndCleansUp(Env env) { - Instance instance = env.createFlatInstance(); - // Far from the route points, so the vanish finds a hidden spot to come back at. - Player survivor = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0)); - Player other = env.createConnection().connect(instance, new Pos(0, 40, -160, 0, 0)); - AtomicLong clock = new AtomicLong(); - CreekService service = new CreekService(CreekConfig.DEFAULT, () -> Set.of(survivor, other), () -> instance, - () -> List.of(ROUTE), CreakingBody::spawn, - (id, _, _) -> id.equals(survivor.getUuid()) ? 0.5D : 0.0D, consequence, - new RoundClock(clock::get), new Random(3), new CreekDebug()); - service.start(); - clock.set(CreekConfig.DEFAULT.vanishMaxSeconds() * 1000L + 1L); - service.tick(); - service.tick(); - CreekVariants variants = service.variants(); assertNotNull(variants); - Creek variant = variants.running().get(survivor.getUuid()); + Creek variant = variants.running().get(round.scared().getUuid()); assertNotNull(variant); - service.stop(); + round.service().stop(); + assertTrue(creek.body().entity().isRemoved()); assertTrue(variant.body().entity().isRemoved()); - assertNull(service.variants()); + assertNull(round.service().creek()); + assertNull(round.service().variants()); assertEquals(1, cleanUps.get()); } @@ -240,27 +189,83 @@ void stopClearsTheSelectionEffects(Env env) { } @Test - @DisplayName("Once a survivor has a variant, the patrolling creek is hidden from them") - void patrolIsHiddenFromTheHaunted(Env env) { + @DisplayName("After the start delay he patrols, a scared survivor gets a variant and no longer sees the patrol") + void patrolsAndHidesFromTheHaunted(Env env) { + Round round = this.roundWithVariant(env, CreekConfig.DEFAULT); + Creek creek = round.service().creek(); + CreekVariants variants = round.service().variants(); + assertNotNull(creek); + assertNotNull(variants); + assertInstanceOf(PatrolState.class, creek.state()); + assertTrue(variants.running().containsKey(round.scared().getUuid())); + assertTrue(creek.body().isVisibleTo(round.scared().getUuid()), "the variant only just started"); + + round.service().tick(); + + assertFalse(creek.body().isVisibleTo(round.scared().getUuid())); + assertTrue(creek.body().isVisibleTo(round.other().getUuid())); + round.service().stop(); + } + + @Test + @DisplayName("With the last survivor and the setting off, he is gone for good and every variant ends") + void goneForGoodWithTheLastSurvivor(Env env) { + Round round = this.roundWithVariant(env, withoutLastSurvivor()); + Creek creek = round.service().creek(); + CreekVariants variants = round.service().variants(); + assertNotNull(creek); + assertNotNull(variants); + Creek variant = variants.running().get(round.scared().getUuid()); + assertNotNull(variant); + round.service().tick(); + assertInstanceOf(PatrolState.class, creek.state(), + "hiding the patrol from the haunted survivor is not being down to the last one"); + + round.survivors().set(Set.of(round.scared())); + round.service().tick(); + + assertInstanceOf(VanishState.class, creek.state()); + assertTrue(((VanishState) creek.state()).isForever()); + assertTrue(variants.running().isEmpty()); + assertTrue(variant.body().entity().isRemoved()); + round.service().stop(); + } + + /** + * A started round with two survivors far from the route, of whom only the first is scared + * enough for a variant. It runs one step past the start delay: the patrol is out, and the + * variant has just started. + */ + private Round roundWithVariant(Env env, CreekConfig config) { Instance instance = env.createFlatInstance(); - Player survivor = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0)); + // Far from the route points, so the vanish finds a hidden spot to come back at. + Player scared = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0)); Player other = env.createConnection().connect(instance, new Pos(0, 40, -160, 0, 0)); + AtomicReference> survivors = new AtomicReference<>(Set.of(scared, other)); AtomicLong clock = new AtomicLong(); - CreekService service = new CreekService(CreekConfig.DEFAULT, () -> Set.of(survivor, other), () -> instance, - () -> List.of(ROUTE), CreakingBody::spawn, - (id, _, _) -> id.equals(survivor.getUuid()) ? 0.5D : 0.0D, consequence, + CreekService service = new CreekService(config, survivors::get, () -> instance, () -> List.of(ROUTE), + CreakingBody::spawn, (id, _, _) -> id.equals(scared.getUuid()) ? 0.5D : 0.0D, consequence, new RoundClock(clock::get), new Random(3), new CreekDebug()); service.start(); clock.set(CreekConfig.DEFAULT.vanishMaxSeconds() * 1000L + 1L); service.tick(); - Creek creek = service.creek(); - assertNotNull(creek); - assertTrue(creek.body().isVisibleTo(survivor.getUuid()), "before the variant everyone sees the patrol"); + return new Round(service, scared, other, survivors); + } - service.tick(); + private record Round(CreekService service, Player scared, Player other, AtomicReference> survivors) { + } - assertFalse(creek.body().isVisibleTo(survivor.getUuid())); - assertTrue(creek.body().isVisibleTo(other.getUuid())); - service.stop(); + 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(), d.routeLinkDistance(), + d.randomStopChance(), d.randomStopMinMillis(), d.randomStopMaxMillis()); } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java index c8dcc1e0..d0db074e 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java @@ -199,23 +199,6 @@ void skipsASurvivorWithoutASpot(Env env) { assertEquals(List.of(second.getUuid()), List.copyOf(variants.running().keySet())); } - @Test - @DisplayName("With the last survivor and the setting off, every variant ends") - void endsWithTheLastSurvivor(Env env) { - Instance instance = env.createFlatInstance(); - Player first = join(env, instance, 0); - Player second = join(env, instance, 10); - CreekConfig config = withoutLastSurvivor(); - CreekVariants variants = variants(instance, config, Optional::of, 0L); - variants.tick(List.of(first, second), List.of(view(first, 0.5D), view(second, 0.0D)), 0L); - Creek variant = variants.running().get(first.getUuid()); - - variants.tick(List.of(first), List.of(view(first, 0.5D)), 100L); - - assertTrue(variants.running().isEmpty()); - assertTrue(variant.body().entity().isRemoved()); - } - @Test @DisplayName("Stopping removes every variant") void stopRemovesEveryVariant(Env env) { @@ -230,18 +213,4 @@ void stopRemovesEveryVariant(Env env) { assertTrue(variants.running().isEmpty()); assertTrue(variant.body().entity().isRemoved()); } - - 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(), d.routeLinkDistance(), - d.randomStopChance(), d.randomStopMinMillis(), d.randomStopMaxMillis()); - } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java index d1e9eba1..b22a9699 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsTest.java @@ -1,6 +1,7 @@ package net.onelitefeather.cygnus.creek; import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.config.CreekConfig; import net.onelitefeather.cygnus.creek.state.SurvivorView; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -30,6 +31,14 @@ void capacity() { assertEquals(3, CreekVariants.capacity(40)); } + @Test + @DisplayName("The breather after a variant shrinks as the dread grows") + void cooldownFollowsDread() { + assertEquals(40_000L, CreekVariants.cooldownMillis(CreekConfig.DEFAULT, 0.0D)); + assertEquals(30_000L, CreekVariants.cooldownMillis(CreekConfig.DEFAULT, 0.5D)); + assertEquals(20_000L, CreekVariants.cooldownMillis(CreekConfig.DEFAULT, 1.0D)); + } + @Test @DisplayName("The survivor with the highest dread above the threshold is picked") void picksTheHighestDread() { diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java index 00216fc8..c0d0bc00 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java @@ -65,26 +65,18 @@ void teleportLandsInRange(Env env) { } @Test - @DisplayName("Without a route point in range there is no teleport") - void noTeleportWithoutAPointInRange(Env env) { + @DisplayName("Without a route point in range there is no teleport, the stun is applied instead") + void fallsBackToTheStunWithoutAFittingPoint(Env env) { Instance instance = env.createFlatInstance(); Player selected = env.createConnection().connect(instance, new Pos(0.5, 40, 0.5)); + // The only point is too close. nextBoolean() is false for 0, so apply() tries the teleport first. SelectionConsequence selection = new SelectionConsequence(() -> List.of(new Pos(5.5, 40, 0.5)), Optional::of, - new Random(1)); - - assertFalse(selection.teleportAway(selected)); - } - - @Test - @DisplayName("When the teleport finds no point, the stun is applied instead") - void fallsBackToTheStunWithoutAFittingPoint(Env env) { - Instance instance = env.createFlatInstance(); - Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); - // nextBoolean() is false for 0, so apply() tries the teleport first - SelectionConsequence selection = new SelectionConsequence(List::of, Optional::of, () -> 0L); + () -> 0L); selection.apply(selected, List.of(selected)); + env.tick(); + assertTrue(selected.getPosition().sameBlock(new Pos(0.5, 40, 0.5)), "stands at " + selected.getPosition()); assertTrue(selected.hasEffect(PotionEffect.SLOWNESS)); } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/debug/CreekDebugTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/debug/CreekDebugTest.java index 0b377b95..cbeb634d 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 @@ -53,7 +53,7 @@ void huntLine() { @Test @DisplayName("Patrolling there is no target, only the dread of everyone") - void wanderLine() { + void patrolLine() { List views = List.of(new SurvivorView(STEVE, new Pos(0, 40, 30), 0.1D, false)); Component line = CreekDebug.line(new PatrolState(), Pos.ZERO, views, NAMES::get); diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java index 54e20a9b..eb488929 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java @@ -82,30 +82,6 @@ void pausesWhenSpotted() { assertEquals(A, body.goal); } - @Test - @DisplayName("A survivor close by does not make him vanish") - void doesNotVanishWhenApproached() { - RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); - PatrolState state = new PatrolState(); - CreekContext ctx = Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), at(FIRST, 10)); - state.enter(ctx); - - assertSame(state, state.tick(ctx)); - assertEquals(A, body.goal); - } - - @Test - @DisplayName("A high dread does not make him stalk") - void neverStalks() { - RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); - PatrolState state = new PatrolState(); - CreekContext ctx = Contexts.context(0L, body, Contexts.route(A), new ArrayList<>(), far(FIRST, 0.9D, false)); - state.enter(ctx); - - assertSame(state, state.tick(ctx)); - assertTrue(body.teleports.isEmpty()); - } - @Test @DisplayName("Making no progress, he picks another point") void picksAnotherPointWhenStuck() { From e30f8b8a51be149e1f7a961cd71e22ad14bae933 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 12:38:31 +0200 Subject: [PATCH 76/92] fix(creek): give every creek its own route walker --- .../net/onelitefeather/cygnus/creek/Creek.java | 4 ++++ .../cygnus/creek/CreekService.java | 10 ++++++---- .../cygnus/creek/world/PathRoute.java | 4 ++++ .../creek/CreekServiceIntegrationTest.java | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java index 6c39ba71..5e9db474 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java @@ -175,6 +175,10 @@ CreekBody body() { return this.body; } + RouteProvider route() { + return this.route; + } + void remove() { this.body.remove(); } 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 436234cd..a4c5232c 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java @@ -133,11 +133,12 @@ void start() { LOGGER.warn("The map has no usable creek routes, so the creek stays away this round"); return; } + // Every creek walks with its own cursor, so this one belongs to the patrolling creek alone. PathRoute pathRoute = new PathRoute(paths); this.route = pathRoute; - SelectionConsequence selection = new SelectionConsequence(pathRoute::points, this.ground, this.random); + SelectionConsequence selection = new SelectionConsequence(paths::allPoints, this.ground, this.random); this.selection = selection; - List points = pathRoute.points(); + List points = paths.allPoints(); this.clock.start(); Pos point = points.get(this.random.nextInt(points.size())); @@ -149,8 +150,9 @@ void start() { this.config, this.random, initial); long variantsFrom = this.clock.now() + this.config.vanishMinSeconds() * 1000L; this.variants = new CreekVariants(this.config, this.spots, this.random, variantsFrom, - (spot, state) -> new Creek(this.bodies.apply(world, spot), this.sight, this.dread, pathRoute, - this.spots, this.consequence, selection, this.config, this.random, state)); + (spot, state) -> new Creek(this.bodies.apply(world, spot), this.sight, this.dread, + new PathRoute(paths), this.spots, this.consequence, selection, this.config, this.random, + state)); this.debug.setActive(true); this.task.start(TICK_MILLIS, ChronoUnit.MILLIS); } 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 index c4d31a07..d3a674ac 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java @@ -16,6 +16,10 @@ * point, after a teleport for example, it joins back in at the nearest point. Each step brings the * pause of its point along, except for an end the creek is just leaving. *

    + *

    + * Because it remembers where its creek is, every creek needs a walker of its own. The + * {@link CreekPaths} behind it can be shared. + *

    * * @author theEvilReaper * @version 1.0.0 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 60516f65..e6a85053 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java @@ -32,6 +32,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -207,6 +208,23 @@ void patrolsAndHidesFromTheHaunted(Env env) { round.service().stop(); } + @Test + @DisplayName("Every creek walks the routes with a walker of its own") + void everyCreekHasItsOwnWalker(Env env) { + Round round = this.roundWithVariant(env, CreekConfig.DEFAULT); + Creek creek = round.service().creek(); + CreekVariants variants = round.service().variants(); + assertNotNull(creek); + assertNotNull(variants); + Creek variant = variants.running().get(round.scared().getUuid()); + assertNotNull(variant); + + assertInstanceOf(PathRoute.class, variant.route()); + assertNotSame(creek.route(), variant.route()); + assertEquals(creek.route().points(), variant.route().points()); + round.service().stop(); + } + @Test @DisplayName("With the last survivor and the setting off, he is gone for good and every variant ends") void goneForGoodWithTheLastSurvivor(Env env) { From d535c57d3c0e5ae3187e4b7eceb88d8b17069181 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 12:38:38 +0200 Subject: [PATCH 77/92] chore(creek): compute route points once --- .../cygnus/creek/world/CreekPaths.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 index 23772788..f1783f0f 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java @@ -36,10 +36,16 @@ public record End(int route, boolean atStart) { private final List routes; private final Map> links; + private final List allPoints; private CreekPaths(List routes, Map> links) { this.routes = routes; this.links = links; + List points = new ArrayList<>(); + for (CreekRoute route : routes) { + route.points().forEach(point -> points.add(toPos(point.position()))); + } + this.allPoints = List.copyOf(points); } /** @@ -152,14 +158,10 @@ public List links(End end) { /** * Every point of every route. * - * @return the points + * @return the points, unmodifiable */ public List allPoints() { - List points = new ArrayList<>(); - for (CreekRoute route : this.routes) { - route.points().forEach(point -> points.add(toPos(point.position()))); - } - return points; + return this.allPoints; } private static Pos position(List routes, End end) { From 7a91da3fdbe621eedabc74d5c88cbf135aaccd63 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 12:38:49 +0200 Subject: [PATCH 78/92] chore(creek): reuse stalk start in variants --- .../onelitefeather/cygnus/creek/CreekVariants.java | 3 +-- .../cygnus/creek/state/StalkState.java | 12 +++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java index 626d0a92..e3052c16 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java @@ -189,8 +189,7 @@ private boolean start(SurvivorView view, long now) { this.random); if (spot.isEmpty()) return false; - long seconds = this.random.nextLong(config.stalkMinSeconds(), config.stalkMaxSeconds() + 1L); - StalkState stalk = new StalkState(view.id(), now + seconds * 1000L); + StalkState stalk = StalkState.starting(view.id(), now, this.config, this.random); this.running.put(view.id(), this.spawner.apply(spot.get(), stalk)); return true; } 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 index 5fada895..1e88a651 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/StalkState.java @@ -8,6 +8,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.random.RandomGenerator; /** * The creek shadows one survivor from a distance, and only that survivor can see it. @@ -51,13 +52,14 @@ public StalkState(UUID target, long endsAt) { * {@code stalkMaxSeconds}. * * @param target the survivor to stalk - * @param ctx the current step + * @param now the current time in milliseconds + * @param config the settings + * @param random the random source * @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); + public static StalkState starting(UUID target, long now, CreekConfig config, RandomGenerator random) { + long seconds = random.nextLong(config.stalkMinSeconds(), config.stalkMaxSeconds() + 1L); + return new StalkState(target, now + seconds * 1000L); } /** From 8c1acfedecd38f1e6a5b9c445165c4534999adcd Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 12:46:53 +0200 Subject: [PATCH 79/92] refactor(creek): share one survivor snapshot per step --- .../onelitefeather/cygnus/creek/Creek.java | 100 +++++++---------- .../cygnus/creek/CreekRound.java | 33 ++++++ .../cygnus/creek/CreekService.java | 30 +++-- .../cygnus/creek/CreekVariants.java | 14 +-- .../cygnus/creek/SurvivorSnapshot.java | 106 ++++++++++++++++++ .../cygnus/creek/state/SurvivorView.java | 10 ++ .../cygnus/creek/CreekIntegrationTest.java | 38 ++++--- .../creek/CreekServiceIntegrationTest.java | 26 ++++- .../creek/CreekVariantsIntegrationTest.java | 38 +++---- .../SurvivorSnapshotIntegrationTest.java | 66 +++++++++++ 10 files changed, 338 insertions(+), 123 deletions(-) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/SurvivorSnapshot.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/SurvivorSnapshotIntegrationTest.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 index 5e9db474..d98c7169 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java @@ -1,34 +1,23 @@ 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.consequence.SelectionConsequence; -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.Set; import java.util.UUID; -import java.util.random.RandomGenerator; /** * One creek in the round: its body, what it is doing right now, and the step that moves it on. *

    - * The patrolling creek is one of these, and so is every variant. Each step it takes a snapshot of - * the survivors and lets its current state decide what happens next. + * The patrolling creek is one of these, and so is every variant. Each step it fills in who can see + * it in the shared snapshot of the survivors and lets its current state decide what happens next. *

    * * @author theEvilReaper @@ -38,40 +27,34 @@ 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 SelectionConsequence selection; - private final CreekConfig config; - private final RandomGenerator random; + private final CreekRound round; private CreekState state; private List lastViews = List.of(); private boolean entered; - Creek(CreekBody body, CreekSight sight, DreadSource dread, RouteProvider route, SpotFinder spots, - CatchConsequence consequence, SelectionConsequence selection, CreekConfig config, RandomGenerator random, - CreekState initial) { + /** + * Sets up a creek. + * + * @param body its body in the world + * @param route its own walker along the routes + * @param round what it shares with every other creek of the round + * @param initial the state it starts in + */ + Creek(CreekBody body, RouteProvider route, CreekRound round, CreekState initial) { this.body = body; - this.sight = sight; - this.dread = dread; this.route = route; - this.spots = spots; - this.consequence = consequence; - this.selection = selection; - this.config = config; - this.random = random; + this.round = round; this.state = initial; } /** * Runs one step. * - * @param survivors the survivors of the round + * @param survivors the survivors of this step * @param now the current time in milliseconds */ - void tick(Collection survivors, long now) { + void tick(SurvivorSnapshot survivors, long now) { this.tick(survivors, Set.of(), now); } @@ -82,11 +65,11 @@ void tick(Collection survivors, long now) { * The snapshots in {@link #lastViews()} still cover every survivor. *

    * - * @param survivors the survivors of the round + * @param survivors the survivors of this step * @param ignored the survivors this creek leaves alone in this step * @param now the current time in milliseconds */ - void tick(Collection survivors, Set ignored, long now) { + void tick(SurvivorSnapshot survivors, Set ignored, long now) { CreekContext ctx = this.context(survivors, ignored, now); if (!this.entered) { this.state.enter(ctx); @@ -99,10 +82,10 @@ void tick(Collection survivors, Set ignored, long now) { /** * Sends the creek away for the rest of the round. Calling it again changes nothing. * - * @param survivors the survivors of the round + * @param survivors the survivors of this step * @param now the current time in milliseconds */ - void vanishForGood(Collection survivors, long now) { + void vanishForGood(SurvivorSnapshot survivors, long now) { CreekContext ctx = this.context(survivors, Set.of(), now); if (this.state instanceof VanishState vanish && vanish.isForever()) return; // The state it had so far is never entered now, and never needs to be. @@ -111,49 +94,44 @@ void vanishForGood(Collection survivors, long now) { } /** - * Takes the snapshots for this step and builds the context for the states. + * Fills in who sees this creek and builds the context for the states. */ - private CreekContext context(Collection survivors, Set ignored, long now) { - Map players = new HashMap<>(); - for (Player survivor : survivors) { - players.put(survivor.getUuid(), survivor); - } + private CreekContext context(SurvivorSnapshot survivors, Set ignored, long now) { this.lastViews = this.views(survivors); List noticed = ignored.isEmpty() ? this.lastViews : this.lastViews.stream().filter(view -> !ignored.contains(view.id())).toList(); - return new CreekContext(now, noticed, this.body, this.route, this.spots, + CreekRound round = this.round; + return new CreekContext(now, noticed, this.body, this.route, round.spots(), id -> { - Player caught = players.get(id); - if (caught != null) this.consequence.apply(caught); + Player caught = survivors.player(id); + if (caught != null) round.consequence().apply(caught); }, id -> { - Player selected = players.get(id); - if (selected != null) this.selection.apply(selected, survivors); + Player selected = survivors.player(id); + if (selected != null) round.selection().apply(selected, survivors.players()); }, - this.config, this.random); + round.config(), round.random()); } /** - * Takes a snapshot of every survivor for the states. + * Tells for every survivor whether they see this creek. *

    * A survivor only counts as seeing the creek if it is shown to them at all. A variant is hidden * from everyone but its target, so the others must not be able to scare it off. *

    * - * @param survivors the survivors of the round + * @param survivors the survivors of this step * @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)); + List views(SurvivorSnapshot survivors) { + List players = survivors.players(); + List base = survivors.views(); + List views = new ArrayList<>(players.size()); + for (int index = 0; index < players.size(); index++) { + Player survivor = players.get(index); + boolean sees = this.body.isVisibleTo(survivor.getUuid()) + && this.round.sight().sees(survivor, this.body.entity()); + views.add(base.get(index).withSeesCreek(sees)); } return views; } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java new file mode 100644 index 00000000..f60be0d2 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java @@ -0,0 +1,33 @@ +package net.onelitefeather.cygnus.creek; + +import net.onelitefeather.cygnus.common.config.CreekConfig; +import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; +import net.onelitefeather.cygnus.creek.consequence.SelectionConsequence; +import net.onelitefeather.cygnus.creek.world.CreekSight; +import net.onelitefeather.cygnus.creek.world.SpotFinder; + +import java.util.random.RandomGenerator; + +/** + * What every creek of a round shares: the patrolling one and each variant. + * + * @param sight decides whether a survivor sees a creek + * @param spots finds places where nobody can see a creek + * @param consequence what happens on a catch + * @param selection what happens to a survivor the patrolling creek picks out + * @param config the settings + * @param random the random source + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +record CreekRound( + CreekSight sight, + SpotFinder spots, + CatchConsequence consequence, + SelectionConsequence selection, + CreekConfig config, + RandomGenerator random +) { +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java index a4c5232c..64e3f5a7 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java @@ -146,13 +146,12 @@ void start() { // Start hidden. The creek only shows up once the survivors have had time to spread out, // somewhere 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, pathRoute, this.spots, this.consequence, selection, - this.config, this.random, initial); + CreekRound round = new CreekRound(this.sight, this.spots, this.consequence, selection, this.config, + this.random); + this.creek = new Creek(body, pathRoute, round, initial); long variantsFrom = this.clock.now() + this.config.vanishMinSeconds() * 1000L; this.variants = new CreekVariants(this.config, this.spots, this.random, variantsFrom, - (spot, state) -> new Creek(this.bodies.apply(world, spot), this.sight, this.dread, - new PathRoute(paths), this.spots, this.consequence, selection, this.config, this.random, - state)); + (spot, state) -> new Creek(this.bodies.apply(world, spot), new PathRoute(paths), round, state)); this.debug.setActive(true); this.task.start(TICK_MILLIS, ChronoUnit.MILLIS); } @@ -168,21 +167,22 @@ void start() { void tick() { Creek current = this.creek; if (current == null) return; - Set players = this.survivors.get(); + // One snapshot for every creek: where the survivors are and how scared they are is the same for all. + SurvivorSnapshot survivors = SurvivorSnapshot.take(this.survivors.get(), this.dread); long now = this.clock.now(); CreekVariants currentVariants = this.variants; // Count every survivor here: hiding the patrol from the haunted is not being down to the last one. - if (!this.config.activeWithLastSurvivor() && players.size() <= 1) { - current.vanishForGood(players, now); + if (!this.config.activeWithLastSurvivor() && survivors.size() <= 1) { + current.vanishForGood(survivors, now); if (currentVariants != null) currentVariants.stop(); } else { // A haunted survivor already has a creek of their own, so the patrolling one leaves them be. Set haunted = currentVariants == null ? Set.of() : currentVariants.running().keySet(); - current.tick(players, haunted, now); - if (currentVariants != null) currentVariants.tick(players, current.lastViews(), now); + current.tick(survivors, haunted, now); + if (currentVariants != null) currentVariants.tick(survivors, now); } if (this.debug.hasWatchers()) { - Function names = id -> nameOf(players, id); + Function names = id -> nameOf(survivors, id); List views = current.lastViews(); Component line = CreekDebug.line(current.state(), current.body().position(), views, names, describeRoute(), now); @@ -194,11 +194,9 @@ void tick() { } } - 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); + private static String nameOf(SurvivorSnapshot survivors, UUID id) { + Player player = survivors.player(id); + return player != null ? player.getUsername() : id.toString().substring(0, 8); } /** diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java index e3052c16..9b7c65e5 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekVariants.java @@ -1,7 +1,6 @@ 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.state.CreekState; import net.onelitefeather.cygnus.creek.state.DoneState; @@ -9,7 +8,6 @@ import net.onelitefeather.cygnus.creek.state.SurvivorView; import net.onelitefeather.cygnus.creek.world.SpotFinder; -import java.util.Collection; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; @@ -118,12 +116,12 @@ static Optional pick(List views, double threshold, P * Runs one step: lets every variant act, clears away the finished ones and starts new ones * while there is room. * - * @param survivors the survivors of the round - * @param views a snapshot of every survivor, for their dread and position + * @param survivors the survivors of this step * @param now the current time in milliseconds */ - void tick(Collection survivors, List views, long now) { - this.tickRunning(survivors, views, now); + void tick(SurvivorSnapshot survivors, long now) { + List views = survivors.views(); + this.tickRunning(survivors, now); if (now < this.allowedAt) return; // Someone with no free spot next to them should not hold up everyone behind them. Set noSpot = new HashSet<>(); @@ -160,7 +158,7 @@ void stop() { this.cooldowns.clear(); } - private void tickRunning(Collection survivors, List views, long now) { + private void tickRunning(SurvivorSnapshot survivors, long now) { Iterator> iterator = this.running.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry entry = iterator.next(); @@ -169,7 +167,7 @@ private void tickRunning(Collection survivors, List views, if (!(variant.state() instanceof DoneState)) continue; variant.remove(); iterator.remove(); - long cooldown = cooldownMillis(this.config, dreadOf(views, entry.getKey())); + long cooldown = cooldownMillis(this.config, dreadOf(survivors.views(), entry.getKey())); this.cooldowns.put(entry.getKey(), now + cooldown); } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/SurvivorSnapshot.java b/game/src/main/java/net/onelitefeather/cygnus/creek/SurvivorSnapshot.java new file mode 100644 index 00000000..6a778e56 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/SurvivorSnapshot.java @@ -0,0 +1,106 @@ +package net.onelitefeather.cygnus.creek; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.creek.dread.DreadSource; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +/** + * The survivors of one step: the players and a snapshot of each of them. + *

    + * It is taken once per step and shared by every creek. Where a survivor stands and how scared they + * are is the same for all of them; only whether they see a creek differs, so every view here says + * {@code false} for that and each creek fills it in for itself. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +final class SurvivorSnapshot { + + private final List players; + private final List views; + + /** + * Pairs up the players with their views. + * + * @param players the survivors + * @param views one view per survivor, in the same order + */ + SurvivorSnapshot(List players, List views) { + if (players.size() != views.size()) { + throw new IllegalArgumentException("Every survivor needs exactly one view"); + } + this.players = List.copyOf(players); + this.views = List.copyOf(views); + } + + /** + * Takes a snapshot of every survivor. + * + * @param players the survivors of the round + * @param dread rates how scared each survivor is + * @return the snapshot + */ + static SurvivorSnapshot take(Collection players, DreadSource dread) { + List survivors = List.copyOf(players); + List views = new ArrayList<>(survivors.size()); + for (Player survivor : survivors) { + Pos position = survivor.getPosition(); + List others = new ArrayList<>(survivors.size()); + for (Player other : survivors) { + if (other != survivor) others.add(other.getPosition()); + } + views.add(new SurvivorView(survivor.getUuid(), position, + dread.dreadOf(survivor.getUuid(), position, others), false)); + } + return new SurvivorSnapshot(survivors, views); + } + + /** + * The survivors. + * + * @return the players, in the same order as {@link #views()} + */ + List players() { + return this.players; + } + + /** + * One view per survivor, none of them seeing a creek. + * + * @return the views, in the same order as {@link #players()} + */ + List views() { + return this.views; + } + + /** + * Looks up a survivor by id. + * + * @param id the id to look for + * @return the player, or {@code null} if they are not among the survivors + */ + @Nullable Player player(UUID id) { + for (Player player : this.players) { + if (player.getUuid().equals(id)) return player; + } + return null; + } + + /** + * How many survivors there are. + * + * @return the count + */ + int size() { + return this.players.size(); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java index be6724b3..549d3186 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/SurvivorView.java @@ -29,4 +29,14 @@ public record SurvivorView(UUID id, Pos position, double dread, boolean seesCree public Pos eyes() { return this.position.add(0, EYE_HEIGHT, 0); } + + /** + * The same snapshot, with the given answer to whether the survivor sees the creek. + * + * @param sees whether the survivor sees the creek + * @return this view if nothing changes, otherwise a copy + */ + public SurvivorView withSeesCreek(boolean sees) { + return sees == this.seesCreek ? this : new SurvivorView(this.id, this.position, this.dread, sees); + } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java index 0ced59f4..4c83d5d2 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java @@ -41,8 +41,14 @@ private static Creek creek(CreekBody body, CreekConfig config, CreekState initia CreekSight sight = new CreekSight(config.sightRange(), config.sightViewAngle()); // No route points: a selection always ends in the stun. SelectionConsequence selection = new SelectionConsequence(List::of, Optional::of, new Random(3)); - return new Creek(body, sight, (_, _, _) -> 0.0D, Contexts.route(), new SpotFinder(sight, Optional::of), - _ -> {}, selection, config, new Random(3), initial); + CreekRound round = new CreekRound(sight, new SpotFinder(sight, Optional::of), _ -> {}, selection, config, + new Random(3)); + return new Creek(body, Contexts.route(), round, initial); + } + + /** A snapshot of the players, none of them scared. */ + private static SurvivorSnapshot survivors(Player... players) { + return SurvivorSnapshot.take(List.of(players), (_, _, _) -> 0.0D); } @Test @@ -53,7 +59,7 @@ void wanderingIsPublicButNotForTheSlender(Env env) { Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0)); CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); - creek(body, CreekConfig.DEFAULT, new PatrolState()).tick(List.of(survivor), 0L); + creek(body, CreekConfig.DEFAULT, new PatrolState()).tick(survivors(survivor), 0L); assertTrue(body.entity().getViewers().contains(survivor)); assertFalse(body.entity().getViewers().contains(slender)); @@ -66,9 +72,9 @@ void lookingCountsAsSeeing(Env env) { Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0, 0, 0)); CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); Creek creek = creek(body, CreekConfig.DEFAULT, new PatrolState()); - creek.tick(List.of(survivor), 0L); + creek.tick(survivors(survivor), 0L); - assertTrue(creek.views(List.of(survivor)).getFirst().seesCreek()); + assertTrue(creek.views(survivors(survivor)).getFirst().seesCreek()); } @Test @@ -79,9 +85,9 @@ void hiddenFromNonTargets(Env env) { 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); + creek.tick(survivors(target, other), 0L); - SurvivorView otherView = creek.views(List.of(target, other)).stream() + SurvivorView otherView = creek.views(survivors(target, other)).stream() .filter(view -> view.id().equals(other.getUuid())) .findFirst().orElseThrow(); assertFalse(otherView.seesCreek()); @@ -95,9 +101,9 @@ void targetLeavesMidHunt(Env env) { 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(survivors(target, other), 0L); - creek.tick(List.of(other), 100L); + creek.tick(survivors(other), 100L); assertInstanceOf(DoneState.class, creek.state()); assertTrue(body.entity().getViewers().isEmpty()); @@ -110,11 +116,11 @@ void vanishForGood(Env env) { Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 30)); Creek creek = creek(body, CreekConfig.DEFAULT, new PatrolState()); - creek.tick(List.of(survivor), 0L); + creek.tick(survivors(survivor), 0L); - creek.vanishForGood(List.of(survivor), 100L); + creek.vanishForGood(survivors(survivor), 100L); CreekState vanished = creek.state(); - creek.vanishForGood(List.of(survivor), 200L); + creek.vanishForGood(survivors(survivor), 200L); assertTrue(((VanishState) vanished).isForever()); assertSame(vanished, creek.state(), "calling it again changes nothing"); @@ -139,7 +145,7 @@ public CreekState tick(CreekContext ctx) { } }; - creek(body, CreekConfig.DEFAULT, selecting).tick(List.of(survivor), 0L); + creek(body, CreekConfig.DEFAULT, selecting).tick(survivors(survivor), 0L); assertTrue(survivor.hasEffect(PotionEffect.SLOWNESS)); } @@ -153,7 +159,7 @@ void patrolIsHiddenFromTheHaunted(Env env) { CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 20)); creek(body, CreekConfig.DEFAULT, new PatrolState()) - .tick(List.of(haunted, other), Set.of(haunted.getUuid()), 0L); + .tick(survivors(haunted, other), Set.of(haunted.getUuid()), 0L); assertFalse(body.isVisibleTo(haunted.getUuid())); assertTrue(body.isVisibleTo(other.getUuid())); @@ -168,8 +174,8 @@ void patrolLeavesTheHauntedAlone(Env env) { CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 2)); Creek creek = creek(body, CreekConfig.DEFAULT, new PatrolState()); - creek.tick(List.of(haunted, other), Set.of(haunted.getUuid()), 0L); - creek.tick(List.of(haunted, other), Set.of(haunted.getUuid()), 1000L); + creek.tick(survivors(haunted, other), Set.of(haunted.getUuid()), 0L); + creek.tick(survivors(haunted, other), Set.of(haunted.getUuid()), 1000L); assertFalse(haunted.hasEffect(PotionEffect.SLOWNESS)); assertEquals(2, creek.lastViews().size(), "the snapshots still cover every survivor"); diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java index e6a85053..31008035 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java @@ -225,6 +225,21 @@ void everyCreekHasItsOwnWalker(Env env) { round.service().stop(); } + @Test + @DisplayName("Each survivor's dread is worked out once per step, however many creeks there are") + void dreadOncePerStep(Env env) { + Round round = this.roundWithVariant(env, CreekConfig.DEFAULT); + CreekVariants variants = round.service().variants(); + assertNotNull(variants); + assertEquals(1, variants.running().size()); + round.dreadCalls().set(0); + + round.service().tick(); + + assertEquals(2, round.dreadCalls().get()); + round.service().stop(); + } + @Test @DisplayName("With the last survivor and the setting off, he is gone for good and every variant ends") void goneForGoodWithTheLastSurvivor(Env env) { @@ -260,17 +275,22 @@ private Round roundWithVariant(Env env, CreekConfig config) { Player scared = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0)); Player other = env.createConnection().connect(instance, new Pos(0, 40, -160, 0, 0)); AtomicReference> survivors = new AtomicReference<>(Set.of(scared, other)); + AtomicInteger dreadCalls = new AtomicInteger(); AtomicLong clock = new AtomicLong(); CreekService service = new CreekService(config, survivors::get, () -> instance, () -> List.of(ROUTE), - CreakingBody::spawn, (id, _, _) -> id.equals(scared.getUuid()) ? 0.5D : 0.0D, consequence, + CreakingBody::spawn, (id, _, _) -> { + dreadCalls.incrementAndGet(); + return id.equals(scared.getUuid()) ? 0.5D : 0.0D; + }, consequence, new RoundClock(clock::get), new Random(3), new CreekDebug()); service.start(); clock.set(CreekConfig.DEFAULT.vanishMaxSeconds() * 1000L + 1L); service.tick(); - return new Round(service, scared, other, survivors); + return new Round(service, scared, other, survivors, dreadCalls); } - private record Round(CreekService service, Player scared, Player other, AtomicReference> survivors) { + private record Round(CreekService service, Player scared, Player other, AtomicReference> survivors, + AtomicInteger dreadCalls) { } private static CreekConfig withoutLastSurvivor() { diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java index d0db074e..0f1e592a 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java @@ -44,8 +44,8 @@ public void cleanUp() { private static final SelectionConsequence SELECTION = new SelectionConsequence(List::of, Optional::of, new Random(1)); private static BiFunction spawner(Instance instance, CreekConfig config, SpotFinder spots) { - return (spot, initial) -> new Creek(CreakingBody.spawn(instance, spot), SIGHT, (_, _, _) -> 0.0D, - Contexts.route(), spots, NO_CATCH, SELECTION, config, new Random(3), initial); + CreekRound round = new CreekRound(SIGHT, spots, NO_CATCH, SELECTION, config, new Random(3)); + return (spot, initial) -> new Creek(CreakingBody.spawn(instance, spot), Contexts.route(), round, initial); } private static CreekVariants variants(Instance instance, CreekConfig config, Ground ground, long allowedAt) { @@ -71,8 +71,8 @@ void startsForTheHighestDread(Env env) { List survivors = List.of(first, second); List views = List.of(view(first, 0.5D), view(second, 0.3D)); - variants.tick(survivors, views, 0L); - variants.tick(survivors, views, 100L); + variants.tick(new SurvivorSnapshot(survivors, views), 0L); + variants.tick(new SurvivorSnapshot(survivors, views), 100L); assertEquals(1, variants.running().size(), "two survivors allow one variant"); Creek variant = variants.running().get(first.getUuid()); @@ -90,8 +90,8 @@ void twoVariantsForFiveSurvivors(Env env) { view(survivors.get(2), 0.3D), view(survivors.get(3), 0.0D), view(survivors.get(4), 0.0D)); CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); - variants.tick(survivors, views, 0L); - variants.tick(survivors, views, 100L); + variants.tick(new SurvivorSnapshot(survivors, views), 0L); + variants.tick(new SurvivorSnapshot(survivors, views), 100L); assertEquals(2, variants.running().size()); assertTrue(variants.running().containsKey(survivors.get(0).getUuid())); @@ -105,7 +105,7 @@ void waitsForTheAllowedTime(Env env) { Player first = join(env, instance, 0); CreekVariants variants = variants(instance, CONFIG, Optional::of, 1000L); - variants.tick(List.of(first), List.of(view(first, 0.9D)), 999L); + variants.tick(new SurvivorSnapshot(List.of(first), List.of(view(first, 0.9D))), 999L); assertTrue(variants.running().isEmpty()); } @@ -119,19 +119,19 @@ void cooldownAfterTheEnd(Env env) { List survivors = List.of(first, second); List views = List.of(view(first, 0.5D), view(second, 0.0D)); CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); - variants.tick(survivors, views, 0L); + variants.tick(new SurvivorSnapshot(survivors, views), 0L); Creek variant = variants.running().get(first.getUuid()); // the stalk lasts at most stalkMaxSeconds (90 s) - variants.tick(survivors, views, 100_000L); + variants.tick(new SurvivorSnapshot(survivors, views), 100_000L); assertTrue(variants.running().isEmpty()); assertTrue(variant.body().entity().isRemoved()); // dread 0.5 gives a 30 s cooldown with the default vanish times - variants.tick(survivors, views, 129_999L); + variants.tick(new SurvivorSnapshot(survivors, views), 129_999L); assertTrue(variants.running().isEmpty()); - variants.tick(survivors, views, 130_000L); + variants.tick(new SurvivorSnapshot(survivors, views), 130_000L); assertEquals(1, variants.running().size()); } @@ -142,10 +142,10 @@ void endsWhenTheTargetLeaves(Env env) { Player first = join(env, instance, 0); Player second = join(env, instance, 10); CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); - variants.tick(List.of(first, second), List.of(view(first, 0.5D), view(second, 0.0D)), 0L); + variants.tick(new SurvivorSnapshot(List.of(first, second), List.of(view(first, 0.5D), view(second, 0.0D))), 0L); Creek variant = variants.running().get(first.getUuid()); - variants.tick(List.of(second), List.of(view(second, 0.0D)), 100L); + variants.tick(new SurvivorSnapshot(List.of(second), List.of(view(second, 0.0D))), 100L); assertTrue(variants.running().isEmpty()); assertTrue(variant.body().entity().isRemoved()); @@ -160,9 +160,9 @@ void droppingCapacityKeepsRunningVariants(Env env) { List views = List.of(view(survivors.get(0), 0.5D), view(survivors.get(1), 0.4D), view(survivors.get(2), 0.3D), view(survivors.get(3), 0.0D), view(survivors.get(4), 0.0D)); CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); - variants.tick(survivors, views, 0L); + variants.tick(new SurvivorSnapshot(survivors, views), 0L); - variants.tick(survivors.subList(0, 4), views.subList(0, 4), 100L); + variants.tick(new SurvivorSnapshot(survivors.subList(0, 4), views.subList(0, 4)), 100L); assertEquals(2, variants.running().size()); } @@ -176,11 +176,11 @@ void retriesWithoutASpot(Env env) { CreekVariants variants = variants(instance, CONFIG, candidate -> floor.get() ? Optional.of(candidate) : Optional.empty(), 0L); - variants.tick(List.of(first), List.of(view(first, 0.5D)), 0L); + variants.tick(new SurvivorSnapshot(List.of(first), List.of(view(first, 0.5D))), 0L); assertTrue(variants.running().isEmpty()); floor.set(true); - variants.tick(List.of(first), List.of(view(first, 0.5D)), 100L); + variants.tick(new SurvivorSnapshot(List.of(first), List.of(view(first, 0.5D))), 100L); assertEquals(1, variants.running().size()); } @@ -194,7 +194,7 @@ void skipsASurvivorWithoutASpot(Env env) { CreekVariants variants = variants(instance, CONFIG, candidate -> candidate.x() > 100 ? Optional.of(candidate) : Optional.empty(), 0L); - variants.tick(List.of(first, second), List.of(view(first, 0.5D), view(second, 0.3D)), 0L); + variants.tick(new SurvivorSnapshot(List.of(first, second), List.of(view(first, 0.5D), view(second, 0.3D))), 0L); assertEquals(List.of(second.getUuid()), List.copyOf(variants.running().keySet())); } @@ -205,7 +205,7 @@ void stopRemovesEveryVariant(Env env) { Instance instance = env.createFlatInstance(); Player first = join(env, instance, 0); CreekVariants variants = variants(instance, CONFIG, Optional::of, 0L); - variants.tick(List.of(first), List.of(view(first, 0.5D)), 0L); + variants.tick(new SurvivorSnapshot(List.of(first), List.of(view(first, 0.5D))), 0L); Creek variant = variants.running().get(first.getUuid()); variants.stop(); diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/SurvivorSnapshotIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/SurvivorSnapshotIntegrationTest.java new file mode 100644 index 00000000..003ff7be --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/SurvivorSnapshotIntegrationTest.java @@ -0,0 +1,66 @@ +package net.onelitefeather.cygnus.creek; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.creek.state.SurvivorView; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SurvivorSnapshotIntegrationTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("The snapshot rates each survivor against the others, and nobody sees a creek yet") + void takesOneViewPerSurvivor(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player second = env.createConnection().connect(instance, new Pos(10, 40, 0)); + List> others = new ArrayList<>(); + + SurvivorSnapshot survivors = SurvivorSnapshot.take(List.of(first, second), (id, _, positions) -> { + others.add(positions); + return id.equals(first.getUuid()) ? 0.7D : 0.2D; + }); + + assertEquals(List.of(first, second), survivors.players()); + SurvivorView view = survivors.views().getFirst(); + assertEquals(first.getUuid(), view.id()); + assertEquals(first.getPosition(), view.position()); + assertEquals(0.7D, view.dread()); + assertFalse(survivors.views().stream().anyMatch(SurvivorView::seesCreek)); + assertEquals(List.of(List.of(second.getPosition()), List.of(first.getPosition())), others, + "each survivor is rated against everyone but themselves"); + } + + @Test + @DisplayName("A survivor is found by id, anyone else is not") + void findsPlayers(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createConnection().connect(instance, new Pos(0, 40, 0)); + SurvivorSnapshot survivors = SurvivorSnapshot.take(List.of(first), (_, _, _) -> 0.0D); + + assertSame(first, survivors.player(first.getUuid())); + assertNull(survivors.player(UUID.randomUUID())); + } + + @Test + @DisplayName("Players and views have to match up") + void rejectsMismatchedViews(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createConnection().connect(instance, new Pos(0, 40, 0)); + + assertThrows(IllegalArgumentException.class, () -> new SurvivorSnapshot(List.of(first), List.of())); + } +} From e8dbb85fba37a65715d77431c3d4c7df04d861f0 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 12:52:19 +0200 Subject: [PATCH 80/92] fix(creek): only take back effects the creek handed out --- .../creek/consequence/CatchEffects.java | 13 +--- .../consequence/SelectionConsequence.java | 23 ++----- .../creek/consequence/TrackedEffects.java | 59 ++++++++++++++++++ .../TrackedEffectsIntegrationTest.java | 62 +++++++++++++++++++ 4 files changed, 130 insertions(+), 27 deletions(-) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffects.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffectsIntegrationTest.java 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 index 9ca1a092..eee2aef2 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java @@ -8,9 +8,6 @@ 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; @@ -32,7 +29,7 @@ public final class CatchEffects implements CatchConsequence { private final Predicate jumpScare; private final Function foodBars; private final int slownessTicks; - private final Set slowed = ConcurrentHashMap.newKeySet(); + private final TrackedEffects effects = new TrackedEffects(); /** * Sets up the effects. @@ -59,17 +56,13 @@ public void apply(Player survivor) { bar.drain(); } if (this.slownessTicks > 0) { - survivor.addEffect(new Potion(PotionEffect.SLOWNESS, 0, this.slownessTicks)); - this.slowed.add(survivor); + this.effects.add(survivor, new Potion(PotionEffect.SLOWNESS, 0, this.slownessTicks)); } } @Override public void cleanUp() { - for (Player player : List.copyOf(this.slowed)) { - player.removeEffect(PotionEffect.SLOWNESS); - } - this.slowed.clear(); + this.effects.removeAll(); } private static void scareWithoutCorpse(Player survivor) { diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java index 08a8302c..eff0f258 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java @@ -10,8 +10,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import java.util.random.RandomGenerator; @@ -50,8 +48,7 @@ public final class SelectionConsequence { private final Supplier> routePoints; private final Ground ground; private final RandomGenerator random; - private final Set slowed = ConcurrentHashMap.newKeySet(); - private final Set blinded = ConcurrentHashMap.newKeySet(); + private final TrackedEffects effects = new TrackedEffects(); /** * Sets up the consequence for a round. @@ -84,13 +81,11 @@ public void apply(Player selected, Collection survivors) { */ void stun(Player selected, Collection survivors) { scare(selected); - selected.addEffect(new Potion(PotionEffect.SLOWNESS, STUN_AMPLIFIER, STUN_TICKS)); - this.slowed.add(selected); + this.effects.add(selected, new Potion(PotionEffect.SLOWNESS, STUN_AMPLIFIER, STUN_TICKS)); Pos center = selected.getPosition(); for (Player other : survivors) { if (other == selected || other.getPosition().distance(center) > BLIND_RADIUS) continue; - other.addEffect(new Potion(PotionEffect.BLINDNESS, 0, BLIND_TICKS)); - this.blinded.add(other); + this.effects.add(other, new Potion(PotionEffect.BLINDNESS, 0, BLIND_TICKS)); } } @@ -116,17 +111,11 @@ boolean teleportAway(Player selected) { } /** - * Takes back every slowness and blindness handed out, so nothing lingers after the round. + * Takes back every slowness and blindness handed out that is still running, so nothing lingers + * after the round. */ public void cleanUp() { - for (Player player : List.copyOf(this.slowed)) { - player.removeEffect(PotionEffect.SLOWNESS); - } - for (Player player : List.copyOf(this.blinded)) { - player.removeEffect(PotionEffect.BLINDNESS); - } - this.slowed.clear(); - this.blinded.clear(); + this.effects.removeAll(); } private static void scare(Player player) { diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffects.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffects.java new file mode 100644 index 00000000..32ca5ba1 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffects.java @@ -0,0 +1,59 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.minestom.server.entity.Player; +import net.minestom.server.potion.Potion; +import net.minestom.server.potion.PotionEffect; +import net.minestom.server.potion.TimedPotion; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Hands out potion effects and takes back exactly those again. + *

    + * Taking back only removes an effect that is still the one handed out here. If it has run out, or + * something else has put the same effect on the player since, it is left alone. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +final class TrackedEffects { + + private final Map given = new ConcurrentHashMap<>(); + + /** + * Puts an effect on a player and remembers it. + * + * @param player the player + * @param potion the effect to put on them + */ + void add(Player player, Potion potion) { + player.addEffect(potion); + TimedPotion active = player.getEffect(potion.effect()); + if (active != null) this.given.put(new Key(player, potion.effect()), active); + } + + /** + * Takes back every effect handed out that is still running, and forgets them all. + */ + void removeAll() { + for (Map.Entry entry : List.copyOf(this.given.entrySet())) { + Player player = entry.getKey().player(); + PotionEffect effect = entry.getKey().effect(); + if (Objects.equals(player.getEffect(effect), entry.getValue())) { + player.removeEffect(effect); + } + } + this.given.clear(); + } + + /** + * One effect on one player. A new effect of the same kind replaces the one remembered before. + */ + private record Key(Player player, PotionEffect effect) { + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffectsIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffectsIntegrationTest.java new file mode 100644 index 00000000..4ef16944 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/TrackedEffectsIntegrationTest.java @@ -0,0 +1,62 @@ +package net.onelitefeather.cygnus.creek.consequence; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.potion.Potion; +import net.minestom.server.potion.PotionEffect; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TrackedEffectsIntegrationTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("Removing takes back every effect it handed out") + void removesItsOwnEffects(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + TrackedEffects effects = new TrackedEffects(); + effects.add(player, new Potion(PotionEffect.SLOWNESS, 0, 100)); + effects.add(player, new Potion(PotionEffect.BLINDNESS, 0, 100)); + + effects.removeAll(); + + assertFalse(player.hasEffect(PotionEffect.SLOWNESS)); + assertFalse(player.hasEffect(PotionEffect.BLINDNESS)); + } + + @Test + @DisplayName("An effect something else put on the player since is left alone") + void leavesForeignEffects(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + TrackedEffects effects = new TrackedEffects(); + effects.add(player, new Potion(PotionEffect.SLOWNESS, 0, 100)); + player.addEffect(new Potion(PotionEffect.SLOWNESS, 2, 400)); + + effects.removeAll(); + + assertTrue(player.hasEffect(PotionEffect.SLOWNESS)); + assertEquals(2, player.getEffectLevel(PotionEffect.SLOWNESS)); + } + + @Test + @DisplayName("Handing out the same effect again replaces the one it remembers") + void remembersTheLatest(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + TrackedEffects effects = new TrackedEffects(); + effects.add(player, new Potion(PotionEffect.SLOWNESS, 0, 100)); + effects.add(player, new Potion(PotionEffect.SLOWNESS, 6, 40)); + + effects.removeAll(); + + assertFalse(player.hasEffect(PotionEffect.SLOWNESS)); + } +} From a4711d8adde1b78153d0ffa527227412b9707096 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 12:52:34 +0200 Subject: [PATCH 81/92] refactor(creek): replace catch and select callbacks with CreekActions --- .../onelitefeather/cygnus/creek/Creek.java | 39 +++++++++++++------ .../cygnus/creek/state/CreekActions.java | 31 +++++++++++++++ .../cygnus/creek/state/CreekContext.java | 7 +--- .../cygnus/creek/state/HuntState.java | 3 +- .../cygnus/creek/state/PatrolState.java | 2 +- .../cygnus/creek/CreekIntegrationTest.java | 2 +- .../cygnus/creek/debug/CreekDebugTest.java | 6 +-- .../cygnus/creek/state/Contexts.java | 25 ++++++++++-- 8 files changed, 89 insertions(+), 26 deletions(-) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.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 index d98c7169..2bde2b6b 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java @@ -2,11 +2,13 @@ import net.minestom.server.entity.Player; import net.onelitefeather.cygnus.creek.body.CreekBody; +import net.onelitefeather.cygnus.creek.state.CreekActions; import net.onelitefeather.cygnus.creek.state.CreekContext; import net.onelitefeather.cygnus.creek.state.CreekState; import net.onelitefeather.cygnus.creek.state.SurvivorView; import net.onelitefeather.cygnus.creek.state.VanishState; import net.onelitefeather.cygnus.creek.world.RouteProvider; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; @@ -29,7 +31,9 @@ final class Creek { private final CreekBody body; private final RouteProvider route; private final CreekRound round; + private final CreekActions actions = new Actions(); private CreekState state; + private @Nullable SurvivorSnapshot survivors; private List lastViews = List.of(); private boolean entered; @@ -97,20 +101,12 @@ void vanishForGood(SurvivorSnapshot survivors, long now) { * Fills in who sees this creek and builds the context for the states. */ private CreekContext context(SurvivorSnapshot survivors, Set ignored, long now) { + this.survivors = survivors; this.lastViews = this.views(survivors); List noticed = ignored.isEmpty() ? this.lastViews : this.lastViews.stream().filter(view -> !ignored.contains(view.id())).toList(); - CreekRound round = this.round; - return new CreekContext(now, noticed, this.body, this.route, round.spots(), - id -> { - Player caught = survivors.player(id); - if (caught != null) round.consequence().apply(caught); - }, - id -> { - Player selected = survivors.player(id); - if (selected != null) round.selection().apply(selected, survivors.players()); - }, - round.config(), round.random()); + return new CreekContext(now, noticed, this.body, this.route, this.round.spots(), this.actions, + this.round.config(), this.round.random()); } /** @@ -165,4 +161,25 @@ private void switchTo(CreekState next, CreekContext ctx) { this.state = next; next.enter(ctx); } + + /** + * Applies the consequences to survivors of the current step. Anyone who is no longer among + * them is left alone. + */ + private final class Actions implements CreekActions { + + @Override + public void caught(UUID survivor) { + SurvivorSnapshot current = Creek.this.survivors; + Player player = current == null ? null : current.player(survivor); + if (player != null) Creek.this.round.consequence().apply(player); + } + + @Override + public void selected(UUID survivor) { + SurvivorSnapshot current = Creek.this.survivors; + Player player = current == null ? null : current.player(survivor); + if (player != null) Creek.this.round.selection().apply(player, current.players()); + } + } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.java new file mode 100644 index 00000000..071a3d12 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.java @@ -0,0 +1,31 @@ +package net.onelitefeather.cygnus.creek.state; + +import java.util.UUID; + +/** + * What a state can do to a survivor, beyond moving the creek's body. + *

    + * The states only know survivors by id. Whoever runs the creek looks the player up and applies the + * consequence, so the states stay free of server objects. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public interface CreekActions { + + /** + * The creek caught a survivor. + * + * @param survivor the id of the survivor + */ + void caught(UUID survivor); + + /** + * The patrolling creek picked out a survivor. + * + * @param survivor the id of the survivor + */ + void selected(UUID survivor); +} 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 index cb446ddf..83a0aec7 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekContext.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekContext.java @@ -11,7 +11,6 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.function.Consumer; import java.util.random.RandomGenerator; /** @@ -26,8 +25,7 @@ * @param body the creek's body * @param route decides where the creek walks next * @param spots finds places where nobody can see the creek - * @param onCatch called with the id of a survivor the creek caught - * @param onSelect called with the id of a survivor the patrolling creek picked out + * @param actions what the states can do to a survivor * @param config the settings * @param random the random source * @@ -41,8 +39,7 @@ public record CreekContext( CreekBody body, RouteProvider route, SpotFinder spots, - Consumer onCatch, - Consumer onSelect, + CreekActions actions, CreekConfig config, RandomGenerator random ) { 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 index b4a4ab7a..8aae06f6 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/HuntState.java @@ -1,6 +1,5 @@ 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; @@ -104,7 +103,7 @@ public CreekState tick(CreekContext ctx) { double distance = body.position().distance(view.position()); if (distance <= config.catchDistance()) { - ctx.onCatch().accept(this.target); + ctx.actions().caught(this.target); return DoneState.INSTANCE; } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java index 86ae075e..9d78571b 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java @@ -150,7 +150,7 @@ private boolean stare(CreekContext ctx) { // They are most likely still looking at it. Walk on rather than stop again to look back. this.watched = true; this.markProgress(ctx.now(), body.position()); - ctx.onSelect().accept(target); + ctx.actions().selected(target); return false; } 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 4c83d5d2..2044c4c3 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java @@ -140,7 +140,7 @@ public void enter(CreekContext ctx) { @Override public CreekState tick(CreekContext ctx) { - ctx.onSelect().accept(survivor.getUuid()); + ctx.actions().selected(survivor.getUuid()); return this; } }; 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 cbeb634d..62824215 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 @@ -108,7 +108,7 @@ void patrolStareIsShown() { PatrolState patrol = new PatrolState(); List views = List.of(new SurvivorView(STEVE, new Pos(0, 40, 2), 0.1D, false)); CreekContext ctx = new CreekContext(0L, views, new RecordingBody(HERE), Contexts.route(new Pos(30, 40, 0)), - SPOTS, _ -> {}, _ -> {}, CreekConfig.DEFAULT, new Random(1)); + SPOTS, Contexts.NO_ACTIONS, CreekConfig.DEFAULT, new Random(1)); patrol.enter(ctx); patrol.tick(ctx); @@ -138,11 +138,11 @@ void selectCooldownIsShown() { List near = List.of(new SurvivorView(STEVE, new Pos(0, 40, 2), 0.1D, false)); RecordingBody body = new RecordingBody(HERE); CreekContext start = new CreekContext(0L, near, body, Contexts.route(new Pos(30, 40, 0)), SPOTS, - _ -> {}, _ -> {}, CreekConfig.DEFAULT, new Random(1)); + Contexts.NO_ACTIONS, CreekConfig.DEFAULT, new Random(1)); patrol.enter(start); patrol.tick(start); patrol.tick(new CreekContext(1000L, near, body, Contexts.route(new Pos(30, 40, 0)), SPOTS, - _ -> {}, _ -> {}, CreekConfig.DEFAULT, new Random(1))); + Contexts.NO_ACTIONS, CreekConfig.DEFAULT, new Random(1))); assertEquals("PATROL · select 7s · Steve 0.10", plain(CreekDebug.line(patrol, HERE, near, NAMES::get, "", 4000L))); diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java index 2e20b74b..66728c1a 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 @@ -13,6 +13,7 @@ import java.util.Optional; import java.util.Random; import java.util.UUID; +import java.util.function.Consumer; /** * Builds contexts for the state tests: the design's settings, ground everywhere, fixed chance. @@ -24,21 +25,39 @@ public final class Contexts { static final CreekSight SIGHT = new CreekSight(CONFIG.sightRange(), CONFIG.sightViewAngle()); static final SpotFinder SPOTS = new SpotFinder(SIGHT, Optional::of); + /** Actions that do nothing. */ + public static final CreekActions NO_ACTIONS = actions(_ -> {}, _ -> {}); + + /** Actions that hand every catch and selection to the given consumers. */ + public static CreekActions actions(Consumer caught, Consumer selected) { + return new CreekActions() { + @Override + public void caught(UUID survivor) { + caught.accept(survivor); + } + + @Override + public void selected(UUID survivor) { + selected.accept(survivor); + } + }; + } + 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, + return new CreekContext(now, List.of(survivors), body, route, SPOTS, actions(caught::add, _ -> {}), CONFIG, new Random(7)); } static CreekContext context(long now, CreekBody body, RouteProvider route, CreekConfig config, SurvivorView... survivors) { - return new CreekContext(now, List.of(survivors), body, route, SPOTS, _ -> {}, _ -> {}, config, new Random(7)); + return new CreekContext(now, List.of(survivors), body, route, SPOTS, NO_ACTIONS, config, new Random(7)); } /** A context that records every survivor the state selects. */ static CreekContext selecting(long now, CreekBody body, RouteProvider route, List selected, SurvivorView... survivors) { - return new CreekContext(now, List.of(survivors), body, route, SPOTS, _ -> {}, selected::add, CONFIG, + return new CreekContext(now, List.of(survivors), body, route, SPOTS, actions(_ -> {}, selected::add), CONFIG, new Random(7)); } From bd50e195f0d6a95f2aeda9606d9fd91139c631e8 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 17:39:53 +0200 Subject: [PATCH 82/92] feat(creek): vanish at dead ends to leave a route island --- .../onelitefeather/cygnus/creek/Creek.java | 14 +++ .../consequence/SelectionConsequence.java | 28 +++++- .../cygnus/creek/state/CreekActions.java | 11 ++- .../cygnus/creek/state/PatrolState.java | 16 +++ .../cygnus/creek/state/VanishState.java | 9 ++ .../cygnus/creek/world/RouteStep.java | 13 ++- .../cygnus/creek/CreekIntegrationTest.java | 25 +++++ .../SelectionConsequenceIntegrationTest.java | 16 +++ .../cygnus/creek/state/Contexts.java | 13 ++- .../cygnus/creek/state/PatrolStateTest.java | 97 +++++++++++++++++++ 10 files changed, 237 insertions(+), 5 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java index 2bde2b6b..0415ec5f 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java @@ -1,5 +1,6 @@ package net.onelitefeather.cygnus.creek; +import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Player; import net.onelitefeather.cygnus.creek.body.CreekBody; import net.onelitefeather.cygnus.creek.state.CreekActions; @@ -34,6 +35,7 @@ final class Creek { private final CreekActions actions = new Actions(); private CreekState state; private @Nullable SurvivorSnapshot survivors; + private Set ignored = Set.of(); private List lastViews = List.of(); private boolean entered; @@ -102,6 +104,7 @@ void vanishForGood(SurvivorSnapshot survivors, long now) { */ private CreekContext context(SurvivorSnapshot survivors, Set ignored, long now) { this.survivors = survivors; + this.ignored = ignored; this.lastViews = this.views(survivors); List noticed = ignored.isEmpty() ? this.lastViews : this.lastViews.stream().filter(view -> !ignored.contains(view.id())).toList(); @@ -181,5 +184,16 @@ public void selected(UUID survivor) { Player player = current == null ? null : current.player(survivor); if (player != null) Creek.this.round.selection().apply(player, current.players()); } + + @Override + public void vanished(Pos where) { + SurvivorSnapshot current = Creek.this.survivors; + if (current == null) return; + // Whoever this creek leaves alone does not see it, so they do not see it vanish either. + List noticing = current.players().stream() + .filter(player -> !Creek.this.ignored.contains(player.getUuid())) + .toList(); + Creek.this.round.selection().vanishAt(where, noticing); + } } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java index eff0f258..c7635a67 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java @@ -6,6 +6,7 @@ import net.minestom.server.potion.Potion; import net.minestom.server.potion.PotionEffect; import net.onelitefeather.cygnus.creek.world.Ground; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.Collection; @@ -20,6 +21,10 @@ * goes blind for a moment, or they suddenly find themselves somewhere else on the map. It is a * scare, not a catch, so it never counts towards giving them away to the slender. *

    + *

    + * When the patrolling creek vanishes at the end of its route, the survivors around it go blind + * the same way. + *

    * * @author theEvilReaper * @version 1.0.0 @@ -82,9 +87,28 @@ public void apply(Player selected, Collection survivors) { void stun(Player selected, Collection survivors) { scare(selected); this.effects.add(selected, new Potion(PotionEffect.SLOWNESS, STUN_AMPLIFIER, STUN_TICKS)); - Pos center = selected.getPosition(); + this.blindAround(selected.getPosition(), survivors, selected); + } + + /** + * The creek vanished: every survivor close by hears it and goes blind for a moment. + * + * @param where where the creek vanished + * @param survivors the survivors it may blind + */ + public void vanishAt(Pos where, Collection survivors) { + for (Player survivor : survivors) { + if (survivor.getPosition().distance(where) <= BLIND_RADIUS) scare(survivor); + } + this.blindAround(where, survivors, null); + } + + /** + * Blinds every survivor within {@link #BLIND_RADIUS} of the centre, except the one spared. + */ + private void blindAround(Pos center, Collection survivors, @Nullable Player spared) { for (Player other : survivors) { - if (other == selected || other.getPosition().distance(center) > BLIND_RADIUS) continue; + if (other == spared || other.getPosition().distance(center) > BLIND_RADIUS) continue; this.effects.add(other, new Potion(PotionEffect.BLINDNESS, 0, BLIND_TICKS)); } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.java index 071a3d12..293cdebe 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/CreekActions.java @@ -1,9 +1,11 @@ package net.onelitefeather.cygnus.creek.state; +import net.minestom.server.coordinate.Pos; + import java.util.UUID; /** - * What a state can do to a survivor, beyond moving the creek's body. + * What a state can do to the survivors, beyond moving the creek's body. *

    * The states only know survivors by id. Whoever runs the creek looks the player up and applies the * consequence, so the states stay free of server objects. @@ -28,4 +30,11 @@ public interface CreekActions { * @param survivor the id of the survivor */ void selected(UUID survivor); + + /** + * The creek vanished on purpose, in front of whoever is nearby. + * + * @param where where it vanished + */ + void vanished(Pos where); } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java index 9d78571b..41b19314 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java @@ -19,6 +19,11 @@ * looks back at you for a moment. At some waypoints it rests for a while, either because the route * says so or just by chance; whichever takes longer wins. *

    + *

    + * At the end of a route no other route links to, it may vanish instead of resting there, which + * blinds anyone close by. It stays away for as long as it would have rested and then turns up + * somewhere else on the map, so it does not keep pacing one stretch of routes all round. + *

    * * @author theEvilReaper * @version 1.0.0 @@ -35,6 +40,9 @@ public final class PatrolState implements CreekState { /** How long it leaves everyone alone after picking someone out, in milliseconds. */ static final long SELECT_COOLDOWN_MILLIS = 10_000L; + /** How likely it vanishes on reaching a dead end instead of resting there. */ + static final double DEAD_END_VANISH_CHANCE = 0.5D; + /** How close counts as having reached a waypoint, in blocks. */ static final double ARRIVED = 2.0D; @@ -45,6 +53,7 @@ public final class PatrolState implements CreekState { private boolean watched; private long pausedUntil; private int goalPauseMillis; + private boolean goalDeadEnd; private long restingUntil; private Pos progressAt = Pos.ZERO; private long progressSince; @@ -102,6 +111,12 @@ public CreekState tick(CreekContext ctx) { return this; } if (this.hasArrived(here)) { + if (this.goalDeadEnd && ctx.random().nextDouble() < DEAD_END_VANISH_CHANCE) { + this.goal = null; + ctx.actions().vanished(here); + // Gone for as long as it would have rested here, then back somewhere else. + return new VanishState(ctx.now() + this.goalPauseMillis); + } long rest = this.restMillis(ctx); this.goal = null; if (rest > 0) { @@ -115,6 +130,7 @@ public CreekState tick(CreekContext ctx) { Optional step = ctx.route().next(here, _ -> true, ctx.random()); this.goal = step.map(RouteStep::target).orElse(null); this.goalPauseMillis = step.map(RouteStep::pauseMillis).orElse(0); + this.goalDeadEnd = step.map(RouteStep::deadEnd).orElse(false); this.markProgress(ctx.now(), here); } if (this.goal == null) { 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 fa9de40f..0f54d363 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 @@ -37,6 +37,15 @@ public static VanishState forever() { return new VanishState(Long.MAX_VALUE); } + /** + * Tells when the creek may come back. + * + * @return the time in milliseconds, {@link Long#MAX_VALUE} for {@link #forever()} + */ + public long until() { + return this.until; + } + /** * Tells whether this vanish never ends. * diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java index 5fdb2d84..9e153321 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/RouteStep.java @@ -7,10 +7,21 @@ * * @param target where the creek walks to * @param pauseMillis how long it rests after arriving, in milliseconds + * @param deadEnd whether the waypoint is the end of a route with no other route linked to it * * @author theEvilReaper * @version 1.0.0 * @since 2.15.0 */ -public record RouteStep(Pos target, int pauseMillis) { +public record RouteStep(Pos target, int pauseMillis, boolean deadEnd) { + + /** + * A step to a waypoint that is no dead end. + * + * @param target where the creek walks to + * @param pauseMillis how long it rests after arriving, in milliseconds + */ + public RouteStep(Pos target, int pauseMillis) { + this(target, pauseMillis, false); + } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java index 2044c4c3..b38cf706 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java @@ -150,6 +150,31 @@ public CreekState tick(CreekContext ctx) { assertTrue(survivor.hasEffect(PotionEffect.SLOWNESS)); } + @Test + @DisplayName("Vanishing blinds the survivors nearby, but not the ones the creek leaves alone") + void vanishSparesTheIgnored(Env env) { + Instance instance = env.createFlatInstance(); + Player haunted = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player other = env.createConnection().connect(instance, new Pos(3, 40, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0, 40, 2)); + CreekState vanishing = new CreekState() { + @Override + public void enter(CreekContext ctx) { + } + + @Override + public CreekState tick(CreekContext ctx) { + ctx.actions().vanished(ctx.body().position()); + return this; + } + }; + + creek(body, CreekConfig.DEFAULT, vanishing).tick(survivors(haunted, other), Set.of(haunted.getUuid()), 0L); + + assertTrue(other.hasEffect(PotionEffect.BLINDNESS)); + assertFalse(haunted.hasEffect(PotionEffect.BLINDNESS)); + } + @Test @DisplayName("A haunted survivor does not see the patrolling creek, everyone else does") void patrolIsHiddenFromTheHaunted(Env env) { diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java index c0d0bc00..56d37e0f 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java @@ -50,6 +50,22 @@ void stunBlindsOtherSurvivorsNearby(Env env) { assertFalse(selected.hasEffect(PotionEffect.BLINDNESS)); } + @Test + @DisplayName("Vanishing blinds the survivors nearby, nobody else") + void vanishBlindsSurvivorsNearby(Env env) { + Instance instance = env.createFlatInstance(); + Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); + Player far = env.createConnection().connect(instance, new Pos(20, 40, 0)); + Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0)); + SelectionConsequence selection = new SelectionConsequence(List::of, Optional::of, new Random(1)); + + selection.vanishAt(new Pos(0, 40, 0), List.of(near, far)); + + assertTrue(near.hasEffect(PotionEffect.BLINDNESS)); + assertFalse(far.hasEffect(PotionEffect.BLINDNESS)); + assertFalse(slender.hasEffect(PotionEffect.BLINDNESS), "only survivors go blind"); + } + @Test @DisplayName("A teleport lands on a route point 20 to 40 blocks away") void teleportLandsInRange(Env env) { 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 66728c1a..8656f37e 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 @@ -14,6 +14,7 @@ import java.util.Random; import java.util.UUID; import java.util.function.Consumer; +import java.util.random.RandomGenerator; /** * Builds contexts for the state tests: the design's settings, ground everywhere, fixed chance. @@ -40,6 +41,10 @@ public void caught(UUID survivor) { public void selected(UUID survivor) { selected.accept(survivor); } + + @Override + public void vanished(Pos where) { + } }; } @@ -54,6 +59,12 @@ static CreekContext context(long now, CreekBody body, RouteProvider route, Creek return new CreekContext(now, List.of(survivors), body, route, SPOTS, NO_ACTIONS, config, new Random(7)); } + /** A context with the given actions and random source. */ + static CreekContext context(long now, CreekBody body, RouteProvider route, CreekActions actions, + RandomGenerator random, SurvivorView... survivors) { + return new CreekContext(now, List.of(survivors), body, route, SPOTS, actions, CONFIG, random); + } + /** A context that records every survivor the state selects. */ static CreekContext selecting(long now, CreekBody body, RouteProvider route, List selected, SurvivorView... survivors) { @@ -81,7 +92,7 @@ public static RouteProvider route(Pos... points) { return new RouteProvider() { @Override public Optional next(Pos current, java.util.function.Predicate allowed, - java.util.random.RandomGenerator random) { + RandomGenerator random) { return Arrays.stream(points) .filter(point -> point.distance(current) >= 1.0D) .filter(allowed) diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java index eb488929..88bfd6c7 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java @@ -17,8 +17,10 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; +import java.util.random.RandomGenerator; 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; @@ -334,4 +336,99 @@ void keepsHisPointAfterAPause() { state.tick(Contexts.context(1700L, body, route, new ArrayList<>(), watcher)); assertEquals(new Pos(20, 40, 0), body.goal, "he goes on to the point he was heading for"); } + + // ---- vanishing at a dead end ---- + + /** Answers every roll with the given value. */ + private static RandomGenerator rolling(double value) { + return new RandomGenerator() { + @Override + public long nextLong() { + return 0L; + } + + @Override + public double nextDouble() { + return value; + } + }; + } + + /** A route that always heads for A with the given pause, as a dead end or not. */ + private static RouteProvider towardsA(int pauseMillis, boolean deadEnd) { + return (_, _, _) -> Optional.of(new RouteStep(A, pauseMillis, deadEnd)); + } + + /** Records every place the creek vanished from. */ + private static CreekActions recordingVanishes(List vanished) { + return new CreekActions() { + @Override + public void caught(UUID survivor) { + } + + @Override + public void selected(UUID survivor) { + } + + @Override + public void vanished(Pos where) { + vanished.add(where); + } + }; + } + + private static CreekContext deadEndContext(long now, RecordingBody body, RouteProvider route, List vanished, + double roll) { + return Contexts.context(now, body, route, recordingVanishes(vanished), rolling(roll), far(FIRST, 0.0D, false)); + } + + @Test + @DisplayName("At a dead end he may vanish, gone for as long as he would have rested there") + void vanishesAtADeadEnd() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = towardsA(3000, true); + List vanished = new ArrayList<>(); + PatrolState state = new PatrolState(); + state.enter(deadEndContext(0L, body, route, vanished, 0.0D)); + state.tick(deadEndContext(0L, body, route, vanished, 0.0D)); + assertEquals(A, body.goal); + + body.position = A; + CreekState next = state.tick(deadEndContext(100L, body, route, vanished, 0.0D)); + + VanishState vanish = assertInstanceOf(VanishState.class, next); + assertEquals(3100L, vanish.until()); + assertEquals(List.of(A), vanished); + } + + @Test + @DisplayName("Missing the roll at a dead end, he rests and walks on as anywhere else") + void restsWhenTheRollMisses() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = towardsA(3000, true); + List vanished = new ArrayList<>(); + PatrolState state = new PatrolState(); + state.enter(deadEndContext(0L, body, route, vanished, 0.99D)); + state.tick(deadEndContext(0L, body, route, vanished, 0.99D)); + + body.position = A; + assertSame(state, state.tick(deadEndContext(100L, body, route, vanished, 0.99D))); + assertNull(body.goal, "he rests at the end"); + assertTrue(vanished.isEmpty()); + } + + @Test + @DisplayName("A point that is no dead end never makes him vanish") + void noVanishWithoutADeadEnd() { + RecordingBody body = new RecordingBody(new Pos(0, 40, 0)); + RouteProvider route = towardsA(3000, false); + List vanished = new ArrayList<>(); + PatrolState state = new PatrolState(); + state.enter(deadEndContext(0L, body, route, vanished, 0.0D)); + state.tick(deadEndContext(0L, body, route, vanished, 0.0D)); + + body.position = A; + assertSame(state, state.tick(deadEndContext(100L, body, route, vanished, 0.0D))); + assertTrue(vanished.isEmpty()); + } } From 83c1bf95984461350bec19a22ac40fdd61c77a3e Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 17:39:58 +0200 Subject: [PATCH 83/92] feat(creek): link route ends to the middle of other routes --- .../cygnus/creek/world/CreekPaths.java | 100 ++++++++++-------- .../cygnus/creek/world/PathRoute.java | 81 ++++++++++---- .../cygnus/creek/world/CreekPathsTest.java | 49 +++++++-- .../cygnus/creek/world/PathRouteTest.java | 82 +++++++++++++- .../cygnus/setup/creek/CreekRoutePreview.java | 43 +++++--- .../setup/creek/CreekRoutePreviewTest.java | 19 +++- 6 files changed, 282 insertions(+), 92 deletions(-) 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 index f1783f0f..e74c3f28 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java @@ -4,6 +4,7 @@ import net.minestom.server.coordinate.Vec; import net.onelitefeather.cygnus.common.creek.CreekRoute; import net.onelitefeather.cygnus.common.creek.CreekWaypoint; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.HashMap; @@ -11,10 +12,12 @@ import java.util.Map; /** - * The creek routes of a map, with the links between their ends worked out once up front. + * The creek routes of a map, with the links between them worked out once up front. *

    - * Two ends are linked when they are at most the link distance apart. The two ends of the same route - * never link to each other. + * An end of a route is linked to the nearest point of every other route that is at most the link + * distance away. That point may be the other route's end, or somewhere in its middle, where the two + * routes cross. Links work both ways. Two points in the middle of routes never link, and the two + * ends of the same route never link to each other. *

    * * @author theEvilReaper @@ -24,21 +27,19 @@ public final class CreekPaths { /** - * One end of a route. + * One point of a route. * - * @param route the route's index - * @param atStart {@code true} for its first point, {@code false} for its last + * @param route the route's index + * @param index the point's index within the route */ - public record End(int route, boolean atStart) { + public record Node(int route, int index) { } - private static final boolean[] BOTH_ENDS = {true, false}; - private final List routes; - private final Map> links; + private final Map> links; private final List allPoints; - private CreekPaths(List routes, Map> links) { + private CreekPaths(List routes, Map> links) { this.routes = routes; this.links = links; List points = new ArrayList<>(); @@ -49,32 +50,56 @@ private CreekPaths(List routes, Map> links) { } /** - * Takes the routes of a map and works out which ends are linked. + * Takes the routes of a map and works out the links between them. * * @param routes the valid routes of the map - * @param linkDistance how close two ends have to be to count as linked, in blocks + * @param linkDistance how close an end has to be to a point of another route to link to it, in blocks * @return the paths */ public static CreekPaths of(List routes, double linkDistance) { List frozenRoutes = List.copyOf(routes); - Map> links = new HashMap<>(); - for (int firstRoute = 0; firstRoute < frozenRoutes.size(); firstRoute++) { - for (int secondRoute = firstRoute + 1; secondRoute < frozenRoutes.size(); secondRoute++) { - for (boolean firstAtStart : BOTH_ENDS) { - for (boolean secondAtStart : BOTH_ENDS) { - End first = new End(firstRoute, firstAtStart); - End second = new End(secondRoute, secondAtStart); - if (position(frozenRoutes, first).distance(position(frozenRoutes, second)) <= linkDistance) { - links.computeIfAbsent(first, _ -> new ArrayList<>()).add(second); - links.computeIfAbsent(second, _ -> new ArrayList<>()).add(first); - } - } + Map> links = new HashMap<>(); + for (int route = 0; route < frozenRoutes.size(); route++) { + int lastIndex = frozenRoutes.get(route).points().size() - 1; + for (int endIndex : new int[]{0, lastIndex}) { + Node end = new Node(route, endIndex); + for (int other = 0; other < frozenRoutes.size(); other++) { + if (other == route) continue; + Node nearest = nearest(frozenRoutes, other, toPos(position(frozenRoutes, end)), linkDistance); + if (nearest == null) continue; + link(links, end, nearest); + link(links, nearest, end); } } } return new CreekPaths(frozenRoutes, links); } + /** + * The point of a route closest to a position, as long as it is within the distance. + */ + private static @Nullable Node nearest(List routes, int route, Pos position, double distance) { + List points = routes.get(route).points(); + Node nearest = null; + double best = distance; + for (int index = 0; index < points.size(); index++) { + double away = toPos(points.get(index).position()).distance(position); + if (away <= best) { + best = away; + nearest = new Node(route, index); + } + } + return nearest; + } + + /** + * Adds a link once. Two ends close together are found from both sides. + */ + private static void link(Map> links, Node from, Node to) { + List linked = links.computeIfAbsent(from, _ -> new ArrayList<>()); + if (!linked.contains(to)) linked.add(to); + } + /** * Tells whether the map has no routes at all. * @@ -136,23 +161,13 @@ public int pauseMillis(int route, int index) { } /** - * Where an end of a route is. - * - * @param end the end - * @return the route's first or last point - */ - public Pos position(End end) { - return position(this.routes, end); - } - - /** - * The ends linked to an end, always in the same order. + * The points linked to a point, always in the same order. * - * @param end the end - * @return the linked ends, empty if there are none + * @param node the point + * @return the linked points, empty if there are none */ - public List links(End end) { - return List.copyOf(this.links.getOrDefault(end, List.of())); + public List links(Node node) { + return List.copyOf(this.links.getOrDefault(node, List.of())); } /** @@ -164,9 +179,8 @@ public List allPoints() { return this.allPoints; } - private static Pos position(List routes, End end) { - List points = routes.get(end.route()).points(); - return toPos((end.atStart() ? points.getFirst() : points.getLast()).position()); + private static Vec position(List routes, Node node) { + return routes.get(node.route()).points().get(node.index()).position(); } private static Pos toPos(Vec point) { 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 index d3a674ac..eaa7ec09 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java @@ -12,9 +12,11 @@ * Walks the creek along the routes of a map. *

    * It remembers the route, the point and the direction. At the end of a route it picks at random - * between turning around and any route linked to that end. If the creek ends up far from its last - * point, after a teleport for example, it joins back in at the nearest point. Each step brings the - * pause of its point along, except for an end the creek is just leaving. + * between turning around and any route linked to that end. Where another route begins or ends in + * the middle of this one, it picks between walking on and turning onto that route. If the creek + * ends up far from its last point, after a teleport for example, it joins back in at the nearest + * point. Each step brings the pause of its point along, except for an end the creek is just leaving + * or a point it just stepped over to from another route. *

    *

    * Because it remembers where its creek is, every creek needs a walker of its own. The @@ -79,7 +81,7 @@ private Optional rejoin(Pos current, Predicate allowed, RandomGe double distance = point.distance(current); if (distance < bestDistance) { bestDistance = distance; - best = new Cursor(route, index, 1); + best = new Cursor(route, index, 1, false); } } } @@ -88,43 +90,67 @@ private Optional rejoin(Pos current, Predicate allowed, RandomGe int lastIndex = 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() == lastIndex ? -1 : (random.nextBoolean() ? 1 : -1); - return this.moveTo(new Cursor(best.route(), best.index(), direction)); + return this.moveTo(new Cursor(best.route(), best.index(), direction, false)); } + /** + * Picks the next waypoint. The last choice is always to carry on the way it is going, or to + * turn around at the end of a route; every link from here is another choice. + */ private Cursor advance(Cursor from, RandomGenerator random) { int nextIndex = from.index() + from.direction(); - if (nextIndex >= 0 && nextIndex < this.paths.pointCount(from.route())) { - return new Cursor(from.route(), nextIndex, from.direction()); - } - // Walking backwards ends at the start, walking forwards at the end. - List links = this.paths.links(new CreekPaths.End(from.route(), from.direction() < 0)); - int choice = random.nextInt(links.size() + 1); - if (choice == links.size()) { - Cursor back = this.behind(from); - return back != null ? back : from; - } - 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); + boolean atEnd = nextIndex < 0 || nextIndex >= this.paths.pointCount(from.route()); + // An end only offers its links once the creek gets there. In the middle, a crossing offers + // them on the way through, unless the creek has only just come over from the other route. + boolean crossing = !atEnd && !from.viaLink() && this.isMiddle(from); + List links = atEnd || crossing ? this.paths.links(this.node(from)) : List.of(); + int choice = atEnd || !links.isEmpty() ? random.nextInt(links.size() + 1) : 0; + if (choice < links.size()) return this.join(links.get(choice), random); + if (!atEnd) return new Cursor(from.route(), nextIndex, from.direction(), false); + Cursor back = this.behind(from); + return back != null ? back : from; + } + + /** + * Steps over to a linked point. At an end the creek walks into the route, in the middle it + * picks a way at random. + */ + private Cursor join(CreekPaths.Node node, RandomGenerator random) { + int lastIndex = this.paths.pointCount(node.route()) - 1; + int direction = node.index() == 0 ? 1 : node.index() == lastIndex ? -1 : (random.nextBoolean() ? 1 : -1); + return new Cursor(node.route(), node.index(), direction, true); } private @Nullable Cursor behind(Cursor from) { int direction = -from.direction(); int index = from.index() + direction; if (index < 0 || index >= this.paths.pointCount(from.route())) return null; - return new Cursor(from.route(), index, direction); + return new Cursor(from.route(), index, direction, false); } private Optional moveTo(Cursor next) { this.cursor = next; - return Optional.of(new RouteStep(this.point(next), this.pauseAt(next))); + return Optional.of(new RouteStep(this.point(next), this.pauseAt(next), this.isDeadEnd(next))); } /** - * An end the creek walks away from is where it sets off, not where it rests. + * Tells whether the creek is heading for the end of a route that no other route is linked to. + * Walking away from an end does not count. + */ + private boolean isDeadEnd(Cursor cursor) { + int lastIndex = this.paths.pointCount(cursor.route()) - 1; + boolean reachingEnd = cursor.index() == lastIndex && cursor.direction() > 0; + boolean reachingStart = cursor.index() == 0 && cursor.direction() < 0; + if (!reachingEnd && !reachingStart) return false; + return this.paths.links(this.node(cursor)).isEmpty(); + } + + /** + * An end the creek walks away from is where it sets off, not where it rests. Neither is a point + * it just stepped over to from another route: it is where it already was. */ private int pauseAt(Cursor cursor) { + if (cursor.viaLink()) return 0; int lastIndex = this.paths.pointCount(cursor.route()) - 1; boolean leavingStart = cursor.index() == 0 && cursor.direction() > 0; boolean leavingEnd = cursor.index() == lastIndex && cursor.direction() < 0; @@ -136,13 +162,22 @@ private Pos point(Cursor cursor) { return this.paths.point(cursor.route(), cursor.index()); } + private CreekPaths.Node node(Cursor cursor) { + return new CreekPaths.Node(cursor.route(), cursor.index()); + } + + private boolean isMiddle(Cursor cursor) { + return cursor.index() > 0 && cursor.index() < this.paths.pointCount(cursor.route()) - 1; + } + /** * Where the creek is on the routes. * * @param route the route's index * @param index the index of the waypoint it was sent to last * @param direction {@code 1} towards the end, {@code -1} towards the start + * @param viaLink whether it just stepped over to this point from another route */ - private record Cursor(int route, int index, int direction) { + private record Cursor(int route, int index, int direction, boolean viaLink) { } } 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 index 54649f25..188b7747 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java @@ -31,10 +31,43 @@ private static CreekRoute route(String name, Vec... points) { 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()); + assertEquals(List.of(new CreekPaths.Node(1, 0), new CreekPaths.Node(2, 0)), + paths.links(new CreekPaths.Node(0, 1))); + assertEquals(List.of(new CreekPaths.Node(0, 1)), paths.links(new CreekPaths.Node(1, 0))); + assertTrue(paths.links(new CreekPaths.Node(3, 0)).isEmpty()); + } + + @Test + @DisplayName("An end on the middle of another route is linked to that point, both ways") + void endOnAMiddleIsLinked() { + CreekPaths paths = CreekPaths.of(List.of( + route("X", new Vec(0, 0, 0), new Vec(10, 0, 0), new Vec(20, 0, 0)), + route("Y", new Vec(10, 0, 2), new Vec(10, 0, 20))), 3.0D); + + assertEquals(List.of(new CreekPaths.Node(0, 1)), paths.links(new CreekPaths.Node(1, 0))); + assertEquals(List.of(new CreekPaths.Node(1, 0)), paths.links(new CreekPaths.Node(0, 1))); + } + + @Test + @DisplayName("Two middles close together are not linked") + void middlesNeverLink() { + CreekPaths paths = CreekPaths.of(List.of( + route("X", new Vec(0, 0, 0), new Vec(10, 0, 0), new Vec(20, 0, 0)), + route("Z", new Vec(-5, 0, 1), new Vec(10, 0, 1), new Vec(25, 0, 1))), 3.0D); + + assertTrue(paths.links(new CreekPaths.Node(0, 1)).isEmpty()); + assertTrue(paths.links(new CreekPaths.Node(1, 1)).isEmpty()); + } + + @Test + @DisplayName("An end links to the nearest point of another route only") + void onlyTheNearestPoint() { + CreekPaths paths = CreekPaths.of(List.of( + route("X", new Vec(-10, 0, 0), new Vec(1, 0, 0), new Vec(2, 0, 0), new Vec(20, 0, 0)), + route("W", new Vec(1, 0, 1), new Vec(1, 0, 20))), 3.0D); + + assertEquals(List.of(new CreekPaths.Node(0, 1)), paths.links(new CreekPaths.Node(1, 0))); + assertTrue(paths.links(new CreekPaths.Node(0, 2)).isEmpty(), "the second closest point stays unlinked"); } @Test @@ -42,17 +75,17 @@ void linksFollowTheDistance() { 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()); + assertTrue(paths.links(new CreekPaths.Node(0, 0)).isEmpty()); } @Test - @DisplayName("Points and positions come out as Pos") - void pointsAndPositions() { + @DisplayName("Points come out as Pos") + void pointsAsPos() { CreekPaths paths = CreekPaths.of(ROUTES, 3.0D); assertEquals(4, paths.size()); assertEquals(new Pos(12, 0, 0), paths.point(1, 0)); - assertEquals(new Pos(20, 0, 0), paths.position(new CreekPaths.End(1, false))); + assertEquals(new Pos(20, 0, 0), paths.point(1, 1)); assertEquals(8, paths.allPoints().size()); assertTrue(CreekPaths.of(List.of(), 3.0D).isEmpty()); } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java index 853b6349..55291b7c 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/PathRouteTest.java @@ -14,6 +14,7 @@ import java.util.random.RandomGenerator; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class PathRouteTest { @@ -174,9 +175,86 @@ void endsInWalkingDirectionPause() { assertEquals(0, step(route, new Pos(0, 40, 0), fixed(0)).pauseMillis(), "it walks away from the start"); assertEquals(0, step(route, new Pos(0, 40, 0), fixed(0)).pauseMillis()); RouteStep end = step(route, new Pos(10, 40, 0), fixed(0)); - assertEquals(new RouteStep(new Pos(20, 40, 0), 2000), end); + assertEquals(new RouteStep(new Pos(20, 40, 0), 2000, true), end); assertEquals(0, step(route, end.target(), fixed(0)).pauseMillis()); - assertEquals(new RouteStep(new Pos(0, 40, 0), 1000), step(route, new Pos(10, 40, 0), fixed(0))); + assertEquals(new RouteStep(new Pos(0, 40, 0), 1000, true), step(route, new Pos(10, 40, 0), fixed(0))); + } + + @Test + @DisplayName("Heading for an end without links is a dead end, heading anywhere else is not") + void marksDeadEnds() { + PathRoute route = route(A); + + assertFalse(step(route, new Pos(0, 40, 0), fixed(0)).deadEnd(), "joining at the start walks away from it"); + assertFalse(step(route, new Pos(0, 40, 0), fixed(0)).deadEnd(), "the middle is no end"); + assertTrue(step(route, new Pos(10, 40, 0), fixed(0)).deadEnd()); + assertFalse(step(route, new Pos(20, 40, 0), fixed(0)).deadEnd(), "turning around leaves the end"); + assertTrue(step(route, new Pos(10, 40, 0), fixed(0)).deadEnd(), "the start is a dead end too"); + } + + @Test + @DisplayName("A linked end is no dead end") + void linkedEndIsNoDeadEnd() { + PathRoute route = route(A, B); + next(route, new Pos(10, 40, 0), fixed(0)); + + assertFalse(step(route, new Pos(10, 40, 0), fixed(0)).deadEnd()); + } + + /** X runs along the x axis; Y starts right next to X's middle point and runs off along z. */ + private static final CreekRoute X = CreekRoute.ofPositions("X", + List.of(new Vec(0, 40, 0), new Vec(10, 40, 0), new Vec(20, 40, 0))); + private static final CreekRoute Y = CreekRoute.ofPositions("Y", + List.of(new Vec(10, 40, 2), new Vec(10, 40, 12), new Vec(10, 40, 22))); + + @Test + @DisplayName("At a crossing in the middle it can turn onto the other route") + void turnsAtACrossing() { + PathRoute route = route(X, Y); + Pos at = next(route, new Pos(0, 40, 0), fixed(0)); + at = next(route, at, fixed(0)); + assertEquals(new Pos(10, 40, 0), at); + + RouteStep turn = step(route, at, fixed(0)); + assertEquals(new RouteStep(new Pos(10, 40, 2), 0), turn, "the other route's end is the stepping stone"); + assertEquals(new Pos(10, 40, 12), next(route, turn.target(), fixed(0))); + } + + @Test + @DisplayName("At a crossing in the middle it can also walk on") + void walksOnAtACrossing() { + PathRoute route = route(X, Y); + Pos at = next(route, new Pos(0, 40, 0), fixed(0)); + at = next(route, at, fixed(0)); + + assertEquals(new Pos(20, 40, 0), next(route, at, fixed(1))); + } + + @Test + @DisplayName("Right after taking a link it does not jump straight back") + void noJumpBackAfterALink() { + PathRoute route = route(X, Y); + Pos at = next(route, new Pos(0, 40, 0), fixed(0)); + at = next(route, at, fixed(0)); + at = next(route, at, fixed(0)); + assertEquals(new Pos(10, 40, 2), at); + + assertEquals(new Pos(10, 40, 12), next(route, at, fixed(0))); + } + + @Test + @DisplayName("From an end it can join the middle of another route and walk on from there") + void joinsAMiddleFromAnEnd() { + PathRoute route = route(X, Y); + next(route, new Pos(10, 40, 22), fixed(0)); + Pos at = next(route, new Pos(10, 40, 22), fixed(0)); + RouteStep start = step(route, at, fixed(0)); + assertEquals(new Pos(10, 40, 2), start.target()); + assertFalse(start.deadEnd(), "an end on another route's middle is no dead end"); + + RouteStep join = step(route, start.target(), fixed(0)); + assertEquals(new RouteStep(new Pos(10, 40, 0), 0), join, "no second stop where the routes meet"); + assertEquals(new Pos(20, 40, 0), next(route, join.target(), fixed(0)), "it walks on, not back"); } @Test 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 index 0e9e58fe..cee02494 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java @@ -27,7 +27,7 @@ /** * Shows the creek route being edited to the player editing it: particles at every point and along - * the route, the route's name above its start, and a marker on ends that link to another route. + * the route, the route's name above its start, and a marker on every point that links to another route. * Without a route being edited it shows nothing. * * @author theEvilReaper @@ -134,7 +134,7 @@ private void draw() { line(points.get(i), points.get(i + 1), LINE_STEP).forEach(spot -> this.particle(LINE_PARTICLE, spot)); } } - linkedEnds(route, routes, this.linkDistance).forEach(end -> this.particle(LINK_PARTICLE, end.add(0, 0.5, 0))); + linkedPoints(route, routes, this.linkDistance).forEach(point -> this.particle(LINK_PARTICLE, point.add(0, 0.5, 0))); } private @Nullable CreekRoute activeRoute(List routes) { @@ -170,26 +170,45 @@ static List line(Vec from, Vec to, double step) { } /** - * Returns the ends of a route that are at most {@code distance} away from an end of another route. + * Returns the points of a route that link to another route, the same way the game links them: + * an end of one route links to the nearest point of another route within {@code distance}, be + * that the other route's end or a point in its middle. * - * @param route the route whose ends are checked + * @param route the route whose points are checked * @param routes all routes of the map, the checked one included * @param distance the link distance, in blocks - * @return the linked ends of {@code route} + * @return the linked points of {@code route} */ - static Set linkedEnds(CreekRoute route, List routes, double distance) { + static Set linkedPoints(CreekRoute route, List routes, double distance) { Set linked = new HashSet<>(); - for (Vec end : ends(route)) { - for (CreekRoute other : routes) { - if (other.name().equals(route.name())) continue; - for (Vec otherEnd : ends(other)) { - if (end.distance(otherEnd) <= distance) linked.add(end); - } + for (CreekRoute other : routes) { + if (other.name().equals(route.name())) continue; + // This route's ends, reaching any point of the other route. + for (Vec end : ends(route)) { + if (nearest(other, end, distance) != null) linked.add(end); + } + // The other route's ends, reaching a point of this one. + for (Vec otherEnd : ends(other)) { + Vec point = nearest(route, otherEnd, distance); + if (point != null) linked.add(point); } } return linked; } + private static @Nullable Vec nearest(CreekRoute route, Vec position, double distance) { + Vec nearest = null; + double best = distance; + for (CreekWaypoint point : route.points()) { + double away = point.position().distance(position); + if (away <= best) { + best = away; + nearest = point.position(); + } + } + return nearest; + } + private static List ends(CreekRoute route) { if (route.points().isEmpty()) return List.of(); return List.of(route.points().getFirst().position(), route.points().getLast().position()); 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 index b1f6fddf..31ac3724 100644 --- a/setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java +++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreviewTest.java @@ -21,15 +21,26 @@ void lineSteps() { } @Test - @DisplayName("Only the ends of the edited route are marked, and only if another route's end is close") + @DisplayName("Ends of the edited route are marked if another route's end is close") void linkedEndsOfTheEditedRoute() { CreekRoute a = CreekRoute.ofPositions("A", List.of(new Vec(0, 80, 0), new Vec(10, 80, 0))); CreekRoute b = CreekRoute.ofPositions("B", List.of(new Vec(12, 80, 0), new Vec(20, 80, 0))); CreekRoute c = CreekRoute.ofPositions("C", List.of(new Vec(50, 80, 0), new Vec(60, 80, 0))); List routes = List.of(a, b, c); - assertEquals(Set.of(new Vec(10, 80, 0)), CreekRoutePreview.linkedEnds(a, routes, 3.0D)); - assertEquals(Set.of(new Vec(12, 80, 0)), CreekRoutePreview.linkedEnds(b, routes, 3.0D)); - assertEquals(Set.of(), CreekRoutePreview.linkedEnds(c, routes, 3.0D)); + assertEquals(Set.of(new Vec(10, 80, 0)), CreekRoutePreview.linkedPoints(a, routes, 3.0D)); + assertEquals(Set.of(new Vec(12, 80, 0)), CreekRoutePreview.linkedPoints(b, routes, 3.0D)); + assertEquals(Set.of(), CreekRoutePreview.linkedPoints(c, routes, 3.0D)); + } + + @Test + @DisplayName("Where another route's end meets the middle, both points are marked") + void crossingsInTheMiddle() { + CreekRoute middle = CreekRoute.ofPositions("M", List.of(new Vec(0, 80, 0), new Vec(10, 80, 0), new Vec(20, 80, 0))); + CreekRoute branch = CreekRoute.ofPositions("N", List.of(new Vec(10, 80, 2), new Vec(10, 80, 20))); + List routes = List.of(middle, branch); + + assertEquals(Set.of(new Vec(10, 80, 0)), CreekRoutePreview.linkedPoints(middle, routes, 3.0D)); + assertEquals(Set.of(new Vec(10, 80, 2)), CreekRoutePreview.linkedPoints(branch, routes, 3.0D)); } } From dd55023c67ca4b47a33ef7435b0904cdbd498118 Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 21:20:43 +0200 Subject: [PATCH 84/92] refactor(creek): simplify after the patrol rework --- .../cygnus/common/creek/CreekLinks.java | 107 ++++++++++++++++++ .../cygnus/common/creek/CreekLinksTest.java | 77 +++++++++++++ .../onelitefeather/cygnus/creek/Creek.java | 37 +++--- .../cygnus/creek/CreekRound.java | 6 +- .../cygnus/creek/CreekService.java | 42 +++---- .../creek/consequence/CatchEffects.java | 11 +- ...onsequence.java => PatrolConsequence.java} | 55 ++++----- .../cygnus/creek/state/PatrolState.java | 37 +++--- .../cygnus/creek/world/CreekPaths.java | 76 ++----------- .../cygnus/creek/world/PathRoute.java | 53 +++++---- .../cygnus/creek/CreekIntegrationTest.java | 8 +- .../creek/CreekServiceIntegrationTest.java | 21 +--- .../creek/CreekVariantsIntegrationTest.java | 6 +- ... => PatrolConsequenceIntegrationTest.java} | 28 ++--- ...StagedCatchConsequenceIntegrationTest.java | 17 +-- .../cygnus/creek/state/Contexts.java | 36 +++++- .../cygnus/creek/state/PatrolStateTest.java | 36 +----- .../cygnus/creek/world/CreekPathsTest.java | 52 +-------- .../cygnus/setup/creek/CreekRoutePreview.java | 42 ++----- 19 files changed, 379 insertions(+), 368 deletions(-) create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekLinks.java create mode 100644 common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekLinksTest.java rename game/src/main/java/net/onelitefeather/cygnus/creek/consequence/{SelectionConsequence.java => PatrolConsequence.java} (69%) rename game/src/test/java/net/onelitefeather/cygnus/creek/consequence/{SelectionConsequenceIntegrationTest.java => PatrolConsequenceIntegrationTest.java} (79%) diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekLinks.java b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekLinks.java new file mode 100644 index 00000000..802316b3 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekLinks.java @@ -0,0 +1,107 @@ +package net.onelitefeather.cygnus.common.creek; + +import net.minestom.server.coordinate.Vec; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Where the creek routes of a map link up, worked out once up front. + *

    + * An end of a route is linked to the nearest point of every other route that is at most the link + * distance away. That point may be the other route's end, or somewhere in its middle, where the two + * routes cross. Links work both ways. Two points in the middle of routes never link, and the two + * ends of the same route never link to each other. + *

    + *

    + * The game walks the creek along these links, and the setup marks them, so both always agree. + *

    + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.15.0 + */ +public final class CreekLinks { + + /** + * One point of a route. + * + * @param route the route's index + * @param index the point's index within the route + */ + public record Node(int route, int index) { + } + + private final Map> links; + + private CreekLinks(Map> links) { + this.links = links; + } + + /** + * Works out the links between the routes of a map. + * + * @param routes the routes + * @param distance how close an end has to be to a point of another route to link to it, in blocks + * @return the links + */ + public static CreekLinks of(List routes, double distance) { + Map> links = new HashMap<>(); + for (int route = 0; route < routes.size(); route++) { + List points = routes.get(route).points(); + if (points.isEmpty()) continue; + for (int endIndex : new int[]{0, points.size() - 1}) { + Node end = new Node(route, endIndex); + Vec position = points.get(endIndex).position(); + for (int other = 0; other < routes.size(); other++) { + if (other == route) continue; + Node nearest = nearest(routes.get(other), other, position, distance); + if (nearest == null) continue; + link(links, end, nearest); + link(links, nearest, end); + } + } + } + Map> frozen = new HashMap<>(); + links.forEach((node, linked) -> frozen.put(node, List.copyOf(linked))); + return new CreekLinks(Map.copyOf(frozen)); + } + + /** + * The points linked to a point, always in the same order. + * + * @param node the point + * @return the linked points, empty if there are none + */ + public List of(Node node) { + return this.links.getOrDefault(node, List.of()); + } + + /** + * The point of a route closest to a position, as long as it is within the distance. + */ + private static @Nullable Node nearest(CreekRoute route, int routeIndex, Vec position, double distance) { + List points = route.points(); + Node nearest = null; + double best = distance; + for (int index = 0; index < points.size(); index++) { + double away = points.get(index).position().distance(position); + if (away <= best) { + best = away; + nearest = new Node(routeIndex, index); + } + } + return nearest; + } + + /** + * Adds a link once. Two ends close together are found from both sides. + */ + private static void link(Map> links, Node from, Node to) { + List linked = links.computeIfAbsent(from, _ -> new ArrayList<>()); + if (!linked.contains(to)) linked.add(to); + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekLinksTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekLinksTest.java new file mode 100644 index 00000000..7f3e6203 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekLinksTest.java @@ -0,0 +1,77 @@ +package net.onelitefeather.cygnus.common.creek; + +import net.minestom.server.coordinate.Vec; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CreekLinksTest { + + private static CreekRoute route(String name, Vec... points) { + return CreekRoute.ofPositions(name, List.of(points)); + } + + /** A ends at (10,0,0); B starts 2 blocks away, C exactly 3, D 3.1. */ + private static final List ROUTES = List.of( + route("A", new Vec(0, 0, 0), new Vec(10, 0, 0)), + route("B", new Vec(12, 0, 0), new Vec(20, 0, 0)), + route("C", new Vec(10, 0, 3), new Vec(10, 0, 20)), + route("D", new Vec(10, 0, -3.1), new Vec(10, 0, -20)) + ); + + @Test + @DisplayName("Ends within the link distance are linked, the one just outside is not") + void linksFollowTheDistance() { + CreekLinks links = CreekLinks.of(ROUTES, 3.0D); + + assertEquals(List.of(new CreekLinks.Node(1, 0), new CreekLinks.Node(2, 0)), + links.of(new CreekLinks.Node(0, 1))); + assertEquals(List.of(new CreekLinks.Node(0, 1)), links.of(new CreekLinks.Node(1, 0))); + assertTrue(links.of(new CreekLinks.Node(3, 0)).isEmpty()); + } + + @Test + @DisplayName("An end on the middle of another route is linked to that point, both ways") + void endOnAMiddleIsLinked() { + CreekLinks links = CreekLinks.of(List.of( + route("X", new Vec(0, 0, 0), new Vec(10, 0, 0), new Vec(20, 0, 0)), + route("Y", new Vec(10, 0, 2), new Vec(10, 0, 20))), 3.0D); + + assertEquals(List.of(new CreekLinks.Node(0, 1)), links.of(new CreekLinks.Node(1, 0))); + assertEquals(List.of(new CreekLinks.Node(1, 0)), links.of(new CreekLinks.Node(0, 1))); + } + + @Test + @DisplayName("Two middles close together are not linked") + void middlesNeverLink() { + CreekLinks links = CreekLinks.of(List.of( + route("X", new Vec(0, 0, 0), new Vec(10, 0, 0), new Vec(20, 0, 0)), + route("Z", new Vec(-5, 0, 1), new Vec(10, 0, 1), new Vec(25, 0, 1))), 3.0D); + + assertTrue(links.of(new CreekLinks.Node(0, 1)).isEmpty()); + assertTrue(links.of(new CreekLinks.Node(1, 1)).isEmpty()); + } + + @Test + @DisplayName("An end links to the nearest point of another route only") + void onlyTheNearestPoint() { + CreekLinks links = CreekLinks.of(List.of( + route("X", new Vec(-10, 0, 0), new Vec(1, 0, 0), new Vec(2, 0, 0), new Vec(20, 0, 0)), + route("W", new Vec(1, 0, 1), new Vec(1, 0, 20))), 3.0D); + + assertEquals(List.of(new CreekLinks.Node(0, 1)), links.of(new CreekLinks.Node(1, 0))); + assertTrue(links.of(new CreekLinks.Node(0, 2)).isEmpty(), "the second closest point stays unlinked"); + } + + @Test + @DisplayName("The two ends of one route never link to each other") + void ownEndsNeverLink() { + CreekLinks links = CreekLinks.of(List.of(route("Short", new Vec(0, 0, 0), new Vec(1, 0, 0))), 3.0D); + + assertTrue(links.of(new CreekLinks.Node(0, 0)).isEmpty()); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java index 0415ec5f..fe414186 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java @@ -9,7 +9,6 @@ import net.onelitefeather.cygnus.creek.state.SurvivorView; import net.onelitefeather.cygnus.creek.state.VanishState; import net.onelitefeather.cygnus.creek.world.RouteProvider; -import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; @@ -32,10 +31,7 @@ final class Creek { private final CreekBody body; private final RouteProvider route; private final CreekRound round; - private final CreekActions actions = new Actions(); private CreekState state; - private @Nullable SurvivorSnapshot survivors; - private Set ignored = Set.of(); private List lastViews = List.of(); private boolean entered; @@ -103,12 +99,11 @@ void vanishForGood(SurvivorSnapshot survivors, long now) { * Fills in who sees this creek and builds the context for the states. */ private CreekContext context(SurvivorSnapshot survivors, Set ignored, long now) { - this.survivors = survivors; - this.ignored = ignored; this.lastViews = this.views(survivors); List noticed = ignored.isEmpty() ? this.lastViews : this.lastViews.stream().filter(view -> !ignored.contains(view.id())).toList(); - return new CreekContext(now, noticed, this.body, this.route, this.round.spots(), this.actions, + return new CreekContext(now, noticed, this.body, this.route, this.round.spots(), + new Actions(this.round, survivors, ignored), this.round.config(), this.round.random()); } @@ -166,34 +161,34 @@ private void switchTo(CreekState next, CreekContext ctx) { } /** - * Applies the consequences to survivors of the current step. Anyone who is no longer among - * them is left alone. + * Applies the consequences to the survivors of one step. Anyone who is no longer among them is + * left alone, and so is everyone this creek ignores in that step. + * + * @param round what the creek shares with the others of the round + * @param survivors the survivors of the step + * @param ignored the survivors the creek leaves alone in the step */ - private final class Actions implements CreekActions { + private record Actions(CreekRound round, SurvivorSnapshot survivors, Set ignored) implements CreekActions { @Override public void caught(UUID survivor) { - SurvivorSnapshot current = Creek.this.survivors; - Player player = current == null ? null : current.player(survivor); - if (player != null) Creek.this.round.consequence().apply(player); + Player player = this.survivors.player(survivor); + if (player != null) this.round.consequence().apply(player); } @Override public void selected(UUID survivor) { - SurvivorSnapshot current = Creek.this.survivors; - Player player = current == null ? null : current.player(survivor); - if (player != null) Creek.this.round.selection().apply(player, current.players()); + Player player = this.survivors.player(survivor); + if (player != null) this.round.patrol().selected(player, this.survivors.players()); } @Override public void vanished(Pos where) { - SurvivorSnapshot current = Creek.this.survivors; - if (current == null) return; // Whoever this creek leaves alone does not see it, so they do not see it vanish either. - List noticing = current.players().stream() - .filter(player -> !Creek.this.ignored.contains(player.getUuid())) + List noticing = this.survivors.players().stream() + .filter(player -> !this.ignored.contains(player.getUuid())) .toList(); - Creek.this.round.selection().vanishAt(where, noticing); + this.round.patrol().vanished(where, noticing); } } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java index f60be0d2..3e963469 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java @@ -2,7 +2,7 @@ import net.onelitefeather.cygnus.common.config.CreekConfig; import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; -import net.onelitefeather.cygnus.creek.consequence.SelectionConsequence; +import net.onelitefeather.cygnus.creek.consequence.PatrolConsequence; import net.onelitefeather.cygnus.creek.world.CreekSight; import net.onelitefeather.cygnus.creek.world.SpotFinder; @@ -14,7 +14,7 @@ * @param sight decides whether a survivor sees a creek * @param spots finds places where nobody can see a creek * @param consequence what happens on a catch - * @param selection what happens to a survivor the patrolling creek picks out + * @param patrol what the patrolling creek does to the survivors around it * @param config the settings * @param random the random source * @@ -26,7 +26,7 @@ record CreekRound( CreekSight sight, SpotFinder spots, CatchConsequence consequence, - SelectionConsequence selection, + PatrolConsequence patrol, CreekConfig config, RandomGenerator random ) { diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java index 64e3f5a7..c640222c 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java @@ -10,7 +10,7 @@ import net.onelitefeather.cygnus.common.creek.CreekRoute; import net.onelitefeather.cygnus.creek.body.CreekBody; import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; -import net.onelitefeather.cygnus.creek.consequence.SelectionConsequence; +import net.onelitefeather.cygnus.creek.consequence.PatrolConsequence; import net.onelitefeather.cygnus.creek.debug.CreekDebug; import net.onelitefeather.cygnus.creek.dread.DreadSource; import net.onelitefeather.cygnus.creek.state.CreekState; @@ -21,7 +21,6 @@ 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.RouteProvider; import net.onelitefeather.cygnus.creek.world.SpotFinder; import net.onelitefeather.cygnus.event.GameFinishEvent; import net.onelitefeather.cygnus.event.GameStartEvent; @@ -71,8 +70,7 @@ public final class CreekService { private final Ground ground; private final SpotFinder spots; private final Supplier> routes; - private volatile @Nullable RouteProvider route; - private volatile @Nullable SelectionConsequence selection; + private volatile @Nullable CreekRound round; private final RepeatingTask task = new RepeatingTask(this::tick); private volatile @Nullable Creek creek; private volatile @Nullable CreekVariants variants; @@ -135,22 +133,19 @@ void start() { } // Every creek walks with its own cursor, so this one belongs to the patrolling creek alone. PathRoute pathRoute = new PathRoute(paths); - this.route = pathRoute; - SelectionConsequence selection = new SelectionConsequence(paths::allPoints, this.ground, this.random); - this.selection = selection; + CreekRound round = new CreekRound(this.sight, this.spots, this.consequence, + new PatrolConsequence(paths::allPoints, this.ground, this.random), this.config, this.random); + this.round = round; List points = paths.allPoints(); this.clock.start(); Pos point = points.get(this.random.nextInt(points.size())); CreekBody body = this.bodies.apply(world, this.ground.settle(point).orElse(point)); - // Start hidden. The creek only shows up once the survivors have had time to spread out, - // somewhere far away from all of them. - CreekState initial = new VanishState(this.clock.now() + this.config.vanishMinSeconds() * 1000L); - CreekRound round = new CreekRound(this.sight, this.spots, this.consequence, selection, this.config, - this.random); - this.creek = new Creek(body, pathRoute, round, initial); - long variantsFrom = this.clock.now() + this.config.vanishMinSeconds() * 1000L; - this.variants = new CreekVariants(this.config, this.spots, this.random, variantsFrom, + // Start hidden. The creek and its variants only show up once the survivors have had time to + // spread out, the creek somewhere far away from all of them. + long showsUp = this.clock.now() + this.config.vanishMinSeconds() * 1000L; + this.creek = new Creek(body, pathRoute, round, new VanishState(showsUp)); + this.variants = new CreekVariants(this.config, this.spots, this.random, showsUp, (spot, state) -> new Creek(this.bodies.apply(world, spot), new PathRoute(paths), round, state)); this.debug.setActive(true); this.task.start(TICK_MILLIS, ChronoUnit.MILLIS); @@ -185,7 +180,7 @@ void tick() { Function names = id -> nameOf(survivors, id); List views = current.lastViews(); Component line = CreekDebug.line(current.state(), current.body().position(), views, names, - describeRoute(), now); + current.route().describe(), now); if (currentVariants != null) { List states = currentVariants.running().values().stream().map(Creek::state).toList(); line = line.append(CreekDebug.variants(states, CreekVariants.capacity(views.size()), names, now)); @@ -209,13 +204,12 @@ void stop() { if (currentVariants != null) currentVariants.stop(); Creek current = this.creek; this.creek = null; - this.route = null; this.clock.reset(); if (current != null) current.remove(); this.consequence.cleanUp(); - SelectionConsequence currentSelection = this.selection; - this.selection = null; - if (currentSelection != null) currentSelection.cleanUp(); + CreekRound currentRound = this.round; + this.round = null; + if (currentRound != null) currentRound.patrol().cleanUp(); this.debug.setActive(false); } @@ -227,12 +221,4 @@ void stop() { return this.variants; } - private String describeRoute() { - RouteProvider current = this.route; - return current == null ? "" : current.describe(); - } - - @Nullable RouteProvider route() { - return this.route; - } } 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 index eee2aef2..002f7d8e 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/CatchEffects.java @@ -66,7 +66,16 @@ public void cleanUp() { } private static void scareWithoutCorpse(Player survivor) { - survivor.playSound(Sound.sound(SCARE_SOUND, Sound.Source.HOSTILE, 1.0F, 0.6F)); + playScare(survivor); survivor.addEffect(new Potion(PotionEffect.DARKNESS, 0, SCARE_DARKNESS_TICKS)); } + + /** + * Plays the creek's scare sound to a player. + * + * @param player the player + */ + static void playScare(Player player) { + player.playSound(Sound.sound(SCARE_SOUND, Sound.Source.HOSTILE, 1.0F, 0.6F)); + } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequence.java similarity index 69% rename from game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java rename to game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequence.java index c7635a67..e0a624a8 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequence.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequence.java @@ -1,12 +1,10 @@ package net.onelitefeather.cygnus.creek.consequence; -import net.kyori.adventure.sound.Sound; import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Player; import net.minestom.server.potion.Potion; import net.minestom.server.potion.PotionEffect; import net.onelitefeather.cygnus.creek.world.Ground; -import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.Collection; @@ -15,22 +13,20 @@ import java.util.random.RandomGenerator; /** - * What happens when the patrolling creek picks out a survivor who came too close. + * What the patrolling creek does to the survivors around it. *

    - * It is a coin toss: either the survivor freezes on the spot and everyone standing next to them - * goes blind for a moment, or they suddenly find themselves somewhere else on the map. It is a - * scare, not a catch, so it never counts towards giving them away to the slender. - *

    - *

    - * When the patrolling creek vanishes at the end of its route, the survivors around it go blind - * the same way. + * When it picks out a survivor who came too close, it is a coin toss: either the survivor freezes + * on the spot and everyone standing next to them goes blind for a moment, or they suddenly find + * themselves somewhere else on the map. It is a scare, not a catch, so it never counts towards + * giving them away to the slender. When it vanishes at the end of its route, everyone close by + * hears it and goes blind the same way. *

    * * @author theEvilReaper * @version 1.0.0 * @since 2.15.0 */ -public final class SelectionConsequence { +public final class PatrolConsequence { /** How long the survivor stays frozen, in ticks (2 seconds). */ static final int STUN_TICKS = 40; @@ -62,7 +58,7 @@ public final class SelectionConsequence { * @param ground finds the floor at such a place * @param random tosses the coin between freezing and teleporting, and picks the place */ - public SelectionConsequence(Supplier> routePoints, Ground ground, RandomGenerator random) { + public PatrolConsequence(Supplier> routePoints, Ground ground, RandomGenerator random) { this.routePoints = routePoints; this.ground = ground; this.random = random; @@ -75,7 +71,7 @@ public SelectionConsequence(Supplier> routePoints, Ground ground, Rand * @param selected the survivor the creek picked out * @param survivors every survivor of the round, to find the ones standing next to a frozen one */ - public void apply(Player selected, Collection survivors) { + public void selected(Player selected, Collection survivors) { if (this.random.nextBoolean() || !this.teleportAway(selected)) { this.stun(selected, survivors); } @@ -85,9 +81,11 @@ public void apply(Player selected, Collection survivors) { * Freezes the survivor for a moment and blinds the other survivors standing next to them. */ void stun(Player selected, Collection survivors) { - scare(selected); + CatchEffects.playScare(selected); this.effects.add(selected, new Potion(PotionEffect.SLOWNESS, STUN_AMPLIFIER, STUN_TICKS)); - this.blindAround(selected.getPosition(), survivors, selected); + for (Player other : near(selected.getPosition(), survivors)) { + if (other != selected) this.blind(other); + } } /** @@ -96,21 +94,22 @@ void stun(Player selected, Collection survivors) { * @param where where the creek vanished * @param survivors the survivors it may blind */ - public void vanishAt(Pos where, Collection survivors) { - for (Player survivor : survivors) { - if (survivor.getPosition().distance(where) <= BLIND_RADIUS) scare(survivor); + public void vanished(Pos where, Collection survivors) { + for (Player survivor : near(where, survivors)) { + CatchEffects.playScare(survivor); + this.blind(survivor); } - this.blindAround(where, survivors, null); + } + + private void blind(Player player) { + this.effects.add(player, new Potion(PotionEffect.BLINDNESS, 0, BLIND_TICKS)); } /** - * Blinds every survivor within {@link #BLIND_RADIUS} of the centre, except the one spared. + * The survivors within {@link #BLIND_RADIUS} of a point. */ - private void blindAround(Pos center, Collection survivors, @Nullable Player spared) { - for (Player other : survivors) { - if (other == spared || other.getPosition().distance(center) > BLIND_RADIUS) continue; - this.effects.add(other, new Potion(PotionEffect.BLINDNESS, 0, BLIND_TICKS)); - } + private static List near(Pos center, Collection survivors) { + return survivors.stream().filter(survivor -> survivor.getPosition().distance(center) <= BLIND_RADIUS).toList(); } /** @@ -130,7 +129,7 @@ boolean teleportAway(Player selected) { if (targets.isEmpty()) return false; Pos target = targets.get(this.random.nextInt(targets.size())); selected.teleport(target.withView(from.yaw(), from.pitch())); - scare(selected); + CatchEffects.playScare(selected); return true; } @@ -141,8 +140,4 @@ boolean teleportAway(Player selected) { public void cleanUp() { this.effects.removeAll(); } - - private static void scare(Player player) { - player.playSound(Sound.sound(CatchEffects.SCARE_SOUND, Sound.Source.HOSTILE, 1.0F, 0.6F)); - } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java index 41b19314..a515c556 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java @@ -49,11 +49,9 @@ public final class PatrolState implements CreekState { /** How far it has to get to count as moving at all, in blocks. */ static final double PROGRESS = 0.5D; - private @Nullable Pos goal; + private @Nullable RouteStep goal; private boolean watched; private long pausedUntil; - private int goalPauseMillis; - private boolean goalDeadEnd; private long restingUntil; private Pos progressAt = Pos.ZERO; private long progressSince; @@ -110,15 +108,15 @@ public CreekState tick(CreekContext ctx) { body.stop(); return this; } - if (this.hasArrived(here)) { - if (this.goalDeadEnd && ctx.random().nextDouble() < DEAD_END_VANISH_CHANCE) { - this.goal = null; + RouteStep reached = this.goal; + if (reached != null && here.distance(reached.target()) < ARRIVED) { + this.goal = null; + if (reached.deadEnd() && ctx.random().nextDouble() < DEAD_END_VANISH_CHANCE) { ctx.actions().vanished(here); // Gone for as long as it would have rested here, then back somewhere else. - return new VanishState(ctx.now() + this.goalPauseMillis); + return new VanishState(ctx.now() + reached.pauseMillis()); } - long rest = this.restMillis(ctx); - this.goal = null; + long rest = this.restMillis(ctx, reached); if (rest > 0) { this.restingUntil = ctx.now() + rest; body.stop(); @@ -127,17 +125,15 @@ public CreekState tick(CreekContext ctx) { } if (this.needsNewGoal(ctx, here)) { - Optional step = ctx.route().next(here, _ -> true, ctx.random()); - this.goal = step.map(RouteStep::target).orElse(null); - this.goalPauseMillis = step.map(RouteStep::pauseMillis).orElse(0); - this.goalDeadEnd = step.map(RouteStep::deadEnd).orElse(false); + this.goal = ctx.route().next(here, _ -> true, ctx.random()).orElse(null); this.markProgress(ctx.now(), here); } - if (this.goal == null) { + RouteStep next = this.goal; + if (next == null) { body.stop(); return this; } - body.moveTo(this.goal, config.wanderSpeed()); + body.moveTo(next.target(), config.wanderSpeed()); return this; } @@ -214,17 +210,13 @@ private boolean pause(CreekContext ctx, SurvivorView watcher) { return true; } - private boolean hasArrived(Pos here) { - return this.goal != null && here.distance(this.goal) < ARRIVED; - } - /** * How long to rest at the point just reached: the route's pause, or a random stop if that * is longer. */ - private long restMillis(CreekContext ctx) { + private long restMillis(CreekContext ctx, RouteStep reached) { CreekConfig config = ctx.config(); - int rest = this.goalPauseMillis; + int rest = reached.pauseMillis(); if (config.randomStopChance() > 0.0D && ctx.random().nextDouble() < config.randomStopChance()) { int spread = config.randomStopMaxMillis() - config.randomStopMinMillis(); rest = Math.max(rest, config.randomStopMinMillis() + ctx.random().nextInt(spread + 1)); @@ -233,7 +225,8 @@ private long restMillis(CreekContext ctx) { } private boolean needsNewGoal(CreekContext ctx, Pos here) { - if (this.goal == null || here.distance(this.goal) < ARRIVED) return true; + RouteStep current = this.goal; + if (current == null || here.distance(current.target()) < ARRIVED) return true; if (here.distance(this.progressAt) >= PROGRESS) { this.markProgress(ctx.now(), here); return false; 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 index e74c3f28..ba50be54 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/CreekPaths.java @@ -2,23 +2,15 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.coordinate.Vec; +import net.onelitefeather.cygnus.common.creek.CreekLinks; import net.onelitefeather.cygnus.common.creek.CreekRoute; -import net.onelitefeather.cygnus.common.creek.CreekWaypoint; -import org.jetbrains.annotations.Nullable; 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 them worked out once up front. - *

    - * An end of a route is linked to the nearest point of every other route that is at most the link - * distance away. That point may be the other route's end, or somewhere in its middle, where the two - * routes cross. Links work both ways. Two points in the middle of routes never link, and the two - * ends of the same route never link to each other. - *

    + * The creek routes of a map, with the links between them worked out once up front, see + * {@link CreekLinks}. * * @author theEvilReaper * @version 1.0.0 @@ -26,20 +18,11 @@ */ public final class CreekPaths { - /** - * One point of a route. - * - * @param route the route's index - * @param index the point's index within the route - */ - public record Node(int route, int index) { - } - private final List routes; - private final Map> links; + private final CreekLinks links; private final List allPoints; - private CreekPaths(List routes, Map> links) { + private CreekPaths(List routes, CreekLinks links) { this.routes = routes; this.links = links; List points = new ArrayList<>(); @@ -58,46 +41,7 @@ private CreekPaths(List routes, Map> links) { */ public static CreekPaths of(List routes, double linkDistance) { List frozenRoutes = List.copyOf(routes); - Map> links = new HashMap<>(); - for (int route = 0; route < frozenRoutes.size(); route++) { - int lastIndex = frozenRoutes.get(route).points().size() - 1; - for (int endIndex : new int[]{0, lastIndex}) { - Node end = new Node(route, endIndex); - for (int other = 0; other < frozenRoutes.size(); other++) { - if (other == route) continue; - Node nearest = nearest(frozenRoutes, other, toPos(position(frozenRoutes, end)), linkDistance); - if (nearest == null) continue; - link(links, end, nearest); - link(links, nearest, end); - } - } - } - return new CreekPaths(frozenRoutes, links); - } - - /** - * The point of a route closest to a position, as long as it is within the distance. - */ - private static @Nullable Node nearest(List routes, int route, Pos position, double distance) { - List points = routes.get(route).points(); - Node nearest = null; - double best = distance; - for (int index = 0; index < points.size(); index++) { - double away = toPos(points.get(index).position()).distance(position); - if (away <= best) { - best = away; - nearest = new Node(route, index); - } - } - return nearest; - } - - /** - * Adds a link once. Two ends close together are found from both sides. - */ - private static void link(Map> links, Node from, Node to) { - List linked = links.computeIfAbsent(from, _ -> new ArrayList<>()); - if (!linked.contains(to)) linked.add(to); + return new CreekPaths(frozenRoutes, CreekLinks.of(frozenRoutes, linkDistance)); } /** @@ -166,8 +110,8 @@ public int pauseMillis(int route, int index) { * @param node the point * @return the linked points, empty if there are none */ - public List links(Node node) { - return List.copyOf(this.links.getOrDefault(node, List.of())); + public List links(CreekLinks.Node node) { + return this.links.of(node); } /** @@ -179,10 +123,6 @@ public List allPoints() { return this.allPoints; } - private static Vec position(List routes, Node node) { - return routes.get(node.route()).points().get(node.index()).position(); - } - 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 index eaa7ec09..0cb34109 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/world/PathRoute.java @@ -1,6 +1,7 @@ package net.onelitefeather.cygnus.creek.world; import net.minestom.server.coordinate.Pos; +import net.onelitefeather.cygnus.common.creek.CreekLinks; import org.jetbrains.annotations.Nullable; import java.util.List; @@ -72,7 +73,7 @@ public String describe() { } private Optional rejoin(Pos current, Predicate allowed, RandomGenerator random) { - Cursor best = null; + CreekLinks.Node best = null; double bestDistance = Double.MAX_VALUE; for (int route = 0; route < this.paths.size(); route++) { for (int index = 0; index < this.paths.pointCount(route); index++) { @@ -81,16 +82,12 @@ private Optional rejoin(Pos current, Predicate allowed, RandomGe double distance = point.distance(current); if (distance < bestDistance) { bestDistance = distance; - best = new Cursor(route, index, 1, false); + best = new CreekLinks.Node(route, index); } } } if (best == null) return Optional.empty(); - - int lastIndex = 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() == lastIndex ? -1 : (random.nextBoolean() ? 1 : -1); - return this.moveTo(new Cursor(best.route(), best.index(), direction, false)); + return this.moveTo(this.enter(best, false, random)); } /** @@ -102,23 +99,23 @@ private Cursor advance(Cursor from, RandomGenerator random) { boolean atEnd = nextIndex < 0 || nextIndex >= this.paths.pointCount(from.route()); // An end only offers its links once the creek gets there. In the middle, a crossing offers // them on the way through, unless the creek has only just come over from the other route. - boolean crossing = !atEnd && !from.viaLink() && this.isMiddle(from); - List links = atEnd || crossing ? this.paths.links(this.node(from)) : List.of(); + boolean crossing = !atEnd && !from.viaLink() && !this.isEnd(from); + List links = atEnd || crossing ? this.paths.links(this.node(from)) : List.of(); int choice = atEnd || !links.isEmpty() ? random.nextInt(links.size() + 1) : 0; - if (choice < links.size()) return this.join(links.get(choice), random); + if (choice < links.size()) return this.enter(links.get(choice), true, random); if (!atEnd) return new Cursor(from.route(), nextIndex, from.direction(), false); Cursor back = this.behind(from); return back != null ? back : from; } /** - * Steps over to a linked point. At an end the creek walks into the route, in the middle it + * Puts the creek on a point. At an end there is only one way into the route, in the middle it * picks a way at random. */ - private Cursor join(CreekPaths.Node node, RandomGenerator random) { + private Cursor enter(CreekLinks.Node node, boolean viaLink, RandomGenerator random) { int lastIndex = this.paths.pointCount(node.route()) - 1; int direction = node.index() == 0 ? 1 : node.index() == lastIndex ? -1 : (random.nextBoolean() ? 1 : -1); - return new Cursor(node.route(), node.index(), direction, true); + return new Cursor(node.route(), node.index(), direction, viaLink); } private @Nullable Cursor behind(Cursor from) { @@ -138,11 +135,7 @@ private Optional moveTo(Cursor next) { * Walking away from an end does not count. */ private boolean isDeadEnd(Cursor cursor) { - int lastIndex = this.paths.pointCount(cursor.route()) - 1; - boolean reachingEnd = cursor.index() == lastIndex && cursor.direction() > 0; - boolean reachingStart = cursor.index() == 0 && cursor.direction() < 0; - if (!reachingEnd && !reachingStart) return false; - return this.paths.links(this.node(cursor)).isEmpty(); + return this.reachesEnd(cursor) && this.paths.links(this.node(cursor)).isEmpty(); } /** @@ -150,11 +143,8 @@ private boolean isDeadEnd(Cursor cursor) { * it just stepped over to from another route: it is where it already was. */ private int pauseAt(Cursor cursor) { - if (cursor.viaLink()) return 0; - int lastIndex = this.paths.pointCount(cursor.route()) - 1; - boolean leavingStart = cursor.index() == 0 && cursor.direction() > 0; - boolean leavingEnd = cursor.index() == lastIndex && cursor.direction() < 0; - if (leavingStart || leavingEnd) return 0; + boolean leavingEnd = this.isEnd(cursor) && !this.reachesEnd(cursor); + if (cursor.viaLink() || leavingEnd) return 0; return this.paths.pauseMillis(cursor.route(), cursor.index()); } @@ -162,12 +152,21 @@ private Pos point(Cursor cursor) { return this.paths.point(cursor.route(), cursor.index()); } - private CreekPaths.Node node(Cursor cursor) { - return new CreekPaths.Node(cursor.route(), cursor.index()); + private CreekLinks.Node node(Cursor cursor) { + return new CreekLinks.Node(cursor.route(), cursor.index()); + } + + private boolean isEnd(Cursor cursor) { + return cursor.index() == 0 || cursor.index() == this.paths.pointCount(cursor.route()) - 1; } - private boolean isMiddle(Cursor cursor) { - return cursor.index() > 0 && cursor.index() < this.paths.pointCount(cursor.route()) - 1; + /** + * Tells whether the creek is walking into an end of its route rather than away from it. + */ + private boolean reachesEnd(Cursor cursor) { + return cursor.direction() > 0 + ? cursor.index() == this.paths.pointCount(cursor.route()) - 1 + : cursor.index() == 0; } /** 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 b38cf706..0bcc206f 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java @@ -9,7 +9,7 @@ import net.onelitefeather.cygnus.common.config.CreekConfig; import net.onelitefeather.cygnus.creek.body.CreakingBody; import net.onelitefeather.cygnus.creek.body.CreekBody; -import net.onelitefeather.cygnus.creek.consequence.SelectionConsequence; +import net.onelitefeather.cygnus.creek.consequence.PatrolConsequence; import net.onelitefeather.cygnus.creek.state.Contexts; import net.onelitefeather.cygnus.creek.state.DoneState; import net.onelitefeather.cygnus.creek.state.CreekContext; @@ -40,8 +40,8 @@ class CreekIntegrationTest extends CygnusPlayerTestBase { private static Creek creek(CreekBody body, CreekConfig config, CreekState initial) { CreekSight sight = new CreekSight(config.sightRange(), config.sightViewAngle()); // No route points: a selection always ends in the stun. - SelectionConsequence selection = new SelectionConsequence(List::of, Optional::of, new Random(3)); - CreekRound round = new CreekRound(sight, new SpotFinder(sight, Optional::of), _ -> {}, selection, config, + PatrolConsequence patrol = new PatrolConsequence(List::of, Optional::of, new Random(3)); + CreekRound round = new CreekRound(sight, new SpotFinder(sight, Optional::of), _ -> {}, patrol, config, new Random(3)); return new Creek(body, Contexts.route(), round, initial); } @@ -128,7 +128,7 @@ void vanishForGood(Env env) { } @Test - @DisplayName("A state selecting a survivor hands them to the selection consequence") + @DisplayName("A state selecting a survivor hands them to the patrol consequence") void selectionReachesTheConsequence(Env env) { Instance instance = env.createFlatInstance(); Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); 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 31008035..b8ad9a6e 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java @@ -15,6 +15,7 @@ import net.onelitefeather.cygnus.creek.body.CreakingBody; import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; import net.onelitefeather.cygnus.creek.debug.CreekDebug; +import net.onelitefeather.cygnus.creek.state.Contexts; import net.onelitefeather.cygnus.creek.state.PatrolState; import net.onelitefeather.cygnus.creek.state.VanishState; import net.onelitefeather.cygnus.creek.world.PathRoute; @@ -138,8 +139,9 @@ void routesAreUsedWhenThereAreAny(Env env) { service.start(); - assertInstanceOf(PathRoute.class, service.route()); - assertNotNull(service.creek()); + Creek creek = service.creek(); + assertNotNull(creek); + assertInstanceOf(PathRoute.class, creek.route()); service.stop(); } @@ -243,7 +245,7 @@ void dreadOncePerStep(Env env) { @Test @DisplayName("With the last survivor and the setting off, he is gone for good and every variant ends") void goneForGoodWithTheLastSurvivor(Env env) { - Round round = this.roundWithVariant(env, withoutLastSurvivor()); + Round round = this.roundWithVariant(env, Contexts.withoutLastSurvivor()); Creek creek = round.service().creek(); CreekVariants variants = round.service().variants(); assertNotNull(creek); @@ -293,17 +295,4 @@ private record Round(CreekService service, Player scared, Player other, AtomicRe AtomicInteger dreadCalls) { } - 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(), d.routeLinkDistance(), - d.randomStopChance(), d.randomStopMinMillis(), d.randomStopMaxMillis()); - } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java index 0f1e592a..67d28f79 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java @@ -8,7 +8,7 @@ import net.onelitefeather.cygnus.common.config.CreekConfig; import net.onelitefeather.cygnus.creek.body.CreakingBody; import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; -import net.onelitefeather.cygnus.creek.consequence.SelectionConsequence; +import net.onelitefeather.cygnus.creek.consequence.PatrolConsequence; import net.onelitefeather.cygnus.creek.state.Contexts; import net.onelitefeather.cygnus.creek.state.CreekState; import net.onelitefeather.cygnus.creek.state.SurvivorView; @@ -41,10 +41,10 @@ public void apply(Player survivor) { public void cleanUp() { } }; - private static final SelectionConsequence SELECTION = new SelectionConsequence(List::of, Optional::of, new Random(1)); + private static final PatrolConsequence PATROL = new PatrolConsequence(List::of, Optional::of, new Random(1)); private static BiFunction spawner(Instance instance, CreekConfig config, SpotFinder spots) { - CreekRound round = new CreekRound(SIGHT, spots, NO_CATCH, SELECTION, config, new Random(3)); + CreekRound round = new CreekRound(SIGHT, spots, NO_CATCH, PATROL, config, new Random(3)); return (spot, initial) -> new Creek(CreakingBody.spawn(instance, spot), Contexts.route(), round, initial); } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequenceIntegrationTest.java similarity index 79% rename from game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java rename to game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequenceIntegrationTest.java index 56d37e0f..e9c41959 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/SelectionConsequenceIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequenceIntegrationTest.java @@ -16,16 +16,16 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -class SelectionConsequenceIntegrationTest extends CygnusPlayerTestBase { +class PatrolConsequenceIntegrationTest extends CygnusPlayerTestBase { @Test @DisplayName("A stun slows the selected survivor and does not count as a catch") void stunSlowsTheSelected(Env env) { Instance instance = env.createFlatInstance(); Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); - SelectionConsequence selection = new SelectionConsequence(List::of, Optional::of, new Random(1)); + PatrolConsequence patrol = new PatrolConsequence(List::of, Optional::of, new Random(1)); - selection.stun(selected, List.of(selected)); + patrol.stun(selected, List.of(selected)); assertTrue(selected.hasEffect(PotionEffect.SLOWNESS)); assertFalse(selected.hasEffect(PotionEffect.BLINDNESS)); @@ -40,9 +40,9 @@ void stunBlindsOtherSurvivorsNearby(Env env) { Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); Player far = env.createConnection().connect(instance, new Pos(20, 40, 0)); Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0)); - SelectionConsequence selection = new SelectionConsequence(List::of, Optional::of, new Random(1)); + PatrolConsequence patrol = new PatrolConsequence(List::of, Optional::of, new Random(1)); - selection.stun(selected, List.of(selected, near, far)); + patrol.stun(selected, List.of(selected, near, far)); assertTrue(near.hasEffect(PotionEffect.BLINDNESS)); assertFalse(far.hasEffect(PotionEffect.BLINDNESS)); @@ -57,9 +57,9 @@ void vanishBlindsSurvivorsNearby(Env env) { Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); Player far = env.createConnection().connect(instance, new Pos(20, 40, 0)); Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0)); - SelectionConsequence selection = new SelectionConsequence(List::of, Optional::of, new Random(1)); + PatrolConsequence patrol = new PatrolConsequence(List::of, Optional::of, new Random(1)); - selection.vanishAt(new Pos(0, 40, 0), List.of(near, far)); + patrol.vanished(new Pos(0, 40, 0), List.of(near, far)); assertTrue(near.hasEffect(PotionEffect.BLINDNESS)); assertFalse(far.hasEffect(PotionEffect.BLINDNESS)); @@ -72,9 +72,9 @@ void teleportLandsInRange(Env env) { Instance instance = env.createFlatInstance(); Player selected = env.createConnection().connect(instance, new Pos(0.5, 40, 0.5)); List points = List.of(new Pos(10.5, 40, 0.5), new Pos(30.5, 40, 0.5), new Pos(60.5, 40, 0.5)); - SelectionConsequence selection = new SelectionConsequence(() -> points, Optional::of, new Random(1)); + PatrolConsequence patrol = new PatrolConsequence(() -> points, Optional::of, new Random(1)); - assertTrue(selection.teleportAway(selected)); + assertTrue(patrol.teleportAway(selected)); env.tick(); assertTrue(selected.getPosition().sameBlock(new Pos(30.5, 40, 0.5)), "stands at " + selected.getPosition()); @@ -86,10 +86,10 @@ void fallsBackToTheStunWithoutAFittingPoint(Env env) { Instance instance = env.createFlatInstance(); Player selected = env.createConnection().connect(instance, new Pos(0.5, 40, 0.5)); // The only point is too close. nextBoolean() is false for 0, so apply() tries the teleport first. - SelectionConsequence selection = new SelectionConsequence(() -> List.of(new Pos(5.5, 40, 0.5)), Optional::of, + PatrolConsequence patrol = new PatrolConsequence(() -> List.of(new Pos(5.5, 40, 0.5)), Optional::of, () -> 0L); - selection.apply(selected, List.of(selected)); + patrol.selected(selected, List.of(selected)); env.tick(); assertTrue(selected.getPosition().sameBlock(new Pos(0.5, 40, 0.5)), "stands at " + selected.getPosition()); @@ -102,10 +102,10 @@ void cleanUpRemovesTheEffects(Env env) { Instance instance = env.createFlatInstance(); Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); - SelectionConsequence selection = new SelectionConsequence(List::of, Optional::of, new Random(1)); - selection.stun(selected, List.of(selected, near)); + PatrolConsequence patrol = new PatrolConsequence(List::of, Optional::of, new Random(1)); + patrol.stun(selected, List.of(selected, near)); - selection.cleanUp(); + patrol.cleanUp(); assertFalse(selected.hasEffect(PotionEffect.SLOWNESS)); assertFalse(near.hasEffect(PotionEffect.BLINDNESS)); diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequenceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequenceIntegrationTest.java index 6782ad19..217819f3 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequenceIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/StagedCatchConsequenceIntegrationTest.java @@ -7,12 +7,12 @@ import net.onelitefeather.cygnus.CygnusPlayerTestBase; import net.onelitefeather.cygnus.common.config.CreekConfig; import org.jetbrains.annotations.Nullable; +import net.onelitefeather.cygnus.creek.state.Contexts; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.List; -import java.util.random.RandomGenerator; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -49,7 +49,7 @@ public void cleanUp() { }; private StagedCatchConsequence consequence(@Nullable Player slender, double draw) { - return new StagedCatchConsequence(effects, reveal, () -> slender, CreekConfig.DEFAULT, always(draw)); + return new StagedCatchConsequence(effects, reveal, () -> slender, CreekConfig.DEFAULT, Contexts.rolling(draw)); } @Test @@ -123,17 +123,4 @@ void cleanUpForgets(Env env) { 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/state/Contexts.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/Contexts.java index 8656f37e..4dd9c0c7 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 @@ -14,6 +14,7 @@ import java.util.Random; import java.util.UUID; import java.util.function.Consumer; +import java.util.function.Predicate; import java.util.random.RandomGenerator; /** @@ -31,6 +32,11 @@ public final class Contexts { /** Actions that hand every catch and selection to the given consumers. */ public static CreekActions actions(Consumer caught, Consumer selected) { + return actions(caught, selected, _ -> {}); + } + + /** Actions that hand every catch, selection and vanish to the given consumers. */ + public static CreekActions actions(Consumer caught, Consumer selected, Consumer vanished) { return new CreekActions() { @Override public void caught(UUID survivor) { @@ -44,6 +50,22 @@ public void selected(UUID survivor) { @Override public void vanished(Pos where) { + vanished.accept(where); + } + }; + } + + /** A random source that answers every roll with the given value. */ + public static RandomGenerator rolling(double value) { + return new RandomGenerator() { + @Override + public long nextLong() { + return 0L; + } + + @Override + public double nextDouble() { + return value; } }; } @@ -74,9 +96,19 @@ static CreekContext selecting(long now, CreekBody body, RouteProvider route, Lis /** The defaults with other random stops. */ static CreekConfig randomStops(double chance, int minMillis, int maxMillis) { + return copy(CreekConfig.DEFAULT.activeWithLastSurvivor(), chance, minMillis, maxMillis); + } + + /** The defaults, but the creek sits out the round once only one survivor is left. */ + public static CreekConfig withoutLastSurvivor() { + CreekConfig d = CreekConfig.DEFAULT; + return copy(false, d.randomStopChance(), d.randomStopMinMillis(), d.randomStopMaxMillis()); + } + + private static CreekConfig copy(boolean activeWithLastSurvivor, double chance, int minMillis, int maxMillis) { CreekConfig d = CreekConfig.DEFAULT; return new CreekConfig( - d.enabled(), d.activeWithLastSurvivor(), d.sightRange(), d.sightViewAngle(), + d.enabled(), activeWithLastSurvivor, d.sightRange(), d.sightViewAngle(), d.wanderPauseMillis(), d.wanderSpeed(), d.huntSpeed(), d.stalkThreshold(), d.huntThreshold(), d.stalkMinDistance(), d.stalkMaxDistance(), d.stalkMinAngle(), d.stalkMaxAngle(), d.stalkRevealMillis(), d.stalkMinSeconds(), d.stalkMaxSeconds(), d.huntMaxSeconds(), @@ -91,7 +123,7 @@ static CreekConfig randomStops(double chance, int minMillis, int maxMillis) { public static RouteProvider route(Pos... points) { return new RouteProvider() { @Override - public Optional next(Pos current, java.util.function.Predicate allowed, + public Optional next(Pos current, Predicate allowed, RandomGenerator random) { return Arrays.stream(points) .filter(point -> point.distance(current) >= 1.0D) diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java index 88bfd6c7..2a684bef 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/state/PatrolStateTest.java @@ -17,7 +17,6 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; -import java.util.random.RandomGenerator; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -339,47 +338,14 @@ void keepsHisPointAfterAPause() { // ---- vanishing at a dead end ---- - /** Answers every roll with the given value. */ - private static RandomGenerator rolling(double value) { - return new RandomGenerator() { - @Override - public long nextLong() { - return 0L; - } - - @Override - public double nextDouble() { - return value; - } - }; - } - /** A route that always heads for A with the given pause, as a dead end or not. */ private static RouteProvider towardsA(int pauseMillis, boolean deadEnd) { return (_, _, _) -> Optional.of(new RouteStep(A, pauseMillis, deadEnd)); } - /** Records every place the creek vanished from. */ - private static CreekActions recordingVanishes(List vanished) { - return new CreekActions() { - @Override - public void caught(UUID survivor) { - } - - @Override - public void selected(UUID survivor) { - } - - @Override - public void vanished(Pos where) { - vanished.add(where); - } - }; - } - private static CreekContext deadEndContext(long now, RecordingBody body, RouteProvider route, List vanished, double roll) { - return Contexts.context(now, body, route, recordingVanishes(vanished), rolling(roll), far(FIRST, 0.0D, false)); + return Contexts.context(now, body, route, Contexts.actions(_ -> {}, _ -> {}, vanished::add), Contexts.rolling(roll), far(FIRST, 0.0D, false)); } @Test 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 index 188b7747..dc349e7a 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/world/CreekPathsTest.java @@ -2,6 +2,7 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.coordinate.Vec; +import net.onelitefeather.cygnus.common.creek.CreekLinks; import net.onelitefeather.cygnus.common.creek.CreekRoute; import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import org.junit.jupiter.api.DisplayName; @@ -27,55 +28,12 @@ private static CreekRoute route(String name, Vec... points) { ); @Test - @DisplayName("Ends within the link distance are linked, the one just outside is not") - void linksFollowTheDistance() { + @DisplayName("Links come from CreekLinks, by route and point") + void linksByNode() { CreekPaths paths = CreekPaths.of(ROUTES, 3.0D); - assertEquals(List.of(new CreekPaths.Node(1, 0), new CreekPaths.Node(2, 0)), - paths.links(new CreekPaths.Node(0, 1))); - assertEquals(List.of(new CreekPaths.Node(0, 1)), paths.links(new CreekPaths.Node(1, 0))); - assertTrue(paths.links(new CreekPaths.Node(3, 0)).isEmpty()); - } - - @Test - @DisplayName("An end on the middle of another route is linked to that point, both ways") - void endOnAMiddleIsLinked() { - CreekPaths paths = CreekPaths.of(List.of( - route("X", new Vec(0, 0, 0), new Vec(10, 0, 0), new Vec(20, 0, 0)), - route("Y", new Vec(10, 0, 2), new Vec(10, 0, 20))), 3.0D); - - assertEquals(List.of(new CreekPaths.Node(0, 1)), paths.links(new CreekPaths.Node(1, 0))); - assertEquals(List.of(new CreekPaths.Node(1, 0)), paths.links(new CreekPaths.Node(0, 1))); - } - - @Test - @DisplayName("Two middles close together are not linked") - void middlesNeverLink() { - CreekPaths paths = CreekPaths.of(List.of( - route("X", new Vec(0, 0, 0), new Vec(10, 0, 0), new Vec(20, 0, 0)), - route("Z", new Vec(-5, 0, 1), new Vec(10, 0, 1), new Vec(25, 0, 1))), 3.0D); - - assertTrue(paths.links(new CreekPaths.Node(0, 1)).isEmpty()); - assertTrue(paths.links(new CreekPaths.Node(1, 1)).isEmpty()); - } - - @Test - @DisplayName("An end links to the nearest point of another route only") - void onlyTheNearestPoint() { - CreekPaths paths = CreekPaths.of(List.of( - route("X", new Vec(-10, 0, 0), new Vec(1, 0, 0), new Vec(2, 0, 0), new Vec(20, 0, 0)), - route("W", new Vec(1, 0, 1), new Vec(1, 0, 20))), 3.0D); - - assertEquals(List.of(new CreekPaths.Node(0, 1)), paths.links(new CreekPaths.Node(1, 0))); - assertTrue(paths.links(new CreekPaths.Node(0, 2)).isEmpty(), "the second closest point stays unlinked"); - } - - @Test - @DisplayName("The two ends of one route never link to each other") - void ownEndsNeverLink() { - 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.Node(0, 0)).isEmpty()); + assertEquals(List.of(new CreekLinks.Node(1, 0), new CreekLinks.Node(2, 0)), + paths.links(new CreekLinks.Node(0, 1))); } @Test 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 index cee02494..ad6e2514 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/creek/CreekRoutePreview.java @@ -15,6 +15,7 @@ import net.minestom.server.particle.Particle; import net.minestom.server.timer.Task; import net.minestom.server.timer.TaskSchedule; +import net.onelitefeather.cygnus.common.creek.CreekLinks; import net.onelitefeather.cygnus.common.creek.CreekRoute; import net.onelitefeather.cygnus.common.creek.CreekWaypoint; import org.jetbrains.annotations.Nullable; @@ -62,7 +63,7 @@ public final class CreekRoutePreview { * @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 + * @param linkDistance how close an end has to be to a point of another route to link to it, in blocks */ public CreekRoutePreview(Player player, Supplier> routes, Supplier<@Nullable String> active, double linkDistance) { @@ -170,9 +171,8 @@ static List line(Vec from, Vec to, double step) { } /** - * Returns the points of a route that link to another route, the same way the game links them: - * an end of one route links to the nearest point of another route within {@code distance}, be - * that the other route's end or a point in its middle. + * Returns the points of a route that link to another route, as {@link CreekLinks} links them + * for the game. * * @param route the route whose points are checked * @param routes all routes of the map, the checked one included @@ -180,37 +180,15 @@ static List line(Vec from, Vec to, double step) { * @return the linked points of {@code route} */ static Set linkedPoints(CreekRoute route, List routes, double distance) { + int index = routes.indexOf(route); + if (index < 0) return Set.of(); + CreekLinks links = CreekLinks.of(routes, distance); Set linked = new HashSet<>(); - for (CreekRoute other : routes) { - if (other.name().equals(route.name())) continue; - // This route's ends, reaching any point of the other route. - for (Vec end : ends(route)) { - if (nearest(other, end, distance) != null) linked.add(end); - } - // The other route's ends, reaching a point of this one. - for (Vec otherEnd : ends(other)) { - Vec point = nearest(route, otherEnd, distance); - if (point != null) linked.add(point); + for (int point = 0; point < route.points().size(); point++) { + if (!links.of(new CreekLinks.Node(index, point)).isEmpty()) { + linked.add(route.points().get(point).position()); } } return linked; } - - private static @Nullable Vec nearest(CreekRoute route, Vec position, double distance) { - Vec nearest = null; - double best = distance; - for (CreekWaypoint point : route.points()) { - double away = point.position().distance(position); - if (away <= best) { - best = away; - nearest = point.position(); - } - } - return nearest; - } - - private static List ends(CreekRoute route) { - if (route.points().isEmpty()) return List.of(); - return List.of(route.points().getFirst().position(), route.points().getLast().position()); - } } From 75939188603499645d8cd742e45922e8427767bf Mon Sep 17 00:00:00 2001 From: Joltras Date: Sun, 27 Sep 2026 21:33:17 +0200 Subject: [PATCH 85/92] feat(creek): fling selected survivors away instead of teleporting them --- .../onelitefeather/cygnus/creek/Creek.java | 8 +- .../cygnus/creek/CreekService.java | 2 +- .../creek/consequence/PatrolConsequence.java | 100 +++++++++++------ .../cygnus/creek/state/PatrolState.java | 2 +- .../cygnus/creek/CreekIntegrationTest.java | 4 +- .../creek/CreekServiceIntegrationTest.java | 15 ++- .../creek/CreekVariantsIntegrationTest.java | 2 +- .../PatrolConsequenceIntegrationTest.java | 104 +++++++++++++----- 8 files changed, 169 insertions(+), 68 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java index fe414186..dd0bde86 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/Creek.java @@ -103,7 +103,7 @@ private CreekContext context(SurvivorSnapshot survivors, Set ignored, long List noticed = ignored.isEmpty() ? this.lastViews : this.lastViews.stream().filter(view -> !ignored.contains(view.id())).toList(); return new CreekContext(now, noticed, this.body, this.route, this.round.spots(), - new Actions(this.round, survivors, ignored), + new Actions(this.round, survivors, ignored, this.body.position()), this.round.config(), this.round.random()); } @@ -167,8 +167,10 @@ private void switchTo(CreekState next, CreekContext ctx) { * @param round what the creek shares with the others of the round * @param survivors the survivors of the step * @param ignored the survivors the creek leaves alone in the step + * @param creek where the creek stands in the step */ - private record Actions(CreekRound round, SurvivorSnapshot survivors, Set ignored) implements CreekActions { + private record Actions(CreekRound round, SurvivorSnapshot survivors, Set ignored, Pos creek) + implements CreekActions { @Override public void caught(UUID survivor) { @@ -179,7 +181,7 @@ public void caught(UUID survivor) { @Override public void selected(UUID survivor) { Player player = this.survivors.player(survivor); - if (player != null) this.round.patrol().selected(player, this.survivors.players()); + if (player != null) this.round.patrol().selected(player, this.creek, this.survivors.players()); } @Override 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 c640222c..48afd36e 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java @@ -134,7 +134,7 @@ void start() { // Every creek walks with its own cursor, so this one belongs to the patrolling creek alone. PathRoute pathRoute = new PathRoute(paths); CreekRound round = new CreekRound(this.sight, this.spots, this.consequence, - new PatrolConsequence(paths::allPoints, this.ground, this.random), this.config, this.random); + new PatrolConsequence(this.random), this.config, this.random); this.round = round; List points = paths.allPoints(); diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequence.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequence.java index e0a624a8..172ffb61 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequence.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequence.java @@ -1,23 +1,22 @@ package net.onelitefeather.cygnus.creek.consequence; import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; import net.minestom.server.potion.Potion; import net.minestom.server.potion.PotionEffect; -import net.onelitefeather.cygnus.creek.world.Ground; -import java.util.ArrayList; import java.util.Collection; import java.util.List; -import java.util.function.Supplier; import java.util.random.RandomGenerator; /** * What the patrolling creek does to the survivors around it. *

    * When it picks out a survivor who came too close, it is a coin toss: either the survivor freezes - * on the spot and everyone standing next to them goes blind for a moment, or they suddenly find - * themselves somewhere else on the map. It is a scare, not a catch, so it never counts towards + * on the spot and everyone standing next to them goes blind for a moment, or the creek flings them + * away from itself. It is a scare, not a catch, so it never counts towards * giving them away to the slender. When it vanishes at the end of its route, everyone close by * hears it and goes blind the same way. *

    @@ -40,39 +39,53 @@ public final class PatrolConsequence { /** How long they stay blind, in ticks (2 seconds). */ static final int BLIND_TICKS = 40; - /** A teleported survivor lands at least this far away, in blocks. */ - static final double TELEPORT_MIN_DISTANCE = 20.0D; + /** A fling needs at least this much room, in blocks. With less, it tries another way. */ + static final double FLING_MIN_DISTANCE = 4.0D; - /** A teleported survivor lands at most this far away, so they stay in the same part of the map. */ - static final double TELEPORT_MAX_DISTANCE = 40.0D; + /** A fling goes at most this far, however much room there is, in blocks. */ + static final double FLING_MAX_DISTANCE = 10.0D; + + /** + * How fast to send a survivor for every block they should fly, in blocks per second. The client + * slows them down on its own, so this is worked out by hand: tune it if flings fall short or + * overshoot. + */ + static final double FLING_SPEED_PER_BLOCK = 2.7D; + + /** How fast a fling lifts the survivor off the ground, in blocks per second. */ + static final double FLING_LIFT = 8.0D; + + /** The ways a fling tries, in degrees off straight away from the creek, in this order. */ + private static final double[] FLING_TURNS = {0.0D, 45.0D, -45.0D, 90.0D, -90.0D}; + + /** How finely the room in front of a survivor is measured, in blocks. */ + private static final double ROOM_STEP = 0.5D; + + /** The heights above the feet at which the room is measured, so the whole body fits through. */ + private static final double[] BODY_HEIGHTS = {0.5D, 1.5D}; - private final Supplier> routePoints; - private final Ground ground; private final RandomGenerator random; private final TrackedEffects effects = new TrackedEffects(); /** * Sets up the consequence for a round. * - * @param routePoints supplies the points of the map's routes, the places a survivor can be sent to - * @param ground finds the floor at such a place - * @param random tosses the coin between freezing and teleporting, and picks the place + * @param random tosses the coin between freezing and flinging */ - public PatrolConsequence(Supplier> routePoints, Ground ground, RandomGenerator random) { - this.routePoints = routePoints; - this.ground = ground; + public PatrolConsequence(RandomGenerator random) { this.random = random; } /** - * Freezes the survivor or sends them away. If there is nowhere to send them, they are frozen + * Freezes the survivor or flings them away. If there is no room to fling them, they are frozen * instead, so a selection never goes unnoticed. * * @param selected the survivor the creek picked out + * @param creek where the creek stands, to fling them away from it * @param survivors every survivor of the round, to find the ones standing next to a frozen one */ - public void selected(Player selected, Collection survivors) { - if (this.random.nextBoolean() || !this.teleportAway(selected)) { + public void selected(Player selected, Pos creek, Collection survivors) { + if (this.random.nextBoolean() || !this.flingAway(selected, creek)) { this.stun(selected, survivors); } } @@ -113,24 +126,43 @@ private static List near(Pos center, Collection survivors) { } /** - * Sends the survivor to a random route point 20 to 40 blocks away. Route points are always - * walkable, so nobody ends up inside a wall. + * Flings the survivor away from the creek, as far as there is room but no further than + * {@link #FLING_MAX_DISTANCE}. Straight away comes first; if something stands in the way within + * {@link #FLING_MIN_DISTANCE}, it tries to the sides. * - * @return {@code false} if none of the route points in that range has a floor + * @return {@code false} if there is not enough room in any of those ways */ - boolean teleportAway(Player selected) { + boolean flingAway(Player selected, Pos creek) { + Instance instance = selected.getInstance(); + if (instance == null) return false; Pos from = selected.getPosition(); - List targets = new ArrayList<>(); - for (Pos point : this.routePoints.get()) { - double distance = point.distance(from); - if (distance < TELEPORT_MIN_DISTANCE || distance > TELEPORT_MAX_DISTANCE) continue; - this.ground.settle(point).ifPresent(targets::add); + double away = Math.atan2(from.z() - creek.z(), from.x() - creek.x()); + for (double turn : FLING_TURNS) { + double angle = away + Math.toRadians(turn); + Vec direction = new Vec(Math.cos(angle), 0.0D, Math.sin(angle)); + double room = room(instance, from, direction); + if (room < FLING_MIN_DISTANCE) continue; + Vec push = direction.mul(room * FLING_SPEED_PER_BLOCK); + selected.setVelocity(new Vec(push.x(), FLING_LIFT, push.z())); + CatchEffects.playScare(selected); + return true; } - if (targets.isEmpty()) return false; - Pos target = targets.get(this.random.nextInt(targets.size())); - selected.teleport(target.withView(from.yaw(), from.pitch())); - CatchEffects.playScare(selected); - return true; + return false; + } + + /** + * How far the survivor can fly in a direction before hitting something, up to + * {@link #FLING_MAX_DISTANCE}. An unloaded chunk counts as a wall. + */ + private static double room(Instance instance, Pos from, Vec direction) { + for (double distance = ROOM_STEP; distance <= FLING_MAX_DISTANCE; distance += ROOM_STEP) { + Pos ahead = from.add(direction.mul(distance)); + if (!instance.isChunkLoaded(ahead)) return distance - ROOM_STEP; + for (double height : BODY_HEIGHTS) { + if (instance.getBlock(ahead.add(0.0D, height, 0.0D)).solid()) return distance - ROOM_STEP; + } + } + return FLING_MAX_DISTANCE; } /** diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java index a515c556..20f53da4 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/state/PatrolState.java @@ -14,7 +14,7 @@ * The creek on its rounds: it walks its routes, and every survivor can see it. *

    * Come within {@link #SELECT_RADIUS} of it and it picks out whoever is closest, stares at them for a - * second and then freezes them or sends them away. After that it simply walks on and leaves + * second and then freezes them or flings them away. After that it simply walks on and leaves * everyone alone for {@link #SELECT_COOLDOWN_MILLIS}. Spot it from farther away and it stops and * looks back at you for a moment. At some waypoints it rests for a while, either because the route * says so or just by chance; whichever takes longer wins. 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 0bcc206f..71e5d69b 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java @@ -39,8 +39,8 @@ class CreekIntegrationTest extends CygnusPlayerTestBase { private static Creek creek(CreekBody body, CreekConfig config, CreekState initial) { CreekSight sight = new CreekSight(config.sightRange(), config.sightViewAngle()); - // No route points: a selection always ends in the stun. - PatrolConsequence patrol = new PatrolConsequence(List::of, Optional::of, new Random(3)); + // nextBoolean() is true for -1, so a selection always ends in the stun. + PatrolConsequence patrol = new PatrolConsequence(() -> -1L); CreekRound round = new CreekRound(sight, new SpotFinder(sight, Optional::of), _ -> {}, patrol, config, new Random(3)); return new Creek(body, Contexts.route(), round, initial); 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 b8ad9a6e..b1819484 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekServiceIntegrationTest.java @@ -4,6 +4,7 @@ import net.minestom.server.coordinate.Vec; import net.minestom.server.entity.Player; import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; import net.minestom.server.network.packet.server.play.ActionBarPacket; import net.minestom.server.potion.PotionEffect; import net.minestom.testing.Collector; @@ -169,8 +170,20 @@ void stopRemovesEverythingAndCleansUp(Env env) { @DisplayName("At the end no stun from the patrol is left on a survivor") void stopClearsTheSelectionEffects(Env env) { Instance instance = env.createFlatInstance(); - // far from every route point: the teleport has no target, so a selection always stuns Player survivor = env.createConnection().connect(instance, new Pos(0, 40, -60, 0, 0)); + // Walled in closely on every side: there is no room to fling them, so a selection always stuns. + for (int i = -3; i <= 3; i++) { + for (int y = 40; y <= 41; y++) { + instance.setBlock(i, y, -63, Block.STONE); + instance.setBlock(i, y, -56, Block.STONE); + } + } + for (int z = -63; z <= -56; z++) { + for (int y = 40; y <= 41; y++) { + instance.setBlock(-3, y, z, Block.STONE); + instance.setBlock(3, y, z, Block.STONE); + } + } AtomicLong clock = new AtomicLong(); CreekService service = service(instance, Set.of(survivor), List.of(ROUTE), clock); service.start(); diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java index 67d28f79..8d79a835 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java @@ -41,7 +41,7 @@ public void apply(Player survivor) { public void cleanUp() { } }; - private static final PatrolConsequence PATROL = new PatrolConsequence(List::of, Optional::of, new Random(1)); + private static final PatrolConsequence PATROL = new PatrolConsequence(new Random(1)); private static BiFunction spawner(Instance instance, CreekConfig config, SpotFinder spots) { CreekRound round = new CreekRound(SIGHT, spots, NO_CATCH, PATROL, config, new Random(3)); diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequenceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequenceIntegrationTest.java index e9c41959..9cc7d2cf 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequenceIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequenceIntegrationTest.java @@ -1,8 +1,10 @@ package net.onelitefeather.cygnus.creek.consequence; import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; import net.minestom.server.entity.Player; import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; import net.minestom.server.potion.PotionEffect; import net.minestom.testing.Env; import net.onelitefeather.cygnus.CygnusPlayerTestBase; @@ -10,9 +12,9 @@ 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.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -23,7 +25,7 @@ class PatrolConsequenceIntegrationTest extends CygnusPlayerTestBase { void stunSlowsTheSelected(Env env) { Instance instance = env.createFlatInstance(); Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); - PatrolConsequence patrol = new PatrolConsequence(List::of, Optional::of, new Random(1)); + PatrolConsequence patrol = new PatrolConsequence(new Random(1)); patrol.stun(selected, List.of(selected)); @@ -40,7 +42,7 @@ void stunBlindsOtherSurvivorsNearby(Env env) { Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); Player far = env.createConnection().connect(instance, new Pos(20, 40, 0)); Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0)); - PatrolConsequence patrol = new PatrolConsequence(List::of, Optional::of, new Random(1)); + PatrolConsequence patrol = new PatrolConsequence(new Random(1)); patrol.stun(selected, List.of(selected, near, far)); @@ -57,7 +59,7 @@ void vanishBlindsSurvivorsNearby(Env env) { Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); Player far = env.createConnection().connect(instance, new Pos(20, 40, 0)); Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0)); - PatrolConsequence patrol = new PatrolConsequence(List::of, Optional::of, new Random(1)); + PatrolConsequence patrol = new PatrolConsequence(new Random(1)); patrol.vanished(new Pos(0, 40, 0), List.of(near, far)); @@ -66,33 +68,85 @@ void vanishBlindsSurvivorsNearby(Env env) { assertFalse(slender.hasEffect(PotionEffect.BLINDNESS), "only survivors go blind"); } - @Test - @DisplayName("A teleport lands on a route point 20 to 40 blocks away") - void teleportLandsInRange(Env env) { - Instance instance = env.createFlatInstance(); - Player selected = env.createConnection().connect(instance, new Pos(0.5, 40, 0.5)); - List points = List.of(new Pos(10.5, 40, 0.5), new Pos(30.5, 40, 0.5), new Pos(60.5, 40, 0.5)); - PatrolConsequence patrol = new PatrolConsequence(() -> points, Optional::of, new Random(1)); + /** Builds a two blocks high wall of stone, on the floor of a flat instance. */ + private static void wall(Instance instance, int fromX, int toX, int fromZ, int toZ) { + for (int x = fromX; x <= toX; x++) { + for (int z = fromZ; z <= toZ; z++) { + instance.setBlock(x, 40, z, Block.STONE); + instance.setBlock(x, 41, z, Block.STONE); + } + } + } - assertTrue(patrol.teleportAway(selected)); - env.tick(); + private static Instance flat(Env env) { + Instance instance = env.createFlatInstance(); + for (int x = -1; x <= 1; x++) { + for (int z = -1; z <= 1; z++) { + instance.loadChunk(x, z).join(); + } + } + return instance; + } - assertTrue(selected.getPosition().sameBlock(new Pos(30.5, 40, 0.5)), "stands at " + selected.getPosition()); + @Test + @DisplayName("On open ground a fling sends the survivor the full distance away from the creek") + void flingsAwayFromTheCreek(Env env) { + Instance instance = flat(env); + Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5)); + PatrolConsequence patrol = new PatrolConsequence(new Random(1)); + + assertTrue(patrol.flingAway(selected, new Pos(8.5, 40, 6.5))); + + Vec velocity = selected.getVelocity(); + assertEquals(PatrolConsequence.FLING_MAX_DISTANCE * PatrolConsequence.FLING_SPEED_PER_BLOCK, velocity.z(), 1.0E-6); + assertEquals(0.0D, velocity.x(), 1.0E-6); + assertEquals(PatrolConsequence.FLING_LIFT, velocity.y(), 1.0E-6); } @Test - @DisplayName("Without a route point in range there is no teleport, the stun is applied instead") - void fallsBackToTheStunWithoutAFittingPoint(Env env) { - Instance instance = env.createFlatInstance(); - Player selected = env.createConnection().connect(instance, new Pos(0.5, 40, 0.5)); - // The only point is too close. nextBoolean() is false for 0, so apply() tries the teleport first. - PatrolConsequence patrol = new PatrolConsequence(() -> List.of(new Pos(5.5, 40, 0.5)), Optional::of, - () -> 0L); + @DisplayName("With a tree in the way the fling only goes as far as there is room") + void flingsOnlyAsFarAsThereIsRoom(Env env) { + Instance instance = flat(env); + Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5)); + wall(instance, 8, 8, 14, 14); + PatrolConsequence patrol = new PatrolConsequence(new Random(1)); - patrol.selected(selected, List.of(selected)); - env.tick(); + assertTrue(patrol.flingAway(selected, new Pos(8.5, 40, 6.5))); - assertTrue(selected.getPosition().sameBlock(new Pos(0.5, 40, 0.5)), "stands at " + selected.getPosition()); + double distance = selected.getVelocity().z() / PatrolConsequence.FLING_SPEED_PER_BLOCK; + assertTrue(distance >= PatrolConsequence.FLING_MIN_DISTANCE && distance < 6.0D, "flung " + distance + " blocks"); + } + + @Test + @DisplayName("With the way ahead blocked the fling goes to a free side") + void flingsToAFreeSide(Env env) { + Instance instance = flat(env); + Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5)); + wall(instance, 0, 15, 10, 10); + PatrolConsequence patrol = new PatrolConsequence(new Random(1)); + + assertTrue(patrol.flingAway(selected, new Pos(8.5, 40, 6.5))); + + Vec velocity = selected.getVelocity(); + assertEquals(0.0D, velocity.z(), 1.0E-6, "straight and diagonal are blocked"); + assertTrue(Math.abs(velocity.x()) >= PatrolConsequence.FLING_MIN_DISTANCE * PatrolConsequence.FLING_SPEED_PER_BLOCK); + } + + @Test + @DisplayName("Boxed in on every side, the survivor is stunned instead") + void stunsWhenBoxedIn(Env env) { + Instance instance = flat(env); + Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5)); + wall(instance, 6, 11, 6, 6); + wall(instance, 6, 11, 11, 11); + wall(instance, 6, 6, 6, 11); + wall(instance, 11, 11, 6, 11); + // nextBoolean() is false for 0, so selected() tries the fling first. + PatrolConsequence patrol = new PatrolConsequence(() -> 0L); + + patrol.selected(selected, new Pos(8.5, 40, 7.5), List.of(selected)); + + assertEquals(Vec.ZERO, selected.getVelocity()); assertTrue(selected.hasEffect(PotionEffect.SLOWNESS)); } @@ -102,7 +156,7 @@ void cleanUpRemovesTheEffects(Env env) { Instance instance = env.createFlatInstance(); Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); - PatrolConsequence patrol = new PatrolConsequence(List::of, Optional::of, new Random(1)); + PatrolConsequence patrol = new PatrolConsequence(new Random(1)); patrol.stun(selected, List.of(selected, near)); patrol.cleanUp(); From c44e04ad8830a4e66d7996d588ab30c379e760b6 Mon Sep 17 00:00:00 2001 From: Joltras Date: Mon, 28 Sep 2026 08:22:24 +0200 Subject: [PATCH 86/92] test(setup): pass a player to the setup data under test --- .../InstanceSetupDataIntegrationTest.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java index 3272ae56..988c9d34 100644 --- a/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java +++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java @@ -14,7 +14,6 @@ import org.junit.jupiter.api.io.TempDir; import java.nio.file.Path; -import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -34,7 +33,7 @@ class InstanceSetupDataIntegrationTest { @Test void testInstanceUsesFalcoChunkLoader(Env env, @TempDir Path worldRoot) { - TestSetupData data = new TestSetupData(MapEntry.of(worldRoot)); + TestSetupData data = new TestSetupData(owner(env), MapEntry.of(worldRoot)); data.loadData(); assertNotNull(data.instance); @@ -47,7 +46,7 @@ void testInstanceUsesFalcoChunkLoader(Env env, @TempDir Path worldRoot) { @Test void testResetClosesChunkLoader(Env env, @TempDir Path worldRoot) { - TestSetupData data = new TestSetupData(MapEntry.of(worldRoot)); + TestSetupData data = new TestSetupData(owner(env), MapEntry.of(worldRoot)); data.loadData(); assertNotNull(data.instance); @@ -57,8 +56,8 @@ void testResetClosesChunkLoader(Env env, @TempDir Path worldRoot) { } @Test - void testResetWithoutInstanceIsHarmless(@TempDir Path worldRoot) { - TestSetupData data = new TestSetupData(MapEntry.of(worldRoot)); + void testResetWithoutInstanceIsHarmless(Env env, @TempDir Path worldRoot) { + TestSetupData data = new TestSetupData(owner(env), MapEntry.of(worldRoot)); data.reset(); @@ -66,14 +65,21 @@ void testResetWithoutInstanceIsHarmless(@TempDir Path worldRoot) { assertNull(data.chunkLoader); } + /** + * Creates the player who owns the setup. Nothing in these tests looks at them. + */ + private static Player owner(Env env) { + return env.createPlayer(env.createFlatInstance()); + } + /** * Minimal {@link InstanceSetupData} which only creates the instance, so the test observes the * chunk loader handling of the base class and nothing else. */ private static final class TestSetupData extends InstanceSetupData { - private TestSetupData(MapEntry mapEntry) { - super(UUID.randomUUID(), mapEntry, BossBar.Color.WHITE); + private TestSetupData(Player owner, MapEntry mapEntry) { + super(owner, mapEntry, BossBar.Color.WHITE); } @Override From 1cf4ae2637c4e0dd0563e5e903673ab05ee4332b Mon Sep 17 00:00:00 2001 From: Joltras Date: Mon, 28 Sep 2026 08:22:29 +0200 Subject: [PATCH 87/92] refactor(map): collect creek routes with the stream api --- .../onelitefeather/cygnus/common/map/GameMapBuilder.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 18e18d56..4e6d9a1d 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 @@ -18,6 +18,7 @@ import java.util.Map; import java.util.Set; import java.util.TreeSet; +import java.util.stream.Collectors; public final class GameMapBuilder extends BaseMapBuilder { @@ -57,8 +58,9 @@ 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()))); + // Mutable on purpose: the setup adds, edits and removes routes. A name seen twice keeps the last route. + this.creekRoutes = gameMap.getCreekRoutes().stream().collect(Collectors.toMap( + CreekRoute::name, route -> new ArrayList<>(route.points()), (first, last) -> last, LinkedHashMap::new)); } /** From 314a649659fef175bbaac3ce846220da1c0980cc Mon Sep 17 00:00:00 2001 From: Joltras Date: Mon, 28 Sep 2026 08:22:35 +0200 Subject: [PATCH 88/92] chore(creek): read the step height from the entity attribute --- .../cygnus/creek/body/CreakingBody.java | 7 +++++ .../cygnus/creek/body/StepFollower.java | 30 ++++++++++++------- .../body/CreakingBodyIntegrationTest.java | 16 ++++++++++ 3 files changed, 42 insertions(+), 11 deletions(-) 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 index 017ef83c..14b0eb97 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java @@ -31,6 +31,12 @@ public final class CreakingBody implements CreekBody { /** How far the goal has to move before the creek works out a new path, in blocks. */ static final double REPATH_DISTANCE = 1.0D; + /** + * The highest step the creek takes without jumping, in blocks: the same as a vanilla mob. A + * creaking would walk straight up a whole block (1.0625), which the paths are not made for. + */ + static final double STEP_HEIGHT = 0.6D; + private final EntityCreature entity; private final Set viewers = ConcurrentHashMap.newKeySet(); private @Nullable Pos goal; @@ -48,6 +54,7 @@ private CreakingBody(EntityCreature entity) { */ public static CreakingBody spawn(Instance instance, Pos position) { CreakingBody body = new CreakingBody(new EntityCreature(EntityType.CREAKING)); + body.entity.getAttribute(Attribute.STEP_HEIGHT).setBaseValue(STEP_HEIGHT); body.entity.getNavigator().setNodeGenerator(FoliageGroundGenerator::new); body.entity.getNavigator().setNodeFollower(() -> new StepFollower(body.entity)); body.entity.updateViewableRule(player -> body.viewers.contains(player.getUuid())); diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java index afc4112d..41d492f4 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java @@ -5,7 +5,8 @@ import net.minestom.server.coordinate.Point; import net.minestom.server.coordinate.Pos; import net.minestom.server.coordinate.Vec; -import net.minestom.server.entity.Entity; +import net.minestom.server.entity.LivingEntity; +import net.minestom.server.entity.attribute.Attribute; import net.minestom.server.entity.pathfinding.followers.GroundNodeFollower; import net.minestom.server.instance.Instance; import net.minestom.server.utils.position.PositionUtils; @@ -20,8 +21,9 @@ *

      *
    • Each step is checked a hair above the feet. Standing right on top of a slab, Minestom * treats the next slab as a wall, and the creek would get stuck at the edge.
    • - *
    • Anything up to {@link #STEP_HEIGHT} high, like slabs and dirt paths, it simply walks up. - * It only jumps when stepping up gets it nowhere.
    • + *
    • Anything up to its step height attribute high, like slabs and dirt paths, it simply walks + * up. That is 0.6 blocks by default, the same as a vanilla mob. It only jumps when stepping up + * gets it nowhere.
    • *
    • A path point counts as reached once the creek stands in its column and less than a block * away in height. The path puts the floor of a slab a block too high, so on slabs the creek * never quite arrived and kept jumping at the point.
    • @@ -33,19 +35,25 @@ */ final class StepFollower extends GroundNodeFollower { - /** The highest step the creek takes without jumping, in blocks. The same as a vanilla mob. */ - static final double STEP_HEIGHT = 0.6D; - /** How far ahead it looks for a blocked step before deciding to jump, in blocks. */ private static final double LOOK_AHEAD = 0.3D; - private final Entity entity; + private final LivingEntity entity; - StepFollower(Entity entity) { + StepFollower(LivingEntity entity) { super(entity); this.entity = entity; } + /** + * The highest step the creek takes without jumping, from its step height attribute. + * + * @return the step height, in blocks + */ + double stepHeight() { + return this.entity.getAttributeValue(Attribute.STEP_HEIGHT); + } + @Override public void moveTowards(Point direction, double speed, Point lookAt) { Instance instance = this.entity.getInstance(); @@ -95,14 +103,14 @@ private Pos walk(Instance instance, Pos from, Vec step) { PhysicsResult over = this.sweep(instance, this.lifted(instance, from), step); if (horizontalDistance(from, over.newPosition()) <= horizontalDistance(from, flatEnd)) return flatEnd; - return this.sweep(instance, over.newPosition(), new Vec(0, -STEP_HEIGHT, 0)).newPosition(); + return this.sweep(instance, over.newPosition(), new Vec(0, -this.stepHeight(), 0)).newPosition(); } /** - * Returns the position {@link #STEP_HEIGHT} higher, or lower if a ceiling is in the way. + * Returns the position {@link #stepHeight()} higher, or lower if a ceiling is in the way. */ private Pos lifted(Instance instance, Pos from) { - return this.sweep(instance, from.add(0, Vec.EPSILON, 0), new Vec(0, STEP_HEIGHT, 0)).newPosition(); + return this.sweep(instance, from.add(0, Vec.EPSILON, 0), new Vec(0, this.stepHeight(), 0)).newPosition(); } private boolean blocked(Instance instance, Pos from, Vec step) { 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 index 7f36a8c5..d3e10366 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java @@ -2,6 +2,7 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.attribute.Attribute; import net.minestom.server.entity.Player; import net.minestom.server.entity.metadata.monster.CreakingMeta; import net.minestom.server.instance.Instance; @@ -143,6 +144,21 @@ void climbsSlabSteps(Env env) { assertTrue(body.position().distance(goal) <= 1.0D, "he gets to the top, stands at " + body.position()); } + @Test + @DisplayName("The step he walks up comes from his step height attribute") + void stepHeightFollowsTheAttribute(Env env) { + Instance instance = env.createFlatInstance(); + env.createConnection().connect(instance, new Pos(0, 40, 0)); + CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40, 0.5)); + EntityCreature creature = (EntityCreature) body.entity(); + StepFollower follower = new StepFollower(creature); + assertEquals(CreakingBody.STEP_HEIGHT, follower.stepHeight(), 1.0E-9, "a vanilla mob's step, not a creaking's"); + + creature.getAttribute(Attribute.STEP_HEIGHT).setBaseValue(1.0D); + + assertEquals(1.0D, follower.stepHeight(), 1.0E-9); + } + @Test @DisplayName("He drops into a gap only one block wide") void dropsIntoANarrowGap(Env env) { From 151dc7f9524661051f5cb060e7dcbdd22f7c2c73 Mon Sep 17 00:00:00 2001 From: Joltras Date: Mon, 28 Sep 2026 08:22:44 +0200 Subject: [PATCH 89/92] chore(creek): stop at the first hidden point when coming back --- .../cygnus/creek/state/VanishState.java | 22 ++++++++++++------- .../cygnus/creek/state/VanishStateTest.java | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+), 8 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 0f54d363..6cf2a765 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 @@ -3,6 +3,8 @@ import net.minestom.server.coordinate.Pos; import net.onelitefeather.cygnus.creek.body.CreekBody; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.Set; @@ -72,13 +74,17 @@ public CreekState tick(CreekContext ctx) { double distance = ctx.config().respawnMinDistance(); // Any point of the route will do. Asking the route for its next point would only offer the // neighbours of where the creek disappeared, and that is often right next to a survivor. - 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(spots.get(ctx.random().nextInt(spots.size()))); - return new PatrolState(); + // Checking them in random order and taking the first hidden one picks just as fairly as + // checking them all, but usually stops after one or two. + List points = new ArrayList<>(ctx.route().points()); + Collections.shuffle(points, ctx.random()); + for (Pos point : points) { + Optional spot = ctx.spots().hiddenSpotAt(point, observers, distance); + if (spot.isPresent()) { + ctx.body().teleport(spot.get()); + return new PatrolState(); + } + } + return this; } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/state/VanishStateTest.java index f1be7efd..cb1800fa 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 @@ -6,13 +6,17 @@ import net.onelitefeather.cygnus.creek.world.CreekPaths; import net.onelitefeather.cygnus.creek.world.PathRoute; import net.onelitefeather.cygnus.creek.world.RouteProvider; +import net.onelitefeather.cygnus.creek.world.SpotFinder; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.List; +import java.util.Optional; +import java.util.Random; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -75,6 +79,24 @@ void staysAwayWithoutAPoint() { assertSame(state, state.tick(Contexts.context(1000L, body, Contexts.route(AHEAD), new ArrayList<>(), WATCHER))); } + @Test + @DisplayName("Coming back, he stops looking once he has found a hidden point") + void stopsAtTheFirstHiddenPoint() { + RecordingBody body = new RecordingBody(new Pos(50, 40, 50)); + AtomicInteger checked = new AtomicInteger(); + SpotFinder counting = new SpotFinder(Contexts.SIGHT, candidate -> { + checked.incrementAndGet(); + return Optional.of(candidate); + }); + Pos[] behind = new Pos[20]; + for (int i = 0; i < behind.length; i++) behind[i] = new Pos(i, 40, -40 - i); + CreekContext ctx = new CreekContext(1000L, List.of(WATCHER), body, Contexts.route(behind), counting, + Contexts.NO_ACTIONS, Contexts.CONFIG, new Random(7)); + + assertInstanceOf(PatrolState.class, new VanishState(1000L).tick(ctx)); + assertEquals(1, checked.get(), "every point behind the survivor is hidden, so the first one will do"); + } + @Test @DisplayName("Forever means forever") void foreverNeverEnds() { From 63cc5348d6e482ccf00264c3f4628272435f7b2b Mon Sep 17 00:00:00 2001 From: Joltras Date: Mon, 28 Sep 2026 08:22:50 +0200 Subject: [PATCH 90/92] refactor(creek): rename PatrolConsequence to PatrolHelper --- .../cygnus/creek/CreekRound.java | 4 +-- .../cygnus/creek/CreekService.java | 4 +-- ...trolConsequence.java => PatrolHelper.java} | 4 +-- .../cygnus/creek/CreekIntegrationTest.java | 6 ++-- .../creek/CreekVariantsIntegrationTest.java | 4 +-- ....java => PatrolHelperIntegrationTest.java} | 28 +++++++++---------- 6 files changed, 25 insertions(+), 25 deletions(-) rename game/src/main/java/net/onelitefeather/cygnus/creek/consequence/{PatrolConsequence.java => PatrolHelper.java} (98%) rename game/src/test/java/net/onelitefeather/cygnus/creek/consequence/{PatrolConsequenceIntegrationTest.java => PatrolHelperIntegrationTest.java} (83%) diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java index 3e963469..7f1105b5 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekRound.java @@ -2,7 +2,7 @@ import net.onelitefeather.cygnus.common.config.CreekConfig; import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; -import net.onelitefeather.cygnus.creek.consequence.PatrolConsequence; +import net.onelitefeather.cygnus.creek.consequence.PatrolHelper; import net.onelitefeather.cygnus.creek.world.CreekSight; import net.onelitefeather.cygnus.creek.world.SpotFinder; @@ -26,7 +26,7 @@ record CreekRound( CreekSight sight, SpotFinder spots, CatchConsequence consequence, - PatrolConsequence patrol, + PatrolHelper patrol, CreekConfig config, RandomGenerator random ) { diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java index 48afd36e..202f1770 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/CreekService.java @@ -10,7 +10,7 @@ import net.onelitefeather.cygnus.common.creek.CreekRoute; import net.onelitefeather.cygnus.creek.body.CreekBody; import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; -import net.onelitefeather.cygnus.creek.consequence.PatrolConsequence; +import net.onelitefeather.cygnus.creek.consequence.PatrolHelper; import net.onelitefeather.cygnus.creek.debug.CreekDebug; import net.onelitefeather.cygnus.creek.dread.DreadSource; import net.onelitefeather.cygnus.creek.state.CreekState; @@ -134,7 +134,7 @@ void start() { // Every creek walks with its own cursor, so this one belongs to the patrolling creek alone. PathRoute pathRoute = new PathRoute(paths); CreekRound round = new CreekRound(this.sight, this.spots, this.consequence, - new PatrolConsequence(this.random), this.config, this.random); + new PatrolHelper(this.random), this.config, this.random); this.round = round; List points = paths.allPoints(); diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequence.java b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelper.java similarity index 98% rename from game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequence.java rename to game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelper.java index 172ffb61..5ac21562 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequence.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelper.java @@ -25,7 +25,7 @@ * @version 1.0.0 * @since 2.15.0 */ -public final class PatrolConsequence { +public final class PatrolHelper { /** How long the survivor stays frozen, in ticks (2 seconds). */ static final int STUN_TICKS = 40; @@ -72,7 +72,7 @@ public final class PatrolConsequence { * * @param random tosses the coin between freezing and flinging */ - public PatrolConsequence(RandomGenerator random) { + public PatrolHelper(RandomGenerator random) { this.random = random; } 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 71e5d69b..ff291b44 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekIntegrationTest.java @@ -9,7 +9,7 @@ import net.onelitefeather.cygnus.common.config.CreekConfig; import net.onelitefeather.cygnus.creek.body.CreakingBody; import net.onelitefeather.cygnus.creek.body.CreekBody; -import net.onelitefeather.cygnus.creek.consequence.PatrolConsequence; +import net.onelitefeather.cygnus.creek.consequence.PatrolHelper; import net.onelitefeather.cygnus.creek.state.Contexts; import net.onelitefeather.cygnus.creek.state.DoneState; import net.onelitefeather.cygnus.creek.state.CreekContext; @@ -40,7 +40,7 @@ class CreekIntegrationTest extends CygnusPlayerTestBase { private static Creek creek(CreekBody body, CreekConfig config, CreekState initial) { CreekSight sight = new CreekSight(config.sightRange(), config.sightViewAngle()); // nextBoolean() is true for -1, so a selection always ends in the stun. - PatrolConsequence patrol = new PatrolConsequence(() -> -1L); + PatrolHelper patrol = new PatrolHelper(() -> -1L); CreekRound round = new CreekRound(sight, new SpotFinder(sight, Optional::of), _ -> {}, patrol, config, new Random(3)); return new Creek(body, Contexts.route(), round, initial); @@ -128,7 +128,7 @@ void vanishForGood(Env env) { } @Test - @DisplayName("A state selecting a survivor hands them to the patrol consequence") + @DisplayName("A state selecting a survivor hands them to the patrol helper") void selectionReachesTheConsequence(Env env) { Instance instance = env.createFlatInstance(); Player survivor = env.createConnection().connect(instance, new Pos(0, 40, 0)); diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java index 8d79a835..267db4bd 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/CreekVariantsIntegrationTest.java @@ -8,7 +8,7 @@ import net.onelitefeather.cygnus.common.config.CreekConfig; import net.onelitefeather.cygnus.creek.body.CreakingBody; import net.onelitefeather.cygnus.creek.consequence.CatchConsequence; -import net.onelitefeather.cygnus.creek.consequence.PatrolConsequence; +import net.onelitefeather.cygnus.creek.consequence.PatrolHelper; import net.onelitefeather.cygnus.creek.state.Contexts; import net.onelitefeather.cygnus.creek.state.CreekState; import net.onelitefeather.cygnus.creek.state.SurvivorView; @@ -41,7 +41,7 @@ public void apply(Player survivor) { public void cleanUp() { } }; - private static final PatrolConsequence PATROL = new PatrolConsequence(new Random(1)); + private static final PatrolHelper PATROL = new PatrolHelper(new Random(1)); private static BiFunction spawner(Instance instance, CreekConfig config, SpotFinder spots) { CreekRound round = new CreekRound(SIGHT, spots, NO_CATCH, PATROL, config, new Random(3)); diff --git a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequenceIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelperIntegrationTest.java similarity index 83% rename from game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequenceIntegrationTest.java rename to game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelperIntegrationTest.java index 9cc7d2cf..e7c73433 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolConsequenceIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/consequence/PatrolHelperIntegrationTest.java @@ -18,14 +18,14 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -class PatrolConsequenceIntegrationTest extends CygnusPlayerTestBase { +class PatrolHelperIntegrationTest extends CygnusPlayerTestBase { @Test @DisplayName("A stun slows the selected survivor and does not count as a catch") void stunSlowsTheSelected(Env env) { Instance instance = env.createFlatInstance(); Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); - PatrolConsequence patrol = new PatrolConsequence(new Random(1)); + PatrolHelper patrol = new PatrolHelper(new Random(1)); patrol.stun(selected, List.of(selected)); @@ -42,7 +42,7 @@ void stunBlindsOtherSurvivorsNearby(Env env) { Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); Player far = env.createConnection().connect(instance, new Pos(20, 40, 0)); Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0)); - PatrolConsequence patrol = new PatrolConsequence(new Random(1)); + PatrolHelper patrol = new PatrolHelper(new Random(1)); patrol.stun(selected, List.of(selected, near, far)); @@ -59,7 +59,7 @@ void vanishBlindsSurvivorsNearby(Env env) { Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); Player far = env.createConnection().connect(instance, new Pos(20, 40, 0)); Player slender = env.createConnection().connect(instance, new Pos(3, 40, 0)); - PatrolConsequence patrol = new PatrolConsequence(new Random(1)); + PatrolHelper patrol = new PatrolHelper(new Random(1)); patrol.vanished(new Pos(0, 40, 0), List.of(near, far)); @@ -93,14 +93,14 @@ private static Instance flat(Env env) { void flingsAwayFromTheCreek(Env env) { Instance instance = flat(env); Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5)); - PatrolConsequence patrol = new PatrolConsequence(new Random(1)); + PatrolHelper patrol = new PatrolHelper(new Random(1)); assertTrue(patrol.flingAway(selected, new Pos(8.5, 40, 6.5))); Vec velocity = selected.getVelocity(); - assertEquals(PatrolConsequence.FLING_MAX_DISTANCE * PatrolConsequence.FLING_SPEED_PER_BLOCK, velocity.z(), 1.0E-6); + assertEquals(PatrolHelper.FLING_MAX_DISTANCE * PatrolHelper.FLING_SPEED_PER_BLOCK, velocity.z(), 1.0E-6); assertEquals(0.0D, velocity.x(), 1.0E-6); - assertEquals(PatrolConsequence.FLING_LIFT, velocity.y(), 1.0E-6); + assertEquals(PatrolHelper.FLING_LIFT, velocity.y(), 1.0E-6); } @Test @@ -109,12 +109,12 @@ void flingsOnlyAsFarAsThereIsRoom(Env env) { Instance instance = flat(env); Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5)); wall(instance, 8, 8, 14, 14); - PatrolConsequence patrol = new PatrolConsequence(new Random(1)); + PatrolHelper patrol = new PatrolHelper(new Random(1)); assertTrue(patrol.flingAway(selected, new Pos(8.5, 40, 6.5))); - double distance = selected.getVelocity().z() / PatrolConsequence.FLING_SPEED_PER_BLOCK; - assertTrue(distance >= PatrolConsequence.FLING_MIN_DISTANCE && distance < 6.0D, "flung " + distance + " blocks"); + double distance = selected.getVelocity().z() / PatrolHelper.FLING_SPEED_PER_BLOCK; + assertTrue(distance >= PatrolHelper.FLING_MIN_DISTANCE && distance < 6.0D, "flung " + distance + " blocks"); } @Test @@ -123,13 +123,13 @@ void flingsToAFreeSide(Env env) { Instance instance = flat(env); Player selected = env.createConnection().connect(instance, new Pos(8.5, 40, 8.5)); wall(instance, 0, 15, 10, 10); - PatrolConsequence patrol = new PatrolConsequence(new Random(1)); + PatrolHelper patrol = new PatrolHelper(new Random(1)); assertTrue(patrol.flingAway(selected, new Pos(8.5, 40, 6.5))); Vec velocity = selected.getVelocity(); assertEquals(0.0D, velocity.z(), 1.0E-6, "straight and diagonal are blocked"); - assertTrue(Math.abs(velocity.x()) >= PatrolConsequence.FLING_MIN_DISTANCE * PatrolConsequence.FLING_SPEED_PER_BLOCK); + assertTrue(Math.abs(velocity.x()) >= PatrolHelper.FLING_MIN_DISTANCE * PatrolHelper.FLING_SPEED_PER_BLOCK); } @Test @@ -142,7 +142,7 @@ void stunsWhenBoxedIn(Env env) { wall(instance, 6, 6, 6, 11); wall(instance, 11, 11, 6, 11); // nextBoolean() is false for 0, so selected() tries the fling first. - PatrolConsequence patrol = new PatrolConsequence(() -> 0L); + PatrolHelper patrol = new PatrolHelper(() -> 0L); patrol.selected(selected, new Pos(8.5, 40, 7.5), List.of(selected)); @@ -156,7 +156,7 @@ void cleanUpRemovesTheEffects(Env env) { Instance instance = env.createFlatInstance(); Player selected = env.createConnection().connect(instance, new Pos(0, 40, 0)); Player near = env.createConnection().connect(instance, new Pos(5, 40, 0)); - PatrolConsequence patrol = new PatrolConsequence(new Random(1)); + PatrolHelper patrol = new PatrolHelper(new Random(1)); patrol.stun(selected, List.of(selected, near)); patrol.cleanUp(); From 1bed5672f3cc9349fd5b57d860c513ccba444866 Mon Sep 17 00:00:00 2001 From: Joltras Date: Mon, 28 Sep 2026 08:23:06 +0200 Subject: [PATCH 91/92] refactor(creek): store a waypoint's position as a Vec --- .../cygnus/common/creek/CreekRoutesFile.java | 5 ++++ .../cygnus/common/creek/CreekWaypoint.java | 24 +++++---------- .../common/creek/CreekRoutesFileTest.java | 29 ++++++++++++++----- 3 files changed, 33 insertions(+), 25 deletions(-) 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 index 42fbd4c9..87873824 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFile.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFile.java @@ -132,11 +132,16 @@ private static List valid(List routes, Path file) { return List.copyOf(valid); } + @SuppressWarnings("ConstantValue") 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"; } + // Gson leaves the position out of a hand-written point, or of one in the older flat layout. + if (route.points().stream().anyMatch(point -> point.position() == null)) { + return "a point has no position"; + } if (route.points().stream().anyMatch(point -> point.pauseMillis() < 0)) { return "a point has a negative pause"; } diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekWaypoint.java b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekWaypoint.java index 14967c0e..c418842c 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekWaypoint.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/creek/CreekWaypoint.java @@ -5,19 +5,18 @@ /** * One point of a creek route: where the creek's feet stand, and how long it waits there. *

      - * The fields are flat so the JSON keeps the {@code x}, {@code y}, {@code z} layout of older files. - * A file without {@code pauseMillis} loads with a pause of 0. + * In the JSON the position is a nested object written by the position adapter, as in + * {@code {"position":{"x":…,"y":…,"z":…},"pauseMillis":…}}. A point without {@code pauseMillis} + * loads with a pause of 0. A point without a position is reported by {@link CreekRoutesFile}. *

      * - * @param x the x coordinate - * @param y the y coordinate - * @param z the z coordinate + * @param position where the creek's feet stand * @param pauseMillis how long the creek waits after reaching this point, in milliseconds * @author theEvilReaper * @version 1.0.0 * @since 2.15.0 */ -public record CreekWaypoint(double x, double y, double z, int pauseMillis) { +public record CreekWaypoint(Vec position, int pauseMillis) { /** * Creates a waypoint without a pause. @@ -26,16 +25,7 @@ public record CreekWaypoint(double x, double y, double z, int pauseMillis) { * @return the waypoint */ public static CreekWaypoint of(Vec position) { - return new CreekWaypoint(position.x(), position.y(), position.z(), 0); - } - - /** - * Returns where the creek's feet stand. - * - * @return the position - */ - public Vec position() { - return new Vec(this.x, this.y, this.z); + return new CreekWaypoint(position, 0); } /** @@ -45,6 +35,6 @@ public Vec position() { * @return the copy */ public CreekWaypoint withPause(int pauseMillis) { - return new CreekWaypoint(this.x, this.y, this.z, pauseMillis); + return new CreekWaypoint(this.position, pauseMillis); } } 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 index ca177605..393f91fa 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFileTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/creek/CreekRoutesFileTest.java @@ -64,9 +64,9 @@ void savedRoutesCanBeLoadedBack(@TempDir Path root) { 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 } ] } + { "name": " ", "points": [ { "position": { "x": 0, "y": 80, "z": 0 } }, { "position": { "x": 5, "y": 80, "z": 0 } } ] }, + { "name": "Stub", "points": [ { "position": { "x": 0, "y": 80, "z": 0 } } ] }, + { "name": "Waldweg Süd", "points": [ { "position": { "x": 0.5, "y": 80, "z": 0.5 } }, { "position": { "x": 0.5, "y": 80, "z": 10.5 } } ] } ] """, StandardCharsets.UTF_8); @@ -77,7 +77,7 @@ void invalidRoutesAreSkipped(@TempDir Path root) throws IOException { @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 } ] } ] + [ { "name": "Stub", "points": [ { "position": { "x": 0, "y": 80, "z": 0 } } ] } ] """, StandardCharsets.UTF_8); assertTrue(CreekRoutesFile.load(mapFile(root)).isEmpty()); @@ -117,10 +117,10 @@ void loadIntoSetsTheRoutes(@TempDir Path root) { } @Test - @DisplayName("A file written before pauses existed loads with no pauses") + @DisplayName("A point without a pause loads with no pause") void oldFileHasNoPauses(@TempDir Path root) throws IOException { Files.writeString(root.resolve("creek.json"), """ - [ { "name": "Alt", "points": [ { "x": 0.5, "y": 80, "z": 0.5 }, { "x": 0.5, "y": 80, "z": 10.5 } ] } ] + [ { "name": "Alt", "points": [ { "position": { "x": 0.5, "y": 80, "z": 0.5 } }, { "position": { "x": 0.5, "y": 80, "z": 10.5 } } ] } ] """, StandardCharsets.UTF_8); List routes = CreekRoutesFile.load(mapFile(root)); @@ -129,6 +129,19 @@ void oldFileHasNoPauses(@TempDir Path root) throws IOException { assertTrue(routes.getFirst().points().stream().allMatch(point -> point.pauseMillis() == 0)); } + @Test + @DisplayName("A route with a point from before positions were nested is skipped, not a crash") + void flatPointIsSkipped(@TempDir Path root) throws IOException { + Files.writeString(root.resolve("creek.json"), """ + [ + { "name": "Flach", "points": [ { "x": 0.5, "y": 80, "z": 0.5 }, { "x": 0.5, "y": 80, "z": 10.5 } ] }, + { "name": "Waldweg Süd", "points": [ { "position": { "x": 0.5, "y": 80, "z": 0.5 } }, { "position": { "x": 0.5, "y": 80, "z": 10.5 } } ] } + ] + """, StandardCharsets.UTF_8); + + assertEquals(List.of(SOUTH), CreekRoutesFile.load(mapFile(root))); + } + @Test @DisplayName("Pauses survive saving and loading") void pausesAreKept(@TempDir Path root) { @@ -146,8 +159,8 @@ void pausesAreKept(@TempDir Path root) { void negativePauseIsSkipped(@TempDir Path root) throws IOException { Files.writeString(root.resolve("creek.json"), """ [ - { "name": "Kaputt", "points": [ { "x": 0, "y": 80, "z": 0, "pauseMillis": -5 }, { "x": 5, "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 } ] } + { "name": "Kaputt", "points": [ { "position": { "x": 0, "y": 80, "z": 0 }, "pauseMillis": -5 }, { "position": { "x": 5, "y": 80, "z": 0 } } ] }, + { "name": "Waldweg Süd", "points": [ { "position": { "x": 0.5, "y": 80, "z": 0.5 } }, { "position": { "x": 0.5, "y": 80, "z": 10.5 } } ] } ] """, StandardCharsets.UTF_8); From 729a5a56861128674fdd7eb0da2d74f7a674e36d Mon Sep 17 00:00:00 2001 From: Joltras Date: Mon, 28 Sep 2026 08:26:29 +0200 Subject: [PATCH 92/92] chore(creek): let the creek step up whole blocks like a vanilla creaking --- .../net/onelitefeather/cygnus/creek/body/CreakingBody.java | 7 ------- .../net/onelitefeather/cygnus/creek/body/StepFollower.java | 6 +++--- .../cygnus/creek/body/CreakingBodyIntegrationTest.java | 6 +++--- 3 files changed, 6 insertions(+), 13 deletions(-) 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 index 14b0eb97..017ef83c 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/CreakingBody.java @@ -31,12 +31,6 @@ public final class CreakingBody implements CreekBody { /** How far the goal has to move before the creek works out a new path, in blocks. */ static final double REPATH_DISTANCE = 1.0D; - /** - * The highest step the creek takes without jumping, in blocks: the same as a vanilla mob. A - * creaking would walk straight up a whole block (1.0625), which the paths are not made for. - */ - static final double STEP_HEIGHT = 0.6D; - private final EntityCreature entity; private final Set viewers = ConcurrentHashMap.newKeySet(); private @Nullable Pos goal; @@ -54,7 +48,6 @@ private CreakingBody(EntityCreature entity) { */ public static CreakingBody spawn(Instance instance, Pos position) { CreakingBody body = new CreakingBody(new EntityCreature(EntityType.CREAKING)); - body.entity.getAttribute(Attribute.STEP_HEIGHT).setBaseValue(STEP_HEIGHT); body.entity.getNavigator().setNodeGenerator(FoliageGroundGenerator::new); body.entity.getNavigator().setNodeFollower(() -> new StepFollower(body.entity)); body.entity.updateViewableRule(player -> body.viewers.contains(player.getUuid())); diff --git a/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java b/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java index 41d492f4..ee4b113f 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java +++ b/game/src/main/java/net/onelitefeather/cygnus/creek/body/StepFollower.java @@ -21,9 +21,9 @@ *
        *
      • Each step is checked a hair above the feet. Standing right on top of a slab, Minestom * treats the next slab as a wall, and the creek would get stuck at the edge.
      • - *
      • Anything up to its step height attribute high, like slabs and dirt paths, it simply walks - * up. That is 0.6 blocks by default, the same as a vanilla mob. It only jumps when stepping up - * gets it nowhere.
      • + *
      • Anything up to its step height attribute high it simply walks up. A creaking's own is + * 1.0625, so like in vanilla it takes whole blocks without hopping, as survivors do in a round. + * It only jumps when stepping up gets it nowhere.
      • *
      • A path point counts as reached once the creek stands in its column and less than a block * away in height. The path puts the floor of a slab a block too high, so on slabs the creek * never quite arrived and kept jumping at the point.
      • 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 index d3e10366..401086e7 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/creek/body/CreakingBodyIntegrationTest.java @@ -152,11 +152,11 @@ void stepHeightFollowsTheAttribute(Env env) { CreakingBody body = CreakingBody.spawn(instance, new Pos(0.5, 40, 0.5)); EntityCreature creature = (EntityCreature) body.entity(); StepFollower follower = new StepFollower(creature); - assertEquals(CreakingBody.STEP_HEIGHT, follower.stepHeight(), 1.0E-9, "a vanilla mob's step, not a creaking's"); + assertEquals(1.0625D, follower.stepHeight(), 1.0E-9, "a creaking's own step, as in vanilla"); - creature.getAttribute(Attribute.STEP_HEIGHT).setBaseValue(1.0D); + creature.getAttribute(Attribute.STEP_HEIGHT).setBaseValue(0.6D); - assertEquals(1.0D, follower.stepHeight(), 1.0E-9); + assertEquals(0.6D, follower.stepHeight(), 1.0E-9); } @Test