diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java index 46c93d3d1..cf3ad2da5 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java @@ -133,6 +133,88 @@ public static Component serverInfo(String serverName) { */ public static final Component LOBBY_RETURN_KICK = Component.text("Returning to the lobby", NamedTextColor.GRAY); + /** @since 2.21.0 */ + public static final Component PASS_UNAVAILABLE = withPrefix(Component.text("Slender passes are not available on this server.", NamedTextColor.RED)); + /** @since 2.21.0 */ + public static final Component PASS_CLOSED = withPrefix(Component.text("Slender passes can only be reserved in the lobby.", NamedTextColor.RED)); + /** @since 2.21.0 */ + public static final Component PASS_NO_BALANCE = withPrefix(Component.text("You have no slender pass to reserve.", NamedTextColor.RED)); + /** @since 2.21.0 */ + public static final Component PASS_TAKEN = withPrefix(Component.text("Another player already holds the slender pass for this round.", NamedTextColor.RED)); + /** @since 2.21.0 */ + public static final Component PASS_ALREADY_RESERVED = withPrefix(Component.text("You already hold the slender pass for this round.", NamedTextColor.YELLOW)); + /** @since 2.21.0 */ + public static final Component PASS_RESERVED = withPrefix(Component.text("You will play the Slender this round. The pass is spent when the round starts.", NamedTextColor.GREEN)); + /** @since 2.21.0 */ + public static final Component PASS_CANCELLED = withPrefix(Component.text("Your slender pass reservation is cancelled.", NamedTextColor.GRAY)); + /** @since 2.21.0 */ + public static final Component PASS_NOTHING_TO_CANCEL = withPrefix(Component.text("You do not hold a slender pass reservation.", NamedTextColor.RED)); + /** Lobby announcement, it names nobody on purpose. @since 2.21.0 */ + public static final Component PASS_REDEEMED = withPrefix(Component.text("A slender pass has been redeemed for this round.", NamedTextColor.GRAY)); + /** @since 2.21.0 */ + public static final Component PASS_ONLY_PLAYERS = withPrefix(Component.text("Only players can hold a slender pass.", NamedTextColor.RED)); + /** @since 2.21.0 */ + public static final Component PASS_NO_PERMISSION = withPrefix(Component.text("You are not allowed to change slender passes.", NamedTextColor.RED)); + /** @since 2.21.0 */ + public static final Component PASS_TARGET_OFFLINE = withPrefix(Component.text("That player is not online.", NamedTextColor.RED)); + /** @since 2.21.0 */ + public static final Component PASS_NEGATIVE_AMOUNT = withPrefix(Component.text("The amount cannot be negative.", NamedTextColor.RED)); + /** @since 2.21.0 */ + public static final Component PASS_NOTHING_REMOVED = withPrefix(Component.text("Nothing was removed, the player has no slender passes.", NamedTextColor.RED)); + + /** + * Tells a player how many slender passes they have. + * + * @param balance the passes + * @return the message + * @since 2.21.0 + */ + public static Component passBalance(int balance) { + return withPrefix(Component.text("You have ", NamedTextColor.GRAY) + .append(Component.text(String.valueOf(balance), NamedTextColor.GREEN)) + .append(Component.text(" slender pass(es).", NamedTextColor.GRAY))); + } + + /** + * Tells a player that a Survivor round earned a pass. + * + * @param balance the new balance + * @return the message + * @since 2.21.0 + */ + public static Component passEarnedPity(int balance) { + return withPrefix(Component.text("You earned a slender pass for playing as a Survivor. You have ", NamedTextColor.GREEN) + .append(Component.text(String.valueOf(balance), NamedTextColor.GREEN)) + .append(Component.text(" slender pass(es).", NamedTextColor.GREEN))); + } + + /** + * Tells a player that the monthly quota of their rank was credited. + * + * @param balance the new balance + * @return the message + * @since 2.21.0 + */ + public static Component passEarnedQuota(int balance) { + return withPrefix(Component.text("Your rank grants slender passes for this month. You have ", NamedTextColor.GREEN) + .append(Component.text(String.valueOf(balance), NamedTextColor.GREEN)) + .append(Component.text(" slender pass(es).", NamedTextColor.GREEN))); + } + + /** + * Tells an administrator the new balance of a player. + * + * @param name the player's name + * @param balance the new balance + * @return the message + * @since 2.21.0 + */ + public static Component passChanged(String name, int balance) { + return withPrefix(Component.text(name + " has ", NamedTextColor.GRAY) + .append(Component.text(String.valueOf(balance), NamedTextColor.GREEN)) + .append(Component.text(" slender pass(es) now.", NamedTextColor.GRAY))); + } + @Contract(value = "_ -> new", pure = true) public static Component withPrefix(String component) { return PREFIX.append(MINI_MESSAGE.deserialize(component)); 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 10a3dbef8..f3aaa81ac 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 @@ -72,6 +72,7 @@ public record GameConfig( ClientGuardConfig clientGuard, ClientApiConfig clientApi, LunarConfig lunar, + SlenderPassConfig pass, float lobbyAtmosphereShare ) { @@ -152,6 +153,7 @@ public record GameConfig( ClientGuardConfig.DEFAULT, ClientApiConfig.DEFAULT, LunarConfig.DEFAULT, + SlenderPassConfig.DEFAULT, DEFAULT_LOBBY_ATMOSPHERE_SHARE ); @@ -190,6 +192,7 @@ public static GameConfig read(ConfigSection root) { ClientGuardConfig.read(root.section("client-guard.")), ClientApiConfig.read(root.section("client-api.")), LunarConfig.read(root.section("lunar.")), + SlenderPassConfig.read(root.section("pass.")), readLobbyAtmosphereShare(root) ); } diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/SlenderPassConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/SlenderPassConfig.java new file mode 100644 index 000000000..027b98513 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/SlenderPassConfig.java @@ -0,0 +1,42 @@ +package net.onelitefeather.cygnus.common.config; + +import static net.onelitefeather.cygnus.common.config.ConfigChecks.atLeast; +import static net.onelitefeather.cygnus.common.config.ConfigChecks.notNegative; + +/** + * The settings of the slender pass earning: how many Survivor rounds earn one pass, and the cap that earned passes + * never lift a balance above. Read from the keys {@code pass.pity-rounds} and {@code pass.max-balance}. + * + * @param pityRounds the Survivor rounds until one pass is earned, at least 1 + * @param maxBalance the cap for earned passes, zero or more + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +public record SlenderPassConfig(int pityRounds, int maxBalance) { + + /** The defaults: a pass after ten Survivor rounds, at most five passes from earning. */ + public static final SlenderPassConfig DEFAULT = new SlenderPassConfig(10, 5); + + /** + * Rejects values that would make the earning meaningless. Invalid values stop the start; they are not replaced by + * the defaults. + */ + public SlenderPassConfig { + atLeast("pass.pity-rounds", pityRounds, 1); + notNegative("pass.max-balance", maxBalance); + } + + /** + * Reads the section {@code pass.*}. A missing key takes its default, an invalid value fails the read. + * + * @param section the section that holds the {@code pass.} keys + * @return the settings + */ + public static SlenderPassConfig read(ConfigSection section) { + return new SlenderPassConfig( + section.getInt("pity-rounds", DEFAULT.pityRounds()), + section.getInt("max-balance", DEFAULT.maxBalance()) + ); + } +} diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/permission/LuckPermsSupport.java b/common/src/main/java/net/onelitefeather/cygnus/common/permission/LuckPermsSupport.java index b0add3ab5..9f5c67b2b 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/permission/LuckPermsSupport.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/permission/LuckPermsSupport.java @@ -3,11 +3,14 @@ import me.lucko.luckperms.minestom.app.LuckPermsMinestomHandle; import me.lucko.luckperms.minestom.app.LuckPermsMinestomOptions; import me.lucko.luckperms.minestom.loader.MinestomLoader; +import net.luckperms.api.LuckPermsProvider; +import net.luckperms.api.model.user.User; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.file.Path; import java.util.List; +import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -28,6 +31,8 @@ public final class LuckPermsSupport { private static final Logger LOGGER = LoggerFactory.getLogger(LuckPermsSupport.class); + /** The LuckPerms meta that gives the monthly quota of slender passes of a player. @since 2.21.0 */ + public static final String MONTHLY_QUOTA_META = "cygnus.slender-pass.monthly-quota"; private static final List COMMAND_ALIASES = List.of("luckperms", "lp", "perm", "perms", "permission", "permissions"); private static final String LOADER_CLASS = "me.lucko.luckperms.minestom.loader.MinestomLoader"; @@ -91,6 +96,35 @@ private static void startLuckPerms() { * * @return {@code true} if the class is available, {@code false} after logging a warning */ + /** + * Reads the monthly quota of slender passes from the LuckPerms meta {@link #MONTHLY_QUOTA_META} of a player. The + * cached user data is read, so no I/O happens. + * + * @param player the player + * @return the quota, {@code 0} without LuckPerms, without user data, without the meta, or for a value that is not + * an integer + * @since 2.21.0 + */ + public static int monthlyQuota(UUID player) { + if (!PRESENT) { + return 0; + } + User user = LuckPermsProvider.get().getUserManager().getUser(player); + if (user == null) { + return 0; + } + String value = user.getCachedData().getMetaData().getMetaValue(MONTHLY_QUOTA_META); + if (value == null) { + return 0; + } + try { + return Math.max(0, Integer.parseInt(value.trim())); + } catch (NumberFormatException exception) { + LOGGER.warn("The meta {} of {} is not a whole number: {}", MONTHLY_QUOTA_META, player, value); + return 0; + } + } + private static boolean detect() { try { Class.forName(LOADER_CLASS, false, LuckPermsSupport.class.getClassLoader()); diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java index b5d177529..4a6b9dd32 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigTest.java @@ -43,7 +43,7 @@ void testInvalidLobbyAtmosphereShare() { assertRejected("lobbyAtmosphereShare (1.5) must be between 0 and 1", () -> new GameConfig( defaults.round(), defaults.teams(), null, defaults.resourcePack(), defaults.pageProximity(), defaults.damageSound(), defaults.glitch(), defaults.pageGlitch(), defaults.creek(), - defaults.sanity(), defaults.stamina(), defaults.adrenaline(), defaults.footprint(), defaults.possession(), defaults.telemetry(), defaults.minimap(), defaults.eightPages(), defaults.regeneration(), defaults.lure(), defaults.clientGuard(), defaults.clientApi(), defaults.lunar(), 1.5F)); + defaults.sanity(), defaults.stamina(), defaults.adrenaline(), defaults.footprint(), defaults.possession(), defaults.telemetry(), defaults.minimap(), defaults.eightPages(), defaults.regeneration(), defaults.lure(), defaults.clientGuard(), defaults.clientApi(), defaults.lunar(), defaults.pass(), 1.5F)); } @Test diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/SlenderPassConfigTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/SlenderPassConfigTest.java new file mode 100644 index 000000000..f7f89221b --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/SlenderPassConfigTest.java @@ -0,0 +1,59 @@ +package net.onelitefeather.cygnus.common.config; + +import org.junit.jupiter.api.Test; + +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the {@code pass.*} keys of the slender pass earning. Every test builds its own properties. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +class SlenderPassConfigTest { + + @Test + void theDefaultsAreTenSurvivorRoundsAndACapOfFive() { + assertEquals(new SlenderPassConfig(10, 5), read(new Properties())); + } + + @Test + void theConfiguredValuesAreRead() { + Properties properties = new Properties(); + properties.setProperty("pass.pity-rounds", "4"); + properties.setProperty("pass.max-balance", "2"); + + assertEquals(new SlenderPassConfig(4, 2), read(properties)); + } + + @Test + void aPityThresholdBelowOneIsRefusedWithItsKey() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> new SlenderPassConfig(0, 5)); + + assertTrue(error.getMessage().contains("pass.pity-rounds (0) must be at least 1"), error.getMessage()); + } + + @Test + void aNegativeCapIsRefusedWithItsKey() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> new SlenderPassConfig(10, -1)); + + assertTrue(error.getMessage().contains("pass.max-balance"), error.getMessage()); + } + + @Test + void anInvalidValueFromTheFileFailsTheStartInsteadOfFallingBackToTheDefault() { + Properties properties = new Properties(); + properties.setProperty("pass.pity-rounds", "0"); + + assertThrows(IllegalArgumentException.class, () -> read(properties)); + } + + private static SlenderPassConfig read(Properties properties) { + return SlenderPassConfig.read(ConfigSection.root(properties).section("pass.")); + } +} diff --git a/config.properties.example b/config.properties.example index 73c3b11ee..a10ec3e64 100644 --- a/config.properties.example +++ b/config.properties.example @@ -455,6 +455,17 @@ survivorTeamSize=12 #lunar.enabled=true #lunar.disabled-mods=minimap,waypoints,freelook,zoom,coordinates,directionhud +# --- Slender pass ----------------------------------------------------------- +# Passes are earned by playing and by a monthly rank quota (the LuckPerms meta +# cygnus.slender-pass.monthly-quota, once per UTC month in the lobby). Both need the statistics +# database, the same as the feature itself. +# +# pity-rounds Survivor rounds played to the end until one pass is earned. At least 1. +# max-balance The cap for earned passes: earning never lifts a balance above it. Administrator +# changes are not capped. Zero or more. +#pass.pity-rounds=10 +#pass.max-balance=5 + # --- Statistics ------------------------------------------------------------- # Yearly player statistics. Every finished round is written to a PostgreSQL database: one row # per player and round, plus one aggregate per player and calendar year. The schema is created diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index c2ea00a8b..492cd8b41 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -2,6 +2,7 @@ import net.minestom.server.event.GlobalEventHandler; import net.minestom.server.instance.Instance; +import net.onelitefeather.cygnus.common.permission.LuckPermsSupport; import net.onelitefeather.cygnus.common.page.event.PageDiscoveryCompletedEvent; import net.onelitefeather.cygnus.event.GameStartEvent; import net.onelitefeather.cygnus.common.page.event.PageSpawnEvent; @@ -96,6 +97,12 @@ import net.onelitefeather.cygnus.movement.PlayerStopSprintingEvent; import net.onelitefeather.cygnus.phase.GamePhase; import net.onelitefeather.cygnus.phase.LobbyPhase; +import net.onelitefeather.cygnus.pass.ConnectionPassFeedback; +import net.onelitefeather.cygnus.pass.MonthlyQuota; +import net.onelitefeather.cygnus.pass.PassFeedback; +import net.onelitefeather.cygnus.pass.PityCounter; +import net.onelitefeather.cygnus.pass.SlenderPassFeature; +import net.onelitefeather.cygnus.pass.SlenderPassService; import net.onelitefeather.cygnus.phase.RestartPhase; import net.onelitefeather.cygnus.phase.WaitingPhase; import net.onelitefeather.cygnus.player.CygnusPlayer; @@ -112,6 +119,7 @@ import net.onelitefeather.cygnus.stats.StatsConfig; import net.onelitefeather.cygnus.stats.StatsMetrics; import net.onelitefeather.cygnus.stats.StatsRepositories; +import net.onelitefeather.cygnus.stats.SlenderPassRepository; import net.onelitefeather.cygnus.stats.StatsRepository; import net.onelitefeather.cygnus.utils.ScoreboardDisplay; import net.onelitefeather.cygnus.utils.StaminaHelper; @@ -148,6 +156,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.Executor; import java.util.Optional; import java.util.UUID; import java.util.function.Supplier; @@ -182,6 +191,8 @@ public final class Cygnus implements TeamCreator, ListenerHandling { private final CygnusTracing tracing; private final RoundTracer roundTracer; + private final SlenderPassService slenderPassService; + private final MonthlyQuota monthlyQuota; private final JoinTracer joinTracer; private final SlowTickTracer slowTickTracer; private final ActionTracer actionTracer; @@ -227,8 +238,23 @@ public Cygnus() { PlayerStatsService playerStats = new PlayerStatsService(statsRepository, Clock.systemUTC(), statsConfig.timeZone(), MinecraftServer.getConnectionManager(), task -> MinecraftServer.getSchedulerManager().scheduleNextTick(task)); + // The single place that picks the pass storage: the same condition as the statistics, so a + // missing database hides the feature for both. The repository shares the statistics' pool. + SlenderPassRepository passes = statsRepository.slenderPasses(Clock.systemUTC()); + Executor tick = task -> MinecraftServer.getSchedulerManager().scheduleNextTick(task); + PassFeedback passFeedback = new ConnectionPassFeedback(MinecraftServer.getConnectionManager()); + PityCounter pityCounter = new PityCounter(passes, this.gameConfig.pass(), tick, passFeedback); + this.monthlyQuota = new MonthlyQuota(passes, Clock.systemUTC(), LuckPermsSupport::monthlyQuota, + this.gameConfig.pass(), tick, passFeedback); this.roundStatsCollector = new RoundStatsCollector(statsRepository, Clock.systemUTC(), - statsConfig.timeZone(), this.roundTracer::currentRoundId, Cygnus::onlineName, playerStats::roundSaved); + statsConfig.timeZone(), this.roundTracer::currentRoundId, Cygnus::onlineName, playerStats::roundSaved, + pityCounter::roundFinished); + this.slenderPassService = new SlenderPassService(passes, tick, + MinecraftServer.getConnectionManager()::getOnlinePlayerByUuid, + () -> this.linearPhaseSeries.getCurrentPhase() instanceof LobbyPhase, + passFeedback, + (player, roundId, cause) -> LOGGER.warn("Spending the slender pass of {} for round {} failed, " + + "the player keeps the Slender role for this round", player, roundId, cause)); this.slowTickTracer = new SlowTickTracer(this.tracing, this.roundTracer, this.gameConfig.telemetry(), Clock.systemUTC(), tickSections, TickMetrics.fromGlobal(serviceVersion(), this.roundTracer::phaseLabel)); this.staminaService = new StaminaService(this.gameConfig.stamina(), tickSections); @@ -300,6 +326,7 @@ sanityService, new HuntedTabWitness(new TracingCreekWitness(sanityService, this. this.spectatorService, new StatsDisplayFeature(playerStats, this.linearPhaseSeries::getCurrentPhase, MinecraftServer.getConnectionManager()), + new SlenderPassFeature(this.slenderPassService, MinecraftServer.getConnectionManager()), new LobbyHintsFeature(ServiceName.of(path), this.linearPhaseSeries::getCurrentPhase, MinecraftServer.getConnectionManager()), // Not part of the OverlayModule: the sound is the feedback a hit owes the player @@ -383,6 +410,21 @@ private static StatsRepository openStatistics(StatsConfig config, StatsMetrics m return activeMap == null ? null : activeMap.name(); } + /** + * Reads the id of the running round as a UUID, the form the statistics store. A round id that is not a UUID + * leaves the pass out of the round. + */ + private static @Nullable UUID roundUuid(@Nullable String id) { + if (id == null) { + return null; + } + try { + return UUID.fromString(id); + } catch (IllegalArgumentException exception) { + return null; + } + } + private static @Nullable String onlineName(UUID uuid) { Player player = MinecraftServer.getConnectionManager().getOnlinePlayerByUuid(uuid); return player == null ? null : player.getUsername(); @@ -442,7 +484,11 @@ private void initListener(Team spectatorTeam) { ); manager.addListener(GameMapLoadedEvent.class, event -> this.roundTracer.pagePoolDrawn(event.pagePool().name())); manager.addListener(GameMapLoadedEvent.class, new GameMapLoadedListener()); - manager.addListener(PlayerSpawnEvent.class, new PlayerSpawnListener(player -> this.mapProvider.teleportToSpawn(player, false), phaseSupplier)); + manager.addListener(PlayerSpawnEvent.class, new PlayerSpawnListener(player -> this.mapProvider.teleportToSpawn(player, false), phaseSupplier, + player -> { + this.slenderPassService.onLobbyJoin(player); + this.monthlyQuota.onLobbyJoin(player.getUuid()); + })); PlayerQuitListener quitListener = new PlayerQuitListener(phaseSupplier, teamService, this.staminaService, this.spectatorService::updateInventory, this.slenderTakeover); manager.addListener(PlayerDisconnectEvent.class, quitListener); manager.addListener(AsyncPlayerConfigurationEvent.class, @@ -459,7 +505,8 @@ private void initListener(Team spectatorTeam) { manager.addListener(GamePrepareEvent.class, _ -> { // The roles are handed out inside initStaminaObjects; the name tag teams can only mirror // them afterwards. - StaminaHelper.initStaminaObjects(this.teamService, this.staminaService); + Player reserved = this.slenderPassService.claimRound(roundUuid(this.roundTracer.currentRoundId())); + StaminaHelper.initStaminaObjects(this.teamService, this.staminaService, reserved); this.scoreboardDisplay.sync(this.teamService); }); registerCancelListener(manager); diff --git a/game/src/main/java/net/onelitefeather/cygnus/listener/PlayerSpawnListener.java b/game/src/main/java/net/onelitefeather/cygnus/listener/PlayerSpawnListener.java index 635c0b459..65f303fdc 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/listener/PlayerSpawnListener.java +++ b/game/src/main/java/net/onelitefeather/cygnus/listener/PlayerSpawnListener.java @@ -24,10 +24,25 @@ public final class PlayerSpawnListener implements Consumer { private final PlayerConsumer spawnSupplier; private final Supplier phaseSupplier; + private final Consumer lobbyJoin; public PlayerSpawnListener(PlayerConsumer spawnSupplier, Supplier phaseSupplier) { + this(spawnSupplier, phaseSupplier, player -> { + }); + } + + /** + * Creates the listener. + * + * @param spawnSupplier the spawn logic + * @param phaseSupplier reads the running phase + * @param lobbyJoin runs for a player who first spawns in the lobby, after the lobby setup + * @since 2.21.0 + */ + public PlayerSpawnListener(PlayerConsumer spawnSupplier, Supplier phaseSupplier, Consumer lobbyJoin) { this.spawnSupplier = spawnSupplier; this.phaseSupplier = phaseSupplier; + this.lobbyJoin = lobbyJoin; } @Override @@ -46,6 +61,7 @@ public void accept(PlayerSpawnEvent event) { Broadcaster.broadcast(Messages.getJoinMessage(player)); this.spawnSupplier.accept(player); lobbyPhase.setLevel(player); + this.lobbyJoin.accept(player); lobbyPhase.checkStartCondition(); return; } diff --git a/game/src/main/java/net/onelitefeather/cygnus/pass/ConnectionPassFeedback.java b/game/src/main/java/net/onelitefeather/cygnus/pass/ConnectionPassFeedback.java new file mode 100644 index 000000000..1c95c1c40 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/pass/ConnectionPassFeedback.java @@ -0,0 +1,52 @@ +package net.onelitefeather.cygnus.pass; + +import net.kyori.adventure.text.Component; +import net.minestom.server.entity.Player; +import net.minestom.server.network.ConnectionManager; +import net.onelitefeather.cygnus.utils.Items; + +import java.util.UUID; + +/** + * Delivers the pass feedback to the online players of this server. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +public final class ConnectionPassFeedback implements PassFeedback { + + private final ConnectionManager connections; + + /** + * Creates the feedback on the server's connections. + * + * @param connections finds the online players + */ + public ConnectionPassFeedback(ConnectionManager connections) { + this.connections = connections; + } + + @Override + public void tell(UUID player, Component message) { + Player online = this.connections.getOnlinePlayerByUuid(player); + if (online != null) { + online.sendMessage(message); + } + } + + @Override + public void tellLobby(Component message, UUID except) { + this.connections.getOnlinePlayers().stream() + .filter(player -> !player.getUuid().equals(except)) + .forEach(player -> player.sendMessage(message)); + } + + @Override + public void showBalance(UUID player, int balance) { + Player online = this.connections.getOnlinePlayerByUuid(player); + if (online != null) { + Items.setPassItem(online, balance); + } + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/pass/MonthlyQuota.java b/game/src/main/java/net/onelitefeather/cygnus/pass/MonthlyQuota.java new file mode 100644 index 000000000..96dbff6eb --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/pass/MonthlyQuota.java @@ -0,0 +1,91 @@ +package net.onelitefeather.cygnus.pass; + +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.common.config.SlenderPassConfig; +import net.onelitefeather.cygnus.stats.SlenderPassRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Clock; +import java.time.YearMonth; +import java.time.ZoneOffset; +import java.util.UUID; +import java.util.concurrent.Executor; + +/** + * Credits the monthly quota of a player's rank once per calendar month, when the player is in the lobby. + *

