From 602bc206b0640a8ad7ac33db9b8dc2c31ba3e13f Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Wed, 9 Sep 2026 20:30:15 +0200 Subject: [PATCH 01/10] feat(config): add spawn dealy --- .../cygnus/common/config/GameConfig.java | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 0f9ee1c3..b0637496 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 @@ -36,6 +36,17 @@ public sealed interface GameConfig permits GameConfigImpl, InternalGameConfig { int MIN_ACTIVE_PAGE_COUNT = 4 * 2; + /** + * How many seconds after a round starts before the first pages spawn. + *

+ * 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 + */ + int PAGE_SPAWN_DELAY = 10; + int PAGE_TTL_TIME = 60; int FORCE_START_TIME = 11; From 9d5680e7d9b6eed65cbe11f34a11f8d0ecc73873 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Wed, 9 Sep 2026 20:31:29 +0200 Subject: [PATCH 02/10] chore(page): add new constant for the page calculation --- .../cygnus/common/page/PageCalculation.java | 24 ++++++++++++++++ .../cygnus/common/page/PageProvider.java | 14 ++++++---- .../common/page/PageCalculationTest.java | 28 +++++++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageCalculation.java b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageCalculation.java index 8da41b60..0d3dc56e 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageCalculation.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageCalculation.java @@ -17,6 +17,9 @@ public final class PageCalculation { private static final int PLAYER_SIZE_FOR_DYNAMIC_PAGE_ALLOCATION = 4; private static final int PAGE_COUNT_MULTIPLIER = 2; + private static final int PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING = 8; + private static final int ACTIVE_PAGE_COUNT_MULTIPLIER = 1; + /** * Calculates the number of pages to allocate for the dynamic page system. *

@@ -36,6 +39,27 @@ public static int calculatePageAmount() { } } + /** + * Calculates how many pages should be concurrently active (spawned in the world at once). + *

+ * This stays below {@link #calculatePageAmount()} on purpose: it governs how many pages exist + * in the world at the same time, not the total pool a round draws from. If the number of online + * players (excluding one) is less than {@value #PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING}, + * {@link GameConfig#MIN_ACTIVE_PAGE_COUNT} is returned. Otherwise, the count is determined by + * multiplying the adjusted player count by {@value #ACTIVE_PAGE_COUNT_MULTIPLIER}. + * + * @return the number of pages to keep active at once, at least {@link GameConfig#MIN_ACTIVE_PAGE_COUNT} + */ + public static int calculateActivePageAmount() { + int currentPlayers = MinecraftServer.getConnectionManager().getOnlinePlayers().size(); + + if (currentPlayers - 1 < PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING) { + return GameConfig.MIN_ACTIVE_PAGE_COUNT; + } else { + return (currentPlayers - 1) * ACTIVE_PAGE_COUNT_MULTIPLIER; + } + } + private PageCalculation() { throw new UnsupportedOperationException("This class cannot be instantiated"); } 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 27050dd9..f8ab0ac6 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 @@ -30,8 +30,6 @@ import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicInteger; -import static net.onelitefeather.cygnus.common.config.GameConfig.MIN_ACTIVE_PAGE_COUNT; - /** * Handles the logic to manage and spawn pages during the {@link GamePhase}. * @@ -77,13 +75,19 @@ public void loadPageData(Set resources) { this.globalCache.addAll(shuffled); } - public void collectStartPages(Instance instance) { - Check.argCondition(this.globalCache.size() < MIN_ACTIVE_PAGE_COUNT, "Not enough pages to start the game"); + /** + * Collects the pages that should be active in the world when a round starts. + * + * @param instance the instance to spawn the pages in + * @param activePageCount how many pages to keep concurrently active, e.g. from {@link PageCalculation#calculateActivePageAmount()} + */ + public void collectStartPages(Instance instance, int activePageCount) { + Check.argCondition(this.globalCache.size() < activePageCount, "Not enough pages to start the game"); var counter = 0; Set candidateHashes = new HashSet<>(); - while (counter < MIN_ACTIVE_PAGE_COUNT && !this.globalCache.isEmpty()) { + while (counter < activePageCount && !this.globalCache.isEmpty()) { var page = this.globalCache.poll(); if (page == null) { break; diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/page/PageCalculationTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/page/PageCalculationTest.java index a9547738..999fb440 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/page/PageCalculationTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/page/PageCalculationTest.java @@ -40,4 +40,32 @@ void testPageCalculationWithScaling(@NotNull Env env) { env.destroyInstance(instance, true); } + + @Test + void testActivePageCalculationWithoutScaling(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + + for (int i = 0; i < 3; i++) { + env.createPlayer(instance); + } + + int activePageCount = PageCalculation.calculateActivePageAmount(); + assertEquals(GameConfig.MIN_ACTIVE_PAGE_COUNT, activePageCount); + + env.destroyInstance(instance, true); + } + + @Test + void testActivePageCalculationWithScaling(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + + for (int i = 0; i < 13; i++) { + env.createPlayer(instance); + } + + int activePageCount = PageCalculation.calculateActivePageAmount(); + assertEquals(12, activePageCount); + + env.destroyInstance(instance, true); + } } From 212b1be6bbf9fd3558a94bf1361d99503f551954 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Wed, 9 Sep 2026 20:34:47 +0200 Subject: [PATCH 03/10] chore(page): remove break statement --- .../net/onelitefeather/cygnus/common/page/PageProvider.java | 4 ---- 1 file changed, 4 deletions(-) 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 f8ab0ac6..3014ea9b 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 @@ -89,10 +89,6 @@ public void collectStartPages(Instance instance, int activePageCount) { while (counter < activePageCount && !this.globalCache.isEmpty()) { var page = this.globalCache.poll(); - if (page == null) { - break; - } - if (candidateHashes.add(page.hashCode())) { Direction direction = page.face(); var position = Helper.updatePosition(page.position().asPos(), direction); From 7d3ab473cbbf254af5ce487d7dbc07c15597b787 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Wed, 9 Sep 2026 20:44:55 +0200 Subject: [PATCH 04/10] chore(config): add delay jitter --- .../cygnus/common/config/GameConfig.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 b0637496..ddc25fb3 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 @@ -47,6 +47,18 @@ public sealed interface GameConfig permits GameConfigImpl, InternalGameConfig { */ int PAGE_SPAWN_DELAY = 10; + /** + * How many seconds {@link #PAGE_SPAWN_DELAY} may randomly shift up or down, re-rolled every + * round. + *

+ * Without this the delay lands on the exact same tick every round, which players learn and + * plan around; the jitter keeps the moment the first pages appear unpredictable. + *

+ * + * @since 2.15.0 + */ + int PAGE_SPAWN_DELAY_JITTER = 2; + int PAGE_TTL_TIME = 60; int FORCE_START_TIME = 11; From 3f53dbf2f58bb152aa5af1e5650763af335325e8 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Wed, 9 Sep 2026 20:45:07 +0200 Subject: [PATCH 05/10] chore(page): add spawn delay --- .../listener/game/GameStartListener.java | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/listener/game/GameStartListener.java b/game/src/main/java/net/onelitefeather/cygnus/listener/game/GameStartListener.java index dea57821..28093fa2 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/listener/game/GameStartListener.java +++ b/game/src/main/java/net/onelitefeather/cygnus/listener/game/GameStartListener.java @@ -1,8 +1,10 @@ package net.onelitefeather.cygnus.listener.game; import net.kyori.adventure.text.Component; +import net.minestom.server.MinecraftServer; import net.minestom.server.entity.Player; import net.minestom.server.event.EventDispatcher; +import net.minestom.server.timer.TaskSchedule; import net.onelitefeather.cygnus.ambient.AmbientProvider; import net.onelitefeather.cygnus.common.Messages; import net.onelitefeather.cygnus.common.Tags; @@ -19,10 +21,13 @@ import net.theevilreaper.xerus.api.team.Team; import net.theevilreaper.xerus.api.team.TeamService; +import java.util.concurrent.ThreadLocalRandom; import java.util.function.Consumer; public final class GameStartListener implements Consumer { + private static final int TICKS_PER_SECOND = 20; + private final TeamService teamService; private final AmbientProvider ambientProvider; private final StaminaService staminaService; @@ -72,10 +77,23 @@ private void handleSurvivorStart() { private void startGlobalMechanics() { this.staminaService.start(); - EventDispatcher.call(new PageSpawnEvent()); this.ambientProvider.startTask(); - // Started after the pages exist: PageSpawnEvent above is what fills the active page map. - this.pageProximityService.startTask(); + // Delayed so survivors get a moment to move away from the spawn point before the first + // pages appear, instead of one being reachable the instant the round starts. The proximity + // task starts alongside it, in the same task, since it depends on pages already existing. + // Jittered so the moment doesn't land on the exact same tick every round. + MinecraftServer.getSchedulerManager().buildTask(() -> { + EventDispatcher.call(new PageSpawnEvent()); + this.pageProximityService.startTask(); + }).delay(TaskSchedule.tick(randomizedSpawnDelayTicks())).schedule(); TeamHelper.updateTabList(this.teamService); } + + private int randomizedSpawnDelayTicks() { + int baseTicks = GameConfig.PAGE_SPAWN_DELAY * TICKS_PER_SECOND; + int jitterTicks = GameConfig.PAGE_SPAWN_DELAY_JITTER * TICKS_PER_SECOND; + ThreadLocalRandom current = ThreadLocalRandom.current(); + int offset = current.nextInt(2 * jitterTicks + 1) - jitterTicks; + return baseTicks + offset; + } } From 75a84e83c051275d950c6f816c35fb0beafd42f9 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Wed, 9 Sep 2026 20:45:28 +0200 Subject: [PATCH 06/10] chore(page): call active page calculation --- .../onelitefeather/cygnus/listener/page/PageSpawnListener.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/listener/page/PageSpawnListener.java b/game/src/main/java/net/onelitefeather/cygnus/listener/page/PageSpawnListener.java index 6dda0456..76a22f54 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/listener/page/PageSpawnListener.java +++ b/game/src/main/java/net/onelitefeather/cygnus/listener/page/PageSpawnListener.java @@ -1,6 +1,7 @@ package net.onelitefeather.cygnus.listener.page; import net.minestom.server.instance.Instance; +import net.onelitefeather.cygnus.common.page.PageCalculation; import net.onelitefeather.cygnus.common.page.PageProvider; import net.onelitefeather.cygnus.common.page.event.PageSpawnEvent; @@ -30,7 +31,7 @@ public void accept(PageSpawnEvent event) { if (activeInstance == null) { throw new IllegalStateException("Active instance not available for page collection"); } - this.pageProvider.collectStartPages(activeInstance); + this.pageProvider.collectStartPages(activeInstance, PageCalculation.calculateActivePageAmount()); this.pageProvider.spawn(); } } From d5b79e8e03995fb286a7c2d4940572db282e8c0a Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Wed, 9 Sep 2026 20:45:38 +0200 Subject: [PATCH 07/10] test(page): add new cases --- .../cygnus/common/page/PageProviderTest.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) 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 b417835d..d8296260 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 @@ -272,6 +272,54 @@ void testAClaimOnAnUnknownPageFiresNoEvent(@NotNull Env env) throws Exception { env.destroyInstance(instance, true); } + @Test + void testCollectStartPagesUsesTheGivenActivePageCount(@NotNull Env env) throws Exception { + Instance instance = env.createFlatInstance(); + int activePageCount = 12; + + PageProvider pageProvider = new PageProvider(); + pageProvider.loadPageData( + IntStream.range(0, activePageCount) + .mapToObj(i -> new PageResource(new Pos(i, 0, 0), Direction.NORTH)) + .collect(Collectors.toSet()) + ); + + pageProvider.collectStartPages(instance, activePageCount); + + assertEquals(activePageCount, activePageCount(pageProvider), + "collectStartPages must collect exactly the requested active page count"); + + env.destroyInstance(instance, true); + } + + @Test + void testCollectStartPagesRejectsAnActivePageCountAboveTheAvailableData(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + + PageProvider pageProvider = new PageProvider(); + pageProvider.loadPageData( + IntStream.range(0, 8) + .mapToObj(i -> new PageResource(new Pos(i, 0, 0), Direction.NORTH)) + .collect(Collectors.toSet()) + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> pageProvider.collectStartPages(instance, 12) + ); + assertEquals("Not enough pages to start the game", exception.getMessage()); + + env.destroyInstance(instance, true); + } + + @SuppressWarnings("unchecked") + private static int activePageCount(PageProvider pageProvider) throws ReflectiveOperationException { + Field field = PageProvider.class.getDeclaredField("activePages"); + field.setAccessible(true); + Map activePages = (Map) field.get(pageProvider); + return activePages.size(); + } + private static String plainStatus(PageProvider pageProvider) { return PlainTextComponentSerializer.plainText().serialize(pageProvider.getPageStatus()); } From 11604b7f55f45ba142f244bf2d0789dfea5393ec Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Wed, 9 Sep 2026 20:49:45 +0200 Subject: [PATCH 08/10] test(game): add start listener test --- .../listener/game/GameStartListenerTest.java | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 game/src/test/java/net/onelitefeather/cygnus/listener/game/GameStartListenerTest.java diff --git a/game/src/test/java/net/onelitefeather/cygnus/listener/game/GameStartListenerTest.java b/game/src/test/java/net/onelitefeather/cygnus/listener/game/GameStartListenerTest.java new file mode 100644 index 00000000..d1b18a78 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/listener/game/GameStartListenerTest.java @@ -0,0 +1,104 @@ +package net.onelitefeather.cygnus.listener.game; + +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.ambient.AmbientProvider; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.common.config.GameConfigReader; +import net.onelitefeather.cygnus.common.page.PageProvider; +import net.onelitefeather.cygnus.common.page.event.PageSpawnEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import net.onelitefeather.cygnus.page.PageProximityService; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import net.onelitefeather.cygnus.stamina.StaminaService; +import net.onelitefeather.cygnus.team.TeamCreator; +import net.theevilreaper.xerus.api.team.Team; +import net.theevilreaper.xerus.api.team.TeamService; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.nio.file.Paths; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that the first page spawn is delayed rather than happening synchronously with + * {@link GameStartEvent}, so survivors get a moment to move away from the spawn point first, and + * that the delay is jittered within {@code GameConfig.PAGE_SPAWN_DELAY} ± + * {@code GameConfig.PAGE_SPAWN_DELAY_JITTER} rather than landing on the exact same tick every round. + */ +class GameStartListenerTest extends CygnusPlayerTestBase { + + private static final int TICKS_PER_SECOND = 20; + + @Test + void pageSpawnEventNeverFiresBeforeTheMinimumJitteredDelay(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + GameStartListener listener = createListener(env, instance); + + AtomicBoolean pageSpawnFired = new AtomicBoolean(false); + env.process().eventHandler().addListener(PageSpawnEvent.class, event -> pageSpawnFired.set(true)); + + listener.accept(new GameStartEvent()); + // No random source is injectable here, but the jitter can never push the delay below + // PAGE_SPAWN_DELAY - PAGE_SPAWN_DELAY_JITTER regardless of what gets rolled. + int minDelayTicks = (GameConfig.PAGE_SPAWN_DELAY - GameConfig.PAGE_SPAWN_DELAY_JITTER) * TICKS_PER_SECOND; + for (int i = 0; i < minDelayTicks - 1; i++) env.tick(); + assertFalse(pageSpawnFired.get(), "the page spawn must not fire before the minimum jittered delay"); + + // Drain the scheduled task here (whatever it actually rolled), unasserted, so it can't leak + // into a later test sharing this Env. + int maxDelayTicks = (GameConfig.PAGE_SPAWN_DELAY + GameConfig.PAGE_SPAWN_DELAY_JITTER) * TICKS_PER_SECOND; + for (int i = minDelayTicks - 1; i < maxDelayTicks + 5; i++) env.tick(); + + env.destroyInstance(instance, true); + } + + @Test + void pageSpawnEventAlwaysFiresByTheMaximumJitteredDelay(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + GameStartListener listener = createListener(env, instance); + + AtomicBoolean pageSpawnFired = new AtomicBoolean(false); + env.process().eventHandler().addListener(PageSpawnEvent.class, event -> pageSpawnFired.set(true)); + + listener.accept(new GameStartEvent()); + // Jitter can only push the delay later, never past PAGE_SPAWN_DELAY + PAGE_SPAWN_DELAY_JITTER. + int maxDelayTicks = (GameConfig.PAGE_SPAWN_DELAY + GameConfig.PAGE_SPAWN_DELAY_JITTER) * TICKS_PER_SECOND; + for (int i = 0; i < maxDelayTicks + 5; i++) env.tick(); + + assertTrue(pageSpawnFired.get(), "the page spawn must fire once the configured delay has passed"); + + env.destroyInstance(instance, true); + } + + private static GameStartListener createListener(Env env, Instance instance) { + CygnusPlayer slender = (CygnusPlayer) env.createPlayer(instance); + CygnusPlayer survivor = (CygnusPlayer) env.createPlayer(instance); + + GameConfig gameConfig = new GameConfigReader(Paths.get("")).getConfig(); + TeamService teamService = TeamService.of(); + TeamCreator teamCreator = new TeamCreator() {}; + teamCreator.createTeams(gameConfig, teamService); + + Team slenderTeam = teamService.getTeam(GameConfig.SLENDER_KEY).orElseThrow(); + Team survivorTeam = teamService.getTeam(GameConfig.SURVIVOR_KEY).orElseThrow(); + slenderTeam.addPlayer(slender); + survivorTeam.addPlayer(survivor); + + AmbientProvider ambientProvider = new AmbientProvider(survivorTeam); + StaminaService staminaService = new StaminaService(); + PageProvider pageProvider = new PageProvider(); + PageProximityService pageProximityService = new PageProximityService( + gameConfig, + survivorTeam::getPlayers, + List::of + ); + + return new GameStartListener(teamService, ambientProvider, staminaService, pageProvider, pageProximityService); + } +} From d282c183649c10de3da84e11fbcfff2bb4236a08 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Wed, 9 Sep 2026 20:51:02 +0200 Subject: [PATCH 09/10] chore(page): add page jitter calculation --- .../cygnus/common/page/PageCalculation.java | 28 +++++++++---- .../common/page/PageCalculationTest.java | 39 ++++++++++++++++++- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageCalculation.java b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageCalculation.java index 0d3dc56e..cd048702 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageCalculation.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageCalculation.java @@ -3,6 +3,8 @@ import net.minestom.server.MinecraftServer; import net.onelitefeather.cygnus.common.config.GameConfig; +import java.util.concurrent.ThreadLocalRandom; + /** * Utility class for calculating the number of pages allocated for the dynamic page system. * The page count is based on the number of current online players. @@ -17,6 +19,16 @@ public final class PageCalculation { private static final int PLAYER_SIZE_FOR_DYNAMIC_PAGE_ALLOCATION = 4; private static final int PAGE_COUNT_MULTIPLIER = 2; + /** + * The largest amount {@link #calculatePageAmount()} may add on top of the base amount, so the + * total can't be worked out in advance from the player count alone. + *

+ * Not exposed through {@link GameConfig}: unlike the other page settings, this one is + * deliberately not something a server operator should be able to turn off or tune. + *

+ */ + private static final int PAGE_COUNT_JITTER_MAX = 2; + private static final int PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING = 8; private static final int ACTIVE_PAGE_COUNT_MULTIPLIER = 1; @@ -24,19 +36,21 @@ public final class PageCalculation { * Calculates the number of pages to allocate for the dynamic page system. *

* If the number of online players (excluding one) is less than {@value #PLAYER_SIZE_FOR_DYNAMIC_PAGE_ALLOCATION}, - * {@link GameConfig#MIN_PAGE_COUNT} is returned. Otherwise, the page count is determined by - * multiplying the adjusted player count by {@value #PAGE_COUNT_MULTIPLIER}. + * the base amount is {@link GameConfig#MIN_PAGE_COUNT}. Otherwise, it is the adjusted player + * count multiplied by {@value #PAGE_COUNT_MULTIPLIER}. A random amount between 0 and + * {@value #PAGE_COUNT_JITTER_MAX} is then added on top, re-rolled every call, so the total + * can't be derived from the player count alone. * * @return the number of pages to allocate, at least {@link GameConfig#MIN_PAGE_COUNT} */ public static int calculatePageAmount() { int currentPlayers = MinecraftServer.getConnectionManager().getOnlinePlayers().size(); - if (currentPlayers - 1 < PLAYER_SIZE_FOR_DYNAMIC_PAGE_ALLOCATION) { - return GameConfig.MIN_PAGE_COUNT; - } else { - return (currentPlayers - 1) * PAGE_COUNT_MULTIPLIER; - } + int baseAmount = currentPlayers - 1 < PLAYER_SIZE_FOR_DYNAMIC_PAGE_ALLOCATION + ? GameConfig.MIN_PAGE_COUNT + : (currentPlayers - 1) * PAGE_COUNT_MULTIPLIER; + + return baseAmount + ThreadLocalRandom.current().nextInt(PAGE_COUNT_JITTER_MAX + 1); } /** diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/page/PageCalculationTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/page/PageCalculationTest.java index 999fb440..dc6550bd 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/page/PageCalculationTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/page/PageCalculationTest.java @@ -8,11 +8,21 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import java.util.HashSet; +import java.util.Set; + import static org.junit.jupiter.api.Assertions.*; @ExtendWith(MicrotusExtension.class) class PageCalculationTest { + /** + * The page count carries an unpredictable +0..+2 on top of the base amount (see + * {@link #testPageCalculationVariesAcrossRounds}), so exact-value assertions elsewhere in this + * class check a range instead of a single number. + */ + private static final int MAX_JITTER = 2; + @Test void testPageCalculationWithoutScaling(@NotNull Env env) { Instance instance = env.createFlatInstance(); @@ -22,7 +32,8 @@ void testPageCalculationWithoutScaling(@NotNull Env env) { } int pageCount = PageCalculation.calculatePageAmount(); - assertEquals(GameConfig.MIN_PAGE_COUNT, pageCount); + assertTrue(pageCount >= GameConfig.MIN_PAGE_COUNT && pageCount <= GameConfig.MIN_PAGE_COUNT + MAX_JITTER, + "the page count must be the minimum plus at most the jitter, was " + pageCount); env.destroyInstance(instance, true); } @@ -36,7 +47,31 @@ void testPageCalculationWithScaling(@NotNull Env env) { } int pageCount = PageCalculation.calculatePageAmount(); - assertEquals(18, pageCount); + assertTrue(pageCount >= 18 && pageCount <= 18 + MAX_JITTER, + "the page count must be the scaled amount plus at most the jitter, was " + pageCount); + + env.destroyInstance(instance, true); + } + + @Test + void testPageCalculationVariesAcrossRounds(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + + for (int i = 0; i < 10; i++) { + env.createPlayer(instance); + } + + Set observed = new HashSet<>(); + for (int i = 0; i < 50; i++) { + observed.add(PageCalculation.calculatePageAmount()); + } + + assertTrue(observed.size() > 1, + "the page count must vary across rounds instead of always landing on the same number, observed " + observed); + for (int value : observed) { + assertTrue(value >= 18 && value <= 18 + MAX_JITTER, + "each roll must stay within the base amount plus at most the jitter, was " + value); + } env.destroyInstance(instance, true); } From fa93f12fdf3f7afaeab75f86cef3f60ec4985de2 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Wed, 9 Sep 2026 22:13:11 +0200 Subject: [PATCH 10/10] chore(page): small adjustment for the calculation --- .../cygnus/common/page/PageCalculation.java | 22 ++++++++++++++----- .../common/page/PageCalculationTest.java | 18 ++++++++++++++- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageCalculation.java b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageCalculation.java index cd048702..aa2578ad 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageCalculation.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageCalculation.java @@ -30,7 +30,16 @@ public final class PageCalculation { private static final int PAGE_COUNT_JITTER_MAX = 2; private static final int PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING = 8; - private static final int ACTIVE_PAGE_COUNT_MULTIPLIER = 1; + + /** + * How many extra survivors above {@value #PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING} it takes for + * {@link #calculateActivePageAmount()} to add one more page. + *

+ * Playtesting a steeper step (one active page per extra survivor) reached 16 concurrently + * active pages, which felt like clutter; this keeps the top of the range below 10. + *

+ */ + private static final int ACTIVE_PAGE_COUNT_STEP_SIZE = 3; /** * Calculates the number of pages to allocate for the dynamic page system. @@ -59,19 +68,20 @@ public static int calculatePageAmount() { * This stays below {@link #calculatePageAmount()} on purpose: it governs how many pages exist * in the world at the same time, not the total pool a round draws from. If the number of online * players (excluding one) is less than {@value #PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING}, - * {@link GameConfig#MIN_ACTIVE_PAGE_COUNT} is returned. Otherwise, the count is determined by - * multiplying the adjusted player count by {@value #ACTIVE_PAGE_COUNT_MULTIPLIER}. + * {@link GameConfig#MIN_ACTIVE_PAGE_COUNT} is returned. Otherwise, one page is added for every + * {@value #ACTIVE_PAGE_COUNT_STEP_SIZE} survivors past that threshold, so the count stays flat + * for most of the range and only rises near the top of a full lobby. * * @return the number of pages to keep active at once, at least {@link GameConfig#MIN_ACTIVE_PAGE_COUNT} */ public static int calculateActivePageAmount() { int currentPlayers = MinecraftServer.getConnectionManager().getOnlinePlayers().size(); + int survivors = currentPlayers - 1; - if (currentPlayers - 1 < PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING) { + if (survivors < PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING) { return GameConfig.MIN_ACTIVE_PAGE_COUNT; - } else { - return (currentPlayers - 1) * ACTIVE_PAGE_COUNT_MULTIPLIER; } + return GameConfig.MIN_ACTIVE_PAGE_COUNT + (survivors - PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING) / ACTIVE_PAGE_COUNT_STEP_SIZE; } private PageCalculation() { diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/page/PageCalculationTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/page/PageCalculationTest.java index dc6550bd..b28493cf 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/page/PageCalculationTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/page/PageCalculationTest.java @@ -90,6 +90,21 @@ void testActivePageCalculationWithoutScaling(@NotNull Env env) { env.destroyInstance(instance, true); } + @Test + void testActivePageCalculationStaysFlatUntilNearTheTopOfTheRange(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + + for (int i = 0; i < 11; i++) { + env.createPlayer(instance); + } + + int activePageCount = PageCalculation.calculateActivePageAmount(); + assertEquals(GameConfig.MIN_ACTIVE_PAGE_COUNT, activePageCount, + "the active page count must still be at the minimum just below the top of the range"); + + env.destroyInstance(instance, true); + } + @Test void testActivePageCalculationWithScaling(@NotNull Env env) { Instance instance = env.createFlatInstance(); @@ -99,7 +114,8 @@ void testActivePageCalculationWithScaling(@NotNull Env env) { } int activePageCount = PageCalculation.calculateActivePageAmount(); - assertEquals(12, activePageCount); + assertEquals(9, activePageCount, + "the active page count at the top of the range must stay below 10"); env.destroyInstance(instance, true); }