From 4ef8d7f185c6b398256c955267c92e35919dec55 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 11 Oct 2026 17:29:53 +0200 Subject: [PATCH 01/11] feat(config): add round start delays for the tracker and the creek's eyes --- .../cygnus/common/config/FootprintConfig.java | 15 ++++++++++++--- .../cygnus/common/config/PossessionConfig.java | 16 ++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/FootprintConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/FootprintConfig.java index b25c0873..d04f8d67 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/config/FootprintConfig.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/FootprintConfig.java @@ -23,6 +23,9 @@ * @param teleportBlocks a single move longer than this is a teleport and not walked distance * @param minSpacing how close two revealed prints may lie, in blocks * @param fadeShare the share of the lifetime at its end that shows the faded stage, from 0 to 1 + * @param scanStartDelaySeconds how long the tracking item stays locked after the round starts. All + * survivors start at the same spot, so an early scan would show where + * each of them ran off to * @author Joltra * @version 1.0.0 * @since 2.16.0 @@ -42,7 +45,8 @@ public record FootprintConfig( int scanCooldownSeconds, double teleportBlocks, double minSpacing, - double fadeShare + double fadeShare, + int scanStartDelaySeconds ) { /** @@ -52,7 +56,8 @@ public record FootprintConfig( 3.0D, 0.25D, 1000, 2000, 12, 2.0D, 30, 40, 5, 40, 15, 120, - 8.0D, 0.7D, 0.33D); + 8.0D, 0.7D, 0.33D, + 150); /** * Checks the values. @@ -94,6 +99,9 @@ public record FootprintConfig( if (fadeShare < 0.0D || fadeShare > 1.0D) { throw new IllegalArgumentException("fadeShare (" + fadeShare + ") must be between 0.0 and 1.0"); } + if (scanStartDelaySeconds < 0) { + throw new IllegalArgumentException("scanStartDelaySeconds (" + scanStartDelaySeconds + ") must not be negative"); + } } /** @@ -119,7 +127,8 @@ public static FootprintConfig read(ConfigSection section) { section.getInt("scanCooldownSeconds", defaults.scanCooldownSeconds()), section.getDouble("teleportBlocks", defaults.teleportBlocks()), section.getDouble("minSpacing", defaults.minSpacing()), - section.getDouble("fadeShare", defaults.fadeShare()) + section.getDouble("fadeShare", defaults.fadeShare()), + section.getInt("scanStartDelaySeconds", defaults.scanStartDelaySeconds()) ), defaults); } diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/PossessionConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/PossessionConfig.java index 35a8048e..eec53555 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/config/PossessionConfig.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/PossessionConfig.java @@ -12,6 +12,8 @@ * @param cooldownSeconds how long the slender waits before he may possess the creek again * @param sightFactor how much farther survivors notice the creek while it is possessed. 1 keeps * the normal range + * @param startDelaySeconds how long the slender waits after the round starts before he may possess + * the creek the first time * @author theEvilReaper * @version 1.0.0 * @since 2.16.0 @@ -19,13 +21,15 @@ public record PossessionConfig( int maxSeconds, int cooldownSeconds, - double sightFactor + double sightFactor, + int startDelaySeconds ) { /** - * The default settings: a short look, a long wait, and half again the normal sight range. + * The default settings: a short look, a long wait, half again the normal sight range, and a + * locked first minute and a half. */ - public static final PossessionConfig DEFAULT = new PossessionConfig(8, 60, 1.5D); + public static final PossessionConfig DEFAULT = new PossessionConfig(8, 60, 1.5D, 90); /** * Checks the values. @@ -42,6 +46,9 @@ public record PossessionConfig( if (sightFactor < 1.0D) { throw new IllegalArgumentException("sightFactor (" + sightFactor + ") must be at least 1.0"); } + if (startDelaySeconds < 0) { + throw new IllegalArgumentException("startDelaySeconds (" + startDelaySeconds + ") must not be negative"); + } } /** @@ -56,7 +63,8 @@ public static PossessionConfig read(ConfigSection section) { return section.orDefault("possession", () -> new PossessionConfig( section.getInt("maxSeconds", defaults.maxSeconds()), section.getInt("cooldownSeconds", defaults.cooldownSeconds()), - section.getDouble("sightFactor", defaults.sightFactor()) + section.getDouble("sightFactor", defaults.sightFactor()), + section.getInt("startDelaySeconds", defaults.startDelaySeconds()) ), defaults); } } From 9f80bdbf2694aa49c03fc44fec62c00d0d537846 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 11 Oct 2026 17:29:53 +0200 Subject: [PATCH 02/11] test(config): cover the round start delays --- .../common/config/FootprintConfigTest.java | 32 ++++++++++++------- .../common/config/GameConfigReaderTest.java | 4 +++ .../common/config/PossessionConfigTest.java | 15 +++++---- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/FootprintConfigTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/FootprintConfigTest.java index cc0fca63..0566ea07 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/config/FootprintConfigTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/FootprintConfigTest.java @@ -31,6 +31,16 @@ void defaults() { assertEquals(8.0D, D.teleportBlocks()); assertEquals(0.7D, D.minSpacing()); assertEquals(0.33D, D.fadeShare()); + assertEquals(150, D.scanStartDelaySeconds()); + } + + @Test + @DisplayName("The start delay may be 0 but not negative") + void startDelayRange() { + assertRejected("scanStartDelaySeconds", () -> new FootprintConfig(3.0D, 0.25D, 1000, 2000, 12, + 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D, -1)); + assertDoesNotThrow(() -> new FootprintConfig(3.0D, 0.25D, 1000, 2000, 12, + 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D, 0)); } @Test @@ -54,46 +64,46 @@ void chanceRange() { @DisplayName("The longest delay may not be shorter than the shortest") void delayOrder() { assertRejected("slenderDelayMaxMillis", () -> new FootprintConfig(3.0D, 0.25D, 2000, 1000, 12, - 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D)); + 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D, 90)); assertRejected("slenderDelayMinMillis", () -> new FootprintConfig(3.0D, 0.25D, -1, 1000, 12, - 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D)); + 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D, 90)); } @Test @DisplayName("The gap has to be shorter than the history, or a scan never finds anything") void gapShorterThanHistory() { assertRejected("scanGapSeconds", () -> new FootprintConfig(3.0D, 0.25D, 1000, 2000, 12, - 2.0D, 30, 40, 30, 40, 15, 120, 8.0D, 0.7D, 0.33D)); + 2.0D, 30, 40, 30, 40, 15, 120, 8.0D, 0.7D, 0.33D, 90)); } @Test @DisplayName("A teleport has to be longer than a step and a sample") void teleportLongerThanSteps() { assertRejected("teleportBlocks", () -> new FootprintConfig(3.0D, 0.25D, 1000, 2000, 12, - 2.0D, 30, 40, 5, 40, 15, 120, 3.0D, 0.7D, 0.33D)); + 2.0D, 30, 40, 5, 40, 15, 120, 3.0D, 0.7D, 0.33D, 90)); } @Test @DisplayName("Counts and durations need at least 1") void lowerBounds() { assertRejected("slenderLifetimeSeconds", () -> new FootprintConfig(3.0D, 0.25D, 1000, 2000, 0, - 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D)); + 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D, 90)); assertRejected("scanRadius", () -> new FootprintConfig(3.0D, 0.25D, 1000, 2000, 12, - 2.0D, 30, 0, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D)); + 2.0D, 30, 0, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D, 90)); assertRejected("scanMaxPrints", () -> new FootprintConfig(3.0D, 0.25D, 1000, 2000, 12, - 2.0D, 30, 40, 5, 0, 15, 120, 8.0D, 0.7D, 0.33D)); + 2.0D, 30, 40, 5, 0, 15, 120, 8.0D, 0.7D, 0.33D, 90)); assertRejected("scanCooldownSeconds", () -> new FootprintConfig(3.0D, 0.25D, 1000, 2000, 12, - 2.0D, 30, 40, 5, 40, 15, -1, 8.0D, 0.7D, 0.33D)); + 2.0D, 30, 40, 5, 40, 15, -1, 8.0D, 0.7D, 0.33D, 90)); assertRejected("slenderStepBlocks", () -> new FootprintConfig(0.0D, 0.25D, 1000, 2000, 12, - 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D)); + 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D, 90)); } private static FootprintConfig withChance(double chance) { - return new FootprintConfig(3.0D, chance, 1000, 2000, 12, 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D); + return new FootprintConfig(3.0D, chance, 1000, 2000, 12, 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D, 90); } private static FootprintConfig withFadeShare(double fadeShare) { - return new FootprintConfig(3.0D, 0.25D, 1000, 2000, 12, 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, fadeShare); + return new FootprintConfig(3.0D, 0.25D, 1000, 2000, 12, 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, fadeShare, 90); } private static void assertRejected(String field, Executable executable) { 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 ed1f4030..411d3a5a 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 @@ -466,6 +466,7 @@ void testPossessionValuesAreRead(@TempDir Path tempDir) throws IOException { possession.maxSeconds=5 possession.cooldownSeconds=90 possession.sightFactor=2.0 + possession.startDelaySeconds=45 """); PossessionConfig possession = new GameConfigReader(tempDir).getConfig().possession(); @@ -473,6 +474,7 @@ void testPossessionValuesAreRead(@TempDir Path tempDir) throws IOException { assertEquals(5, possession.maxSeconds()); assertEquals(90, possession.cooldownSeconds()); assertEquals(2.0D, possession.sightFactor(), 1.0E-9); + assertEquals(45, possession.startDelaySeconds()); } @Test @@ -514,6 +516,7 @@ void testFootprintValuesAreRead(@TempDir Path tempDir) throws IOException { footprint.scanRadius=25 footprint.scanCooldownSeconds=90 footprint.fadeShare=0.5 + footprint.scanStartDelaySeconds=30 """); FootprintConfig footprint = new GameConfigReader(tempDir).getConfig().footprint(); @@ -522,6 +525,7 @@ void testFootprintValuesAreRead(@TempDir Path tempDir) throws IOException { assertEquals(25, footprint.scanRadius()); assertEquals(90, footprint.scanCooldownSeconds()); assertEquals(0.5D, footprint.fadeShare(), 1.0E-9); + assertEquals(30, footprint.scanStartDelaySeconds()); assertEquals(FootprintConfig.DEFAULT.scanMaxPrints(), footprint.scanMaxPrints()); } diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/PossessionConfigTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/PossessionConfigTest.java index 694974b1..5065e4e7 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/config/PossessionConfigTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/PossessionConfigTest.java @@ -10,22 +10,25 @@ class PossessionConfigTest { @Test - @DisplayName("The possession lasts 8 seconds, has a 60 second cooldown and widens the sight by half") + @DisplayName("The possession lasts 8 seconds, has a 60 second cooldown, widens the sight by half and unlocks after 90 seconds") void defaultsMatchTheDesign() { assertEquals(8, PossessionConfig.DEFAULT.maxSeconds()); assertEquals(60, PossessionConfig.DEFAULT.cooldownSeconds()); assertEquals(1.5D, PossessionConfig.DEFAULT.sightFactor()); + assertEquals(90, PossessionConfig.DEFAULT.startDelaySeconds()); } @Test - @DisplayName("The possession needs a duration above 0, no negative cooldown and a factor of at least 1") + @DisplayName("The possession needs a duration above 0, no negative cooldown or start delay and a factor of at least 1") void valuesAreChecked() { - assertTrue(assertThrows(IllegalArgumentException.class, () -> new PossessionConfig(0, 60, 1.5D)) + assertTrue(assertThrows(IllegalArgumentException.class, () -> new PossessionConfig(0, 60, 1.5D, 90)) .getMessage().contains("maxSeconds")); - assertTrue(assertThrows(IllegalArgumentException.class, () -> new PossessionConfig(8, -1, 1.5D)) + assertTrue(assertThrows(IllegalArgumentException.class, () -> new PossessionConfig(8, -1, 1.5D, 90)) .getMessage().contains("cooldownSeconds")); - assertTrue(assertThrows(IllegalArgumentException.class, () -> new PossessionConfig(8, 60, 0.9D)) + assertTrue(assertThrows(IllegalArgumentException.class, () -> new PossessionConfig(8, 60, 0.9D, 90)) .getMessage().contains("sightFactor")); - new PossessionConfig(1, 0, 1.0D); + assertTrue(assertThrows(IllegalArgumentException.class, () -> new PossessionConfig(8, 60, 1.5D, -1)) + .getMessage().contains("startDelaySeconds")); + new PossessionConfig(1, 0, 1.0D, 0); } } From 93831bb8e13888861b013f37f25b458b6f11d13d Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 11 Oct 2026 17:29:53 +0200 Subject: [PATCH 03/11] feat(footprint): lock the tracker for a while after the round start --- .../net/onelitefeather/cygnus/Cygnus.java | 4 +- .../cygnus/footprint/FootprintFeature.java | 38 ++++++++++++++----- .../cygnus/footprint/TrackingScan.java | 10 ++++- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index d367373c..feeb8115 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -251,7 +251,9 @@ sanityService, new HuntedTabWitness(new TracingCreekWitness(sanityService, this. this.gameConfig.adrenaline(), () -> TeamHelper.survivorsOf(this.teamService), System::currentTimeMillis), - new FootprintFeature(this.gameConfig.footprint(), System::currentTimeMillis), + new FootprintFeature(this.gameConfig.footprint(), + () -> TeamHelper.slenderOf(this.teamService), + System::currentTimeMillis), new OverlayModule(this.gameConfig.glitch(), this.gameConfig.pageGlitch(), this.teamService, this.staminaService, tickSections) )).toList(); diff --git a/game/src/main/java/net/onelitefeather/cygnus/footprint/FootprintFeature.java b/game/src/main/java/net/onelitefeather/cygnus/footprint/FootprintFeature.java index 3d6fdd52..2f277896 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/footprint/FootprintFeature.java +++ b/game/src/main/java/net/onelitefeather/cygnus/footprint/FootprintFeature.java @@ -16,9 +16,11 @@ import net.onelitefeather.cygnus.event.SlenderReviveEvent; import net.onelitefeather.cygnus.team.TeamHelper; import net.onelitefeather.cygnus.utils.Items; +import org.jetbrains.annotations.Nullable; import java.util.SplittableRandom; import java.util.function.LongSupplier; +import java.util.function.Supplier; import java.util.random.RandomGenerator; /** @@ -26,7 +28,8 @@ *

