diff --git a/build.gradle.kts b/build.gradle.kts index 5dc40527..8b9001d5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { libs.bundles.nightconfig.get().forEach { include(implementation(it)!!) } + include(implementation(libs.openskill.get())!!) implementation(libs.fabric.loader) implementation(libs.fabric.api) implementation(libs.modmenu) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fafb52b0..fee8e167 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ mod-publish-plugin = "1.0.0" modmenu = "18.0.0-alpha.8" neoforge = "26.1.2.31-beta" nightconfig = "3.8.2" +openskill = "1.1" [libraries] fabric-api = { module = "net.fabricmc.fabric-api:fabric-api", version.ref = "fabric-api" } @@ -26,6 +27,7 @@ minecraft = { module = "com.mojang:minecraft", version.ref = "minecraft-exact" } modmenu = { module = "com.terraformersmc:modmenu", version.ref = "modmenu" } nightconfig-core = { module = "com.electronwill.night-config:core", version.ref = "nightconfig" } nightconfig-toml = { module = "com.electronwill.night-config:toml", version.ref = "nightconfig" } +openskill = { module = "com.pocketcombats:openskill", version.ref = "openskill" } [bundles] nightconfig = ["nightconfig-core", "nightconfig-toml"] diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index 0a875416..725d3855 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -1,6 +1,7 @@ package io.github.gaming32.bingo; import com.demonwav.mcdev.annotations.Translatable; +import com.google.common.collect.Comparators; import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.IntegerArgumentType; @@ -8,6 +9,7 @@ import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.Dynamic2CommandExceptionType; import com.mojang.brigadier.exceptions.Dynamic3CommandExceptionType; import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; @@ -30,7 +32,24 @@ import io.github.gaming32.bingo.game.InvalidGoalException; import io.github.gaming32.bingo.game.mode.BingoGameMode; import io.github.gaming32.bingo.network.messages.s2c.RemoveBoardPayload; +import io.github.gaming32.bingo.rating.BingoRatingEngine; +import io.github.gaming32.bingo.rating.BingoRatings; +import io.github.gaming32.bingo.util.BingoUtil; import io.github.gaming32.bingo.util.Vec2i; +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.objects.ObjectDoubleImmutablePair; +import it.unimi.dsi.fastutil.objects.ObjectDoublePair; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.stream.Collectors; import net.minecraft.ChatFormatting; import net.minecraft.commands.CommandBuildContext; import net.minecraft.commands.CommandSourceStack; @@ -54,6 +73,7 @@ import net.minecraft.server.ServerScoreboard; import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.permissions.Permissions; +import net.minecraft.server.players.NameAndId; import net.minecraft.util.RandomSource; import net.minecraft.world.MenuProvider; import net.minecraft.world.SimpleContainer; @@ -68,14 +88,6 @@ import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.function.TriFunction; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - import static net.minecraft.commands.Commands.*; public class BingoCommand { @@ -123,6 +135,13 @@ public class BingoCommand { private static final SimpleCommandExceptionType NOT_NERFED = new SimpleCommandExceptionType(Bingo.translatable("bingo.not_nerfed")); + private static final Dynamic2CommandExceptionType TOO_MANY_PLAYERS = + new Dynamic2CommandExceptionType((safe, provided) -> Bingo.translatableEscape("bingo.balance.too_many_players", safe, provided)); + private static final Dynamic2CommandExceptionType NOT_ENOUGH_TEAMS = + new Dynamic2CommandExceptionType((required, found) -> Bingo.translatableEscape("bingo.balance.not_enough_teams", required, found)); + private static final Dynamic2CommandExceptionType MISMATCHED_PLAYER_COUNT = + new Dynamic2CommandExceptionType((expected, actual) -> Bingo.translatableEscape("bingo.balance.mismatched_player_count", expected, actual)); + private static final SuggestionProvider ACTIVE_GOAL_SUGGESTOR = (context, builder) -> { final var game = ((MinecraftServerExt) context.getSource().getServer()).bingo$getGame(); if (game == null) { @@ -136,6 +155,7 @@ public class BingoCommand { private static final CommandSwitch REQUIRE_CLIENT = CommandSwitch.storeTrue("--require-client"); private static final CommandSwitch CONTINUE_AFTER_WIN = CommandSwitch.storeTrue("--continue-after-win"); private static final CommandSwitch INCLUDE_INACTIVE_TEAMS = CommandSwitch.storeTrue("--include-inactive-teams"); + private static final CommandSwitch RATED = CommandSwitch.storeFalse("--unrated"); private static final CommandSwitch SHAPE = CommandSwitch .argument("--shape", StringArgumentType.word()) @@ -385,6 +405,21 @@ public Component getDisplayName() { ) ) ) + .then(literal("balance") + .executes(context -> balanceTeams( + context, + context.getSource() + .getServer() + .getPlayerList() + .getPlayers() + .stream() + .filter(p -> !p.isSpectator()) + .toList() + )) + .then(argument("players", EntityArgument.players()) + .executes(context -> balanceTeams(context, EntityArgument.getPlayers(context, "players"))) + ) + ) ) .then(literal("time-limit") .requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER)) @@ -431,6 +466,54 @@ public Component getDisplayName() { ) ) ) + .then(literal("rate") + .executes(context -> ratePlayers(context, List.of(context.getSource().getPlayerOrException()))) + .then(argument("player", EntityArgument.players()) + .requires(hasPermission(LEVEL_MODERATORS)) + .executes(context -> ratePlayers(context, EntityArgument.getPlayers(context, "player"))) + ) + ) + .then(literal("leaderboard") + .executes(context -> { + final var server = context.getSource().getServer(); + final var ratings = server + .getDataStorage() + .computeIfAbsent(BingoRatings.TYPE); + final var playerList = server.getPlayerList(); + final var nameIdCache = server.services().nameToIdCache(); + + final var topPlayerCount = 10; + final var topPlayers = ratings + .getRatings() + .entrySet() + .stream() + .map(entry -> ObjectDoubleImmutablePair.of(entry.getKey(), BingoRatingEngine.bingoRating(entry.getValue()))) + .collect(Comparators.greatest(topPlayerCount, Comparator.comparingDouble(ObjectDoublePair::valueDouble))); + + final var result = Bingo.translatable("bingo.leaderboard.header", topPlayers.size()); + int rank = 1; + for (final var entry : topPlayers) { + final var playerEntity = playerList.getPlayer(entry.left()); + result.append("\n"); + result.append(Bingo.translatable( + "bingo.leaderboard.player", + rank, + playerEntity != null + ? playerEntity.getDisplayName() + : Component.literal( + nameIdCache.get(entry.left()) + .map(NameAndId::name) + .orElseGet(entry.left()::toString) + ), + (int) entry.rightDouble() + )); + rank++; + } + + context.getSource().sendSuccess(() -> result, false); + return 0; + }) + ) ); { @@ -439,6 +522,7 @@ public Component getDisplayName() { REQUIRE_CLIENT.addTo(startCommand); CONTINUE_AFTER_WIN.addTo(startCommand); INCLUDE_INACTIVE_TEAMS.addTo(startCommand); + RATED.addTo(startCommand); SHAPE.addTo(startCommand); SIZE.addTo(startCommand); @@ -461,6 +545,18 @@ public Component getDisplayName() { currentCommand = subCommand; } } + + { + var currentCommand = bingoCommand.getChild("teams").getChild("balance").getChild("players"); + for (int i = 1; i <= 32; i++) { + final var teamCount = i; + final var subCommand = argument("team-size-" + i, IntegerArgumentType.integer(1)) + .executes(context -> balanceTeams(context, teamCount)) + .build(); + currentCommand.addChild(subCommand); + currentCommand = subCommand; + } + } } private static int startGame(CommandContext context, int teamCount) throws CommandSyntaxException { @@ -487,6 +583,7 @@ private static int startGame(CommandContext context, int tea final boolean requireClient = REQUIRE_CLIENT.get(context); final boolean continueAfterWin = CONTINUE_AFTER_WIN.get(context); final boolean includeInactiveTeams = INCLUDE_INACTIVE_TEAMS.get(context); + final boolean rated = RATED.get(context); final int timeLimit = TIME_LIMIT.get(context); final int autoForfeitTicks = AUTO_FORFEIT_TIME.get(context); @@ -539,7 +636,7 @@ private static int startGame(CommandContext context, int tea Bingo.LOGGER.info("Generated board (seed {}):\n{}", seed, board); final long scheduledEndTime = timeLimit > 0 ? context.getSource().getServer().overworld().getGameTime() + timeLimit : 0; - final var game = new BingoGame(board, gamemode, requireClient, continueAfterWin, scheduledEndTime, autoForfeitTicks, teams.toArray(PlayerTeam[]::new)); + final var game = new BingoGame(board, gamemode, requireClient, continueAfterWin, rated, scheduledEndTime, autoForfeitTicks, teams.toArray(PlayerTeam[]::new)); for (ServerPlayer player : context.getSource().getServer().getPlayerList().getPlayers()) { if (Bingo.CONFIG.getNerfedPlayers().contains(player.getUUID())) { @@ -658,4 +755,129 @@ private static int randomizeTeams( ); return players.size(); } + + private static int balanceTeams(CommandContext context, Collection players) throws CommandSyntaxException { + final var maxPlayerCount = 14; + if (players.size() > maxPlayerCount) { + throw TOO_MANY_PLAYERS.create(maxPlayerCount, players.size()); + } + + if (players.isEmpty()) { + throw EntityArgument.NO_PLAYERS_FOUND.create(); + } + + final var scoreboardTeams = context.getSource().getServer().getScoreboard().getPlayerTeams(); + if (scoreboardTeams.size() < (players.size() > 1 ? 2 : 1)) { + throw NOT_ENOUGH_TEAMS.create(players.size(), scoreboardTeams.size()); + } + + return basicBalanceTeams( + context, + players, + scoreboardTeams, + players.size() > 1 + ? scoreComputer -> BingoUtil.forEachGroupParallel(List.copyOf(players), teams -> { + if (teams.size() < 2) return; + if (teams.size() > scoreboardTeams.size()) return; + scoreComputer.accept(teams); + }) + : scoreComputer -> scoreComputer.accept(List.of(List.copyOf(players))) + ); + } + + private static int balanceTeams(CommandContext context, int teamCount) throws CommandSyntaxException { + final var players = EntityArgument.getPlayers(context, "players"); + + final var teams = new IntArrayList(teamCount); + var totalPlayers = 0; + for (int i = 1; i <= teamCount; i++) { + final var teamSize = IntegerArgumentType.getInteger(context, "team-size-" + i); + totalPlayers += teamSize; + teams.add(teamSize); + } + if (totalPlayers != players.size()) { + throw MISMATCHED_PLAYER_COUNT.create(totalPlayers, players.size()); + } + + final var scoreboardTeams = context.getSource().getServer().getScoreboard().getPlayerTeams(); + if (teams.size() > scoreboardTeams.size()) { + throw NOT_ENOUGH_TEAMS.create(teams.size(), scoreboardTeams.size()); + } + + return basicBalanceTeams( + context, + players, + scoreboardTeams, + scoreComputer -> BingoUtil.forEachGroupParallel(List.copyOf(players), teams, scoreComputer) + ); + } + + private static int basicBalanceTeams( + CommandContext context, + Collection players, + Collection teams, + Consumer>>> teamLister + ) { + final var server = context.getSource().getServer(); + final var scoreboard = server.getScoreboard(); + + final var ratings = server.getDataStorage().computeIfAbsent(BingoRatings.TYPE); + + final var bestChoice = new AtomicReference<>(ObjectDoublePair.of(List.>of(), Double.NEGATIVE_INFINITY)); + teamLister.accept(possibility -> { + final var createdTeams = possibility.stream() + .map(t -> t.stream().map(p -> ratings.getRating(p.getUUID())).toList()) + .toList(); + final var drawChance = BingoRatingEngine.predictDraw(createdTeams); + bestChoice.getAndAccumulate( + ObjectDoublePair.of(possibility, drawChance), + (a, b) -> Comparators.max(a, b, Comparator.comparingDouble(ObjectDoublePair::valueDouble)) + ); + }); + + Bingo.LOGGER.info("Best score found: {}", bestChoice.get().valueDouble()); + Bingo.LOGGER.info( + "Teams:\n{}", + bestChoice.get() + .key() + .stream() + .map(team -> " - " + team.stream().map(ServerPlayer::getPlainTextName).collect(Collectors.joining(", ", "[", "]"))) + .collect(Collectors.joining("\n")) + ); + + final var scoreboardTeams = new ArrayList<>(teams); + Collections.shuffle(scoreboardTeams); + for (final var foundTeam : bestChoice.get().key()) { + final var scoreboardTeam = scoreboardTeams.removeLast(); + for (final var player : foundTeam) { + scoreboard.addPlayerToTeam(player.getScoreboardName(), scoreboardTeam); + } + } + + context.getSource().sendSuccess( + () -> Bingo.translatable( + "bingo.balance.success", + players.size(), + bestChoice.get().key().size(), + Math.round(bestChoice.get().valueDouble() * 100.0) + ), + true + ); + + return 0; + } + + private static int ratePlayers(CommandContext context, Collection players) { + final var ratings = context.getSource() + .getServer() + .getDataStorage() + .computeIfAbsent(BingoRatings.TYPE); + var summedRating = 0; + for (final var player : players) { + final var rating = (int) BingoRatingEngine.bingoRating(ratings.getRating(player.getUUID())); + context.getSource().sendSuccess(() -> Bingo.translatable("bingo.rate", player.getDisplayName(), rating), false); + summedRating += rating; + } + return summedRating; + } } diff --git a/src/main/java/io/github/gaming32/bingo/game/BingoGame.java b/src/main/java/io/github/gaming32/bingo/game/BingoGame.java index cebe5c18..baa61ac9 100644 --- a/src/main/java/io/github/gaming32/bingo/game/BingoGame.java +++ b/src/main/java/io/github/gaming32/bingo/game/BingoGame.java @@ -1,8 +1,14 @@ package io.github.gaming32.bingo.game; +import com.google.common.collect.ImmutableList; import com.mojang.datafixers.util.Either; import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; +import com.pocketcombats.openskill.data.SimpleMatchMakingRating; +import com.pocketcombats.openskill.data.SimplePlayerResult; +import com.pocketcombats.openskill.data.SimpleTeamResult; +import com.pocketcombats.openskill.data.TeamResult; import io.github.gaming32.bingo.Bingo; import io.github.gaming32.bingo.data.BingoTag; import io.github.gaming32.bingo.ext.MinecraftServerExt; @@ -17,6 +23,8 @@ import io.github.gaming32.bingo.network.messages.s2c.UpdateEndTimePayload; import io.github.gaming32.bingo.network.messages.s2c.UpdateProgressPayload; import io.github.gaming32.bingo.network.messages.s2c.UpdateStatePayload; +import io.github.gaming32.bingo.rating.BingoRatingEngine; +import io.github.gaming32.bingo.rating.BingoRatings; import io.github.gaming32.bingo.triggers.progress.ProgressibleTrigger; import io.github.gaming32.bingo.util.BingoCodecs; import io.github.gaming32.bingo.util.BingoUtil; @@ -30,6 +38,19 @@ import it.unimi.dsi.fastutil.objects.Object2IntMap; import it.unimi.dsi.fastutil.objects.Object2IntMaps; import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; import net.minecraft.ChatFormatting; import net.minecraft.SharedConstants; import net.minecraft.advancements.AdvancementHolder; @@ -61,17 +82,6 @@ import org.apache.commons.lang3.ArrayUtils; import org.jspecify.annotations.Nullable; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.OptionalLong; -import java.util.Set; -import java.util.UUID; -import java.util.function.Function; - public class BingoGame { public static final Component REQUIRED_CLIENT_KICK = Component.literal( "This bingo game requires the Bingo mod to be installed on the client. Please install it before joining." @@ -82,6 +92,7 @@ public class BingoGame { private final BingoGameMode gameMode; private final boolean requireClient; private final boolean continueAfterWin; + private final boolean rated; private final int autoForfeitTicks; private final PlayerTeam[] teams; private long scheduledEndTime; @@ -94,21 +105,39 @@ public class BingoGame { private final Map>> baseStats = new HashMap<>(); private final ServerBossEvent vanillaRemainingTime = new ServerBossEvent(Mth.createInsecureUUID(this.random), Bingo.translatable("bingo.remaining_time"), BossEvent.BossBarColor.WHITE, BossEvent.BossBarOverlay.PROGRESS); private final OptionalLong[] lastActiveTimes; + private final Set[] historicallyActiveMembers; + private final List ranks = new ArrayList<>(); + private final List forfeitedTeams = new ArrayList<>(); private BingoBoard.Teams remainingTeams; private BingoBoard.Teams winningTeams = BingoBoard.Teams.NONE; private BingoBoard.Teams finishedTeams = BingoBoard.Teams.NONE; private BingoBoard.Teams nerfedTeams = BingoBoard.Teams.NONE; - public BingoGame(BingoBoard board, BingoGameMode gameMode, boolean requireClient, boolean continueAfterWin, long scheduledEndTime, int autoForfeitTicks, PlayerTeam... teams) { + @SuppressWarnings("unchecked") + public BingoGame( + BingoBoard board, + BingoGameMode gameMode, + boolean requireClient, + boolean continueAfterWin, + boolean rated, + long scheduledEndTime, + int autoForfeitTicks, + PlayerTeam... teams + ) { this.board = board; this.gameMode = gameMode; this.requireClient = requireClient; this.continueAfterWin = continueAfterWin; + this.rated = rated; this.scheduledEndTime = scheduledEndTime; this.autoForfeitTicks = autoForfeitTicks; this.teams = teams; this.lastActiveTimes = new OptionalLong[teams.length]; Arrays.fill(this.lastActiveTimes, OptionalLong.empty()); + this.historicallyActiveMembers = new Set[teams.length]; + for (int i = 0; i < teams.length; i++) { + this.historicallyActiveMembers[i] = new HashSet<>(); + } this.remainingTeams = BingoBoard.Teams.fromAll(teams.length); } @@ -138,7 +167,7 @@ public void addPlayer(ServerPlayer player) { } RemoveBoardPayload.INSTANCE.sendTo(player); - if (((ServerPlayerExt)player).bingo$clearAdvancementsNeedClearing()) { + if (((ServerPlayerExt) player).bingo$clearAdvancementsNeedClearing()) { player.connection.send(new ClientboundUpdateAdvancementsPacket( false, List.of(), Set.of(VanillaNetworking.ROOT_ADVANCEMENT.id()), Map.of(), false )); @@ -150,7 +179,7 @@ public void addPlayer(ServerPlayer player) { new SyncTeamPayload(team).sendTo(player); InitBoardPayload.create(this, team, obfuscateTeam(team, player), this.scheduledEndTime).sendTo(player); - if (!((PlayerAdvancementsAccessor)player.getAdvancements()).getIsFirstPacket()) { + if (!((PlayerAdvancementsAccessor) player.getAdvancements()).getIsFirstPacket()) { syncAdvancementsTo(player); } @@ -172,12 +201,17 @@ public void addPlayer(ServerPlayer player) { public void syncAdvancementsTo(ServerPlayer player) { player.connection.send(new ClientboundUpdateAdvancementsPacket( false, - VanillaNetworking.generateAdvancements(player.registryAccess(), board.getShape(), board.getSize(), board.getGoals()), + VanillaNetworking.generateAdvancements( + player.registryAccess(), + board.getShape(), + board.getSize(), + board.getGoals() + ), Set.of(), VanillaNetworking.generateProgressMap(board.getStates(), getTeam(player)), false )); - ((ServerPlayerExt)player).bingo$markAdvancementsNeedClearing(); + ((ServerPlayerExt) player).bingo$markAdvancementsNeedClearing(); } public void removePlayer(ServerPlayer player) { @@ -254,10 +288,22 @@ public void endGame(PlayerList playerList) { final Component message; if (!winningTeams.any()) { winningTeams = getWinner(true); + if (winningTeams.any()) { + ranks.add(winningTeams); + } else { + winningTeams = remainingTeams; + remainingTeams = BingoBoard.Teams.NONE; + if (winningTeams.any()) { + ranks.add(winningTeams); + } + } } - if (!winningTeams.any()) { - winningTeams = remainingTeams; + if (remainingTeams.any()) { + ranks.add(remainingTeams); } + Collections.reverse(forfeitedTeams); // Forfeited teams get a higher ranking if they forfeited later + ranks.addAll(forfeitedTeams); + if (winningTeams.any()) { if (!winningTeams.one()) { message = Bingo.translatable("bingo.ended.tie"); @@ -279,7 +325,7 @@ public void endGame(PlayerList playerList) { for (final ServerPlayer player : playerList.getPlayers()) { player.connection.send(new ClientboundSoundEntityPacket( Holder.direct(SoundEvents.UI_TOAST_CHALLENGE_COMPLETE), SoundSource.MASTER, - player,1f, 1f, player.getRandom().nextLong() + player, 1f, 1f, player.getRandom().nextLong() )); } } else { @@ -287,6 +333,11 @@ public void endGame(PlayerList playerList) { } playerList.broadcastSystemMessage(message, false); + // Don't rate when there's only one team, as that just makes everyone's sigma go up + if (rated && (ranks.size() > 1 || winningTeams.count() != 1)) { + applyRatingChanges(playerList); + } + ((MinecraftServerExt) playerList.getServer()).bingo$setGame(null); new ResyncStatesPayload(board.getStates()).sendTo(playerList.getPlayers()); if (scheduledEndTime > 0) { @@ -295,6 +346,59 @@ public void endGame(PlayerList playerList) { Bingo.updateCommandTree(playerList); } + private void applyRatingChanges(PlayerList playerList) { + final var ratings = playerList.getServer().getDataStorage().computeIfAbsent(BingoRatings.TYPE); + + final var teamResults = new ArrayList>(); + var rank = 1; + for (final var rankGroup : ranks) { + final var fRank = rank; + rankGroup.stream() + .forEach(teamIndex -> { + final var playerResults = historicallyActiveMembers[teamIndex] + .stream() + .map(uuid -> { + final var rating = ratings.getRating(uuid); + return new SimplePlayerResult<>(uuid, rating.mu(), rating.sigma()); + }) + .toList(); + final var teamRating = BingoRatingEngine.AGGREGATOR.computeTeamRating(playerResults); + teamResults.add(new SimpleTeamResult<>(teamRating.mu(), teamRating.sigma(), fRank, playerResults)); + }); + rank++; + } + + final var adjustments = BingoRatingEngine.ADJUDICATOR.rate(teamResults); + for (final var adjustment : adjustments) { + final var player = playerList.getPlayer(adjustment.playerId()); + final var newRating = new SimpleMatchMakingRating(adjustment.mu(), adjustment.sigma()); + final var oldRating = Objects.requireNonNullElse( + ratings.getRatings().put(adjustment.playerId(), newRating), + BingoRatingEngine.DEFAULT_RATING + ); + final var oldBingoRating = BingoRatingEngine.bingoRating(oldRating); + final var newBingoRating = BingoRatingEngine.bingoRating(newRating); + Bingo.LOGGER.info( + "Player {} has changed their rank {} -> {}", + player != null ? player.getPlainTextName() : adjustment.playerId().toString(), + (int) oldBingoRating, + (int) newBingoRating + ); + if (player != null) { + player.sendSystemMessage(Bingo.translatable( + newBingoRating >= oldBingoRating + ? "bingo.rating_changed.increase" + : "bingo.rating_changed.decrease", + (int) oldBingoRating, + (int) newBingoRating, + Math.abs((int) newBingoRating - (int) oldBingoRating) + )); + } + } + + ratings.setDirty(); + } + public void tick(MinecraftServer server) { if (requireClient) { for (final ServerPlayer player : new ArrayList<>(server.getPlayerList().getPlayers())) { @@ -304,20 +408,31 @@ public void tick(MinecraftServer server) { } } - if (autoForfeitTicks > 0 && server.getTickCount() % 20 == 0) { + if (server.getTickCount() % 20 == 0) { long gameTime = server.overworld().getGameTime(); for (int i = 0; i < teams.length; i++) { BingoBoard.Teams team = BingoBoard.Teams.fromOne(i); if (remainingTeams.and(team)) { - boolean isTeamActive = teams[i].getPlayers().stream().anyMatch(playerName -> server.getPlayerList().getPlayerByName(playerName) != null); - if (isTeamActive) { - lastActiveTimes[i] = OptionalLong.of(gameTime); - } else { - OptionalLong lastActiveTime = lastActiveTimes[i]; - if (lastActiveTime.isPresent() && (gameTime - lastActiveTime.getAsLong()) >= autoForfeitTicks) { - forfeit(server.getPlayerList(), team); + if (autoForfeitTicks > 0) { + boolean isTeamActive = teams[i].getPlayers().stream() + .anyMatch(playerName -> server.getPlayerList().getPlayerByName(playerName) != null); + if (isTeamActive) { + lastActiveTimes[i] = OptionalLong.of(gameTime); + } else { + OptionalLong lastActiveTime = lastActiveTimes[i]; + if (lastActiveTime.isPresent() + && (gameTime - lastActiveTime.getAsLong()) >= autoForfeitTicks) { + forfeit(server.getPlayerList(), team); + } } } + teams[i] + .getPlayers() + .stream() + .map(server.getPlayerList()::getPlayerByName) + .filter(Objects::nonNull) + .map(ServerPlayer::getUUID) + .forEach(historicallyActiveMembers[i]::add); } } } @@ -336,6 +451,7 @@ public boolean forfeit(PlayerList playerList, BingoBoard.Teams team) { return false; } remainingTeams = remainingTeams.andNot(team); + forfeitedTeams.add(team); PlayerTeam playerTeam = getTeam(team); Component message = BingoUtil.mapEither( @@ -407,7 +523,8 @@ private void addListener( private void removeListener( Criterion criterion, String criterionId, ServerPlayer player, ActiveGoal goal ) { - criterion.trigger().removePlayerListener(player.getAdvancements(), createListener(criterion, criterionId, goal)); + criterion.trigger() + .removePlayerListener(player.getAdvancements(), createListener(criterion, criterionId, goal)); if (criterion.trigger() instanceof ProgressibleTrigger progressibleTrigger) { progressibleTrigger.removeProgressListener( player.getAdvancements(), @@ -473,7 +590,8 @@ public void updateProgress(ServerPlayer player, ActiveGoal goal, int progress, i player.getUUID(), k -> HashMap.newHashMap(board.getGoals().length) ); GoalProgress existingProgress = goalProgress.get(goal); - if (existingProgress != null && existingProgress.progress() == progress && existingProgress.maxProgress() == maxProgress) { + if (existingProgress != null && existingProgress.progress() == progress + && existingProgress.maxProgress() == maxProgress) { return; } @@ -509,7 +627,8 @@ public boolean award(ServerPlayer player, ActiveGoal goal, String criterion, int awarded = true; } if (!wasDone && progress.isDone()) { - int completedCount = goalAchievedCount.computeIfAbsent(player.getUUID(), k -> new Object2IntOpenHashMap<>()).addTo(goal, count) + count; + int completedCount = goalAchievedCount.computeIfAbsent(player.getUUID(), k -> new Object2IntOpenHashMap<>()) + .addTo(goal, count) + count; if (completedCount > goal.requiredCount()) { completedCount = goal.requiredCount(); } @@ -652,7 +771,10 @@ private void notifyTeam( final BingoBoard.Teams boardState = board.getStates()[boardIndex]; final boolean showOtherTeam = gameMode.getRenderMode() == BingoGameMode.RenderMode.ALL_TEAMS; final UpdateStatePayload statePayload = new UpdateStatePayload(boardIndex, boardState); - final UpdateStatePayload obfuscatedStatePayload = new UpdateStatePayload(boardIndex, obfuscateTeam(team, boardState)); + final UpdateStatePayload obfuscatedStatePayload = new UpdateStatePayload( + boardIndex, + obfuscateTeam(team, boardState) + ); final ClientboundUpdateAdvancementsPacket vanillaPacket = new ClientboundUpdateAdvancementsPacket( false, List.of(), @@ -674,7 +796,7 @@ private void notifyTeam( player.connection.send(new ClientboundSoundEntityPacket( isLoss ? SoundEvents.RESPAWN_ANCHOR_DEPLETE : SoundEvents.NOTE_BLOCK_CHIME, SoundSource.MASTER, player, - isLoss ? 1f: 0.5f, 1f, player.getRandom().nextLong() + isLoss ? 1f : 0.5f, 1f, player.getRandom().nextLong() )); player.sendSystemMessage(message); } @@ -758,6 +880,8 @@ public void checkForWin(PlayerList playerList) { } remainingTeams = remainingTeams.andNot(newFinishers); + ranks.add(newFinishers); + if (continueAfterWin) { notifyFinishedTeam(playerList, newFinishers, place); } @@ -799,7 +923,7 @@ private void notifyFinishedTeam(PlayerList playerList, BingoBoard.Teams newFinis for (final ServerPlayer player : playerList.getPlayers()) { player.connection.send(new ClientboundSoundEntityPacket( Holder.direct(SoundEvents.UI_TOAST_CHALLENGE_COMPLETE), SoundSource.MASTER, - player,1f, 1f, player.getRandom().nextLong() + player, 1f, 1f, player.getRandom().nextLong() )); } } @@ -831,45 +955,37 @@ public record PersistenceData( BingoGameMode gameMode, boolean requireClient, boolean continueAfterWin, + boolean rated, long scheduledEndTime, int autoForfeitTicks, List teamNames, - Map> advancementProgress, - Map> goalProgress, - Map goalAchievedCount, - Map queuedGoals, - Map>> baseStats, + PlayersData playersData, Optional playingTeams, + List> historicallyActiveMembers, + List ranks, + List forfeitedTeams, BingoBoard.Teams winningTeams, BingoBoard.Teams finishedTeams, BingoBoard.Teams nerfedTeams ) { - private static final Codec>> ADVANCEMENT_PROGRESS_CODEC = - Codec.unboundedMap(UUIDUtil.STRING_CODEC, BingoCodecs.int2ObjectMap(AdvancementProgress.CODEC)); - private static final Codec>> GOAL_PROGRESS_CODEC = - Codec.unboundedMap(UUIDUtil.STRING_CODEC, BingoCodecs.int2ObjectMap(GoalProgress.PERSISTENCE_CODEC)); - private static final Codec> GOAL_ACHIEVED_COUNT_CODEC = - Codec.unboundedMap(UUIDUtil.STRING_CODEC, BingoCodecs.INT_2_INT_MAP); - private static final Codec> QUEUED_GOALS_CODEC = - Codec.unboundedMap(UUIDUtil.STRING_CODEC, BingoCodecs.INT_LIST); - private static final Codec>>> BASE_STATS_CODEC = - Codec.unboundedMap(UUIDUtil.STRING_CODEC, BingoCodecs.object2IntMap(StatCodecs.STRING_CODEC)); - public static final Codec CODEC = RecordCodecBuilder.create( instance -> instance.group( BingoBoard.PERSISTENCE_CODEC.fieldOf("board").forGetter(PersistenceData::board), BingoGameMode.PERSISTENCE_CODEC.fieldOf("game_mode").forGetter(PersistenceData::gameMode), Codec.BOOL.fieldOf("require_client").forGetter(PersistenceData::requireClient), Codec.BOOL.optionalFieldOf("continue_after_win", false).forGetter(PersistenceData::continueAfterWin), + Codec.BOOL.optionalFieldOf("rated", false).forGetter(PersistenceData::rated), ExtraCodecs.NON_NEGATIVE_LONG.optionalFieldOf("scheduled_end_time", 0L).forGetter(PersistenceData::scheduledEndTime), ExtraCodecs.NON_NEGATIVE_INT.optionalFieldOf("auto_forfeit_ticks", DEFAULT_AUTO_FORFEIT_TICKS).forGetter(PersistenceData::autoForfeitTicks), Codec.STRING.listOf().fieldOf("team_names").forGetter(PersistenceData::teamNames), - ADVANCEMENT_PROGRESS_CODEC.fieldOf("advancement_progress").forGetter(PersistenceData::advancementProgress), - GOAL_PROGRESS_CODEC.fieldOf("goal_progress").forGetter(PersistenceData::goalProgress), - GOAL_ACHIEVED_COUNT_CODEC.fieldOf("goal_achieved_count").forGetter(PersistenceData::goalAchievedCount), - QUEUED_GOALS_CODEC.fieldOf("queued_goals").forGetter(PersistenceData::queuedGoals), - BASE_STATS_CODEC.fieldOf("base_stats").forGetter(PersistenceData::baseStats), + PlayersData.CODEC.forGetter(PersistenceData::playersData), BingoBoard.Teams.CODEC.optionalFieldOf("playing_teams").forGetter(PersistenceData::playingTeams), + BingoCodecs.setOf(UUIDUtil.CODEC) + .listOf() + .optionalFieldOf("historically_active_members", List.of()) + .forGetter(PersistenceData::historicallyActiveMembers), + BingoBoard.Teams.CODEC.listOf().optionalFieldOf("ranks", List.of()).forGetter(PersistenceData::ranks), + BingoBoard.Teams.CODEC.listOf().optionalFieldOf("forfeited_teams", List.of()).forGetter(PersistenceData::forfeitedTeams), BingoBoard.Teams.CODEC.optionalFieldOf("winning_teams", BingoBoard.Teams.NONE).forGetter(PersistenceData::winningTeams), BingoBoard.Teams.CODEC.optionalFieldOf("finished_teams", BingoBoard.Teams.NONE).forGetter(PersistenceData::finishedTeams), BingoBoard.Teams.CODEC.optionalFieldOf("nerfed_teams", BingoBoard.Teams.NONE).forGetter(PersistenceData::nerfedTeams) @@ -884,9 +1000,9 @@ public BingoGame createGame(Scoreboard scoreboard) throws IllegalStateException throw new IllegalStateException("Team '" + teamNames.get(i) + "' no longer exists"); } } - final BingoGame game = new BingoGame(board, gameMode, requireClient, continueAfterWin, scheduledEndTime, autoForfeitTicks, teams); + final BingoGame game = new BingoGame(board, gameMode, requireClient, continueAfterWin, rated, scheduledEndTime, autoForfeitTicks, teams); - for (final var entry : advancementProgress.entrySet()) { + for (final var entry : playersData.advancementProgress.entrySet()) { final Map subTarget = HashMap.newHashMap(entry.getValue().size()); for (final var subEntry : entry.getValue().int2ObjectEntrySet()) { final ActiveGoal goal = getGoal(subEntry.getIntKey()); @@ -897,7 +1013,7 @@ public BingoGame createGame(Scoreboard scoreboard) throws IllegalStateException game.advancementProgress.put(entry.getKey(), subTarget); } - for (final var entry : goalProgress.entrySet()) { + for (final var entry : playersData.goalProgress.entrySet()) { final Map subTarget = HashMap.newHashMap(entry.getValue().size()); for (final var subEntry : entry.getValue().int2ObjectEntrySet()) { subTarget.put(getGoal(subEntry.getIntKey()), subEntry.getValue()); @@ -905,7 +1021,7 @@ public BingoGame createGame(Scoreboard scoreboard) throws IllegalStateException game.goalProgress.put(entry.getKey(), subTarget); } - for (final var entry : goalAchievedCount.entrySet()) { + for (final var entry : playersData.goalAchievedCount.entrySet()) { final Object2IntOpenHashMap subTarget = new Object2IntOpenHashMap<>(entry.getValue().size()); for (final var subEntry : entry.getValue().int2IntEntrySet()) { subTarget.put(getGoal(subEntry.getIntKey()), subEntry.getIntValue()); @@ -913,7 +1029,7 @@ public BingoGame createGame(Scoreboard scoreboard) throws IllegalStateException game.goalAchievedCount.put(entry.getKey(), subTarget); } - for (final var entry : queuedGoals.entrySet()) { + for (final var entry : playersData.queuedGoals.entrySet()) { final List subTarget = new ArrayList<>(entry.getValue().size()); for (final int goal : entry.getValue()) { subTarget.add(getGoal(goal)); @@ -921,9 +1037,14 @@ public BingoGame createGame(Scoreboard scoreboard) throws IllegalStateException game.queuedGoals.put(entry.getKey(), subTarget); } - game.baseStats.putAll(baseStats); + game.baseStats.putAll(playersData.baseStats); game.remainingTeams = playingTeams.orElseGet(() -> BingoBoard.Teams.fromAll(teams.length)); + for (int i = 0; i < historicallyActiveMembers.size(); i++) { + game.historicallyActiveMembers[i].addAll(historicallyActiveMembers.get(i)); + } + game.ranks.addAll(ranks); + game.forfeitedTeams.addAll(forfeitedTeams); game.winningTeams = winningTeams; game.finishedTeams = finishedTeams; @@ -954,13 +1075,23 @@ private static PersistenceData create(BingoGame game) { } return new PersistenceData( - game.board, game.gameMode, game.requireClient, game.continueAfterWin, + game.board, game.gameMode, game.requireClient, game.continueAfterWin, game.rated, game.scheduledEndTime, game.autoForfeitTicks, Arrays.stream(game.teams).map(PlayerTeam::getName).toList(), - createMap(game, game.advancementProgress), - createMap(game, game.goalProgress), - goalAchievedCount, queuedGoals, game.baseStats, - Optional.of(game.remainingTeams), game.winningTeams, game.finishedTeams, game.nerfedTeams + new PlayersData( + createMap(game, game.advancementProgress), + createMap(game, game.goalProgress), + goalAchievedCount, + queuedGoals, + game.baseStats + ), + Optional.of(game.remainingTeams), + ImmutableList.copyOf(game.historicallyActiveMembers), + game.ranks, + game.forfeitedTeams, + game.winningTeams, + game.finishedTeams, + game.nerfedTeams ); } @@ -979,5 +1110,34 @@ private static Map> createMap(BingoGame game, Map> advancementProgress, + Map> goalProgress, + Map goalAchievedCount, + Map queuedGoals, + Map>> baseStats + ) { + private static final Codec>> ADVANCEMENT_PROGRESS_CODEC = + Codec.unboundedMap(UUIDUtil.STRING_CODEC, BingoCodecs.int2ObjectMap(AdvancementProgress.CODEC)); + private static final Codec>> GOAL_PROGRESS_CODEC = + Codec.unboundedMap(UUIDUtil.STRING_CODEC, BingoCodecs.int2ObjectMap(GoalProgress.PERSISTENCE_CODEC)); + private static final Codec> GOAL_ACHIEVED_COUNT_CODEC = + Codec.unboundedMap(UUIDUtil.STRING_CODEC, BingoCodecs.INT_2_INT_MAP); + private static final Codec> QUEUED_GOALS_CODEC = + Codec.unboundedMap(UUIDUtil.STRING_CODEC, BingoCodecs.INT_LIST); + private static final Codec>>> BASE_STATS_CODEC = + Codec.unboundedMap(UUIDUtil.STRING_CODEC, BingoCodecs.object2IntMap(StatCodecs.STRING_CODEC)); + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec( + i -> i.group( + ADVANCEMENT_PROGRESS_CODEC.fieldOf("advancement_progress").forGetter(PlayersData::advancementProgress), + GOAL_PROGRESS_CODEC.fieldOf("goal_progress").forGetter(PlayersData::goalProgress), + GOAL_ACHIEVED_COUNT_CODEC.fieldOf("goal_achieved_count").forGetter(PlayersData::goalAchievedCount), + QUEUED_GOALS_CODEC.fieldOf("queued_goals").forGetter(PlayersData::queuedGoals), + BASE_STATS_CODEC.fieldOf("base_stats").forGetter(PlayersData::baseStats) + ).apply(i, PlayersData::new) + ); + } } } diff --git a/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java b/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java new file mode 100644 index 00000000..b2f69898 --- /dev/null +++ b/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java @@ -0,0 +1,160 @@ +package io.github.gaming32.bingo.rating; + +import com.pocketcombats.openskill.Adjudicator; +import com.pocketcombats.openskill.QualityEvaluator; +import com.pocketcombats.openskill.RatingModelConfig; +import com.pocketcombats.openskill.aggregate.DefaultTeamRatingAggregator; +import com.pocketcombats.openskill.aggregate.TeamRatingAggregator; +import com.pocketcombats.openskill.data.MatchMakingRating; +import com.pocketcombats.openskill.data.SimpleMatchMakingRating; +import com.pocketcombats.openskill.math.Gaussian; +import com.pocketcombats.openskill.model.PlackettLuce; +import com.pocketcombats.openskill.model.RatingModel; +import io.github.gaming32.bingo.util.BingoUtil; +import it.unimi.dsi.fastutil.doubles.DoubleArrayList; +import java.util.List; +import java.util.UUID; + +public final class BingoRatingEngine { + private static final RatingModelConfig CONFIG = RatingModelConfig.builder() + .setZ(3.0) + .setAlpha(25.0) + .setTarget(1200.0) + .build(); + private static final RatingModel MODEL = new PlackettLuce(CONFIG); + + public static final Adjudicator ADJUDICATOR = new Adjudicator<>(CONFIG, MODEL); + public static final TeamRatingAggregator AGGREGATOR = new DefaultTeamRatingAggregator(CONFIG); + public static final MatchMakingRating DEFAULT_RATING = new SimpleMatchMakingRating(25.0, 25.0 / 3.0); + + private BingoRatingEngine() { + } + + public static double bingoRating(MatchMakingRating rating) { + return ordinalToBingoRating(ordinal(rating)); + } + + private static double ordinal(MatchMakingRating rating) { + return rating.mu() - CONFIG.balance().z() * rating.sigma(); + } + + private static double ordinalToBingoRating(double ordinal) { + return CONFIG.balance().target() + ordinal * CONFIG.balance().alpha(); + } + + // https://github.com/vivekjoshy/openskill.py/blob/65a675d21d9a0d5405731385e83be9aa781537ec/openskill/models/weng_lin/plackett_luce.py#L964 + public static double predictDraw(List> teams) { + final var totalPlayerCount = teams.stream().mapToInt(List::size).sum(); + final var drawProbability = 1.0 / totalPlayerCount; + final var drawMargin = + Math.sqrt(totalPlayerCount) + * CONFIG.beta() + * inverseCdf((1.0 + drawProbability) / 2.0); + + record TeamRating(double mu, double sigmaSquared) { + } + final var teamRatings = teams.stream() + .map(team -> { + var mu = 0.0; + var sigmaSquared = 0.0; + for (final var rate : team) { + mu += rate.mu(); + sigmaSquared += rate.sigma() * rate.sigma(); + } + return new TeamRating(mu, sigmaSquared); + }) + .toList(); + + final var pairwiseProbabilities = new DoubleArrayList(BingoUtil.fullMesh(teams.size())); + for (int i = 0; i < teams.size() - 1; i++) { + for (int j = i + 1; j < teams.size(); j++) { + final var pairA = teamRatings.get(i); + final var pairB = teamRatings.get(j); + final var root = Math.sqrt(2 * CONFIG.beta() * CONFIG.beta() + pairA.sigmaSquared + pairB.sigmaSquared); + pairwiseProbabilities.add( + Gaussian.cdf((drawMargin - pairA.mu + pairB.mu) / root) + - Gaussian.cdf((pairB.mu - pairA.mu - drawMargin) / root) + ); + } + } + return pairwiseProbabilities.doubleStream().average().orElse(1.0); + } + + // https://github.com/python/cpython/blob/09ff114e8c6a532ca6025450141c47df4db12f32/Lib/statistics.py#L1797 + private static double inverseCdf(double p) { + final var mu = 0.0; + final var sigma = 1.0; + + final var q = p - 0.5; + + if (Math.abs(q) <= 0.425) { + final var r = 0.180625 - q * q; + final var num = (((((((2.50908_09287_30122_6727e+3 * r + + 3.34305_75583_58812_8105e+4) * r + + 6.72657_70927_00870_0853e+4) * r + + 4.59219_53931_54987_1457e+4) * r + + 1.37316_93765_50946_1125e+4) * r + + 1.97159_09503_06551_4427e+3) * r + + 1.33141_66789_17843_7745e+2) * r + + 3.38713_28727_96366_6080e+0) * q; + final var den = (((((((5.22649_52788_52854_5610e+3 * r + + 2.87290_85735_72194_2674e+4) * r + + 3.93078_95800_09271_0610e+4) * r + + 2.12137_94301_58659_5867e+4) * r + + 5.39419_60214_24751_1077e+3) * r + + 6.87187_00749_20579_0830e+2) * r + + 4.23133_30701_60091_1252e+1) * r + + 1.0); + final var x = num / den; + return mu + x * sigma; + } + + final double num, den; + var r = q <= 0.0 ? p : 1.0 - p; + r = Math.sqrt(-Math.log(r)); + if (r <= 5.0) { + r = r - 1.6; + num = (((((((7.74545_01427_83414_07640e-4 * r + + 2.27238_44989_26918_45833e-2) * r + + 2.41780_72517_74506_11770e-1) * r + + 1.27045_82524_52368_38258e+0) * r + + 3.64784_83247_63204_60504e+0) * r + + 5.76949_72214_60691_40550e+0) * r + + 4.63033_78461_56545_29590e+0) * r + + 1.42343_71107_49683_57734e+0); + den = (((((((1.05075_00716_44416_84324e-9 * r + + 5.47593_80849_95344_94600e-4) * r + + 1.51986_66563_61645_71966e-2) * r + + 1.48103_97642_74800_74590e-1) * r + + 6.89767_33498_51000_04550e-1) * r + + 1.67638_48301_83803_84940e+0) * r + + 2.05319_16266_37758_82187e+0) * r + + 1.0); + } else { + r = r - 5.0; + num = (((((((2.01033_43992_92288_13265e-7 * r + + 2.71155_55687_43487_57815e-5) * r + + 1.24266_09473_88078_43860e-3) * r + + 2.65321_89526_57612_30930e-2) * r + + 2.96560_57182_85048_91230e-1) * r + + 1.78482_65399_17291_33580e+0) * r + + 5.46378_49111_64114_36990e+0) * r + + 6.65790_46435_01103_77720e+0); + den = (((((((2.04426_31033_89939_78564e-15 * r + + 1.42151_17583_16445_88870e-7) * r + + 1.84631_83175_10054_68180e-5) * r + + 7.86869_13114_56132_59100e-4) * r + + 1.48753_61290_85061_48525e-2) * r + + 1.36929_88092_27358_05310e-1) * r + + 5.99832_20655_58879_37690e-1) * r + + 1.0); + } + + var x = num / den; + if (q < 0.0) { + x = -x; + } + + return mu + x * sigma; + } +} diff --git a/src/main/java/io/github/gaming32/bingo/rating/BingoRatings.java b/src/main/java/io/github/gaming32/bingo/rating/BingoRatings.java new file mode 100644 index 00000000..414d4f52 --- /dev/null +++ b/src/main/java/io/github/gaming32/bingo/rating/BingoRatings.java @@ -0,0 +1,49 @@ +package io.github.gaming32.bingo.rating; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import com.pocketcombats.openskill.data.MatchMakingRating; +import com.pocketcombats.openskill.data.SimpleMatchMakingRating; +import io.github.gaming32.bingo.util.Identifiers; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.minecraft.core.UUIDUtil; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +public final class BingoRatings extends SavedData { + private static final Codec RATING_CODEC = RecordCodecBuilder.create( + i -> i.group( + Codec.DOUBLE.fieldOf("mu").forGetter(MatchMakingRating::mu), + Codec.DOUBLE.fieldOf("sigma").forGetter(MatchMakingRating::sigma) + ).apply(i, SimpleMatchMakingRating::new) + ); + public static final Codec CODEC = RecordCodecBuilder.create( + i -> i.group( + Codec.unboundedMap(UUIDUtil.STRING_CODEC, RATING_CODEC).fieldOf("ratings") + .forGetter(BingoRatings::getRatings) + ).apply(i, BingoRatings::new) + ); + public static final SavedDataType TYPE = new SavedDataType<>( + Identifiers.bingo("ratings"), BingoRatings::new, CODEC, null + ); + + private final Map ratings; + + public BingoRatings() { + this.ratings = new HashMap<>(); + } + + public BingoRatings(Map ratings) { + this.ratings = new HashMap<>(ratings); + } + + public Map getRatings() { + return ratings; + } + + public MatchMakingRating getRating(UUID player) { + return ratings.getOrDefault(player, BingoRatingEngine.DEFAULT_RATING); + } +} diff --git a/src/main/java/io/github/gaming32/bingo/rating/package-info.java b/src/main/java/io/github/gaming32/bingo/rating/package-info.java new file mode 100644 index 00000000..d5241447 --- /dev/null +++ b/src/main/java/io/github/gaming32/bingo/rating/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package io.github.gaming32.bingo.rating; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java b/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java index 88d9a5c7..ecdb84c6 100644 --- a/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java +++ b/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java @@ -3,6 +3,7 @@ import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Multimap; +import com.google.common.collect.Sets; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.mojang.datafixers.util.Either; @@ -13,6 +14,25 @@ import com.mojang.serialization.JsonOps; import io.github.gaming32.bingo.Bingo; import it.unimi.dsi.fastutil.Hash; +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.ints.IntArraySet; +import it.unimi.dsi.fastutil.ints.IntCollection; +import it.unimi.dsi.fastutil.ints.IntList; +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.IntConsumer; +import java.util.function.Supplier; +import java.util.stream.Collector; +import java.util.stream.IntStream; import net.minecraft.SharedConstants; import net.minecraft.commands.arguments.ResourceOrTagKeyArgument; import net.minecraft.core.Holder; @@ -33,23 +53,13 @@ import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.players.PlayerList; import net.minecraft.util.RandomSource; +import net.minecraft.util.Util; import net.minecraft.world.item.ItemInstance; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStackTemplate; import net.minecraft.world.phys.Vec3; import net.minecraft.world.scores.PlayerTeam; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.function.Supplier; -import java.util.stream.Collector; - public class BingoUtil { private static final Hash.Strategy> HOLDER_STRATEGY = new Hash.Strategy<>() { @Override @@ -317,4 +327,85 @@ public static DataComponentPatch.Builder builderFrom(DataComponentPatch patch) { public static T sneakyThrow(Throwable t) throws T { throw (T) t; } + + public static void forEachGroupParallel(List items, IntCollection sizes, Consumer>> handler) { + final var indices = new IntArraySet(IntStream.range(0, items.size()).toArray()); + final var sizesList = new IntArrayList(sizes); + sizesList.unstableSort(null); + forEachGroupParallelInner(indices, items, sizesList, 0, IntList.of(), handler); + } + + private static void forEachGroupParallelInner( + IntSet remaining, + List items, + IntList sizes, + int depth, + IntList prev, + Consumer>> handler + ) { + if (depth == sizes.size()) { + handler.accept(new ArrayList<>(depth)); + return; + } + + final int size = sizes.getInt(depth); + final var sameAsPrev = depth > 0 && sizes.getInt(depth - 1) == size; + + Sets.combinations(remaining, size).parallelStream().forEach(chosen -> { + final var chosenList = new IntArrayList(chosen); + chosenList.unstableSort(null); + if (sameAsPrev && chosenList.compareTo(prev) <= 0) { + return; + } + + final var rest = new IntOpenHashSet(remaining); + rest.removeAll(chosenList); + forEachGroupParallelInner(rest, items, sizes, depth + 1, chosenList, results -> { + results.add(chosenList.intStream().mapToObj(items::get).toList()); + handler.accept(results); + }); + }); + } + + // https://stackoverflow.com/a/39199937/8840278 + public static void forEachGroupParallel(List items, Consumer>> handler) { + IntStream.rangeClosed(1, items.size()) + .parallel() + .forEach(k -> forEachGroupParallelInner(items.size(), k, items, List.of(), 0, handler)); + } + + private static void forEachGroupParallelInner(int n, int k, List items, List> groups, int depth, Consumer>> handler) { + if (depth >= n) { + handler.accept(groups); + return; + } + + final IntConsumer handleGroup = groupIndex -> { + final var newGroups = new ArrayList<>(groups); + if (groupIndex < groups.size()) { + newGroups.set(groupIndex, Util.copyAndAdd(groups.get(groupIndex), items.get(depth))); + } else { + newGroups.add(List.of(items.get(depth))); + } + forEachGroupParallelInner(n, k, items, newGroups, depth + 1, handler); + }; + + final var editGroups = n - depth > k - groups.size(); + final var makeNewGroup = groups.size() < k; + if (editGroups && makeNewGroup) { + IntStream.rangeClosed(0, groups.size()).parallel().forEach(handleGroup); + } else if (editGroups) { + IntStream.range(0, groups.size()).parallel().forEach(handleGroup); + } else { + handleGroup.accept(groups.size()); + } + } + + public static int fullMesh(int n) { + if ((n & 1) == 0) { + return n / 2 * (n - 1); + } else { + return (n - 1) / 2 * n; + } + } } diff --git a/src/main/resources/assets/bingo/lang/en_us.json b/src/main/resources/assets/bingo/lang/en_us.json index 8a1ea3cb..662eb173 100644 --- a/src/main/resources/assets/bingo/lang/en_us.json +++ b/src/main/resources/assets/bingo/lang/en_us.json @@ -270,6 +270,8 @@ "bingo.finished": "Team %s has finished in %s place!", "bingo.finished.single": "%s has finished in %s place!", "bingo.finished.tie": "%s have finished in joint %s place!", + "bingo.rating_changed.increase": "Your Bingo Rating has increased from %s to %s (+%s).", + "bingo.rating_changed.decrease": "Your Bingo Rating has decreased from %s to %s (-%s).", "bingo.forfeited": "Team %s has forfeited!", "bingo.forfeited.single": "%s has forfeited!", "bingo.goal_obtained": "%s got the goal %s for your team.", @@ -290,6 +292,14 @@ "bingo.team_already_exists": "The team %s already exists.", "bingo.added_to_teams": "Added %s player(s) to %s team(s).", "bingo.no_teams": "No teams exist. Use \"/bingo teams create\" to create some teams first.", + "bingo.balance.too_many_players": "Too many players were provided. This command only supports up to %s players, but %s were given.", + "bingo.balance.not_enough_teams": "Not enough created teams. You asked to balance %s teams, but only %s teams exist on the server.", + "bingo.balance.mismatched_player_count": "Command required exactly %s players to be online, but %s players were provided.", + "bingo.balance.success": "Balanced %s players across %s teams with %s%% matchup quality.", + "bingo.rate": "Player %s has a rating of %s", + "bingo.leaderboard.header": "Top %s highest-rated Bingo players:", + "bingo.leaderboard.player": " - #%s %s is rated %s", + "bingo.formatting.black": "Black", "bingo.formatting.dark_blue": "Dark Blue", "bingo.formatting.dark_green": "Dark Green",