+ * The month is the UTC month of the injected clock. The repository decides whether this month is still to be credited, + * so a second lobby join in the same month credits nothing, and a month that was missed is not paid back. The quota is + * read on the tick thread from the cached LuckPerms data; the database write runs off it. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +public final class MonthlyQuota { + + private static final Logger LOGGER = LoggerFactory.getLogger(MonthlyQuota.class); + + private final SlenderPassRepository repository; + private final Clock clock; + private final MonthlyQuotaSource source; + private final SlenderPassConfig config; + private final Executor tick; + private final PassFeedback feedback; + + /** + * Reads the quota of a player. Implementations must not block. + */ + @FunctionalInterface + public interface MonthlyQuotaSource { + + /** + * Reads the quota. + * + * @param player the player + * @return the number of passes per month, {@code 0} for none + */ + int monthlyQuota(UUID player); + } + + /** + * Creates the quota. + * + * @param repository where the passes are stored + * @param clock the source of the month, read in UTC + * @param source reads the quota of a player + * @param config the cap + * @param tick runs the announcement on the tick thread + * @param feedback delivers the announcement + */ + public MonthlyQuota(SlenderPassRepository repository, Clock clock, MonthlyQuotaSource source, SlenderPassConfig config, + Executor tick, PassFeedback feedback) { + this.repository = repository; + this.clock = clock; + this.source = source; + this.config = config; + this.tick = tick; + this.feedback = feedback; + } + + /** + * Credits the quota of a player who is in the lobby. Called on the tick thread. + * + * @param player the player + */ + public void onLobbyJoin(UUID player) { + YearMonth month = YearMonth.from(this.clock.instant().atZone(ZoneOffset.UTC)); + this.repository.grantQuota(player, month, this.source.monthlyQuota(player), this.config.maxBalance()) + .thenAcceptAsync(credit -> { + if (credit.delta() > 0) { + this.feedback.tell(player, Messages.passEarnedQuota(credit.balance())); + } + }, this.tick) + .exceptionally(error -> { + LOGGER.warn("Crediting the monthly slender pass quota of {} failed", player, error); + return null; + }); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/pass/PassCommand.java b/game/src/main/java/net/onelitefeather/cygnus/pass/PassCommand.java new file mode 100644 index 000000000..0964f83f1 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/pass/PassCommand.java @@ -0,0 +1,127 @@ +package net.onelitefeather.cygnus.pass; + +import net.kyori.adventure.permission.PermissionChecker; +import net.kyori.adventure.util.TriState; +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.Argument; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.stats.SlenderPassRepository; + +import java.util.function.Consumer; + +/** + * The {@code /pass} command. Players reserve, cancel and check their own passes; {@value #PERMISSION} holders and + * the console change balances with {@code give}, {@code take} and {@code set}. + *

+ * While passes are not available every subcommand answers that the feature is unavailable. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +public final class PassCommand extends Command { + + /** The permission that lets a player change balances. The console has it implicitly. */ + static final String PERMISSION = "cygnus.pass.admin"; + + private static final String SLENDER = "slender"; + private static final String CANCEL = "cancel"; + private static final String BALANCE = "balance"; + private static final String GIVE = "give"; + private static final String TAKE = "take"; + private static final String SET = "set"; + + private final SlenderPassService service; + + /** + * Creates the command and its syntaxes. + * + * @param service the service the subcommands act on + */ + public PassCommand(SlenderPassService service) { + super("pass"); + this.service = service; + + Argument player = ArgumentType.Word("player"); + Argument amount = ArgumentType.Integer("amount"); + + addSyntax((sender, context) -> playerAction(sender, this.service::reserve)); + addSyntax((sender, context) -> playerAction(sender, this.service::reserve), ArgumentType.Literal(SLENDER)); + addSyntax((sender, context) -> playerAction(sender, this.service::cancel), ArgumentType.Literal(CANCEL)); + addSyntax((sender, context) -> playerAction(sender, this.service::balance), ArgumentType.Literal(BALANCE)); + addSyntax((sender, context) -> admin(sender, context.get(player), context.get(amount), Operation.GIVE), + ArgumentType.Literal(GIVE), player, amount); + addSyntax((sender, context) -> admin(sender, context.get(player), context.get(amount), Operation.TAKE), + ArgumentType.Literal(TAKE), player, amount); + addSyntax((sender, context) -> admin(sender, context.get(player), context.get(amount), Operation.SET), + ArgumentType.Literal(SET), player, amount); + } + + /** + * Tells whether the sender may change balances. Players need {@value #PERMISSION} through the permission + * pointer of the server; the console always may. + * + * @param sender the sender to check + * @return {@code true} for the console and for players who hold the permission + */ + static boolean mayAdminister(CommandSender sender) { + return !(sender instanceof Player) + || sender.getOrDefault(PermissionChecker.POINTER, PermissionChecker.always(TriState.FALSE)).test(PERMISSION); + } + + private void playerAction(CommandSender sender, Consumer action) { + if (!this.service.available()) { + sender.sendMessage(Messages.PASS_UNAVAILABLE); + return; + } + if (sender instanceof Player player) { + action.accept(player); + } else { + sender.sendMessage(Messages.PASS_ONLY_PLAYERS); + } + } + + private void admin(CommandSender sender, String name, int amount, Operation operation) { + if (!this.service.available()) { + sender.sendMessage(Messages.PASS_UNAVAILABLE); + return; + } + if (!mayAdminister(sender)) { + sender.sendMessage(Messages.PASS_NO_PERMISSION); + return; + } + if (amount < 0) { + sender.sendMessage(Messages.PASS_NEGATIVE_AMOUNT); + return; + } + Player target = MinecraftServer.getConnectionManager().getOnlinePlayers().stream() + .filter(player -> player.getUsername().equalsIgnoreCase(name)) + .findFirst() + .orElse(null); + if (target == null) { + sender.sendMessage(Messages.PASS_TARGET_OFFLINE); + return; + } + Consumer reply = result -> { + if (operation == Operation.TAKE && result.delta() == 0) { + sender.sendMessage(Messages.PASS_NOTHING_REMOVED); + } else { + sender.sendMessage(Messages.passChanged(target.getUsername(), result.balance())); + } + }; + switch (operation) { + case GIVE -> this.service.give(target.getUuid(), amount, reply); + case TAKE -> this.service.take(target.getUuid(), amount, reply); + case SET -> this.service.set(target.getUuid(), amount, reply); + } + } + + private enum Operation { + GIVE, TAKE, SET + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/pass/PassFeedback.java b/game/src/main/java/net/onelitefeather/cygnus/pass/PassFeedback.java new file mode 100644 index 000000000..29ec774dd --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/pass/PassFeedback.java @@ -0,0 +1,40 @@ +package net.onelitefeather.cygnus.pass; + +import net.kyori.adventure.text.Component; + +import java.util.UUID; + +/** + * Delivers what the slender pass service says to the players: messages and the balance on the lobby item. The + * service only names players by UUID, so it can be tested without a server. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +public interface PassFeedback { + + /** + * Sends a message to one player, if the player is online. + * + * @param player the player + * @param message the message + */ + void tell(UUID player, Component message); + + /** + * Sends a message to every online player except one. + * + * @param message the message + * @param except the player who does not get it + */ + void tellLobby(Component message, UUID except); + + /** + * Shows a balance on the player's lobby item. + * + * @param player the player + * @param balance the balance + */ + void showBalance(UUID player, int balance); +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/pass/PityCounter.java b/game/src/main/java/net/onelitefeather/cygnus/pass/PityCounter.java new file mode 100644 index 000000000..837bbf3e6 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/pass/PityCounter.java @@ -0,0 +1,78 @@ +package net.onelitefeather.cygnus.pass; + +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.common.config.SlenderPassConfig; +import net.onelitefeather.cygnus.stats.FinishedRound; +import net.onelitefeather.cygnus.stats.RoundStats; +import net.onelitefeather.cygnus.stats.RoundStats.Outcome; +import net.onelitefeather.cygnus.stats.RoundStats.Role; +import net.onelitefeather.cygnus.stats.SlenderPassRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Executor; + +/** + * Counts the Survivor rounds of every player and credits a pass when a player reaches the pity threshold. + *

+ * The input is the snapshot the statistics build when a round ends, so "a Survivor who played to the end" means the + * same here as in the statistics. A player who was the Slender in the round, by any path, or who was taken over, starts + * the count again. A player who quit before the end is not counted at all. The repository counts in one transaction; + * the credits are announced on the tick thread. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +public final class PityCounter { + + private static final Logger LOGGER = LoggerFactory.getLogger(PityCounter.class); + + private final SlenderPassRepository repository; + private final SlenderPassConfig config; + private final Executor tick; + private final PassFeedback feedback; + + /** + * Creates the counter. + * + * @param repository where the counters and the passes are stored + * @param config the threshold and the cap + * @param tick runs the announcements on the tick thread + * @param feedback delivers the announcements + */ + public PityCounter(SlenderPassRepository repository, SlenderPassConfig config, Executor tick, PassFeedback feedback) { + this.repository = repository; + this.config = config; + this.tick = tick; + this.feedback = feedback; + } + + /** + * Counts a finished round. Called on the tick thread; the database write runs off it. + * + * @param round the round the statistics have just snapshotted + */ + public void roundFinished(FinishedRound round) { + List survivors = new ArrayList<>(); + List slenders = new ArrayList<>(); + for (RoundStats stats : round.players()) { + if (stats.role() == Role.SLENDER || stats.takenOver()) { + slenders.add(stats.playerUuid()); + } else if (stats.outcome() != Outcome.ABANDONED) { + survivors.add(stats.playerUuid()); + } + } + this.repository.recordRounds(survivors, slenders, this.config.pityRounds(), this.config.maxBalance()) + .thenAcceptAsync(credits -> credits.forEach((player, credit) -> + this.feedback.tell(player, Messages.passEarnedPity(credit.balance()))), this.tick) + .exceptionally(error -> { + LOGGER.warn("Counting the Survivor rounds of round {} failed", round.roundId(), error); + return null; + }); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/pass/SlenderPassFeature.java b/game/src/main/java/net/onelitefeather/cygnus/pass/SlenderPassFeature.java new file mode 100644 index 000000000..49782cc9c --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/pass/SlenderPassFeature.java @@ -0,0 +1,73 @@ +package net.onelitefeather.cygnus.pass; + +import net.minestom.server.command.CommandManager; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.item.ItemDropEvent; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.minestom.server.event.player.PlayerUseItemEvent; +import net.minestom.server.network.ConnectionManager; +import net.onelitefeather.cygnus.GameFeature; +import net.onelitefeather.cygnus.event.GameStartEvent; +import net.onelitefeather.cygnus.utils.Items; + +/** + * Brings the slender pass to the players: the lobby item that reserves the Slender, the {@code /pass} command and + * the release of a reservation when its holder leaves. + *

+ * The feature is disabled while passes are not stored; its command is still registered and answers that the + * feature is unavailable. The item is handed out by the lobby spawn listener, and taken away when the round starts. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +public final class SlenderPassFeature implements GameFeature { + + private final EventNode node = EventNode.all("slender-pass"); + private final SlenderPassService service; + private final ConnectionManager connections; + + /** + * Creates the feature and puts its listeners on its node. + * + * @param service the service the item and the command act on + * @param connections finds the online players + */ + public SlenderPassFeature(SlenderPassService service, ConnectionManager connections) { + this.service = service; + this.connections = connections; + registerListeners(); + } + + @Override + public EventNode node() { + return this.node; + } + + @Override + public boolean enabled() { + return this.service.available(); + } + + @Override + public void registerCommands(CommandManager manager) { + manager.register(new PassCommand(this.service)); + } + + private void registerListeners() { + this.node.addListener(PlayerUseItemEvent.class, event -> { + if (Items.isPassItem(event.getItemStack())) { + this.service.toggle(event.getPlayer()); + } + }); + this.node.addListener(PlayerDisconnectEvent.class, event -> this.service.release(event.getPlayer().getUuid())); + this.node.addListener(GameStartEvent.class, _ -> this.connections.getOnlinePlayers().forEach(Items::removePassItem)); + this.node.addListener(ItemDropEvent.class, event -> { + if (Items.isPassItem(event.getItemStack())) { + event.setCancelled(true); + } + }); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/pass/SlenderPassService.java b/game/src/main/java/net/onelitefeather/cygnus/pass/SlenderPassService.java new file mode 100644 index 000000000..0e617e499 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/pass/SlenderPassService.java @@ -0,0 +1,305 @@ +package net.onelitefeather.cygnus.pass; + +import net.kyori.adventure.text.Component; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.stats.SlenderPassRepository; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.Executor; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * Lets a player with a slender pass reserve the Slender for the next round, and spends the pass when that round + * starts. + *

+ * There is one reservation at a time, held in memory by this server. A reservation reads the balance off the tick + * thread; the decision runs on the tick thread when the read returns. At the round start the holder who is online + * gets the role at once, and the pass is spent in the background with a conditional update. When that spend fails + * (the balance was taken away in the meantime, or the database failed), the holder keeps the role for the round and + * the failure is reported through {@link DebitFailureListener}. A reservation ends when its holder cancels, leaves + * or is offline at the round start. + *

+ *

+ * Every field is read and written on the tick thread, except what the repository completes on its own executor, + * which only reaches the tick thread through the executor given here. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +public final class SlenderPassService { + + /** + * Receives a spend that failed at the round start. The holder keeps the role for that round. + */ + @FunctionalInterface + public interface DebitFailureListener { + + /** + * Reports the failed spend. + * + * @param player the holder who keeps the role + * @param roundId the round the spend was for, {@code null} when the round is not known + * @param cause the database error, or {@code null} when the balance was already zero + */ + void debitFailed(UUID player, @Nullable UUID roundId, @Nullable Throwable cause); + } + + private final SlenderPassRepository repository; + private final Executor tick; + private final Function online; + private final BooleanSupplier lobbyOpen; + private final PassFeedback feedback; + private final DebitFailureListener debitFailures; + private @Nullable UUID holder; + + /** + * Creates the service. + * + * @param repository stores the balances and the ledger + * @param tick applies the results of the repository on the tick thread + * @param online finds an online player by UUID, {@code null} when offline + * @param lobbyOpen tells whether the lobby phase is running + * @param feedback delivers the messages and the item balance + * @param debitFailures receives a spend that failed at the round start + */ + public SlenderPassService(SlenderPassRepository repository, Executor tick, Function online, + BooleanSupplier lobbyOpen, PassFeedback feedback, DebitFailureListener debitFailures) { + this.repository = repository; + this.tick = tick; + this.online = online; + this.lobbyOpen = lobbyOpen; + this.feedback = feedback; + this.debitFailures = debitFailures; + } + + /** + * Tells whether the feature is available, that is whether passes are stored. + * + * @return {@code true} when passes are stored + */ + public boolean available() { + return this.repository.available(); + } + + /** + * Returns the holder of the current reservation. + * + * @return the holder, empty when nobody holds it + */ + public Optional reservedBy() { + return Optional.ofNullable(this.holder); + } + + /** + * Shows the balance on the lobby item of a player who joins the lobby. + * + * @param player the player + */ + public void onLobbyJoin(Player player) { + if (!available()) { + return; + } + UUID uuid = player.getUuid(); + this.repository.balance(uuid) + .thenAcceptAsync(balance -> showIfLobby(uuid, balance), this.tick) + .exceptionallyAsync(error -> unavailable(uuid), this.tick); + } + + /** + * The lobby item: reserves for a player who holds no reservation, cancels for the holder. + * + * @param player the player who used the item + */ + public void toggle(Player player) { + if (player.getUuid().equals(this.holder)) { + cancel(player); + } else { + reserve(player); + } + } + + /** + * Reserves the Slender for the next round. Refused outside the lobby, while another player holds the + * reservation, and without a pass. The balance is read first, the decision follows on the tick thread. + * + * @param player the player + */ + public void reserve(Player player) { + UUID uuid = player.getUuid(); + if (!available()) { + this.feedback.tell(uuid, Messages.PASS_UNAVAILABLE); + return; + } + if (!this.lobbyOpen.getAsBoolean()) { + this.feedback.tell(uuid, Messages.PASS_CLOSED); + return; + } + if (uuid.equals(this.holder)) { + this.feedback.tell(uuid, Messages.PASS_ALREADY_RESERVED); + return; + } + if (this.holder != null) { + this.feedback.tell(uuid, Messages.PASS_TAKEN); + return; + } + this.repository.balance(uuid) + .thenAcceptAsync(balance -> decideReservation(uuid, balance), this.tick) + .exceptionallyAsync(error -> unavailable(uuid), this.tick); + } + + /** + * Cancels the reservation of a player. + * + * @param player the player + */ + public void cancel(Player player) { + UUID uuid = player.getUuid(); + if (!available()) { + this.feedback.tell(uuid, Messages.PASS_UNAVAILABLE); + return; + } + if (!uuid.equals(this.holder)) { + this.feedback.tell(uuid, Messages.PASS_NOTHING_TO_CANCEL); + return; + } + this.holder = null; + this.feedback.tell(uuid, Messages.PASS_CANCELLED); + } + + /** + * Tells a player how many passes they have, and refreshes the lobby item. + * + * @param player the player + */ + public void balance(Player player) { + UUID uuid = player.getUuid(); + if (!available()) { + this.feedback.tell(uuid, Messages.PASS_UNAVAILABLE); + return; + } + this.repository.balance(uuid) + .thenAcceptAsync(balance -> { + this.feedback.tell(uuid, Messages.passBalance(balance)); + showIfLobby(uuid, balance); + }, this.tick) + .exceptionallyAsync(error -> unavailable(uuid), this.tick); + } + + /** + * Drops the reservation of a player who disconnects or leaves the lobby. Nothing is announced. + * + * @param player the player + */ + public void release(UUID player) { + if (player.equals(this.holder)) { + this.holder = null; + } + } + + /** + * Takes the reservation for the round that starts. The reservation ends here, whatever the outcome. + *

+ * A holder who is offline loses the reservation and nothing is spent. A holder who is online gets the + * returned player as the Slender, and the pass is spent in the background. + *

+ * + * @param roundId the round that starts, {@code null} when the round is not known + * @return the holder who plays the Slender, or {@code null} to draw the Slender at random + */ + public @Nullable Player claimRound(@Nullable UUID roundId) { + UUID claimed = this.holder; + this.holder = null; + if (claimed == null) { + return null; + } + Player player = this.online.apply(claimed); + if (player == null) { + return null; + } + this.repository.redeem(claimed, roundId).whenComplete((spent, error) -> { + if (error != null) { + this.debitFailures.debitFailed(claimed, roundId, error); + } else if (!spent) { + this.debitFailures.debitFailed(claimed, roundId, null); + } + }); + return player; + } + + /** + * Adds passes to a balance. The result is handed to the reply on the tick thread. + * + * @param target the player + * @param amount the passes, zero or more + * @param reply receives the new balance + */ + public void give(UUID target, int amount, Consumer reply) { + this.repository.give(target, amount).thenAcceptAsync(reply, this.tick); + } + + /** + * Removes passes from a balance, never below zero. The result is handed to the reply on the tick thread. + * + * @param target the player + * @param amount the passes, zero or more + * @param reply receives the new balance and the delta + */ + public void take(UUID target, int amount, Consumer reply) { + this.repository.take(target, amount).thenAcceptAsync(reply, this.tick); + } + + /** + * Sets a balance. The result is handed to the reply on the tick thread. + * + * @param target the player + * @param amount the new balance, zero or more + * @param reply receives the new balance and the delta + */ + public void set(UUID target, int amount, Consumer reply) { + this.repository.set(target, amount).thenAcceptAsync(reply, this.tick); + } + + private void decideReservation(UUID uuid, int balance) { + if (!this.lobbyOpen.getAsBoolean()) { + this.feedback.tell(uuid, Messages.PASS_CLOSED); + return; + } + if (uuid.equals(this.holder)) { + this.feedback.tell(uuid, Messages.PASS_ALREADY_RESERVED); + return; + } + if (this.holder != null) { + this.feedback.tell(uuid, Messages.PASS_TAKEN); + return; + } + if (balance <= 0) { + this.feedback.tell(uuid, Messages.PASS_NO_BALANCE); + return; + } + if (this.online.apply(uuid) == null) { + return; + } + this.holder = uuid; + this.feedback.tell(uuid, Messages.PASS_RESERVED); + this.feedback.tellLobby(Messages.PASS_REDEEMED, uuid); + this.feedback.showBalance(uuid, balance); + } + + private void showIfLobby(UUID uuid, int balance) { + if (this.lobbyOpen.getAsBoolean()) { + this.feedback.showBalance(uuid, balance); + } + } + + private @Nullable Void unavailable(UUID uuid) { + this.feedback.tell(uuid, Messages.PASS_UNAVAILABLE); + return null; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/pass/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/pass/package-info.java new file mode 100644 index 000000000..a08f3b1bc --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/pass/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.pass; + +import org.jetbrains.annotations.NotNullByDefault; \ No newline at end of file diff --git a/game/src/main/java/net/onelitefeather/cygnus/stats/RoundStatsCollector.java b/game/src/main/java/net/onelitefeather/cygnus/stats/RoundStatsCollector.java index c5eb995cc..ca2ce4a7f 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stats/RoundStatsCollector.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stats/RoundStatsCollector.java @@ -47,6 +47,7 @@ public final class RoundStatsCollector { private final Supplier<@Nullable String> roundIds; private final Function names; private final Consumer> totalsHandler; + private final Consumer roundHandler; /** * Creates the collector without names and without a hand-over of the committed totals. @@ -75,11 +76,32 @@ public RoundStatsCollector(StatsRepository repository, Clock clock, ZoneId zone, */ public RoundStatsCollector(StatsRepository repository, Clock clock, ZoneId zone, Supplier<@Nullable String> roundIds, Function names, Consumer> totalsHandler) { + this(repository, clock, zone, roundIds, names, totalsHandler, _ -> { + }); + } + + /** + * Creates the collector with a hook that sees every finished round before it is written. + * + * @param repository where finished rounds go + * @param clock the source of every timestamp + * @param zone the zone whose calendar decides the year of a round + * @param roundIds the id of the running round as the round telemetry knows it + * @param names reads the name of a participant when the round starts, {@code null} for an unknown one + * @param totalsHandler receives the year totals the repository committed for the round's participants + * @param roundHandler receives each finished round on the thread that ends the round, before the write starts; + * a failure is logged and does not stop the write + * @since 2.21.0 + */ + public RoundStatsCollector(StatsRepository repository, Clock clock, ZoneId zone, Supplier<@Nullable String> roundIds, + Function names, Consumer> totalsHandler, + Consumer roundHandler) { this.repository = repository; this.tracker = new RoundStatsTracker(clock, zone); this.roundIds = roundIds; this.names = names; this.totalsHandler = totalsHandler; + this.roundHandler = roundHandler; } /** @@ -107,6 +129,11 @@ public void roundStarted(Map players) { */ public void gameFinished(GameFinishEvent.Reason reason) { this.tracker.finish(FinishReason.valueOf(reason.name()), () -> roundId(this.roundIds.get())).ifPresent(round -> { + try { + this.roundHandler.accept(round); + } catch (RuntimeException exception) { + LOGGER.error("Handing the finished round {} to its listeners failed", round.roundId(), exception); + } try { this.repository.saveRound(round).whenComplete((totals, failure) -> { if (failure == null) { diff --git a/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java b/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java index 678e82c7f..a4a787e2c 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java +++ b/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java @@ -47,9 +47,23 @@ public record TeamAllocation(Player slender, Set survivors) { * @return the resulting allocation, containing the slender player and the survivors */ public static TeamAllocation prepareTeamAllocation(Team slenderTeam, Team survivorTeam) { + return prepareTeamAllocation(slenderTeam, survivorTeam, null); + } + + /** + * Prepares the allocation with the player who holds a slender pass for the round as the slender. Without + * such a player the slender is drawn at random. + * + * @param slenderTeam the team for the slender + * @param survivorTeam the team for the survivors + * @param reservedSlender the player who plays the Slender by a pass, or {@code null} to draw one at random + * @return the resulting allocation, containing the slender player and the survivors + * @since 2.21.0 + */ + public static TeamAllocation prepareTeamAllocation(Team slenderTeam, Team survivorTeam, @Nullable Player reservedSlender) { Check.argCondition(slenderTeam.getCapacity() != 1, "The slender team must have a capacity from one"); - Player slenderPlayer = selectSlenderPlayer(); + Player slenderPlayer = reservedSlender != null ? reservedSlender : selectSlenderPlayer(); assignSlender(slenderPlayer, slenderTeam); Set survivors = collectSurvivors(slenderPlayer); 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 dd860a929..b5e191130 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java +++ b/game/src/main/java/net/onelitefeather/cygnus/utils/Items.java @@ -34,6 +34,10 @@ public final class Items { public static final byte SERVER_ITEM = (byte) 0x07; public static final byte LOBBY_ITEM = (byte) 0x08; public static final byte PAGE_REVEAL_ITEM = (byte) 0x09; + public static final byte PASS_ITEM = (byte) 0x0A; + + /** The hotbar slot of the lobby's slender pass, the third one, between the server item and the statistics. */ + public static final int PASS_SLOT = 2; /** The tracker's material, which is also the client's cooldown group for it. */ public static final Material TRACKING_MATERIAL = Material.RABBIT_FOOT; @@ -226,6 +230,50 @@ public static boolean hasStatsItem(Player player) { return false; } + /** + * Gives the player the lobby's slender pass item, with the balance last read shown in its name. + * + * @param player who should receive the item + * @param balance the passes the player has + * @since 2.21.0 + */ + public static void setPassItem(Player player, int balance) { + player.getInventory().setItemStack(PASS_SLOT, ItemStack.builder(Material.PAPER) + .customName(Component.text("Slender Pass: " + balance, NamedTextColor.DARK_RED) + .decoration(TextDecoration.ITALIC, false)) + .lore(lore("Right click to play the Slender", "next round, one pass is spent", + "Right click again to cancel")) + .set(Tags.ITEM_TAG, PASS_ITEM) + .build()); + } + + /** + * Takes the slender pass item out of the player's inventory, wherever it ended up. + * + * @param player who should lose the item + * @since 2.21.0 + */ + public static void removePassItem(Player player) { + var inventory = player.getInventory(); + for (int slot = 0; slot < inventory.getSize(); slot++) { + if (isPassItem(inventory.getItemStack(slot))) { + inventory.setItemStack(slot, ItemStack.AIR); + } + } + } + + /** + * Tells whether the stack is the slender pass item. + * + * @param stack the stack to look at + * @return {@code true} if the stack carries the pass tag + * @since 2.21.0 + */ + public static boolean isPassItem(ItemStack stack) { + Byte tag = stack.getTag(Tags.ITEM_TAG); + return tag != null && tag == PASS_ITEM; + } + /** * Gives the player the lobby's item that shows the server they are on. * diff --git a/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java b/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java index 8f1e6f832..eb2ef9aab 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java +++ b/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java @@ -5,6 +5,7 @@ import net.theevilreaper.xerus.api.team.Team; import net.theevilreaper.xerus.api.team.TeamService; import net.minestom.server.entity.Player; +import org.jetbrains.annotations.Nullable; import net.onelitefeather.cygnus.stamina.FoodBar; import net.onelitefeather.cygnus.stamina.StaminaService; @@ -22,14 +23,15 @@ public final class StaminaHelper { * Initializes the stamina objects for the slender and the survivors. * * @param teamService The service that contains the teams - * @param staminaService The service that contains the stamina bars + * @param staminaService The service that contains the stamina bars + * @param reservedSlender The player who holds a slender pass for this round, or {@code null} to draw at random */ - public static void initStaminaObjects(TeamService teamService, StaminaService staminaService) { + public static void initStaminaObjects(TeamService teamService, StaminaService staminaService, @Nullable Player reservedSlender) { Team slenderTeam = teamService.getTeam(GameConfig.SLENDER_KEY) .orElseThrow(() -> new IllegalStateException("Slender team not found")); Team survivorTeam = teamService.getTeam(GameConfig.SURVIVOR_KEY) .orElseThrow(() -> new IllegalStateException("Survivor team not found")); - TeamHelper.TeamAllocation allocation = TeamHelper.prepareTeamAllocation(slenderTeam, survivorTeam); + TeamHelper.TeamAllocation allocation = TeamHelper.prepareTeamAllocation(slenderTeam, survivorTeam, reservedSlender); staminaService.setSlenderBar(allocation.slender(), true); staminaService.createStaminaBars(allocation.survivors()); diff --git a/game/src/test/java/net/onelitefeather/cygnus/listener/PlayerSpawnListenerTest.java b/game/src/test/java/net/onelitefeather/cygnus/listener/PlayerSpawnListenerTest.java index 7d906489b..a6c7e1ace 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/listener/PlayerSpawnListenerTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/listener/PlayerSpawnListenerTest.java @@ -100,4 +100,21 @@ void testFirstSpawnWithTeamTagSkipsTeleportOutsideLobby(@NotNull Env env) { env.destroyInstance(instance, true); } + + @Test + void testFirstSpawnInLobbyRunsTheLobbyJoinHook(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + LobbyPhase lobbyPhase = new LobbyPhase(lobbyConfig()); + + AtomicBoolean hooked = new AtomicBoolean(false); + PlayerSpawnListener listener = new PlayerSpawnListener(p -> { + }, () -> lobbyPhase, p -> hooked.set(true)); + + listener.accept(new PlayerSpawnEvent(player, instance, true)); + + assertTrue(hooked.get(), "the lobby join hook must run for a first spawn in the lobby"); + + env.destroyInstance(instance, true); + } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/pass/MonthlyQuotaTest.java b/game/src/test/java/net/onelitefeather/cygnus/pass/MonthlyQuotaTest.java new file mode 100644 index 000000000..286065b7a --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/pass/MonthlyQuotaTest.java @@ -0,0 +1,122 @@ +package net.onelitefeather.cygnus.pass; + +import net.kyori.adventure.text.Component; +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.common.config.SlenderPassConfig; +import net.onelitefeather.cygnus.stats.InMemorySlenderPassRepository; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the monthly rank quota with a fixed clock and a fake LuckPerms meta. Every test has its own repository and + * feedback and runs its completions on the calling thread. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +class MonthlyQuotaTest { + + private static final UUID ALICE = UUID.fromString("00000000-0000-0000-0000-0000000000a1"); + private static final Instant OCTOBER = Instant.parse("2026-10-15T10:00:00Z"); + + private final InMemorySlenderPassRepository repository = new InMemorySlenderPassRepository(clockAt(OCTOBER)); + private final RecordingFeedback feedback = new RecordingFeedback(); + + @Test + void theFirstJoinOfTheMonthCreditsTheQuotaAndSaysSo() { + quota(clockAt(OCTOBER), player -> 2, 5).onLobbyJoin(ALICE); + + assertEquals(2, this.repository.balance(ALICE).join()); + assertEquals(List.of(Messages.passEarnedQuota(2)), this.feedback.messagesTo(ALICE)); + } + + @Test + void aSecondJoinInTheSameMonthCreditsNothing() { + MonthlyQuota quota = quota(clockAt(OCTOBER), player -> 2, 5); + quota.onLobbyJoin(ALICE); + + quota.onLobbyJoin(ALICE); + + assertEquals(2, this.repository.balance(ALICE).join()); + assertEquals(1, this.feedback.messagesTo(ALICE).size(), "only the first join of the month is announced"); + } + + @Test + void missedMonthsAreNotPaidBack() { + quota(clockAt(Instant.parse("2026-03-10T10:00:00Z")), player -> 2, 10).onLobbyJoin(ALICE); + + quota(clockAt(OCTOBER), player -> 2, 10).onLobbyJoin(ALICE); + + assertEquals(4, this.repository.balance(ALICE).join(), "March and October are paid, the months in between are not"); + } + + @Test + void theMonthChangesAtMidnightUtc() { + quota(clockAt(Instant.parse("2026-10-31T23:30:00Z")), player -> 1, 5).onLobbyJoin(ALICE); + + quota(clockAt(Instant.parse("2026-11-01T00:30:00Z")), player -> 1, 5).onLobbyJoin(ALICE); + + assertEquals(2, this.repository.balance(ALICE).join(), "October and November are two months, so two quotas"); + } + + @Test + void aQuotaOfZeroMarksTheMonthAndCreditsNothing() { + quota(clockAt(OCTOBER), player -> 0, 5).onLobbyJoin(ALICE); + + quota(clockAt(OCTOBER), player -> 2, 5).onLobbyJoin(ALICE); + + assertEquals(0, this.repository.balance(ALICE).join(), "the month is already marked, so the later quota waits for November"); + assertTrue(this.feedback.messagesTo(ALICE).isEmpty()); + } + + private MonthlyQuota quota(Clock clock, MonthlyQuota.MonthlyQuotaSource source, int maxBalance) { + InMemorySlenderPassRepository passes = this.repository; + return new MonthlyQuota(passes, clock, source, new SlenderPassConfig(10, maxBalance), Runnable::run, this.feedback); + } + + private static Clock clockAt(Instant instant) { + return Clock.fixed(instant, ZoneOffset.UTC); + } + + private static final class RecordingFeedback implements PassFeedback { + + private final List recipients = new ArrayList<>(); + private final List messages = new ArrayList<>(); + + @Override + public void tell(UUID player, Component message) { + this.recipients.add(player); + this.messages.add(message); + } + + @Override + public void tellLobby(Component message, UUID except) { + throw new AssertionError("the quota never speaks to the lobby"); + } + + @Override + public void showBalance(UUID player, int balance) { + throw new AssertionError("the quota never shows a balance on the item"); + } + + List messagesTo(UUID player) { + List sent = new ArrayList<>(); + for (int index = 0; index < this.recipients.size(); index++) { + if (this.recipients.get(index).equals(player)) { + sent.add(this.messages.get(index)); + } + } + return sent; + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/pass/PassCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/pass/PassCommandTest.java new file mode 100644 index 000000000..cbf5cff6a --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/pass/PassCommandTest.java @@ -0,0 +1,36 @@ +package net.onelitefeather.cygnus.pass; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.entity.Player; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers who may change slender pass balances: the console always, a player only with the permission. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +@ExtendWith(MicrotusExtension.class) +class PassCommandTest { + + @Test + void consoleMayChangeBalances(@NotNull Env env) { + assertTrue(PassCommand.mayAdminister(MinecraftServer.getCommandManager().getConsoleSender()), + "the console has the admin rights implicitly"); + } + + @Test + void playerWithoutThePermissionMayNotChangeBalances(@NotNull Env env) { + Player player = env.createPlayer(env.createFlatInstance()); + + assertFalse(PassCommand.mayAdminister(player), "a player needs cygnus.pass.admin"); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/pass/PityCounterTest.java b/game/src/test/java/net/onelitefeather/cygnus/pass/PityCounterTest.java new file mode 100644 index 000000000..ca631e2fc --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/pass/PityCounterTest.java @@ -0,0 +1,154 @@ +package net.onelitefeather.cygnus.pass; + +import net.kyori.adventure.text.Component; +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.common.config.SlenderPassConfig; +import net.onelitefeather.cygnus.stats.FinishReason; +import net.onelitefeather.cygnus.stats.FinishedRound; +import net.onelitefeather.cygnus.stats.InMemorySlenderPassRepository; +import net.onelitefeather.cygnus.stats.RoundStats.Role; +import net.onelitefeather.cygnus.stats.RoundStatsTracker; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the pity counter on the round snapshots the statistics build. Every test has its own repository, feedback and + * tracker, and runs its completions on the calling thread. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +class PityCounterTest { + + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-26T12:00:00Z"), ZoneOffset.UTC); + private static final UUID ALICE = UUID.fromString("00000000-0000-0000-0000-0000000000a1"); + private static final UUID BOB = UUID.fromString("00000000-0000-0000-0000-0000000000b2"); + private static final UUID ROUND = UUID.fromString("00000000-0000-0000-0000-00000000f001"); + + private final InMemorySlenderPassRepository repository = new InMemorySlenderPassRepository(CLOCK); + private final RecordingFeedback feedback = new RecordingFeedback(); + + @Test + void theRoundThatReachesThePityThresholdCreditsOnePassAndSaysSo() { + PityCounter counter = counter(3, 5); + + for (int round = 0; round < 3; round++) { + counter.roundFinished(playRound(Map.of(ALICE, Role.SURVIVOR, BOB, Role.SLENDER), tracker -> { })); + } + + assertEquals(1, this.repository.balance(ALICE).join()); + assertEquals(List.of(Messages.passEarnedPity(1)), this.feedback.messagesTo(ALICE)); + } + + @Test + void aRoundBelowTheThresholdCreditsNothing() { + PityCounter counter = counter(3, 5); + + for (int round = 0; round < 2; round++) { + counter.roundFinished(playRound(Map.of(ALICE, Role.SURVIVOR, BOB, Role.SLENDER), tracker -> { })); + } + + assertEquals(0, this.repository.balance(ALICE).join()); + assertTrue(this.feedback.messagesTo(ALICE).isEmpty()); + } + + @Test + void aPlayerWhoQuitsBeforeTheEndIsNotCounted() { + PityCounter counter = counter(2, 5); + counter.roundFinished(playRound(Map.of(ALICE, Role.SURVIVOR, BOB, Role.SLENDER), tracker -> { })); + + counter.roundFinished(playRound(Map.of(ALICE, Role.SURVIVOR, BOB, Role.SLENDER), tracker -> tracker.playerLeft(ALICE))); + + assertEquals(0, this.repository.balance(ALICE).join(), "the quit round must not reach the threshold"); + counter.roundFinished(playRound(Map.of(ALICE, Role.SURVIVOR, BOB, Role.SLENDER), tracker -> { })); + assertEquals(1, this.repository.balance(ALICE).join(), "the second full round reaches the threshold"); + } + + @Test + void aRoundAsSlenderResetsTheCounter() { + PityCounter counter = counter(2, 5); + counter.roundFinished(playRound(Map.of(ALICE, Role.SURVIVOR, BOB, Role.SLENDER), tracker -> { })); + + counter.roundFinished(playRound(Map.of(ALICE, Role.SLENDER, BOB, Role.SURVIVOR), tracker -> { })); + counter.roundFinished(playRound(Map.of(ALICE, Role.SURVIVOR, BOB, Role.SLENDER), tracker -> { })); + + assertEquals(0, this.repository.balance(ALICE).join(), "the Slender round starts the count again"); + } + + @Test + void aTakeoverDuringTheRoundResetsTheCounter() { + PityCounter counter = counter(2, 5); + counter.roundFinished(playRound(Map.of(ALICE, Role.SURVIVOR, BOB, Role.SLENDER), tracker -> { })); + + counter.roundFinished(playRound(Map.of(ALICE, Role.SURVIVOR, BOB, Role.SLENDER), tracker -> tracker.slenderTakenOver(ALICE))); + counter.roundFinished(playRound(Map.of(ALICE, Role.SURVIVOR, BOB, Role.SLENDER), tracker -> { })); + + assertEquals(0, this.repository.balance(ALICE).join(), "a survivor who was taken over starts the count again"); + } + + @Test + void aPassAtTheCapIsNeitherCreditedNorAnnounced() { + this.repository.give(ALICE, 5).join(); + PityCounter counter = counter(1, 5); + + counter.roundFinished(playRound(Map.of(ALICE, Role.SURVIVOR, BOB, Role.SLENDER), tracker -> { })); + + assertEquals(5, this.repository.balance(ALICE).join()); + assertTrue(this.feedback.messagesTo(ALICE).isEmpty()); + } + + private PityCounter counter(int pityRounds, int maxBalance) { + return new PityCounter(this.repository, new SlenderPassConfig(pityRounds, maxBalance), Runnable::run, this.feedback); + } + + private static FinishedRound playRound(Map players, Consumer events) { + RoundStatsTracker tracker = new RoundStatsTracker(CLOCK, ZoneOffset.UTC); + tracker.roundStarted(players); + events.accept(tracker); + return tracker.finish(FinishReason.TIME_OVER, () -> ROUND).orElseThrow(); + } + + private static final class RecordingFeedback implements PassFeedback { + + private final List recipients = new ArrayList<>(); + private final List messages = new ArrayList<>(); + + @Override + public void tell(UUID player, Component message) { + this.recipients.add(player); + this.messages.add(message); + } + + @Override + public void tellLobby(Component message, UUID except) { + throw new AssertionError("the pity counter never speaks to the lobby"); + } + + @Override + public void showBalance(UUID player, int balance) { + throw new AssertionError("the pity counter never shows a balance on the item"); + } + + List messagesTo(UUID player) { + List sent = new ArrayList<>(); + for (int index = 0; index < this.recipients.size(); index++) { + if (this.recipients.get(index).equals(player)) { + sent.add(this.messages.get(index)); + } + } + return sent; + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/pass/SlenderPassServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/pass/SlenderPassServiceTest.java new file mode 100644 index 000000000..5989ce197 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/pass/SlenderPassServiceTest.java @@ -0,0 +1,409 @@ +package net.onelitefeather.cygnus.pass; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.stats.InMemorySlenderPassRepository; +import net.onelitefeather.cygnus.stats.LedgerReason; +import net.onelitefeather.cygnus.stats.NoOpSlenderPassRepository; +import net.onelitefeather.cygnus.stats.SlenderPassRepository; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the lobby reservation and the round-start claim of the slender pass. Every test builds its own service, + * repository and players; the balance reads complete at once unless a test queues them on purpose. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +@ExtendWith(MicrotusExtension.class) +class SlenderPassServiceTest { + + private static final Instant NOW = Instant.parse("2026-10-26T12:00:00Z"); + private static final UUID ROUND = UUID.fromString("00000000-0000-0000-0000-00000000f001"); + + private final InMemorySlenderPassRepository repository = new InMemorySlenderPassRepository(Clock.fixed(NOW, ZoneOffset.UTC)); + private final RecordingFeedback feedback = new RecordingFeedback(); + private final List failures = new ArrayList<>(); + private final Map online = new HashMap<>(); + private final AtomicBoolean lobby = new AtomicBoolean(true); + private final Deque tickQueue = new ArrayDeque<>(); + private Executor tick = Runnable::run; + + private SlenderPassService service(SlenderPassRepository passes) { + return new SlenderPassService(passes, tick, online::get, lobby::get, feedback, + (player, roundId, cause) -> failures.add(new DebitFailure(player, roundId))); + } + + private void drainTickQueue() { + while (!tickQueue.isEmpty()) { + tickQueue.poll().run(); + } + } + + private static Instance instance(Env env) { + return env.createFlatInstance(); + } + + private Player join(Env env, Instance instance) { + Player player = env.createPlayer(instance); + online.put(player.getUuid(), player); + return player; + } + + @Test + void reservationHoldsTheSlotWhenThePlayerHasAPass(@NotNull Env env) { + Player alice = join(env, instance(env)); + repository.give(alice.getUuid(), 1).join(); + SlenderPassService service = service(repository); + + service.reserve(alice); + + assertEquals(Optional.of(alice.getUuid()), service.reservedBy()); + assertEquals(1, repository.balance(alice.getUuid()).join(), "a reservation must not spend the pass"); + } + + @Test + void secondPlayerIsRefusedWhileTheSlotIsHeld(@NotNull Env env) { + Instance instance = instance(env); + Player alice = join(env, instance); + Player bob = join(env, instance); + repository.give(alice.getUuid(), 1).join(); + repository.give(bob.getUuid(), 1).join(); + SlenderPassService service = service(repository); + service.reserve(alice); + + service.reserve(bob); + + assertEquals(Optional.of(alice.getUuid()), service.reservedBy(), "the first holder keeps the slot"); + assertEquals(Messages.PASS_TAKEN, feedback.lastTold(bob.getUuid())); + assertEquals(1, repository.balance(bob.getUuid()).join(), "the refused player keeps the pass"); + } + + @Test + void playerWithZeroBalanceIsRefused(@NotNull Env env) { + Player bob = join(env, instance(env)); + SlenderPassService service = service(repository); + + service.reserve(bob); + + assertEquals(Optional.empty(), service.reservedBy()); + assertEquals(Messages.PASS_NO_BALANCE, feedback.lastTold(bob.getUuid())); + } + + @Test + void reservationIsRefusedOutsideTheLobby(@NotNull Env env) { + Player alice = join(env, instance(env)); + repository.give(alice.getUuid(), 1).join(); + lobby.set(false); + SlenderPassService service = service(repository); + + service.reserve(alice); + + assertEquals(Optional.empty(), service.reservedBy()); + assertEquals(Messages.PASS_CLOSED, feedback.lastTold(alice.getUuid())); + } + + @Test + void toggleCancelsTheOwnReservationWithoutChangingTheBalance(@NotNull Env env) { + Player alice = join(env, instance(env)); + repository.give(alice.getUuid(), 1).join(); + SlenderPassService service = service(repository); + service.reserve(alice); + + service.toggle(alice); + + assertEquals(Optional.empty(), service.reservedBy()); + assertEquals(1, repository.balance(alice.getUuid()).join()); + assertEquals(Messages.PASS_CANCELLED, feedback.lastTold(alice.getUuid())); + } + + @Test + void slotIsFreeAgainAfterTheHolderCancels(@NotNull Env env) { + Instance instance = instance(env); + Player alice = join(env, instance); + Player bob = join(env, instance); + repository.give(alice.getUuid(), 1).join(); + repository.give(bob.getUuid(), 1).join(); + SlenderPassService service = service(repository); + service.reserve(alice); + service.cancel(alice); + + service.reserve(bob); + + assertEquals(Optional.of(bob.getUuid()), service.reservedBy()); + } + + @Test + void cancelWithoutReservationTellsThePlayer(@NotNull Env env) { + Player alice = join(env, instance(env)); + SlenderPassService service = service(repository); + + service.cancel(alice); + + assertEquals(Messages.PASS_NOTHING_TO_CANCEL, feedback.lastTold(alice.getUuid())); + } + + @Test + void lobbyAnnouncementNamesNoPlayer(@NotNull Env env) { + Instance instance = instance(env); + Player alice = join(env, instance); + join(env, instance); + repository.give(alice.getUuid(), 1).join(); + SlenderPassService service = service(repository); + + service.reserve(alice); + + RecordingFeedback.Lobby announcement = feedback.lobbyMessages().get(0); + assertEquals(Messages.PASS_REDEEMED, announcement.message()); + assertEquals(alice.getUuid(), announcement.except(), "the holder hears the reservation as a personal message"); + String text = PlainTextComponentSerializer.plainText().serialize(announcement.message()); + assertFalse(text.contains(alice.getUsername()), "the lobby announcement must not name the holder"); + } + + @Test + void disconnectReleasesTheReservation(@NotNull Env env) { + Player alice = join(env, instance(env)); + repository.give(alice.getUuid(), 1).join(); + SlenderPassService service = service(repository); + service.reserve(alice); + + service.release(alice.getUuid()); + + assertEquals(Optional.empty(), service.reservedBy()); + } + + @Test + void reservationWaitsForTheBalanceReadToBeHandledOnTheTick(@NotNull Env env) { + tick = tickQueue::add; + Player alice = join(env, instance(env)); + repository.give(alice.getUuid(), 1).join(); + SlenderPassService service = service(repository); + + service.reserve(alice); + + assertEquals(Optional.empty(), service.reservedBy(), "the decision must wait for the tick"); + drainTickQueue(); + assertEquals(Optional.of(alice.getUuid()), service.reservedBy()); + } + + @Test + void reservationOfPlayerWhoLeftWhileTheBalanceWasReadIsDropped(@NotNull Env env) { + tick = tickQueue::add; + Instance instance = instance(env); + Player alice = join(env, instance); + Player bob = join(env, instance); + repository.give(alice.getUuid(), 1).join(); + repository.give(bob.getUuid(), 1).join(); + SlenderPassService service = service(repository); + service.reserve(alice); + online.remove(alice.getUuid()); + service.release(alice.getUuid()); + + drainTickQueue(); + + assertEquals(Optional.empty(), service.reservedBy(), "an offline player must not hold the slot"); + service.reserve(bob); + drainTickQueue(); + assertEquals(Optional.of(bob.getUuid()), service.reservedBy(), + "the slot must be free for the next player after the offline reservation was dropped"); + } + + @Test + void roundStartSelectsTheHolderAndSpendsOneTimeWithTheRound(@NotNull Env env) { + Player alice = join(env, instance(env)); + repository.give(alice.getUuid(), 2).join(); + SlenderPassService service = service(repository); + service.reserve(alice); + + Player slender = service.claimRound(ROUND); + + assertEquals(alice, slender); + assertEquals(Optional.empty(), service.reservedBy(), "the slot is used up by the round"); + assertEquals(1, repository.balance(alice.getUuid()).join()); + InMemorySlenderPassRepository.LedgerEntry redeem = repository.ledger().get(1); + assertEquals(LedgerReason.REDEEM, redeem.reason()); + assertEquals(-1, redeem.delta()); + assertEquals(ROUND, redeem.roundId()); + assertTrue(failures.isEmpty()); + } + + @Test + void roundStartWithoutReservationSelectsNobody(@NotNull Env env) { + join(env, instance(env)); + SlenderPassService service = service(repository); + + assertNull(service.claimRound(ROUND)); + assertTrue(repository.ledger().isEmpty(), "nothing is debited without a reservation"); + } + + @Test + void roundStartDropsTheReservationOfAnOfflineHolderWithoutDebit(@NotNull Env env) { + Player alice = join(env, instance(env)); + repository.give(alice.getUuid(), 1).join(); + SlenderPassService service = service(repository); + service.reserve(alice); + online.remove(alice.getUuid()); + + assertNull(service.claimRound(ROUND)); + assertEquals(Optional.empty(), service.reservedBy()); + assertEquals(1, repository.balance(alice.getUuid()).join(), "the pass stays with the player"); + assertEquals(1, repository.ledger().size(), "no REDEEM row is written for an offline holder"); + } + + @Test + void failedDebitKeepsTheRoleForTheRound(@NotNull Env env) { + Player alice = join(env, instance(env)); + repository.give(alice.getUuid(), 1).join(); + SlenderPassService service = service(repository); + service.reserve(alice); + repository.set(alice.getUuid(), 0).join(); + + Player slender = service.claimRound(ROUND); + + assertEquals(alice, slender, "the holder plays the Slender even though the debit failed"); + assertEquals(0, repository.balance(alice.getUuid()).join()); + assertTrue(repository.ledger().stream().noneMatch(row -> row.reason() == LedgerReason.REDEEM), + "a failed debit writes no REDEEM row"); + } + + @Test + void failedDebitIsReportedWithTheRound(@NotNull Env env) { + Player alice = join(env, instance(env)); + repository.give(alice.getUuid(), 1).join(); + SlenderPassService service = service(repository); + service.reserve(alice); + repository.set(alice.getUuid(), 0).join(); + + service.claimRound(ROUND); + + assertEquals(List.of(new DebitFailure(alice.getUuid(), ROUND)), failures); + } + + @Test + void lobbyJoinShowsTheBalanceOnTheItem(@NotNull Env env) { + Player alice = join(env, instance(env)); + repository.give(alice.getUuid(), 3).join(); + SlenderPassService service = service(repository); + + service.onLobbyJoin(alice); + + assertEquals(List.of(new RecordingFeedback.Shown(alice.getUuid(), 3)), feedback.shown()); + } + + @Test + void disabledFeatureAnswersUnavailable(@NotNull Env env) { + Player alice = join(env, instance(env)); + SlenderPassService service = service(new NoOpSlenderPassRepository()); + + service.reserve(alice); + + assertFalse(service.available()); + assertEquals(Optional.empty(), service.reservedBy()); + assertEquals(Messages.PASS_UNAVAILABLE, feedback.lastTold(alice.getUuid())); + } + + @Test + void adminGiveReportsTheNewBalance(@NotNull Env env) { + Player alice = join(env, instance(env)); + SlenderPassService service = service(repository); + List results = new ArrayList<>(); + + service.give(alice.getUuid(), 2, results::add); + + assertEquals(List.of(new SlenderPassRepository.Adjustment(2, 2)), results); + } + + @Test + void reservationGetsAnItemRefreshWithTheNewBalance(@NotNull Env env) { + Player alice = join(env, instance(env)); + repository.give(alice.getUuid(), 2).join(); + SlenderPassService service = service(repository); + + service.reserve(alice); + + assertNotNull(feedback.shown().stream().filter(shown -> shown.balance() == 2).findFirst().orElse(null), + "the item must show the balance that was read for the reservation"); + } + + private record DebitFailure(UUID player, UUID roundId) { + } + + /** + * Records every message the service sends, so the tests can assert on the text and on the audience. + */ + private static final class RecordingFeedback implements PassFeedback { + + private final List personal = new ArrayList<>(); + private final List lobby = new ArrayList<>(); + private final List shown = new ArrayList<>(); + + record Sent(UUID player, Component message) { + } + + record Lobby(Component message, UUID except) { + } + + record Shown(UUID player, int balance) { + } + + @Override + public void tell(UUID player, Component message) { + personal.add(new Sent(player, message)); + } + + @Override + public void tellLobby(Component message, UUID except) { + lobby.add(new Lobby(message, except)); + } + + @Override + public void showBalance(UUID player, int balance) { + shown.add(new Shown(player, balance)); + } + + Component lastTold(UUID player) { + for (int index = personal.size() - 1; index >= 0; index--) { + if (personal.get(index).player().equals(player)) { + return personal.get(index).message(); + } + } + throw new AssertionError("no message was sent to " + player); + } + + List lobbyMessages() { + return lobby; + } + + List shown() { + return shown; + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stats/RoundStatsCollectorTest.java b/game/src/test/java/net/onelitefeather/cygnus/stats/RoundStatsCollectorTest.java index 0e3548cbf..18ed26583 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/stats/RoundStatsCollectorTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/stats/RoundStatsCollectorTest.java @@ -75,6 +75,20 @@ void finishWithoutRoundWritesNothing() { assertTrue(this.rounds.isEmpty(), "no participants means no round"); } + @Test + @DisplayName("The round handler sees the finished round before the repository writes it") + void roundHandlerSeesTheRoundBeforeTheWrite() { + List roundsWrittenWhenHandled = new ArrayList<>(); + RoundStatsCollector collector = new RoundStatsCollector(this.repository, CLOCK, ZoneOffset.UTC, () -> ROUND_ID, + _ -> null, _ -> { }, round -> roundsWrittenWhenHandled.add(this.rounds.size())); + collector.roundStarted(Map.of(ALICE, Role.SURVIVOR)); + + collector.gameFinished(GameFinishEvent.Reason.TIME_OVER); + + assertEquals(List.of(0), roundsWrittenWhenHandled, "the hook runs before the round reaches the repository"); + assertEquals(1, this.rounds.size(), "the round is still written after the hook"); + } + @Test @DisplayName("A failing repository does not escape into the game") void repositoryFailureDoesNotEscape() { diff --git a/game/src/test/java/net/onelitefeather/cygnus/utils/TeamHelperTest.java b/game/src/test/java/net/onelitefeather/cygnus/utils/TeamHelperTest.java index bf15ba51c..ca8914843 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/utils/TeamHelperTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/utils/TeamHelperTest.java @@ -109,6 +109,24 @@ void testTeamAllocation(@NotNull Env env) { createdPlayers.forEach(Entity::remove); } + @Test + void reservedPlayerBecomesTheSlender(@NotNull Env env) { + Player reserved = env.createPlayer(instance); + Set onlinePlayers = Set.copyOf(MinecraftServer.getConnectionManager().getOnlinePlayers()); + + Team slenderTeam = Team.of(GameConfig.SLENDER_KEY, 1); + Team survivorTeam = Team.of(GameConfig.SURVIVOR_KEY, 11); + + TeamHelper.TeamAllocation allocation = TeamHelper.prepareTeamAllocation(slenderTeam, survivorTeam, reserved); + + assertEquals(reserved, allocation.slender(), "the holder of the slender pass must play the Slender"); + assertTrue(TeamHelper.isSlenderTeam(reserved)); + assertFalse(allocation.survivors().contains(reserved)); + + onlinePlayers.forEach(TeamHelperTest::resetRole); + reserved.remove(); + } + private static void resetRole(@NotNull Player player) { player.removeTag(Tags.TEAM_KEY); player.removeTag(Tags.HIDDEN); diff --git a/stats/src/main/java/net/onelitefeather/cygnus/stats/HibernateSlenderPassRepository.java b/stats/src/main/java/net/onelitefeather/cygnus/stats/HibernateSlenderPassRepository.java new file mode 100644 index 000000000..5233978ea --- /dev/null +++ b/stats/src/main/java/net/onelitefeather/cygnus/stats/HibernateSlenderPassRepository.java @@ -0,0 +1,292 @@ +package net.onelitefeather.cygnus.stats; + +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.jetbrains.annotations.Nullable; + +import java.time.Clock; +import java.time.YearMonth; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.function.Function; +import java.util.function.IntUnaryOperator; + +/** + * Stores the slender passes through Hibernate, on the executor the statistics use. + *

+ * A redemption is one conditional update, {@code balance = balance - 1 WHERE balance > 0}, and the ledger row in + * the same transaction, so two servers cannot spend the same pass twice. Balance changes load the row or create + * it, then write the ledger row in the same transaction. A conflict with another server (an optimistic lock or + * a row another server created first) rolls the transaction back and it is tried again, up to + * {@link HibernateStatsRepository#MAX_ATTEMPTS} times. A write that still fails completes its future + * exceptionally; nothing throws into the caller. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +public final class HibernateSlenderPassRepository implements SlenderPassRepository { + + private final SessionFactory sessionFactory; + private final Executor executor; + private final Clock clock; + + /** + * Creates the repository on an existing session factory. + * + * @param sessionFactory the session factory that knows the pass entities + * @param executor runs the reads and writes + * @param clock the time of every ledger row + */ + public HibernateSlenderPassRepository(SessionFactory sessionFactory, Executor executor, Clock clock) { + this.sessionFactory = sessionFactory; + this.executor = executor; + this.clock = clock; + } + + /** + * Reads the balance on the executor. + * + * @param player the player + * @return the balance, {@code 0} without a row + */ + @Override + public CompletableFuture balance(UUID player) { + return read(session -> { + SlenderPassBalance row = session.find(SlenderPassBalance.class, player); + return row == null ? 0 : row.balance(); + }); + } + + /** + * Spends one pass with a conditional update and writes the ledger row in the same transaction. + * + * @param player the player + * @param roundId the round + * @return whether a pass was spent + */ + @Override + public CompletableFuture redeem(UUID player, @Nullable UUID roundId) { + return write(session -> { + int updated = session.createMutationQuery("update SlenderPassBalance b " + + "set b.balance = b.balance - 1, b.version = b.version + 1 " + + "where b.playerUuid = :player and b.balance > 0") + .setParameter("player", player) + .executeUpdate(); + if (updated == 0) { + return false; + } + session.persist(new SlenderPassEntry(player, -1, LedgerReason.REDEEM, roundId, this.clock.instant())); + return true; + }); + } + + /** + * Adds passes. + * + * @param player the player + * @param amount the passes, zero or more + * @return the adjustment + */ + @Override + public CompletableFuture give(UUID player, int amount) { + requireNonNegative(amount); + return adjust(player, current -> amount, LedgerReason.ADMIN_GIVE); + } + + /** + * Removes passes, clamped at zero. + * + * @param player the player + * @param amount the passes, zero or more + * @return the adjustment + */ + @Override + public CompletableFuture take(UUID player, int amount) { + requireNonNegative(amount); + return adjust(player, current -> -Math.min(amount, current), LedgerReason.ADMIN_TAKE); + } + + /** + * Sets the balance. + * + * @param player the player + * @param amount the new balance, zero or more + * @return the adjustment + */ + @Override + public CompletableFuture set(UUID player, int amount) { + requireNonNegative(amount); + return adjust(player, current -> amount - current, LedgerReason.ADMIN_SET); + } + + private CompletableFuture adjust(UUID player, IntUnaryOperator deltaFor, LedgerReason reason) { + return write(session -> { + SlenderPassBalance row = session.find(SlenderPassBalance.class, player); + if (row == null) { + row = new SlenderPassBalance(player); + session.persist(row); + } + int current = row.balance(); + int delta = deltaFor.applyAsInt(current); + if (delta == 0) { + return new Adjustment(current, 0); + } + row.add(delta); + session.persist(new SlenderPassEntry(player, delta, reason, null, this.clock.instant())); + return new Adjustment(current + delta, delta); + }); + } + + /** + * Applies one finished round to the pity counters in one transaction. + * + * @param survivors the Survivors of the round + * @param slenders the Slender of the round + * @param pityRounds the threshold + * @param maxBalance the cap + * @return the credits of the players who received a pass + */ + @Override + public CompletableFuture> recordRounds(Collection survivors, Collection slenders, + int pityRounds, int maxBalance) { + requirePityRounds(pityRounds); + requireNonNegative(maxBalance); + return write(session -> { + for (UUID player : slenders) { + SlenderPassBalance row = session.find(SlenderPassBalance.class, player); + if (row != null) { + row.resetRounds(); + } + } + Map credits = new HashMap<>(); + for (UUID player : survivors) { + SlenderPassBalance row = loadOrCreate(session, player); + row.countSurvivorRound(); + if (row.roundsSinceSlender() < pityRounds) { + continue; + } + row.resetRounds(); + Adjustment credit = credit(session, player, row, 1, maxBalance, LedgerReason.EARNED_PITY); + if (credit.delta() > 0) { + credits.put(player, credit); + } + } + return Map.copyOf(credits); + }); + } + + /** + * Credits the monthly quota once per month. The conditional update marks the month only when it is later than the + * last marked one, so two servers that see the same join cannot both credit it. + * + * @param player the player + * @param month the month of the join + * @param amount the quota + * @param maxBalance the cap + * @return the adjustment + */ + @Override + public CompletableFuture grantQuota(UUID player, YearMonth month, int amount, int maxBalance) { + requireNonNegative(amount); + requireNonNegative(maxBalance); + return write(session -> { + SlenderPassBalance row = loadOrCreate(session, player); + int marked = session.createMutationQuery("update SlenderPassBalance b " + + "set b.lastQuotaMonth = :month, b.version = b.version + 1 " + + "where b.playerUuid = :player and (b.lastQuotaMonth is null or b.lastQuotaMonth < :month)") + .setParameter("player", player) + .setParameter("month", month) + .executeUpdate(); + if (marked == 0) { + return new Adjustment(row.balance(), 0); + } + session.refresh(row); + return credit(session, player, row, amount, maxBalance, LedgerReason.RANK_QUOTA); + }); + } + + private static SlenderPassBalance loadOrCreate(Session session, UUID player) { + SlenderPassBalance row = session.find(SlenderPassBalance.class, player); + if (row == null) { + row = new SlenderPassBalance(player); + session.persist(row); + session.flush(); + } + return row; + } + + private Adjustment credit(Session session, UUID player, SlenderPassBalance row, int amount, int maxBalance, + LedgerReason reason) { + int delta = Math.min(amount, Math.max(0, maxBalance - row.balance())); + if (delta == 0) { + return new Adjustment(row.balance(), 0); + } + row.add(delta); + session.persist(new SlenderPassEntry(player, delta, reason, null, this.clock.instant())); + return new Adjustment(row.balance(), delta); + } + + private static void requirePityRounds(int pityRounds) { + if (pityRounds < 1) { + throw new IllegalArgumentException("The pity threshold must be at least 1: " + pityRounds); + } + } + + private CompletableFuture read(Function query) { + CompletableFuture result = new CompletableFuture<>(); + try { + this.executor.execute(() -> { + try { + result.complete(this.sessionFactory.fromSession(query)); + } catch (RuntimeException exception) { + result.completeExceptionally(exception); + } + }); + } catch (RejectedExecutionException exception) { + result.completeExceptionally(exception); + } + return result; + } + + private CompletableFuture write(Function task) { + CompletableFuture result = new CompletableFuture<>(); + try { + this.executor.execute(() -> { + try { + result.complete(attempt(task)); + } catch (RuntimeException exception) { + result.completeExceptionally(exception); + } + }); + } catch (RejectedExecutionException exception) { + result.completeExceptionally(exception); + } + return result; + } + + private T attempt(Function task) { + for (int attempt = 1; ; attempt++) { + try { + return this.sessionFactory.fromTransaction(task::apply); + } catch (RuntimeException exception) { + if (attempt < HibernateStatsRepository.MAX_ATTEMPTS && HibernateStatsRepository.conflict(exception)) { + continue; + } + throw exception; + } + } + } + + private static void requireNonNegative(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("A pass amount cannot be negative: " + amount); + } + } +} diff --git a/stats/src/main/java/net/onelitefeather/cygnus/stats/HibernateStatsRepository.java b/stats/src/main/java/net/onelitefeather/cygnus/stats/HibernateStatsRepository.java index f4e303aa2..028125f78 100644 --- a/stats/src/main/java/net/onelitefeather/cygnus/stats/HibernateStatsRepository.java +++ b/stats/src/main/java/net/onelitefeather/cygnus/stats/HibernateStatsRepository.java @@ -16,6 +16,7 @@ import org.jetbrains.annotations.Nullable; import javax.sql.DataSource; +import java.time.Clock; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -126,6 +127,8 @@ public static SessionFactory buildSessionFactory(DataSource dataSource, @Nullabl .addAnnotatedClass(PlayerRoundStats.class) .addAnnotatedClass(PlayerYearStats.class) .addAnnotatedClass(PlayerName.class) + .addAnnotatedClass(SlenderPassBalance.class) + .addAnnotatedClass(SlenderPassEntry.class) .buildMetadata() .buildSessionFactory(); } catch (RuntimeException exception) { @@ -275,7 +278,7 @@ private void fail(FinishedRound round, RuntimeException exception) { * Tells whether another server won the race: an optimistic lock failure or a unique-key violation * anywhere in the cause chain. */ - private static boolean conflict(Throwable exception) { + static boolean conflict(Throwable exception) { for (Throwable cause = exception; cause != null; cause = cause.getCause()) { if (cause instanceof OptimisticLockException || cause instanceof StaleStateException || cause instanceof EntityExistsException || cause instanceof ConstraintViolationException) { @@ -286,7 +289,7 @@ private static boolean conflict(Throwable exception) { } /** - * Deletes the player's round rows and year rows in one transaction. + * Deletes the player's round rows, year rows, stored name and slender pass rows in one transaction. * * @param playerUuid the player * @return how many rows were removed in total @@ -303,10 +306,28 @@ public int deleteByPlayer(UUID playerUuid) { int names = session.createMutationQuery("delete from PlayerName n where n.playerUuid = :player") .setParameter("player", playerUuid) .executeUpdate(); - return rounds + years + names; + int passes = session.createMutationQuery("delete from SlenderPassEntry e where e.playerUuid = :player") + .setParameter("player", playerUuid) + .executeUpdate(); + int balances = session.createMutationQuery("delete from SlenderPassBalance b where b.playerUuid = :player") + .setParameter("player", playerUuid) + .executeUpdate(); + return rounds + years + names + passes + balances; }); } + /** + * Returns the slender pass repository on the same session factory and executor, so a deletion by player + * covers the passes in the same transaction. + * + * @param clock the time of every ledger row + * @return the Hibernate-backed pass repository + */ + @Override + public SlenderPassRepository slenderPasses(Clock clock) { + return new HibernateSlenderPassRepository(this.sessionFactory, this.executor, clock); + } + /** * Drains the executor so a queued round is still written, closes the session factory, then releases * the pool. diff --git a/stats/src/main/java/net/onelitefeather/cygnus/stats/InMemorySlenderPassRepository.java b/stats/src/main/java/net/onelitefeather/cygnus/stats/InMemorySlenderPassRepository.java new file mode 100644 index 000000000..70f0621d2 --- /dev/null +++ b/stats/src/main/java/net/onelitefeather/cygnus/stats/InMemorySlenderPassRepository.java @@ -0,0 +1,211 @@ +package net.onelitefeather.cygnus.stats; + +import org.jetbrains.annotations.Nullable; + +import java.time.Clock; +import java.time.Instant; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Keeps the balances and the ledger in memory, with the same rules as the database: no negative balance, one + * ledger row per real change, the time from the injected clock. Used by the tests of the game. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +public final class InMemorySlenderPassRepository implements SlenderPassRepository { + + private final Clock clock; + private final Map balances = new HashMap<>(); + private final Map roundsSinceSlender = new HashMap<>(); + private final Map quotaMonths = new HashMap<>(); + private final List ledger = new ArrayList<>(); + + /** + * Creates an empty repository. + * + * @param clock the time of every ledger row + */ + public InMemorySlenderPassRepository(Clock clock) { + this.clock = clock; + } + + /** + * Reads the balance. + * + * @param player the player + * @return the balance, {@code 0} for an unknown player + */ + @Override + public synchronized CompletableFuture balance(UUID player) { + return CompletableFuture.completedFuture(balanceOf(player)); + } + + /** + * Spends one pass when the balance is above zero. + * + * @param player the player + * @param roundId the round + * @return whether a pass was spent + */ + @Override + public synchronized CompletableFuture redeem(UUID player, @Nullable UUID roundId) { + if (balanceOf(player) <= 0) { + return CompletableFuture.completedFuture(false); + } + record(player, -1, LedgerReason.REDEEM, roundId); + return CompletableFuture.completedFuture(true); + } + + /** + * Adds passes. + * + * @param player the player + * @param amount the passes, zero or more + * @return the adjustment + */ + @Override + public synchronized CompletableFuture give(UUID player, int amount) { + requireNonNegative(amount); + return CompletableFuture.completedFuture(record(player, amount, LedgerReason.ADMIN_GIVE, null)); + } + + /** + * Removes passes, clamped at zero. Nothing removed writes no row. + * + * @param player the player + * @param amount the passes, zero or more + * @return the adjustment + */ + @Override + public synchronized CompletableFuture take(UUID player, int amount) { + requireNonNegative(amount); + int current = balanceOf(player); + int removed = Math.min(amount, current); + if (removed == 0) { + return CompletableFuture.completedFuture(new Adjustment(current, 0)); + } + return CompletableFuture.completedFuture(record(player, -removed, LedgerReason.ADMIN_TAKE, null)); + } + + /** + * Sets the balance. Setting the current value writes no row. + * + * @param player the player + * @param amount the new balance, zero or more + * @return the adjustment + */ + @Override + public synchronized CompletableFuture set(UUID player, int amount) { + requireNonNegative(amount); + int delta = amount - balanceOf(player); + if (delta == 0) { + return CompletableFuture.completedFuture(new Adjustment(amount, 0)); + } + return CompletableFuture.completedFuture(record(player, delta, LedgerReason.ADMIN_SET, null)); + } + + /** + * Returns every ledger row in the order they were written. + * + * @return a copy of the ledger + */ + public synchronized List ledger() { + return List.copyOf(this.ledger); + } + + /** + * Applies one finished round to the pity counters, with the rules of the database. + * + * @param survivors the Survivors of the round + * @param slenders the Slender of the round + * @param pityRounds the threshold, at least 1 + * @param maxBalance the cap, zero or more + * @return the credits of the players who received a pass + */ + @Override + public synchronized CompletableFuture> recordRounds(Collection survivors, + Collection slenders, + int pityRounds, int maxBalance) { + if (pityRounds < 1) { + throw new IllegalArgumentException("The pity threshold must be at least 1: " + pityRounds); + } + requireNonNegative(maxBalance); + slenders.forEach(this.roundsSinceSlender::remove); + Map credits = new HashMap<>(); + for (UUID player : survivors) { + int counted = this.roundsSinceSlender.getOrDefault(player, 0) + 1; + if (counted < pityRounds) { + this.roundsSinceSlender.put(player, counted); + continue; + } + this.roundsSinceSlender.remove(player); + if (Math.min(1, Math.max(0, maxBalance - balanceOf(player))) > 0) { + credits.put(player, record(player, 1, LedgerReason.EARNED_PITY, null)); + } + } + return CompletableFuture.completedFuture(Map.copyOf(credits)); + } + + /** + * Credits the monthly quota once per month, with the rules of the database. + * + * @param player the player + * @param month the month of the join + * @param amount the quota, zero or more + * @param maxBalance the cap, zero or more + * @return the adjustment + */ + @Override + public synchronized CompletableFuture grantQuota(UUID player, YearMonth month, int amount, int maxBalance) { + requireNonNegative(amount); + requireNonNegative(maxBalance); + YearMonth last = this.quotaMonths.get(player); + if (last != null && !last.isBefore(month)) { + return CompletableFuture.completedFuture(new Adjustment(balanceOf(player), 0)); + } + this.quotaMonths.put(player, month); + int delta = Math.min(amount, Math.max(0, maxBalance - balanceOf(player))); + if (delta == 0) { + return CompletableFuture.completedFuture(new Adjustment(balanceOf(player), 0)); + } + return CompletableFuture.completedFuture(record(player, delta, LedgerReason.RANK_QUOTA, null)); + } + + private int balanceOf(UUID player) { + return this.balances.getOrDefault(player, 0); + } + + private Adjustment record(UUID player, int delta, LedgerReason reason, @Nullable UUID roundId) { + int balance = balanceOf(player) + delta; + this.balances.put(player, balance); + this.ledger.add(new LedgerEntry(player, delta, reason, roundId, this.clock.instant())); + return new Adjustment(balance, delta); + } + + private static void requireNonNegative(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("A pass amount cannot be negative: " + amount); + } + } + + /** + * One row of the ledger. + * + * @param player the player + * @param delta the change that was written + * @param reason why it changed + * @param roundId the round for a {@link LedgerReason#REDEEM}, otherwise {@code null} + * @param createdAt when the row was written + */ + public record LedgerEntry(UUID player, int delta, LedgerReason reason, @Nullable UUID roundId, Instant createdAt) { + } +} diff --git a/stats/src/main/java/net/onelitefeather/cygnus/stats/LedgerReason.java b/stats/src/main/java/net/onelitefeather/cygnus/stats/LedgerReason.java new file mode 100644 index 000000000..d8e2cf54b --- /dev/null +++ b/stats/src/main/java/net/onelitefeather/cygnus/stats/LedgerReason.java @@ -0,0 +1,23 @@ +package net.onelitefeather.cygnus.stats; + +/** + * Why a slender pass balance changed. The names are stored as they are, the ledger checks them. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +public enum LedgerReason { + /** An administrator added passes. */ + ADMIN_GIVE, + /** An administrator removed passes. */ + ADMIN_TAKE, + /** An administrator set the balance. */ + ADMIN_SET, + /** A pass was spent for a round in which the player played the Slender. */ + REDEEM, + /** A pass was earned by playing the pity number of rounds as a Survivor. @since 2.21.0 */ + EARNED_PITY, + /** A pass was credited by the monthly quota of the player's rank. @since 2.21.0 */ + RANK_QUOTA +} diff --git a/stats/src/main/java/net/onelitefeather/cygnus/stats/NoOpSlenderPassRepository.java b/stats/src/main/java/net/onelitefeather/cygnus/stats/NoOpSlenderPassRepository.java new file mode 100644 index 000000000..f75419259 --- /dev/null +++ b/stats/src/main/java/net/onelitefeather/cygnus/stats/NoOpSlenderPassRepository.java @@ -0,0 +1,118 @@ +package net.onelitefeather.cygnus.stats; + +import java.time.YearMonth; +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * The repository used while no statistics database is configured: it keeps nothing and reports the feature as + * unavailable. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +public final class NoOpSlenderPassRepository implements SlenderPassRepository { + + /** + * Reports that passes are not stored. + * + * @return always {@code false} + */ + @Override + public boolean available() { + return false; + } + + /** + * Knows no balance. + * + * @param player the player + * @return an already completed {@code 0} + */ + @Override + public CompletableFuture balance(UUID player) { + return CompletableFuture.completedFuture(0); + } + + /** + * Spends nothing. + * + * @param player the player + * @param roundId the round + * @return an already completed {@code false} + */ + @Override + public CompletableFuture redeem(UUID player, UUID roundId) { + return CompletableFuture.completedFuture(false); + } + + /** + * Stores nothing. + * + * @param player the player + * @param amount the passes + * @return an already completed, empty adjustment + */ + @Override + public CompletableFuture give(UUID player, int amount) { + return CompletableFuture.completedFuture(new Adjustment(0, 0)); + } + + /** + * Stores nothing. + * + * @param player the player + * @param amount the passes + * @return an already completed, empty adjustment + */ + @Override + public CompletableFuture take(UUID player, int amount) { + return CompletableFuture.completedFuture(new Adjustment(0, 0)); + } + + /** + * Stores nothing. + * + * @param player the player + * @param amount the balance + * @return an already completed, empty adjustment + */ + @Override + public CompletableFuture set(UUID player, int amount) { + return CompletableFuture.completedFuture(new Adjustment(0, 0)); + } + + /** + * Keeps no counter and credits nothing. + * + * @param survivors the Survivors + * @param slenders the Slender + * @param pityRounds the threshold + * @param maxBalance the cap + * @return an already completed, empty map + * @since 2.21.0 + */ + @Override + public CompletableFuture> recordRounds(Collection survivors, Collection slenders, + int pityRounds, int maxBalance) { + return CompletableFuture.completedFuture(Map.of()); + } + + /** + * Credits nothing. + * + * @param player the player + * @param month the month + * @param amount the quota + * @param maxBalance the cap + * @return an already completed, empty adjustment + * @since 2.21.0 + */ + @Override + public CompletableFuture grantQuota(UUID player, YearMonth month, int amount, int maxBalance) { + return CompletableFuture.completedFuture(new Adjustment(0, 0)); + } +} diff --git a/stats/src/main/java/net/onelitefeather/cygnus/stats/SlenderPassBalance.java b/stats/src/main/java/net/onelitefeather/cygnus/stats/SlenderPassBalance.java new file mode 100644 index 000000000..88abb1156 --- /dev/null +++ b/stats/src/main/java/net/onelitefeather/cygnus/stats/SlenderPassBalance.java @@ -0,0 +1,108 @@ +package net.onelitefeather.cygnus.stats; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import java.time.YearMonth; +import java.util.UUID; + +/** + * The slender pass balance of one player. The {@link Version} column is the optimistic lock two servers + * writing the same balance rely on. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +@Entity +@Table(name = "player_slender_pass") +public class SlenderPassBalance { + + @Id + @Column(name = "player_uuid") + private UUID playerUuid; + @Column(nullable = false) + private int balance; + @Version + @Column(nullable = false) + private long version; + @Column(name = "rounds_since_slender", nullable = false) + private int roundsSinceSlender; + @Convert(converter = YearMonthConverter.class) + @Column(name = "last_quota_month", length = 7) + private YearMonth lastQuotaMonth; + + /** + * For Hibernate. + */ + protected SlenderPassBalance() { + } + + /** + * Creates the balance row of a player with no passes. + * + * @param playerUuid the player + */ + public SlenderPassBalance(UUID playerUuid) { + this.playerUuid = playerUuid; + } + + /** + * Returns the player. + * + * @return the player + */ + public UUID playerUuid() { + return this.playerUuid; + } + + /** + * Returns the balance. + * + * @return the number of passes + */ + public int balance() { + return this.balance; + } + + /** + * Adds a delta to the balance. The caller keeps the balance from going below zero. + * + * @param delta the change + */ + public void add(int delta) { + this.balance += delta; + } + + /** + * Returns the Survivor rounds the player has played since the last Slender round or the last earned pass. + * + * @return the counter, zero or more + * @since 2.21.0 + */ + public int roundsSinceSlender() { + return this.roundsSinceSlender; + } + + /** + * Counts one more Survivor round that the player played to the end. + * + * @since 2.21.0 + */ + public void countSurvivorRound() { + this.roundsSinceSlender++; + } + + /** + * Sets the Survivor counter back to zero. + * + * @since 2.21.0 + */ + public void resetRounds() { + this.roundsSinceSlender = 0; + } +} diff --git a/stats/src/main/java/net/onelitefeather/cygnus/stats/SlenderPassEntry.java b/stats/src/main/java/net/onelitefeather/cygnus/stats/SlenderPassEntry.java new file mode 100644 index 000000000..382e7d80b --- /dev/null +++ b/stats/src/main/java/net/onelitefeather/cygnus/stats/SlenderPassEntry.java @@ -0,0 +1,108 @@ +package net.onelitefeather.cygnus.stats; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.UUID; + +/** + * One row of the append-only slender pass ledger. Rows are written, never changed. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +@Entity +@Table(name = "slender_pass_transaction") +public class SlenderPassEntry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "player_uuid", nullable = false) + private UUID playerUuid; + @Column(nullable = false) + private int delta; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private LedgerReason reason; + @Column(name = "round_id") + private UUID roundId; + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + /** + * For Hibernate. + */ + protected SlenderPassEntry() { + } + + /** + * Creates a ledger row. + * + * @param playerUuid the player + * @param delta the change that was written + * @param reason why it changed + * @param roundId the round for a redemption, {@code null} otherwise + * @param createdAt when the row is written + */ + public SlenderPassEntry(UUID playerUuid, int delta, LedgerReason reason, UUID roundId, Instant createdAt) { + this.playerUuid = playerUuid; + this.delta = delta; + this.reason = reason; + this.roundId = roundId; + this.createdAt = createdAt; + } + + /** + * Returns the generated id of the row, {@code null} until it is written. + * + * @return the id + */ + public Long id() { + return this.id; + } + + /** + * Returns the change that was written. + * + * @return the delta + */ + public int delta() { + return this.delta; + } + + /** + * Returns the round the row belongs to. + * + * @return the round id, {@code null} for an administrator change + */ + public UUID roundId() { + return this.roundId; + } + + /** + * Returns when the row was written. + * + * @return the time from the clock + */ + public Instant createdAt() { + return this.createdAt; + } + + /** + * Returns the reason of the row. + * + * @return the reason + */ + public LedgerReason reason() { + return this.reason; + } +} diff --git a/stats/src/main/java/net/onelitefeather/cygnus/stats/SlenderPassRepository.java b/stats/src/main/java/net/onelitefeather/cygnus/stats/SlenderPassRepository.java new file mode 100644 index 000000000..49dad938b --- /dev/null +++ b/stats/src/main/java/net/onelitefeather/cygnus/stats/SlenderPassRepository.java @@ -0,0 +1,122 @@ +package net.onelitefeather.cygnus.stats; + +import org.jetbrains.annotations.Nullable; + +import java.time.YearMonth; +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Stores the slender passes of the players: one balance per player and an append-only ledger of every change. + *

+ * Every method returns at once and completes later, so the game calls it from the tick thread and applies the + * result on a later tick. A balance is never negative, and every real change writes exactly one ledger row in + * the same transaction as the balance change. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +public interface SlenderPassRepository { + + /** + * Tells whether passes are stored at all. A repository that is not available keeps nothing, and the game + * hides the feature. + * + * @return {@code true} when passes are stored + */ + default boolean available() { + return true; + } + + /** + * Reads the balance of a player. + * + * @param player the player + * @return the balance, {@code 0} for a player without a row + */ + CompletableFuture balance(UUID player); + + /** + * Spends one pass for a round with one conditional update: the balance drops only while it is above zero. + * + * @param player the player who plays the Slender + * @param roundId the round the pass is spent for + * @return {@code true} when a pass was spent, {@code false} when the balance was already zero + */ + CompletableFuture redeem(UUID player, @Nullable UUID roundId); + + /** + * Adds passes to a balance. + * + * @param player the player + * @param amount the passes to add, zero or more + * @return the new balance and the delta + * @throws IllegalArgumentException if the amount is negative + */ + CompletableFuture give(UUID player, int amount); + + /** + * Removes passes from a balance, never below zero. The delta is what was really removed. + * + * @param player the player + * @param amount the passes to remove, zero or more + * @return the new balance and the delta; a delta of zero writes no ledger row + * @throws IllegalArgumentException if the amount is negative + */ + CompletableFuture take(UUID player, int amount); + + /** + * Sets a balance to the given value. The ledger row holds the difference. + * + * @param player the player + * @param amount the new balance, zero or more + * @return the new balance and the delta; setting the current value writes no ledger row + * @throws IllegalArgumentException if the amount is negative + */ + CompletableFuture set(UUID player, int amount); + + /** + * Applies one finished round to the pity counters of its players, in one transaction. A Survivor's counter goes up + * by one; when it reaches the threshold, it goes back to zero and one pass is credited, capped by the maximum + * balance. A Slender's counter goes back to zero. A credit that the cap reduces to nothing writes no ledger row and + * is not in the result. + * + * @param survivors the players who played the round to the end as Survivors + * @param slenders the players who were the Slender in the round, by any path + * @param pityRounds the Survivor rounds until one pass is earned, at least 1 + * @param maxBalance the cap for earned passes, zero or more + * @return the players who received a pass, with their new balance and the delta + * @throws IllegalArgumentException if the threshold is below 1 or the cap is negative + * @since 2.21.0 + */ + CompletableFuture> recordRounds(Collection survivors, Collection slenders, + int pityRounds, int maxBalance); + + /** + * Credits the monthly quota of a player once per calendar month. The first call in a month marks the month and + * credits up to the quota within the cap; a later call in the same or an earlier month is not a new month, so it + * credits nothing and writes nothing. A quota of zero marks the month as well. Missed months are not paid back. + * + * @param player the player + * @param month the calendar month (UTC) of the join + * @param amount the quota of the player's rank, zero or more + * @param maxBalance the cap for earned passes, zero or more + * @return the new balance and the delta; a delta of zero writes no ledger row + * @throws IllegalArgumentException if the amount or the cap is negative + * @since 2.21.0 + */ + CompletableFuture grantQuota(UUID player, YearMonth month, int amount, int maxBalance); + + /** + * The outcome of a balance change. + * + * @param balance the balance after the change + * @param delta the change that was really written, zero when nothing changed + */ + record Adjustment(int balance, int delta) { + } +} diff --git a/stats/src/main/java/net/onelitefeather/cygnus/stats/StatsRepository.java b/stats/src/main/java/net/onelitefeather/cygnus/stats/StatsRepository.java index bf0c41e33..250fb805f 100644 --- a/stats/src/main/java/net/onelitefeather/cygnus/stats/StatsRepository.java +++ b/stats/src/main/java/net/onelitefeather/cygnus/stats/StatsRepository.java @@ -1,5 +1,6 @@ package net.onelitefeather.cygnus.stats; +import java.time.Clock; import java.util.List; import java.util.Map; import java.util.Optional; @@ -74,6 +75,18 @@ default boolean enabled() { */ int deleteByPlayer(UUID playerUuid); + /** + * Returns the repository for the slender passes. It shares the connections and the executor of this one, so + * a deletion by player covers the passes too. A repository without storage answers with the no-op one. + * + * @param clock the time of every ledger row + * @return the pass repository + * @since 2.21.0 + */ + default SlenderPassRepository slenderPasses(Clock clock) { + return new NoOpSlenderPassRepository(); + } + /** * Writes what is still queued and releases the connections. */ diff --git a/stats/src/main/java/net/onelitefeather/cygnus/stats/YearMonthConverter.java b/stats/src/main/java/net/onelitefeather/cygnus/stats/YearMonthConverter.java new file mode 100644 index 000000000..58e57a1b1 --- /dev/null +++ b/stats/src/main/java/net/onelitefeather/cygnus/stats/YearMonthConverter.java @@ -0,0 +1,39 @@ +package net.onelitefeather.cygnus.stats; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.time.YearMonth; + +/** + * Stores a {@link YearMonth} as text {@code YYYY-MM}, which sorts as a string and needs no date type in the database. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +@Converter +public class YearMonthConverter implements AttributeConverter { + + /** + * Writes the month as {@code YYYY-MM}. + * + * @param month the month, {@code null} for none + * @return the text, {@code null} for none + */ + @Override + public String convertToDatabaseColumn(YearMonth month) { + return month == null ? null : month.toString(); + } + + /** + * Reads the month from {@code YYYY-MM}. + * + * @param column the text, {@code null} for none + * @return the month, {@code null} for none + */ + @Override + public YearMonth convertToEntityAttribute(String column) { + return column == null ? null : YearMonth.parse(column); + } +} diff --git a/stats/src/main/resources/db/migration/V2__slender_pass.sql b/stats/src/main/resources/db/migration/V2__slender_pass.sql new file mode 100644 index 000000000..c63ebbedf --- /dev/null +++ b/stats/src/main/resources/db/migration/V2__slender_pass.sql @@ -0,0 +1,27 @@ +-- Slender passes: one balance per player and an append-only ledger of every change. +-- +-- Plain SQL that PostgreSQL and H2 in PostgreSQL mode both accept, the same as V1. The balance never goes +-- below zero (CHECK), and every change writes one ledger row in the same transaction. + +-- One balance per player. version is the optimistic lock two servers writing the same balance rely on. +CREATE TABLE player_slender_pass ( + player_uuid UUID NOT NULL, + balance INTEGER NOT NULL CHECK (balance >= 0), + version BIGINT NOT NULL, + PRIMARY KEY (player_uuid) +); + +-- The ledger. delta is the change that was really written, reason says why, round_id is set for REDEEM only. +CREATE TABLE slender_pass_transaction ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY, + player_uuid UUID NOT NULL, + delta INTEGER NOT NULL, + reason VARCHAR(16) NOT NULL, + round_id UUID, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + PRIMARY KEY (id), + CONSTRAINT ck_slender_pass_transaction_reason CHECK (reason IN ('ADMIN_GIVE', 'ADMIN_TAKE', 'ADMIN_SET', 'REDEEM')) +); + +-- The rows of one player, for the GDPR deletion and for reading the history. +CREATE INDEX idx_slender_pass_transaction_player ON slender_pass_transaction (player_uuid); diff --git a/stats/src/main/resources/db/migration/V3__slender_pass_earning.sql b/stats/src/main/resources/db/migration/V3__slender_pass_earning.sql new file mode 100644 index 000000000..13a24bf76 --- /dev/null +++ b/stats/src/main/resources/db/migration/V3__slender_pass_earning.sql @@ -0,0 +1,17 @@ +-- Slender pass earning: the pity counter, the monthly quota marker and the two earned ledger reasons. +-- +-- Plain SQL that PostgreSQL and H2 in PostgreSQL mode both accept. V1 and V2 are not changed; the reason check +-- of V2 is named, so it is replaced in place and the ledger rows keep their ids. + +-- Survivor rounds since the last Slender round or the last earned pass. Existing balances start at zero. +ALTER TABLE player_slender_pass + ADD COLUMN rounds_since_slender INTEGER NOT NULL DEFAULT 0 CHECK (rounds_since_slender >= 0); + +-- The calendar month ('YYYY-MM', UTC) of the last monthly quota, empty until the first grant. +ALTER TABLE player_slender_pass + ADD COLUMN last_quota_month VARCHAR(7); + +-- Admit the two earned reasons next to the admin and redeem reasons. +ALTER TABLE slender_pass_transaction DROP CONSTRAINT ck_slender_pass_transaction_reason; +ALTER TABLE slender_pass_transaction ADD CONSTRAINT ck_slender_pass_transaction_reason + CHECK (reason IN ('ADMIN_GIVE', 'ADMIN_TAKE', 'ADMIN_SET', 'REDEEM', 'EARNED_PITY', 'RANK_QUOTA')); diff --git a/stats/src/test/java/net/onelitefeather/cygnus/stats/HibernateSlenderPassEarningIntegrationTest.java b/stats/src/test/java/net/onelitefeather/cygnus/stats/HibernateSlenderPassEarningIntegrationTest.java new file mode 100644 index 000000000..98ce6a753 --- /dev/null +++ b/stats/src/test/java/net/onelitefeather/cygnus/stats/HibernateSlenderPassEarningIntegrationTest.java @@ -0,0 +1,263 @@ +package net.onelitefeather.cygnus.stats; + +import org.flywaydb.core.Flyway; +import org.h2.jdbcx.JdbcDataSource; +import org.hibernate.SessionFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Clock; +import java.time.Instant; +import java.time.YearMonth; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Runs the earning rules of the slender pass against H2 in PostgreSQL mode, with the real migrations {@code V1} to + * {@code V3} and Hibernate in {@code validate} mode. Every test gets a database of its own and runs on the calling + * thread. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +class HibernateSlenderPassEarningIntegrationTest { + + private static final Instant NOW = Instant.parse("2026-10-26T12:00:00Z"); + private static final UUID ALICE = UUID.fromString("00000000-0000-0000-0000-0000000000a1"); + private static final UUID BOB = UUID.fromString("00000000-0000-0000-0000-0000000000b2"); + private static final int PITY_ROUNDS = 3; + private static final int MAX_BALANCE = 5; + + private DataSource dataSource; + private SessionFactory sessionFactory; + private HibernateSlenderPassRepository passes; + + @BeforeEach + void setUp() { + this.dataSource = freshDatabase(); + Flyway.configure().dataSource(this.dataSource).locations("classpath:db/migration").load().migrate(); + this.sessionFactory = HibernateStatsRepository.buildSessionFactory(this.dataSource); + this.passes = new HibernateSlenderPassRepository(this.sessionFactory, Runnable::run, + Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @AfterEach + void tearDown() throws SQLException { + this.sessionFactory.close(); + try (Connection connection = this.dataSource.getConnection(); Statement statement = connection.createStatement()) { + statement.execute("SHUTDOWN"); + } + } + + @Test + void theRoundThatReachesThePityThresholdCreditsOnePassAndResetsTheCounter() { + recordSurvivorRounds(ALICE, PITY_ROUNDS - 1); + + Map credits = this.passes.recordRounds(List.of(ALICE), List.of(), + PITY_ROUNDS, MAX_BALANCE).join(); + + assertEquals(new SlenderPassRepository.Adjustment(1, 1), credits.get(ALICE)); + assertEquals(1, this.passes.balance(ALICE).join()); + assertEquals(0, counter(ALICE), "the threshold resets the counter"); + assertEquals(LedgerReason.EARNED_PITY, ledger(ALICE).get(0).reason()); + assertEquals(1, ledger(ALICE).get(0).delta()); + } + + @Test + void aRoundBelowTheThresholdCountsButCreditsNothing() { + Map credits = this.passes.recordRounds(List.of(ALICE), List.of(), + PITY_ROUNDS, MAX_BALANCE).join(); + + assertTrue(credits.isEmpty()); + assertEquals(1, counter(ALICE)); + assertTrue(ledger(ALICE).isEmpty(), "no credit, no ledger row"); + } + + @Test + void aSlenderRoundResetsTheCounter() { + recordSurvivorRounds(ALICE, PITY_ROUNDS - 1); + + this.passes.recordRounds(List.of(), List.of(ALICE), PITY_ROUNDS, MAX_BALANCE).join(); + + assertEquals(0, counter(ALICE)); + assertTrue(this.passes.recordRounds(List.of(ALICE), List.of(), PITY_ROUNDS, MAX_BALANCE).join().isEmpty(), + "the rounds after a Slender round start from zero again"); + } + + @Test + void aPityCreditIsReducedToTheCap() { + this.passes.give(ALICE, MAX_BALANCE - 1).join(); + recordSurvivorRounds(ALICE, PITY_ROUNDS - 1); + + this.passes.recordRounds(List.of(ALICE), List.of(), PITY_ROUNDS, MAX_BALANCE).join(); + + assertEquals(MAX_BALANCE, this.passes.balance(ALICE).join()); + assertEquals(1, ledger(ALICE).get(1).delta()); + } + + @Test + void aPityCreditAtTheCapWritesNoLedgerRowButStillResetsTheCounter() { + this.passes.give(ALICE, MAX_BALANCE).join(); + recordSurvivorRounds(ALICE, PITY_ROUNDS - 1); + + Map credits = this.passes.recordRounds(List.of(ALICE), List.of(), + PITY_ROUNDS, MAX_BALANCE).join(); + + assertTrue(credits.isEmpty()); + assertEquals(MAX_BALANCE, this.passes.balance(ALICE).join()); + assertEquals(1, ledger(ALICE).size(), "only the administrator row"); + assertEquals(0, counter(ALICE), "the cap discards the pass, it does not reopen the counter"); + } + + @Test + void aQuotaIsGrantedOnceInAMonth() { + YearMonth october = YearMonth.of(2026, 10); + + SlenderPassRepository.Adjustment first = this.passes.grantQuota(ALICE, october, 2, MAX_BALANCE).join(); + SlenderPassRepository.Adjustment second = this.passes.grantQuota(ALICE, october, 2, MAX_BALANCE).join(); + + assertEquals(new SlenderPassRepository.Adjustment(2, 2), first); + assertEquals(new SlenderPassRepository.Adjustment(2, 0), second); + assertEquals(1, ledger(ALICE).size(), "the second call in the month writes no row"); + assertEquals(LedgerReason.RANK_QUOTA, ledger(ALICE).get(0).reason()); + } + + @Test + void missedMonthsAreNotPaidBack() { + this.passes.grantQuota(ALICE, YearMonth.of(2026, 3), 2, MAX_BALANCE).join(); + + SlenderPassRepository.Adjustment result = this.passes.grantQuota(ALICE, YearMonth.of(2026, 10), 2, 10).join(); + + assertEquals(new SlenderPassRepository.Adjustment(4, 2), result, "one quota for October, none for April to September"); + } + + @Test + void aQuotaOfZeroSetsTheMonthAndWritesNothing() { + SlenderPassRepository.Adjustment zero = this.passes.grantQuota(ALICE, YearMonth.of(2026, 10), 0, MAX_BALANCE).join(); + SlenderPassRepository.Adjustment next = this.passes.grantQuota(ALICE, YearMonth.of(2026, 10), 2, MAX_BALANCE).join(); + + assertEquals(new SlenderPassRepository.Adjustment(0, 0), zero); + assertEquals(new SlenderPassRepository.Adjustment(0, 0), next, "October is already marked, so no quota this month"); + assertTrue(ledger(ALICE).isEmpty()); + } + + @Test + void aQuotaIsCappedAtTheMaximumBalance() { + this.passes.give(ALICE, 4).join(); + + SlenderPassRepository.Adjustment result = this.passes.grantQuota(ALICE, YearMonth.of(2026, 10), 3, MAX_BALANCE).join(); + + assertEquals(new SlenderPassRepository.Adjustment(5, 1), result); + assertEquals(1, ledger(ALICE).get(1).delta()); + } + + @Test + void theLedgerStillRejectsUnknownReasons() throws SQLException { + try (Connection connection = this.dataSource.getConnection(); Statement statement = connection.createStatement()) { + assertThrows(SQLException.class, () -> statement.execute("INSERT INTO slender_pass_transaction " + + "(player_uuid, delta, reason, created_at) VALUES ('" + ALICE + "', 1, 'BOGUS', '2026-10-26 12:00:00+00')")); + } + } + + @Test + void theEarnedReasonsAreAcceptedByTheLedger() { + this.passes.give(ALICE, 1).join(); + this.passes.recordRounds(List.of(BOB), List.of(), 1, MAX_BALANCE).join(); + this.passes.grantQuota(ALICE, YearMonth.of(2026, 10), 1, MAX_BALANCE).join(); + + assertEquals(LedgerReason.RANK_QUOTA, ledger(ALICE).get(1).reason()); + assertEquals(LedgerReason.EARNED_PITY, ledger(BOB).get(0).reason()); + assertNull(ledger(BOB).get(0).roundId()); + } + + @Test + void theV3MigrationKeepsTheLedgerRowsAndTheirIds() throws SQLException { + DataSource fresh = freshDatabase(); + Flyway.configure().dataSource(fresh).locations("classpath:db/migration").target("2").load().migrate(); + long givenId; + long redeemId; + try (Connection connection = fresh.getConnection(); Statement statement = connection.createStatement()) { + statement.execute("INSERT INTO player_slender_pass (player_uuid, balance, version) VALUES ('" + ALICE + "', 1, 0)"); + statement.execute("INSERT INTO slender_pass_transaction (player_uuid, delta, reason, created_at) " + + "VALUES ('" + ALICE + "', 2, 'ADMIN_GIVE', '2026-10-01 10:00:00+00')"); + statement.execute("INSERT INTO slender_pass_transaction (player_uuid, delta, reason, round_id, created_at) " + + "VALUES ('" + ALICE + "', -1, 'REDEEM', '00000000-0000-0000-0000-00000000f001', '2026-10-02 10:00:00+00')"); + givenId = ledgerId(statement, "ADMIN_GIVE"); + redeemId = ledgerId(statement, "REDEEM"); + } + + Flyway.configure().dataSource(fresh).locations("classpath:db/migration").load().migrate(); + + try (Connection connection = fresh.getConnection(); Statement statement = connection.createStatement()) { + try (ResultSet rows = statement.executeQuery("SELECT id, reason, delta, round_id FROM slender_pass_transaction ORDER BY id")) { + assertTrue(rows.next()); + assertEquals(givenId, rows.getLong("id"), "the migration keeps the id of the first ledger row"); + assertEquals("ADMIN_GIVE", rows.getString("reason")); + assertEquals(2, rows.getInt("delta")); + assertTrue(rows.next()); + assertEquals(redeemId, rows.getLong("id"), "the migration keeps the id of the second ledger row"); + assertEquals("REDEEM", rows.getString("reason")); + assertEquals(-1, rows.getInt("delta")); + assertEquals("00000000-0000-0000-0000-00000000f001", rows.getString("round_id")); + assertFalse(rows.next()); + } + statement.execute("INSERT INTO slender_pass_transaction (player_uuid, delta, reason, created_at) " + + "VALUES ('" + ALICE + "', 1, 'EARNED_PITY', '2026-10-03 10:00:00+00')"); + assertTrue(ledgerId(statement, "EARNED_PITY") > redeemId, "new rows get ids after the migrated ones"); + try (ResultSet balance = statement.executeQuery("SELECT balance, rounds_since_slender FROM player_slender_pass")) { + assertTrue(balance.next()); + assertEquals(1, balance.getInt("balance")); + assertEquals(0, balance.getInt("rounds_since_slender"), "existing balances start with a zero counter"); + } + } finally { + try (Connection connection = fresh.getConnection(); Statement statement = connection.createStatement()) { + statement.execute("SHUTDOWN"); + } + } + } + + private static long ledgerId(Statement statement, String reason) throws SQLException { + try (ResultSet row = statement.executeQuery("SELECT id FROM slender_pass_transaction WHERE reason = '" + reason + "'")) { + assertTrue(row.next(), "a ledger row with reason " + reason); + return row.getLong("id"); + } + } + + private void recordSurvivorRounds(UUID player, int rounds) { + for (int round = 0; round < rounds; round++) { + this.passes.recordRounds(List.of(player), List.of(), PITY_ROUNDS, MAX_BALANCE).join(); + } + } + + private int counter(UUID player) { + return this.sessionFactory.fromSession(session -> session.find(SlenderPassBalance.class, player).roundsSinceSlender()); + } + + private List ledger(UUID player) { + return this.sessionFactory.fromSession(session -> session + .createSelectionQuery("from SlenderPassEntry e where e.playerUuid = :player order by e.id", SlenderPassEntry.class) + .setParameter("player", player) + .getResultList()); + } + + private static DataSource freshDatabase() { + JdbcDataSource source = new JdbcDataSource(); + source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=PostgreSQL;DB_CLOSE_DELAY=-1"); + return source; + } +} diff --git a/stats/src/test/java/net/onelitefeather/cygnus/stats/HibernateSlenderPassRepositoryIntegrationTest.java b/stats/src/test/java/net/onelitefeather/cygnus/stats/HibernateSlenderPassRepositoryIntegrationTest.java new file mode 100644 index 000000000..4cdd9816b --- /dev/null +++ b/stats/src/test/java/net/onelitefeather/cygnus/stats/HibernateSlenderPassRepositoryIntegrationTest.java @@ -0,0 +1,150 @@ +package net.onelitefeather.cygnus.stats; + +import org.flywaydb.core.Flyway; +import org.h2.jdbcx.JdbcDataSource; +import org.hibernate.SessionFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Runs the slender pass repository against H2 in PostgreSQL mode with the real Flyway migrations and Hibernate in + * {@code validate} mode. Every test gets a database of its own and runs on the calling thread. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +class HibernateSlenderPassRepositoryIntegrationTest { + + private static final Instant NOW = Instant.parse("2026-10-26T12:00:00Z"); + private static final UUID ALICE = UUID.fromString("00000000-0000-0000-0000-0000000000a1"); + private static final UUID ROUND = UUID.fromString("00000000-0000-0000-0000-00000000f001"); + + private DataSource dataSource; + private SessionFactory sessionFactory; + private HibernateStatsRepository statistics; + private HibernateSlenderPassRepository passes; + + @BeforeEach + void setUp() { + this.dataSource = freshDatabase(); + Flyway.configure().dataSource(this.dataSource).locations("classpath:db/migration").load().migrate(); + this.sessionFactory = HibernateStatsRepository.buildSessionFactory(this.dataSource); + this.statistics = new HibernateStatsRepository(this.sessionFactory, Runnable::run, StatsMetrics.NONE, () -> { + }); + this.passes = new HibernateSlenderPassRepository(this.sessionFactory, Runnable::run, + Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @AfterEach + void tearDown() throws SQLException { + this.sessionFactory.close(); + try (Connection connection = this.dataSource.getConnection(); Statement statement = connection.createStatement()) { + statement.execute("SHUTDOWN"); + } + } + + @Test + void giveWritesTheBalanceAndOneLedgerRow() { + SlenderPassRepository.Adjustment result = this.passes.give(ALICE, 2).join(); + + assertEquals(2, result.balance()); + assertEquals(2, this.passes.balance(ALICE).join()); + List ledger = ledger(); + assertEquals(1, ledger.size()); + assertEquals(2, ledger.get(0).delta()); + assertEquals(LedgerReason.ADMIN_GIVE, ledger.get(0).reason()); + assertEquals(NOW, ledger.get(0).createdAt(), "the ledger time comes from the injected clock"); + } + + @Test + void redeemSpendsAPassOnlyWhileTheBalanceIsAboveZero() { + this.passes.give(ALICE, 1).join(); + + assertTrue(this.passes.redeem(ALICE, ROUND).join()); + assertFalse(this.passes.redeem(ALICE, ROUND).join(), "the second redemption finds no pass"); + + assertEquals(0, this.passes.balance(ALICE).join(), "the balance never goes below zero"); + assertEquals(2, ledger().size(), "only the successful redemption writes a row"); + } + + @Test + void redeemWritesARedeemRowWithTheRound() { + this.passes.give(ALICE, 1).join(); + + this.passes.redeem(ALICE, ROUND).join(); + + SlenderPassEntry row = ledger().get(1); + assertEquals(LedgerReason.REDEEM, row.reason()); + assertEquals(-1, row.delta()); + assertEquals(ROUND, row.roundId()); + } + + @Test + void redeemWithoutAnyRowSpendsNothing() { + assertFalse(this.passes.redeem(ALICE, ROUND).join()); + assertTrue(ledger().isEmpty()); + } + + @Test + void takeIsClampedAndTheLedgerHoldsTheActualDelta() { + this.passes.give(ALICE, 1).join(); + + SlenderPassRepository.Adjustment result = this.passes.take(ALICE, 3).join(); + + assertEquals(0, result.balance()); + assertEquals(-1, ledger().get(1).delta()); + assertEquals(LedgerReason.ADMIN_TAKE, ledger().get(1).reason()); + } + + @Test + void setWritesTheDifference() { + this.passes.give(ALICE, 1).join(); + + this.passes.set(ALICE, 4).join(); + + assertEquals(4, this.passes.balance(ALICE).join()); + assertEquals(3, ledger().get(1).delta()); + assertEquals(LedgerReason.ADMIN_SET, ledger().get(1).reason()); + } + + @Test + void deletingAPlayerRemovesTheBalanceAndTheLedger() { + this.passes.give(ALICE, 2).join(); + this.passes.redeem(ALICE, ROUND).join(); + + int removed = this.statistics.deleteByPlayer(ALICE); + + assertEquals(0, this.passes.balance(ALICE).join()); + assertTrue(ledger().isEmpty(), "the GDPR deletion must remove every ledger row of the player"); + assertEquals(3, removed, "one balance row and two ledger rows"); + } + + private List ledger() { + return this.sessionFactory.fromSession(session -> session + .createSelectionQuery("from SlenderPassEntry e where e.playerUuid = :player order by e.id", SlenderPassEntry.class) + .setParameter("player", ALICE) + .getResultList()); + } + + private static DataSource freshDatabase() { + JdbcDataSource source = new JdbcDataSource(); + source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=PostgreSQL;DB_CLOSE_DELAY=-1"); + return source; + } +} diff --git a/stats/src/test/java/net/onelitefeather/cygnus/stats/HibernateStatsRepositoryIntegrationTest.java b/stats/src/test/java/net/onelitefeather/cygnus/stats/HibernateStatsRepositoryIntegrationTest.java index bbc692100..8a128f302 100644 --- a/stats/src/test/java/net/onelitefeather/cygnus/stats/HibernateStatsRepositoryIntegrationTest.java +++ b/stats/src/test/java/net/onelitefeather/cygnus/stats/HibernateStatsRepositoryIntegrationTest.java @@ -422,7 +422,7 @@ void migrationCreatesTheTables() { List tables = new ArrayList<>(); try (Connection connection = this.dataSource.getConnection(); ResultSet result = connection.createStatement().executeQuery( - "select table_name from information_schema.tables where table_name like 'PLAYER_%'")) { + "select table_name from information_schema.tables where table_name like 'PLAYER_%' or table_name like 'SLENDER_PASS_%'")) { while (result.next()) { tables.add(result.getString(1)); } @@ -430,7 +430,7 @@ void migrationCreatesTheTables() { throw new AssertionError(exception); } - assertEquals(List.of("PLAYER_NAMES", "PLAYER_ROUND_STATS", "PLAYER_YEAR_STATS"), tables.stream().sorted().toList()); + assertEquals(List.of("PLAYER_NAMES", "PLAYER_ROUND_STATS", "PLAYER_SLENDER_PASS", "PLAYER_YEAR_STATS", "SLENDER_PASS_TRANSACTION"), tables.stream().sorted().toList()); } @Test diff --git a/stats/src/test/java/net/onelitefeather/cygnus/stats/InMemorySlenderPassRepositoryTest.java b/stats/src/test/java/net/onelitefeather/cygnus/stats/InMemorySlenderPassRepositoryTest.java new file mode 100644 index 000000000..531a43a6b --- /dev/null +++ b/stats/src/test/java/net/onelitefeather/cygnus/stats/InMemorySlenderPassRepositoryTest.java @@ -0,0 +1,150 @@ +package net.onelitefeather.cygnus.stats; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; + +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 static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the rules of the in-memory pass repository: the balance never goes negative and every real change + * writes exactly one ledger row with the actual delta. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +class InMemorySlenderPassRepositoryTest { + + private static final Instant NOW = Instant.parse("2026-10-26T12:00:00Z"); + private static final UUID ROUND = UUID.fromString("00000000-0000-0000-0000-00000000f001"); + + private static InMemorySlenderPassRepository repository() { + return new InMemorySlenderPassRepository(Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + void giveWritesOneLedgerRowWithTheGivenDelta() { + InMemorySlenderPassRepository repository = repository(); + UUID player = UUID.randomUUID(); + repository.give(player, 1).join(); + + SlenderPassRepository.Adjustment result = repository.give(player, 2).join(); + + assertEquals(3, result.balance(), "give adds to the existing balance"); + assertEquals(2, result.delta()); + List ledger = repository.ledger(); + assertEquals(2, ledger.size()); + assertEquals(2, ledger.get(1).delta()); + assertEquals(LedgerReason.ADMIN_GIVE, ledger.get(1).reason()); + } + + @Test + void ledgerRowCarriesTheTimeFromTheInjectedClock() { + InMemorySlenderPassRepository repository = repository(); + + repository.give(UUID.randomUUID(), 1).join(); + + assertEquals(NOW, repository.ledger().get(0).createdAt()); + } + + @Test + void takeIsClampedAtZeroAndWritesTheActualDelta() { + InMemorySlenderPassRepository repository = repository(); + UUID player = UUID.randomUUID(); + repository.give(player, 1).join(); + + SlenderPassRepository.Adjustment result = repository.take(player, 3).join(); + + assertEquals(0, result.balance(), "take must never go below zero"); + assertEquals(-1, result.delta(), "the ledger must record what was really removed"); + assertEquals(-1, repository.ledger().get(1).delta()); + assertEquals(LedgerReason.ADMIN_TAKE, repository.ledger().get(1).reason()); + } + + @Test + void takeFromEmptyBalanceWritesNoLedgerRow() { + InMemorySlenderPassRepository repository = repository(); + UUID player = UUID.randomUUID(); + + SlenderPassRepository.Adjustment result = repository.take(player, 3).join(); + + assertEquals(0, result.balance()); + assertEquals(0, result.delta()); + assertTrue(repository.ledger().isEmpty(), "nothing was removed, so nothing is recorded"); + } + + @Test + void setWritesTheDifferenceAsDelta() { + InMemorySlenderPassRepository repository = repository(); + UUID player = UUID.randomUUID(); + repository.give(player, 1).join(); + + SlenderPassRepository.Adjustment result = repository.set(player, 4).join(); + + assertEquals(4, result.balance()); + assertEquals(3, result.delta()); + assertEquals(LedgerReason.ADMIN_SET, repository.ledger().get(1).reason()); + assertEquals(3, repository.ledger().get(1).delta()); + } + + @Test + void setToTheCurrentValueWritesNoLedgerRow() { + InMemorySlenderPassRepository repository = repository(); + UUID player = UUID.randomUUID(); + repository.give(player, 2).join(); + + repository.set(player, 2).join(); + + assertEquals(1, repository.ledger().size(), "only the give is recorded"); + } + + @Test + void redeemSpendsOnePassAndWritesARedeemRowWithTheRound() { + InMemorySlenderPassRepository repository = repository(); + UUID player = UUID.randomUUID(); + repository.give(player, 2).join(); + + boolean spent = repository.redeem(player, ROUND).join(); + + assertTrue(spent); + assertEquals(1, repository.balance(player).join()); + InMemorySlenderPassRepository.LedgerEntry row = repository.ledger().get(1); + assertEquals(-1, row.delta()); + assertEquals(LedgerReason.REDEEM, row.reason()); + assertEquals(ROUND, row.roundId()); + } + + @Test + void redeemOnZeroBalanceSpendsNothing() { + InMemorySlenderPassRepository repository = repository(); + UUID player = UUID.randomUUID(); + + boolean spent = repository.redeem(player, ROUND).join(); + + assertFalse(spent); + assertEquals(0, repository.balance(player).join(), "the balance must stay at zero"); + assertTrue(repository.ledger().isEmpty()); + } + + @Test + void giveRejectsNegativeAmounts() { + InMemorySlenderPassRepository repository = repository(); + + assertThrows(IllegalArgumentException.class, () -> repository.give(UUID.randomUUID(), -1)); + } + + @Test + void setRejectsNegativeAmounts() { + InMemorySlenderPassRepository repository = repository(); + + assertThrows(IllegalArgumentException.class, () -> repository.set(UUID.randomUUID(), -1)); + } +} diff --git a/stats/src/test/java/net/onelitefeather/cygnus/stats/NoOpSlenderPassRepositoryTest.java b/stats/src/test/java/net/onelitefeather/cygnus/stats/NoOpSlenderPassRepositoryTest.java new file mode 100644 index 000000000..8cc9c134b --- /dev/null +++ b/stats/src/test/java/net/onelitefeather/cygnus/stats/NoOpSlenderPassRepositoryTest.java @@ -0,0 +1,37 @@ +package net.onelitefeather.cygnus.stats; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * Covers the switched-off repository that is used while no statistics database is configured. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.21.0 + */ +class NoOpSlenderPassRepositoryTest { + + @Test + void reportsTheFeatureAsUnavailable() { + assertFalse(new NoOpSlenderPassRepository().available()); + } + + @Test + void neverSpendsAPass() { + assertFalse(new NoOpSlenderPassRepository().redeem(UUID.randomUUID(), UUID.randomUUID()).join()); + } + + @Test + void reportsAZeroBalanceAndNoChange() { + NoOpSlenderPassRepository repository = new NoOpSlenderPassRepository(); + UUID player = UUID.randomUUID(); + + assertEquals(0, repository.balance(player).join()); + assertEquals(0, repository.give(player, 5).join().delta()); + } +}