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);
}
}
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);
}
}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index d367373c..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
@@ -251,7 +253,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.
*
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;
+ }
+}
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);
}
/**
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..9302ddb6 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.
*
@@ -29,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
@@ -38,21 +42,26 @@ 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")))
+ .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
}
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);
+ }
}
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);
+ }
+}
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) {
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);
}