feat(base): implement progression system

This commit is contained in:
dmg
2026-08-09 15:01:44 -04:00
commit c87d923cad
76 changed files with 5092 additions and 0 deletions
@@ -0,0 +1,48 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class AdminProgressionServiceTest {
private final AdminProgressionService service = new AdminProgressionService();
@Test
void settingTeleportUpgradeGrantsBasePrerequisites() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
PlayerState updated = service.setLevel(player, ProgressionPath.WARMUP, 2);
assertEquals(3, updated.baseLevel());
assertEquals(2, updated.warmupLevel());
}
@Test
void loweringBaseRemovesDependentBenefits() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withAdministrativeLevels(4, 3, 3, 3, 4, true, true, true);
PlayerState updated = service.setLevel(player, ProgressionPath.BASE, 0);
assertEquals(0, updated.sizeLevel());
assertEquals(0, updated.flightLevel());
assertEquals(0, updated.warmupLevel());
assertEquals(0, updated.cooldownLevel());
assertFalse(updated.navigationEnabled());
assertFalse(updated.flightEnabled());
assertFalse(updated.visitorsEnabled());
}
@Test
void rejectsUnknownLevel() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
assertThrows(
IllegalArgumentException.class,
() -> service.setLevel(player, ProgressionPath.FLIGHT, 4)
);
}
}
@@ -0,0 +1,25 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class BaseAreaTest {
@Test
void usesCircularHorizontalAndSymmetricVerticalBounds() {
UUID worldId = UUID.randomUUID();
BaseArea area = new BaseArea(
new BaseLocation(worldId, "world", 0, 64, 0, 0, 0),
10,
25
);
assertTrue(area.contains(worldId, 6, 89, 8));
assertTrue(area.contains(worldId, 6, 39, 8));
assertFalse(area.contains(worldId, 10, 64, 10));
assertFalse(area.contains(worldId, 0, 90, 0));
assertFalse(area.contains(UUID.randomUUID(), 0, 64, 0));
}
}
@@ -0,0 +1,33 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class BaseBoundsServiceTest {
private final BaseBoundsService service = new BaseBoundsService(PluginSettings.from(Map.of()));
@Test
void sizeTiersSelectConfiguredRadius() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(250, 1);
assertEquals(10, service.radius(player));
assertEquals(25, service.radius(player.withSizeProgress(500, 0, 0, 1)));
assertEquals(75, service.radius(player.withSizeProgress(500, 1_000, 0, 2)));
assertEquals(150, service.radius(player.withSizeProgress(500, 1_000, 1_000, 3)));
}
@Test
void flightTiersSelectSymmetricVerticalRange() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(250, 1);
assertEquals(25, service.verticalRange(player));
assertEquals(25, service.verticalRange(player.withFlightLevel(1, true)));
assertEquals(100, service.verticalRange(player.withFlightLevel(2, true)));
assertEquals(Integer.MAX_VALUE, service.verticalRange(player.withFlightLevel(3, true)));
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class BaseNavigationProgressionTest {
@Test
void fiveHundredthGrassOrDirtBlockUnlocksBaseII() {
BaseProgressionService service =
new BaseProgressionService(PluginSettings.from(Map.of()));
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(499, 1);
ProgressionUpdate update = service.recordGrassOrDirtBreak(player);
assertEquals(2, update.player().baseLevel());
assertTrue(update.player().navigationEnabled());
assertTrue(update.unlockedBaseLevel());
}
}
@@ -0,0 +1,38 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class BaseProgressionServiceTest {
private final PluginSettings settings = PluginSettings.from(java.util.Map.of());
private final BaseProgressionService service = new BaseProgressionService(settings);
@Test
void qualifyingBreakIncrementsProgress() {
PlayerState initial = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
ProgressionUpdate update = service.recordGrassOrDirtBreak(initial);
assertEquals(1, update.player().grassAndDirtBroken());
assertEquals(0, update.player().baseLevel());
assertFalse(update.unlockedBaseLevel());
}
@Test
void thresholdUnlocksBaseIExactlyOnce() {
PlayerState initial = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(249, 0);
ProgressionUpdate unlocked = service.recordGrassOrDirtBreak(initial);
ProgressionUpdate later = service.recordGrassOrDirtBreak(unlocked.player());
assertEquals(1, unlocked.player().baseLevel());
assertTrue(unlocked.unlockedBaseLevel());
assertEquals(1, later.player().baseLevel());
assertFalse(later.unlockedBaseLevel());
}
}
@@ -0,0 +1,51 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class BaseServiceTest {
private final PluginSettings settings = PluginSettings.from(Map.of());
private final BaseService service = new BaseService(settings);
@Test
void firstBaseCanBeSetImmediately() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(250, 1);
assertTrue(service.canSetBase(player, Instant.EPOCH));
}
@Test
void relocationUsesElapsedTwentyFourHours() {
Instant firstSet = Instant.parse("2026-08-09T12:00:00Z");
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(250, 1)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
firstSet
);
assertFalse(service.canSetBase(player, firstSet.plus(Duration.ofHours(23))));
assertEquals(
Duration.ofHours(1),
service.relocationRemaining(player, firstSet.plus(Duration.ofHours(23))).orElseThrow()
);
assertTrue(service.canSetBase(player, firstSet.plus(Duration.ofHours(24))));
}
@Test
void lockedPlayerCannotSetBase() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
assertFalse(service.canSetBase(player, Instant.EPOCH));
assertEquals(Optional.empty(), service.relocationRemaining(player, Instant.EPOCH));
}
}
@@ -0,0 +1,32 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
import java.util.UUID;
import java.util.logging.Logger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class BaseStateManagerTest {
@TempDir
Path temporaryDirectory;
@Test
void durableUpdateIsImmediatelyReadableFromRepository() throws Exception {
Path stateFile = temporaryDirectory.resolve("state.yml");
BaseStateManager manager = new BaseStateManager(
new YamlBaseStateRepository(stateFile), Logger.getAnonymousLogger()
);
UUID playerId = UUID.randomUUID();
manager.updateAndSave(
playerId,
"Alex",
player -> player.withGrassAndDirtProgress(250, 1)
);
PlayerState loaded = new YamlBaseStateRepository(stateFile).load().players().get(playerId);
assertEquals(1, loaded.baseLevel());
}
}
@@ -0,0 +1,32 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class PlayerStateTest {
@Test
void rejectsLevelsOutsideKnownRanges() {
UUID playerId = UUID.randomUUID();
assertThrows(IllegalArgumentException.class, () -> new PlayerState(
playerId, "Alex", Optional.empty(), 5, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
false, false, true, false, Optional.empty(), Optional.empty(), Map.of()
));
}
@Test
void rejectsBenefitsWhosePrerequisitesAreMissing() {
UUID playerId = UUID.randomUUID();
assertThrows(IllegalArgumentException.class, () -> new PlayerState(
playerId, "Alex", Optional.empty(), 0, 1, 0, 0, 0,
0, 0, 0, 0, 0, 0,
false, false, true, false, Optional.empty(), Optional.empty(), Map.of()
));
}
}
@@ -0,0 +1,33 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.InputStream;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
final class PluginMetadataTest {
@Test
void declaresPluginEntrypointCommandsAndPermissions() {
InputStream stream = getClass().getClassLoader().getResourceAsStream("plugin.yml");
assertNotNull(stream);
Map<?, ?> plugin = new Yaml().load(stream);
assertEquals("SpigotBase", plugin.get("name"));
assertEquals("games.dmg.spigotbase.SpigotBasePlugin", plugin.get("main"));
assertEquals("1.20", plugin.get("api-version"));
Map<?, ?> commands = (Map<?, ?>) plugin.get("commands");
for (String command : new String[] {
"setbase", "base", "basenavigation", "baseflight",
"basevisitors", "gotobase", "baseprogress", "baseadmin"
}) {
assertNotNull(commands.get(command), command);
}
Map<?, ?> permissions = (Map<?, ?>) plugin.get("permissions");
assertNotNull(permissions.get("spigotbase.admin"));
}
}
@@ -0,0 +1,42 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Map;
import org.junit.jupiter.api.Test;
final class PluginSettingsTest {
@Test
void defaultsMatchApprovedProgression() {
PluginSettings settings = PluginSettings.from(Map.of());
assertEquals(250, settings.baseUnlockBlocks());
assertEquals(500, settings.navigationUnlockBlocks());
assertEquals(10, settings.initialRadius());
assertEquals(25, settings.initialVerticalRange());
assertEquals(86_400L, settings.relocationCooldownSeconds());
assertEquals(5, settings.flightWarningBuffer());
assertEquals(200, settings.teleportUnlockPlacements());
assertEquals(10_800L, settings.initialTeleportCooldownSeconds());
assertEquals(128, settings.visitorUnlockDiamondCost());
}
@Test
void rejectsNavigationThresholdBeforeBaseThreshold() {
Map<String, Object> values = Map.of(
"base-unlock-blocks", 500,
"navigation-unlock-blocks", 250
);
assertThrows(IllegalArgumentException.class, () -> PluginSettings.from(values));
}
@Test
void rejectsNegativeDurationsAndDistances() {
assertThrows(IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("relocation-cooldown-seconds", -1)));
assertThrows(IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("initial-radius", 0)));
}
}
@@ -0,0 +1,61 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class SecondaryProgressionServiceTest {
private final PluginSettings settings = PluginSettings.from(Map.of());
private final SecondaryProgressionService service = new SecondaryProgressionService(settings);
@Test
void stoneUnlocksFirstSizeTierAfterBaseI() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(250, 1)
.withSizeProgress(499, 0, 0, 0);
ProgressionUpdate update = service.recordStoneBreak(player);
assertEquals(1, update.player().sizeLevel());
assertTrue(update.unlockedSizeLevel());
}
@Test
void laterMaterialsDoNotCountBeforeTheirTierIsActive() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(250, 1);
ProgressionUpdate update = service.recordDeepslateBreak(player);
assertEquals(0, update.player().deepslateBroken());
assertFalse(update.unlockedSizeLevel());
}
@Test
void threeElytraUnlockAllFlightTiersWithoutConsumption() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(250, 1)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
java.time.Instant.EPOCH
);
ProgressionUpdate update = service.observeElytraCount(player, 3);
assertEquals(3, update.player().flightLevel());
assertTrue(update.player().flightEnabled());
assertTrue(update.unlockedFlightLevel());
}
@Test
void secondaryProgressDoesNothingBeforeBaseI() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
assertEquals(player, service.recordStoneBreak(player).player());
assertEquals(player, service.observeElytraCount(player, 3).player());
}
}
@@ -0,0 +1,52 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class TeleportPolicyTest {
private final TeleportPolicy policy = new TeleportPolicy(PluginSettings.from(Map.of()));
@Test
void warmupLevelsUseApprovedDurations() {
PlayerState player = baseThreePlayer();
assertEquals(Duration.ofSeconds(30), policy.warmup(player));
assertEquals(Duration.ofSeconds(15), policy.warmup(player.withTeleportLevels(1, 0)));
assertEquals(Duration.ofSeconds(5), policy.warmup(player.withTeleportLevels(2, 0)));
assertEquals(Duration.ZERO, policy.warmup(player.withTeleportLevels(3, 0)));
}
@Test
void cooldownLevelsUseApprovedDurations() {
PlayerState player = baseThreePlayer();
assertEquals(Duration.ofHours(3), policy.cooldown(player));
assertEquals(Duration.ofHours(2), policy.cooldown(player.withTeleportLevels(0, 1)));
assertEquals(Duration.ofHours(1), policy.cooldown(player.withTeleportLevels(0, 2)));
assertEquals(Duration.ofMinutes(30), policy.cooldown(player.withTeleportLevels(0, 3)));
assertEquals(Duration.ZERO, policy.cooldown(player.withTeleportLevels(0, 4)));
}
@Test
void remainingCooldownUsesElapsedTime() {
Instant usedAt = Instant.parse("2026-08-09T10:00:00Z");
PlayerState player = baseThreePlayer().withLastBaseTeleport(usedAt);
assertEquals(
Duration.ofHours(1),
policy.remainingCooldown(player, usedAt.plus(Duration.ofHours(2))).orElseThrow()
);
assertTrue(policy.remainingCooldown(player, usedAt.plus(Duration.ofHours(3))).isEmpty());
}
private static PlayerState baseThreePlayer() {
return PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(500, 3);
}
}
@@ -0,0 +1,56 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class TeleportProgressionServiceTest {
private final PluginSettings settings = PluginSettings.from(Map.of());
private final TeleportProgressionService service = new TeleportProgressionService(settings);
@Test
void twoHundredthPlacementUnlocksBaseIII() {
PlayerState player = baseTwoPlayer().withTeleportProgress(199, 0, 0, 0, 2);
ProgressionUpdate update = service.recordPlacement(player);
assertEquals(3, update.player().baseLevel());
assertEquals(200, update.player().blocksPlacedInBase());
assertTrue(update.unlockedBaseLevel());
}
@Test
void cumulativePlacementsReduceWarmup() {
PlayerState player = baseTwoPlayer().withTeleportProgress(999, 0, 0, 0, 3);
ProgressionUpdate update = service.recordPlacement(player);
assertEquals(1, update.player().warmupLevel());
assertTrue(update.unlockedWarmupLevel());
}
@Test
void cumulativeBreaksReduceCooldownSequentially() {
PlayerState player = baseTwoPlayer().withTeleportProgress(200, 999, 0, 0, 3);
ProgressionUpdate update = service.recordBreak(player);
assertEquals(1, update.player().cooldownLevel());
assertEquals(1_000, update.player().blocksBrokenInBase());
assertTrue(update.unlockedCooldownLevel());
}
private static PlayerState baseTwoPlayer() {
return PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(500, 2)
.withNavigationEnabled(true)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
}
}
@@ -0,0 +1,44 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class VisitorPolicyTest {
private final VisitorPolicy policy = new VisitorPolicy();
@Test
void onlyBaseIIIPlayerCanPurchaseBaseIV() {
PlayerState baseThree = PlayerState.newPlayer(UUID.randomUUID(), "Owner")
.withGrassAndDirtProgress(500, 3)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
assertTrue(policy.canPurchase(baseThree));
assertFalse(policy.canPurchase(baseThree.withBaseLevel(4)));
assertFalse(policy.canPurchase(
PlayerState.newPlayer(UUID.randomUUID(), "Locked").withGrassAndDirtProgress(500, 2)
));
assertFalse(policy.canPurchase(
PlayerState.newPlayer(UUID.randomUUID(), "Unset").withGrassAndDirtProgress(500, 3)
));
}
@Test
void visitorCooldownIsIndependentForEachOwner() {
UUID firstOwner = UUID.randomUUID();
UUID secondOwner = UUID.randomUUID();
Instant now = Instant.parse("2026-08-09T12:00:00Z");
PlayerState visitor = PlayerState.newPlayer(UUID.randomUUID(), "Visitor")
.withVisitorCooldown(firstOwner, now.plus(Duration.ofHours(2)));
assertTrue(policy.remaining(visitor, firstOwner, now).isPresent());
assertTrue(policy.remaining(visitor, secondOwner, now).isEmpty());
}
}
@@ -0,0 +1,66 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class YamlBaseStateRepositoryTest {
@TempDir
Path temporaryDirectory;
@Test
void roundTripsCompletePlayerState() throws Exception {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
BaseLocation base = new BaseLocation(worldId, "world", 12, 70, -8, 90.0F, 5.0F);
PlayerState player = new PlayerState(
playerId, "Alex", Optional.of(base), 4, 3, 2, 3, 4,
500, 500, 1_000, 1_000, 2_000, 3_000,
true, true, false, true,
Optional.of(Instant.ofEpochMilli(1_750_000_000_000L)),
Optional.of(Instant.ofEpochMilli(1_750_000_100_000L)),
Map.of(ownerId, Instant.ofEpochMilli(1_750_001_000_000L))
);
PersistentState expected = new PersistentState(Map.of(playerId, player));
YamlBaseStateRepository repository =
new YamlBaseStateRepository(temporaryDirectory.resolve("state.yml"));
repository.save(expected);
assertEquals(expected, repository.load());
}
@Test
void invalidRecordsCannotGrantProgression() throws Exception {
UUID playerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
players:
%s:
name: Alex
base-level: 99
visitors-enabled: true
""".formatted(playerId));
PersistentState loaded = new YamlBaseStateRepository(stateFile).load();
assertFalse(loaded.players().containsKey(playerId));
}
@Test
void missingFileLoadsEmptyState() throws Exception {
YamlBaseStateRepository repository =
new YamlBaseStateRepository(temporaryDirectory.resolve("missing.yml"));
assertEquals(PersistentState.empty(), repository.load());
}
}