Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,29 @@ 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.
* <p>
* 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.
* </p>
*
* @since 2.15.0
*/
int PAGE_SPAWN_DELAY = 10;

/**
* How many seconds {@link #PAGE_SPAWN_DELAY} may randomly shift up or down, re-rolled every
* round.
* <p>
* 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.
* </p>
*
* @since 2.15.0
*/
int PAGE_SPAWN_DELAY_JITTER = 2;

int PAGE_TTL_TIME = 60;

int FORCE_START_TIME = 11;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -17,23 +19,69 @@ 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.
* <p>
* 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.
* </p>
*/
private static final int PAGE_COUNT_JITTER_MAX = 2;

private static final int PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING = 8;

/**
* How many extra survivors above {@value #PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING} it takes for
* {@link #calculateActivePageAmount()} to add one more page.
* <p>
* 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.
* </p>
*/
private static final int ACTIVE_PAGE_COUNT_STEP_SIZE = 3;

/**
* Calculates the number of pages to allocate for the dynamic page system.
* <p>
* 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);
}

/**
* Calculates how many pages should be concurrently active (spawned in the world at once).
* <p>
* 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, 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 (survivors < PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING) {
return GameConfig.MIN_ACTIVE_PAGE_COUNT;
}
return GameConfig.MIN_ACTIVE_PAGE_COUNT + (survivors - PLAYER_SIZE_FOR_ACTIVE_PAGE_SCALING) / ACTIVE_PAGE_COUNT_STEP_SIZE;
}

private PageCalculation() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
*
Expand Down Expand Up @@ -77,18 +75,20 @@ public void loadPageData(Set<PageResource> 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<Integer> 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;
}

if (candidateHashes.add(page.hashCode())) {
Direction direction = page.face();
var position = Helper.updatePosition(page.position().asPos(), direction);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
}
Expand All @@ -36,7 +47,75 @@ 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<Integer> 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);
}

@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 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();

for (int i = 0; i < 13; i++) {
env.createPlayer(instance);
}

int activePageCount = PageCalculation.calculateActivePageAmount();
assertEquals(9, activePageCount,
"the active page count at the top of the range must stay below 10");

env.destroyInstance(instance, true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID, ?> activePages = (Map<UUID, ?>) field.get(pageProvider);
return activePages.size();
}

private static String plainStatus(PageProvider pageProvider) {
return PlainTextComponentSerializer.plainText().serialize(pageProvider.getPageStatus());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<GameStartEvent> {

private static final int TICKS_PER_SECOND = 20;

private final TeamService teamService;
private final AmbientProvider ambientProvider;
private final StaminaService staminaService;
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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();
}
}
Loading
Loading