* The hidden slender now and then leaves a print that only survivors see. Survivors are recorded * all the time, and the slender reveals their recent tracks around him with his tracker. Moves only - * count while a round runs, and the round end takes every print and every track away. + * count while a round runs, and the round end takes every print and every track away. The tracker + * starts each round locked for a while. *

* * @author Joltra @@ -41,26 +44,31 @@ public final class FootprintFeature implements GameFeature { private final SurvivorTrackLog log; private final SlenderTrail trail; private final TrackingScan scan; + private final Supplier<@Nullable Player> slender; private volatile boolean running; /** * Creates the feature. * - * @param config the footprint values - * @param clock supplies the current time in milliseconds + * @param config the footprint values + * @param slender supplies the slender of the round, or {@code null} while there is none + * @param clock supplies the current time in milliseconds */ - public FootprintFeature(FootprintConfig config, LongSupplier clock) { - this(config, clock, new SplittableRandom()); + public FootprintFeature(FootprintConfig config, Supplier<@Nullable Player> slender, LongSupplier clock) { + this(config, slender, clock, new SplittableRandom()); } /** * Creates the feature with a given random source, for tests. * - * @param config the footprint values - * @param clock supplies the current time in milliseconds - * @param random the source for rolls, delays and facings + * @param config the footprint values + * @param slender supplies the slender of the round, or {@code null} while there is none + * @param clock supplies the current time in milliseconds + * @param random the source for rolls, delays and facings */ - FootprintFeature(FootprintConfig config, LongSupplier clock, RandomGenerator random) { + FootprintFeature(FootprintConfig config, Supplier<@Nullable Player> slender, LongSupplier clock, + RandomGenerator random) { + this.slender = slender; this.clock = clock; this.spawner = new FootprintSpawner(random, config.fadeShare()); this.log = new SurvivorTrackLog(config); @@ -75,7 +83,7 @@ public EventNode node() { } private void registerListeners() { - this.node.addListener(GameStartEvent.class, _ -> this.running = true); + this.node.addListener(GameStartEvent.class, _ -> this.started()); this.node.addListener(PlayerMoveEvent.class, this::moved); this.node.addListener(PlayerUseItemEvent.class, this::used); this.node.addListener(PlayerChunkLoadEvent.class, @@ -86,6 +94,16 @@ private void registerListeners() { this.node.addListener(GameFinishEvent.class, _ -> this.stop()); } + /** + * Starts the round with the tracker locked, and shows the slender how long it stays locked. + */ + private void started() { + this.running = true; + this.scan.start(); + Player current = this.slender.get(); + if (current != null) this.scan.showCooldown(current); + } + private void moved(PlayerMoveEvent event) { if (!this.running) return; Player player = event.getPlayer(); diff --git a/game/src/main/java/net/onelitefeather/cygnus/footprint/TrackingScan.java b/game/src/main/java/net/onelitefeather/cygnus/footprint/TrackingScan.java index f99c93f3..7b7a9eea 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/footprint/TrackingScan.java +++ b/game/src/main/java/net/onelitefeather/cygnus/footprint/TrackingScan.java @@ -22,7 +22,8 @@ * The newest seconds are left out, so the prints never lead straight to a survivor. A scan that * finds nothing still costs the cooldown. That is an answer too, and it keeps the tracker from * being a free probe. The cooldown belongs to the slender role, so a new slender after a - * takeover inherits what is left of it. + * takeover inherits what is left of it. A round starts with the tracker locked for a while: all + * survivors start at the same spot, and an early scan would show where each of them ran off to. *

* * @author Joltra @@ -92,6 +93,13 @@ boolean use(Player slender) { return true; } + /** + * Locks the tracker for the start delay of a new round. + */ + void start() { + this.readyAt = this.clock.getAsLong() + this.config.scanStartDelaySeconds() * 1000L; + } + /** * Shows a slender what is left of the cooldown, for example after a takeover. * From c7d99c89cf1496e8751ae26ad956722a1b3c3386 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 11 Oct 2026 17:29:53 +0200 Subject: [PATCH 04/11] test(footprint): cover the tracker lock at the round start --- .../footprint/FootprintFeatureTest.java | 25 +++++++++++++++++-- .../cygnus/footprint/SlenderTrailTest.java | 2 +- .../cygnus/footprint/TrackingScanTest.java | 16 ++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/game/src/test/java/net/onelitefeather/cygnus/footprint/FootprintFeatureTest.java b/game/src/test/java/net/onelitefeather/cygnus/footprint/FootprintFeatureTest.java index 620c4d6e..4759d739 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/footprint/FootprintFeatureTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/footprint/FootprintFeatureTest.java @@ -38,12 +38,12 @@ class FootprintFeatureTest extends CygnusPlayerTestBase { private static final FootprintConfig CONFIG = new FootprintConfig(3.0D, 1.0D, 50, 50, 12, - 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D); + 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D, 0); private final AtomicLong clock = new AtomicLong(); private FootprintFeature feature(Env env) { - FootprintFeature feature = new FootprintFeature(CONFIG, this.clock::get, new FixedRandom(0.0D)); + FootprintFeature feature = new FootprintFeature(CONFIG, () -> null, this.clock::get, new FixedRandom(0.0D)); env.process().eventHandler().addChild(feature.node()); return feature; } @@ -125,6 +125,27 @@ void deathClearsTrack(Env env) { env.destroyInstance(instance, true); } + @Test + @DisplayName("The round start shows the slender how long the tracker stays locked") + void startShowsLock(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player slender = connection.connect(instance, new Pos(0.5, 40, 0.5)); + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + FootprintConfig delayed = new FootprintConfig(3.0D, 1.0D, 50, 50, 12, + 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D, 60); + FootprintFeature feature = new FootprintFeature(delayed, () -> slender, this.clock::get, new FixedRandom(0.0D)); + env.process().eventHandler().addChild(feature.node()); + Collector packets = connection.trackIncoming(SetCooldownPacket.class); + + EventDispatcher.call(new GameStartEvent()); + + assertTrue(packets.collect().stream().anyMatch(packet -> packet.cooldownGroup().equals(TrackingScan.COOLDOWN_GROUP) + && packet.cooldownTicks() == 60 * 20)); + env.process().eventHandler().removeChild(feature.node()); + env.destroyInstance(instance, true); + } + @Test @DisplayName("A new slender sees the cooldown left from the old one") void takeoverKeepsCooldown(Env env) { diff --git a/game/src/test/java/net/onelitefeather/cygnus/footprint/SlenderTrailTest.java b/game/src/test/java/net/onelitefeather/cygnus/footprint/SlenderTrailTest.java index b4ec89f8..b6d63e63 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/footprint/SlenderTrailTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/footprint/SlenderTrailTest.java @@ -20,7 +20,7 @@ class SlenderTrailTest extends CygnusPlayerTestBase { /** Default values, but a one tick delay so the tests stay short. */ private static final FootprintConfig CONFIG = new FootprintConfig(3.0D, 0.25D, 50, 50, 12, - 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D); + 2.0D, 30, 40, 5, 40, 15, 120, 8.0D, 0.7D, 0.33D, 0); private static void tick(Env env, int ticks) { for (int i = 0; i < ticks; i++) env.tick(); diff --git a/game/src/test/java/net/onelitefeather/cygnus/footprint/TrackingScanTest.java b/game/src/test/java/net/onelitefeather/cygnus/footprint/TrackingScanTest.java index 2c436726..395ceb9c 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/footprint/TrackingScanTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/footprint/TrackingScanTest.java @@ -130,4 +130,20 @@ void showsRemainingCooldown(Env env) { && cooldown.cooldownTicks() == 100 * 20)); env.destroyInstance(instance, true); } + + @Test + @DisplayName("A round starts with the tracker locked for the start delay") + void lockedAtStart(Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance, new Pos(0.5, 40, 0.5)); + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + + this.scan.start(); + + this.clock.set(149_999L); + assertFalse(this.scan.use(slender)); + this.clock.set(150_000L); + assertTrue(this.scan.use(slender)); + env.destroyInstance(instance, true); + } } From 01509d36b3133518d1096309214658a33dcbe422 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 11 Oct 2026 17:29:53 +0200 Subject: [PATCH 05/11] feat(possession): lock the creek's eyes for a while after the round start --- .../cygnus/possession/PossessionCooldown.java | 37 +++++++++++++++++-- .../cygnus/possession/PossessionFeature.java | 20 ++++++---- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/possession/PossessionCooldown.java b/game/src/main/java/net/onelitefeather/cygnus/possession/PossessionCooldown.java index 47238720..f761c82a 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/possession/PossessionCooldown.java +++ b/game/src/main/java/net/onelitefeather/cygnus/possession/PossessionCooldown.java @@ -13,7 +13,8 @@ * The wait between two possessions, kept per player. *

* It always runs in full once a possession started, also when the slender ended it early. A use - * that never got into the creek costs nothing. + * that never got into the creek costs nothing. On top of that, a lock at the round start holds + * every player back until the start delay is over. *

* * @author theEvilReaper @@ -30,6 +31,7 @@ final class PossessionCooldown { private final long millis; private final LongSupplier clock; private final Map readyAt = new ConcurrentHashMap<>(); + private volatile long unlockAt; /** * Creates the cooldown. @@ -49,7 +51,27 @@ final class PossessionCooldown { * @return {@code true} once the cooldown is over or never ran */ boolean isReady(Player player) { - return this.clock.getAsLong() >= this.readyAt.getOrDefault(player.getUuid(), 0L); + return this.clock.getAsLong() >= this.readyAt(player); + } + + /** + * Locks the possession for every player, for the start of a round. + * + * @param seconds how long the lock lasts + */ + void lock(int seconds) { + this.unlockAt = this.clock.getAsLong() + seconds * 1000L; + } + + /** + * Shows a player on the item what is left of his cooldown or of the round start lock. + * + * @param player the player + */ + void show(Player player) { + long left = this.readyAt(player) - this.clock.getAsLong(); + if (left <= 0L || !player.isOnline()) return; + this.send(player, left); } /** @@ -60,7 +82,7 @@ boolean isReady(Player player) { void start(Player player) { this.readyAt.put(player.getUuid(), this.clock.getAsLong() + this.millis); if (this.millis <= 0L || !player.isOnline()) return; - player.sendPacket(new SetCooldownPacket(COOLDOWN_GROUP, (int) Math.ceil(this.millis / TICK_MILLIS))); + this.send(player, this.millis); } /** @@ -68,5 +90,14 @@ void start(Player player) { */ void reset() { this.readyAt.clear(); + this.unlockAt = 0L; + } + + private long readyAt(Player player) { + return Math.max(this.readyAt.getOrDefault(player.getUuid(), 0L), this.unlockAt); + } + + private void send(Player player, long millis) { + player.sendPacket(new SetCooldownPacket(COOLDOWN_GROUP, (int) Math.ceil(millis / TICK_MILLIS))); } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/possession/PossessionFeature.java b/game/src/main/java/net/onelitefeather/cygnus/possession/PossessionFeature.java index 546f2e93..54f9d94b 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/possession/PossessionFeature.java +++ b/game/src/main/java/net/onelitefeather/cygnus/possession/PossessionFeature.java @@ -38,7 +38,7 @@ * Meanwhile his body stands revealed and frozen. Only {@link Tags#HIDDEN} changes, the slender bar * keeps its state, so the body does no damage. Survivors around the creek glow for him, and the * creek is noticed from farther away. Sneaking, the maximum time or the creek going away ends it, - * and then the full cooldown runs. + * and then the full cooldown runs. A round starts with the item locked for a while. *

* * @author theEvilReaper @@ -128,22 +128,26 @@ private void registerListeners() { } /** - * Starts the round for the possession and hands the slender his item. + * Starts the round for the possession with the item locked, and hands the slender his item. */ synchronized void started() { this.running = true; + this.cooldown.lock(this.config.startDelaySeconds()); Player current = this.slender.get(); - if (current != null) giveItem(current); + if (current != null) this.giveItem(current); } /** - * Hands out the item one tick later. The game's own listeners fill the slender's inventory on - * the same event, and they clear it first. + * Hands out the item one tick later, together with what is left of its cooldown. The game's own + * listeners fill the slender's inventory on the same event, and they clear it first. * * @param player the slender */ - private static void giveItem(Player player) { - MinecraftServer.getSchedulerManager().scheduleNextTick(() -> Items.setPossessionItem(player)); + private void giveItem(Player player) { + MinecraftServer.getSchedulerManager().scheduleNextTick(() -> { + Items.setPossessionItem(player); + this.cooldown.show(player); + }); } /** @@ -255,7 +259,7 @@ synchronized void sneaked(Player player) { private synchronized void slenderChanged(Player newSlender) { Possession current = this.possession; if (current != null && !current.slender().equals(newSlender)) this.end(current, true); - giveItem(newSlender); + this.giveItem(newSlender); } /** From 5da9e9a2223e3ebca1a72749b92871f690d3f07e Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 11 Oct 2026 17:29:53 +0200 Subject: [PATCH 06/11] test(possession): cover the lock at the round start --- .../possession/PossessionCooldownTest.java | 36 +++++++++++++++++++ .../possession/PossessionFeatureTest.java | 23 +++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/game/src/test/java/net/onelitefeather/cygnus/possession/PossessionCooldownTest.java b/game/src/test/java/net/onelitefeather/cygnus/possession/PossessionCooldownTest.java index 61623b09..618d0f84 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/possession/PossessionCooldownTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/possession/PossessionCooldownTest.java @@ -61,4 +61,40 @@ void perPlayerAndReset(Env env) { assertTrue(this.cooldown.isReady(first)); env.destroyInstance(instance, true); } + + @Test + @DisplayName("The round start lock holds every player back and is shown on the item") + void startLock(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player slender = connection.connect(instance, new Pos(0, 40, 0)); + Player other = env.createPlayer(instance, new Pos(2, 40, 0)); + Collector packets = connection.trackIncoming(SetCooldownPacket.class); + + this.cooldown.lock(90); + this.clock.set(30_000L); + this.cooldown.show(slender); + + assertFalse(this.cooldown.isReady(slender)); + assertFalse(this.cooldown.isReady(other)); + List sent = packets.collect(); + assertEquals(1, sent.size()); + assertEquals(60 * 20, sent.getFirst().cooldownTicks()); + this.clock.set(90_000L); + assertTrue(this.cooldown.isReady(slender)); + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("A reset lifts the round start lock") + void resetLiftsLock(Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance, new Pos(0, 40, 0)); + + this.cooldown.lock(90); + this.cooldown.reset(); + + assertTrue(this.cooldown.isReady(slender)); + env.destroyInstance(instance, true); + } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/possession/PossessionFeatureTest.java b/game/src/test/java/net/onelitefeather/cygnus/possession/PossessionFeatureTest.java index 208b7713..caf7dd59 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/possession/PossessionFeatureTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/possession/PossessionFeatureTest.java @@ -72,8 +72,11 @@ public int sightRange() { } } + /** The default values without the round start lock, so the tests can use the item right away. */ + private static final PossessionConfig CONFIG = new PossessionConfig(8, 60, 1.5D, 0); + private PossessionFeature feature(@Nullable Player slender) { - PossessionFeature feature = new PossessionFeature(PossessionConfig.DEFAULT, true, this.target, this.survivors::get, + PossessionFeature feature = new PossessionFeature(CONFIG, true, this.target, this.survivors::get, () -> slender, this.clock::get); feature.started(); return feature; @@ -118,6 +121,24 @@ void possessing(Env env) { env.destroyInstance(instance, true); } + @Test + @DisplayName("The round starts with the possession locked for the start delay") + void lockedAtStart(Env env) { + Instance instance = env.createFlatInstance(); + Player slender = slender(env.createPlayer(instance, new Pos(0, 40, 0))); + this.creek(instance); + PossessionFeature feature = new PossessionFeature(new PossessionConfig(8, 60, 1.5D, 90), true, this.target, + this.survivors::get, () -> slender, this.clock::get); + feature.started(); + + this.clock.set(89_999L); + assertFalse(feature.use(slender)); + this.clock.set(90_000L); + assertTrue(feature.use(slender)); + feature.stop(); + env.destroyInstance(instance, true); + } + @Test @DisplayName("After the maximum time he is hidden again, the camera is back and the full cooldown runs") void endsAfterTheMaximum(Env env) { From 528b88323cf3d63eb6690f038f212dd588e3c77c Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 11 Oct 2026 17:29:53 +0200 Subject: [PATCH 07/11] feat(player): stop players from dropping or moving their game items --- .../net/onelitefeather/cygnus/Cygnus.java | 2 + .../cygnus/player/InventoryLock.java | 58 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/player/InventoryLock.java diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index feeb8115..a75ce528 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -86,6 +86,7 @@ import net.onelitefeather.cygnus.phase.RestartPhase; import net.onelitefeather.cygnus.phase.WaitingPhase; import net.onelitefeather.cygnus.player.CygnusPlayer; +import net.onelitefeather.cygnus.player.InventoryLock; import net.onelitefeather.cygnus.possession.PossessionFeature; import net.onelitefeather.cygnus.resourcepack.ResourcePackService; import net.onelitefeather.cygnus.sanity.SanityService; @@ -233,6 +234,7 @@ sanityService, new HuntedTabWitness(new TracingCreekWitness(sanityService, this. MinecraftServer.getConnectionManager()::getOnlinePlayerByUuid), this.jumpscareManager, this.staminaService, tickSections); this.features = Stream.concat(this.resourcePackService.stream(), Stream.of( new EpilepsyDisclaimer(), + new InventoryLock(), this.slenderTakeover, this.spectatorService, // Not part of the OverlayModule: the sound is the feedback a hit owes the player diff --git a/game/src/main/java/net/onelitefeather/cygnus/player/InventoryLock.java b/game/src/main/java/net/onelitefeather/cygnus/player/InventoryLock.java new file mode 100644 index 00000000..dc9ba3fc --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/player/InventoryLock.java @@ -0,0 +1,58 @@ +package net.onelitefeather.cygnus.player; + +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.inventory.InventoryPreClickEvent; +import net.minestom.server.event.item.ItemDropEvent; +import net.minestom.server.event.player.PlayerSwapItemEvent; +import net.minestom.server.inventory.PlayerInventory; +import net.onelitefeather.cygnus.GameFeature; + +/** + * Keeps the items the game hands out where the game put them. + *

+ * Players may neither drop them, nor move them around in their own inventory, nor swap them into + * the offhand. A dropped item would not even land on the ground, Minestom only takes it out of the + * inventory, and the SlenderEye or the tracker would be gone for the rest of the round. Clicks in + * an opened inventory, like the spectator menu, are left to that inventory. Creative players stay + * free, so builders can still work. + *

+ * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.16.0 + */ +public final class InventoryLock implements GameFeature { + + private final EventNode node = EventNode.all("inventory-lock"); + + /** + * Creates the lock. + */ + public InventoryLock() { + this.node.addListener(ItemDropEvent.class, event -> event.setCancelled(isLocked(event.getPlayer()))); + this.node.addListener(PlayerSwapItemEvent.class, event -> event.setCancelled(isLocked(event.getPlayer()))); + this.node.addListener(InventoryPreClickEvent.class, event -> { + if (event.getInventory() instanceof PlayerInventory && isLocked(event.getPlayer())) { + event.setCancelled(true); + } + }); + } + + @Override + public EventNode node() { + return this.node; + } + + /** + * Tells whether a player's inventory is locked. + * + * @param player the player + * @return {@code true} for everyone outside creative mode + */ + static boolean isLocked(Player player) { + return player.getGameMode() != GameMode.CREATIVE; + } +} From 0c6c7db295ef9cd7535f6e27606d8c491df7c336 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 11 Oct 2026 17:29:53 +0200 Subject: [PATCH 08/11] test(player): cover the inventory lock --- .../cygnus/player/InventoryLockTest.java | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 game/src/test/java/net/onelitefeather/cygnus/player/InventoryLockTest.java diff --git a/game/src/test/java/net/onelitefeather/cygnus/player/InventoryLockTest.java b/game/src/test/java/net/onelitefeather/cygnus/player/InventoryLockTest.java new file mode 100644 index 00000000..447e8866 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/player/InventoryLockTest.java @@ -0,0 +1,88 @@ +package net.onelitefeather.cygnus.player; + +import net.kyori.adventure.text.Component; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.event.inventory.InventoryPreClickEvent; +import net.minestom.server.event.item.ItemDropEvent; +import net.minestom.server.event.player.PlayerSwapItemEvent; +import net.minestom.server.instance.Instance; +import net.minestom.server.inventory.Inventory; +import net.minestom.server.inventory.InventoryType; +import net.minestom.server.inventory.click.Click; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +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 InventoryLockTest extends CygnusPlayerTestBase { + + private static final ItemStack ITEM = ItemStack.of(Material.ENDER_EYE); + + private static InventoryLock lock(Env env) { + InventoryLock lock = new InventoryLock(); + env.process().eventHandler().addChild(lock.node()); + return lock; + } + + @Test + @DisplayName("Dropping, moving and swapping into the offhand are cancelled") + void locked(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance, new Pos(0, 40, 0)); + player.setGameMode(GameMode.ADVENTURE); + InventoryLock lock = lock(env); + + ItemDropEvent drop = new ItemDropEvent(player, ITEM); + PlayerSwapItemEvent swap = new PlayerSwapItemEvent(player, ITEM, ItemStack.AIR); + InventoryPreClickEvent click = new InventoryPreClickEvent(player.getInventory(), player, new Click.Left(0)); + EventDispatcher.call(drop); + EventDispatcher.call(swap); + EventDispatcher.call(click); + + assertTrue(drop.isCancelled()); + assertTrue(swap.isCancelled()); + assertTrue(click.isCancelled()); + env.process().eventHandler().removeChild(lock.node()); + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("Clicks in an opened inventory are left to that inventory") + void openedInventoryIsFree(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance, new Pos(0, 40, 0)); + InventoryLock lock = lock(env); + + InventoryPreClickEvent click = new InventoryPreClickEvent( + new Inventory(InventoryType.CHEST_1_ROW, Component.text("Menu")), player, new Click.Left(0)); + EventDispatcher.call(click); + + assertFalse(click.isCancelled()); + env.process().eventHandler().removeChild(lock.node()); + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("Creative players stay free") + void creativeIsFree(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance, new Pos(0, 40, 0)); + player.setGameMode(GameMode.CREATIVE); + InventoryLock lock = lock(env); + + ItemDropEvent drop = new ItemDropEvent(player, ITEM); + EventDispatcher.call(drop); + + assertFalse(drop.isCancelled()); + env.process().eventHandler().removeChild(lock.node()); + env.destroyInstance(instance, true); + } +} From f86fa02ef0edc8d2e9ebb82447d2b37458428431 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 11 Oct 2026 17:29:53 +0200 Subject: [PATCH 09/11] feat(items): describe the slender items with a lore --- .../onelitefeather/cygnus/utils/Items.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java b/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java index f34ec186..34735e3f 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java +++ b/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java @@ -3,6 +3,7 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; import net.minestom.server.entity.EntityType; import net.minestom.server.entity.Player; import net.minestom.server.item.ItemStack; @@ -10,6 +11,9 @@ import net.onelitefeather.cygnus.common.Tags; import net.theevilreaper.aves.hotbar.HotBarLayout; +import java.util.ArrayList; +import java.util.List; + /** * The class contains each {@link ItemStack} reference which is required for the game. * @@ -43,16 +47,21 @@ public final class Items { private static final ItemStack slenderEye = ItemStack.builder(Material.ENDER_EYE) .customName(Component.text("SlenderEye").color(TextColor.fromHexString("#ff00d4"))) + .lore(lore("Right click to hide yourself", "or to show yourself again")) .set(Tags.ITEM_TAG, SLENDER_ITEM) .build(); private static final ItemStack tracker = ItemStack.builder(TRACKING_MATERIAL) .customName(Component.text("Tracker", NamedTextColor.DARK_RED)) + .lore(lore("Right click to reveal where survivors", "walked around you a little while ago", + "Only you can see the tracks")) .set(Tags.ITEM_TAG, TRACKING_ITEM) .build(); private static final ItemStack possession = ItemStack.builder(POSSESSION_MATERIAL) .customName(Component.text("Creek's Eyes", NamedTextColor.DARK_GREEN)) + .lore(lore("Right click while hidden to look", "through the eyes of the creek", + "Survivors close to it glow for you", "Sneak to return to your body")) .set(Tags.ITEM_TAG, POSSESSION_ITEM) .build(); @@ -104,6 +113,23 @@ public static void setSpectatorLayout(Player player) { SPECTATOR_LAYOUT.apply(player, true); } + /** + * Builds an item description: gray, upright lines between two empty ones, like the other menus. + * The cooldowns are left out, the client draws them on the item itself. + * + * @param lines the lines of the description + * @return the lore + */ + private static List lore(String... lines) { + List lore = new ArrayList<>(lines.length + 2); + lore.add(Component.empty()); + for (String line : lines) { + lore.add(Component.text(line, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)); + } + lore.add(Component.empty()); + return lore; + } + private Items() { // Nothing do to here } From ea6a847fafdc3794930c8e1ef4b1984d6893c693 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 11 Oct 2026 17:29:53 +0200 Subject: [PATCH 10/11] test(items): check the lore of the slender items --- .../cygnus/utils/ItemsTest.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/game/src/test/java/net/onelitefeather/cygnus/utils/ItemsTest.java b/game/src/test/java/net/onelitefeather/cygnus/utils/ItemsTest.java index 44ba59c6..540bb011 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/utils/ItemsTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/utils/ItemsTest.java @@ -1,5 +1,6 @@ package net.onelitefeather.cygnus.utils; +import net.minestom.server.component.DataComponents; import net.minestom.server.entity.Player; import net.minestom.server.instance.Instance; import net.minestom.server.item.ItemStack; @@ -11,7 +12,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +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.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -34,6 +38,22 @@ void testSlenderItemsSet(@NotNull Env env) { ItemStack tracker = player.getInventory().getItemStack(Items.TRACKING_SLOT); assertEquals(Items.TRACKING_MATERIAL, tracker.material()); assertEquals(Items.TRACKING_ITEM, tracker.getTag(Tags.ITEM_TAG).byteValue()); + assertFalse(eye.get(DataComponents.LORE, List.of()).isEmpty()); + assertFalse(tracker.get(DataComponents.LORE, List.of()).isEmpty()); + + env.destroyInstance(instance, true); + } + + @Test + void testPossessionItemSet(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + + Items.setPossessionItem(player); + ItemStack possession = player.getInventory().getItemStack(Items.POSSESSION_SLOT); + assertEquals(Items.POSSESSION_MATERIAL, possession.material()); + assertEquals(Items.POSSESSION_ITEM, possession.getTag(Tags.ITEM_TAG).byteValue()); + assertFalse(possession.get(DataComponents.LORE, List.of()).isEmpty()); env.destroyInstance(instance, true); } From 06caa15f831fc3443e7c18eef6438dffd52b2451 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 11 Oct 2026 17:34:26 +0200 Subject: [PATCH 11/11] feat(items): move the tracker and the creek's eyes to the last hotbar slots --- .../main/java/net/onelitefeather/cygnus/utils/Items.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java b/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java index 34735e3f..9302ddb6 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java +++ b/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java @@ -33,8 +33,8 @@ public final class Items { /** The tracker's material, which is also the client's cooldown group for it. */ public static final Material TRACKING_MATERIAL = Material.RABBIT_FOOT; - /** The hotbar slot of the tracker, next to the SlenderEye. */ - public static final int TRACKING_SLOT = 1; + /** The hotbar slot of the tracker, the second to last one, away from the SlenderEye. */ + public static final int TRACKING_SLOT = 7; /** * The possession item's material, which is also the client's cooldown group for it. Not a block @@ -42,8 +42,8 @@ public final class Items { */ public static final Material POSSESSION_MATERIAL = Material.HEART_OF_THE_SEA; - /** The hotbar slot of the possession item, next to the tracker. */ - public static final int POSSESSION_SLOT = 2; + /** The hotbar slot of the possession item, the last one, next to the tracker. */ + public static final int POSSESSION_SLOT = 8; private static final ItemStack slenderEye = ItemStack.builder(Material.ENDER_EYE) .customName(Component.text("SlenderEye").color(TextColor.fromHexString("#ff00d4")))