From 35d0349a41e96ec08b644a668ecd2508f166f359 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Sun, 24 May 2026 11:27:38 -0500 Subject: [PATCH 01/16] Initial rating system --- build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + .../github/gaming32/bingo/game/BingoGame.java | 291 ++++++++++++++---- .../bingo/rating/BingoRatingEngine.java | 43 +++ .../gaming32/bingo/rating/BingoRatings.java | 49 +++ .../resources/assets/bingo/lang/en_us.json | 4 +- 6 files changed, 323 insertions(+), 67 deletions(-) create mode 100644 src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java create mode 100644 src/main/java/io/github/gaming32/bingo/rating/BingoRatings.java 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/game/BingoGame.java b/src/main/java/io/github/gaming32/bingo/game/BingoGame.java index cebe5c18..2cb987cd 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." @@ -94,12 +104,25 @@ 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; + private boolean rated = true; // TODO: --unrated? - 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, + long scheduledEndTime, + int autoForfeitTicks, + PlayerTeam... teams + ) { this.board = board; this.gameMode = gameMode; this.requireClient = requireClient; @@ -109,6 +132,10 @@ public BingoGame(BingoBoard board, BingoGameMode gameMode, boolean requireClient 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 +165,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 +177,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 +199,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 +286,19 @@ public void endGame(PlayerList playerList) { final Component message; if (!winningTeams.any()) { winningTeams = getWinner(true); + if (winningTeams.any()) { + ranks.addFirst(winningTeams); + } else { + winningTeams = remainingTeams; + remainingTeams = BingoBoard.Teams.NONE; + if (winningTeams.any()) { + ranks.addFirst(winningTeams); + } + } } - if (!winningTeams.any()) { - winningTeams = 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 +320,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 +328,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) { + applyRatingChanges(playerList); + } + ((MinecraftServerExt) playerList.getServer()).bingo$setGame(null); new ResyncStatesPayload(board.getStates()).sendTo(playerList.getPlayers()); if (scheduledEndTime > 0) { @@ -295,6 +341,60 @@ 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); + final var formattedOldRating = BingoRatingEngine.formatBingoRating(oldBingoRating); + final var formattedNewRating = BingoRatingEngine.formatBingoRating(newBingoRating); + Bingo.LOGGER.info( + "Player {} has changed their rank {} -> {}", + player != null ? player.getPlainTextName() : adjustment.playerId().toString(), + formattedOldRating, + formattedNewRating + ); + if (player != null) { + player.sendSystemMessage(Component.translatable( + newBingoRating >= oldBingoRating + ? "bingo.rating_changed.increase" + : "bingo.rating_changed.decrease", + formattedOldRating, + formattedNewRating + )); + } + } + + ratings.setDirty(); + } + public void tick(MinecraftServer server) { if (requireClient) { for (final ServerPlayer player : new ArrayList<>(server.getPlayerList().getPlayers())) { @@ -304,20 +404,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 +447,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 +519,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 +586,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 +623,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 +767,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 +792,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 +876,8 @@ public void checkForWin(PlayerList playerList) { } remainingTeams = remainingTeams.andNot(newFinishers); + ranks.addFirst(newFinishers); + if (continueAfterWin) { notifyFinishedTeam(playerList, newFinishers, place); } @@ -799,7 +919,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() )); } } @@ -834,27 +954,16 @@ public record PersistenceData( 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 + BingoBoard.Teams nerfedTeams, + boolean rated ) { - 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), @@ -864,15 +973,18 @@ public record PersistenceData( 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) + BingoBoard.Teams.CODEC.optionalFieldOf("nerfed_teams", BingoBoard.Teams.NONE).forGetter(PersistenceData::nerfedTeams), + Codec.BOOL.optionalFieldOf("rated", false).forGetter(PersistenceData::rated) ).apply(instance, PersistenceData::new) ); @@ -886,7 +998,7 @@ public BingoGame createGame(Scoreboard scoreboard) throws IllegalStateException } final BingoGame game = new BingoGame(board, gameMode, requireClient, continueAfterWin, 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 +1009,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 +1017,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 +1025,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,12 +1033,19 @@ 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; + game.rated = rated; + return game; } @@ -957,10 +1076,21 @@ private static PersistenceData create(BingoGame game) { game.board, game.gameMode, game.requireClient, game.continueAfterWin, 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, + game.rated ); } @@ -979,5 +1109,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..386b8072 --- /dev/null +++ b/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java @@ -0,0 +1,43 @@ +package io.github.gaming32.bingo.rating; + +import com.pocketcombats.openskill.Adjudicator; +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.model.PlackettLuce; +import com.pocketcombats.openskill.model.RatingModel; +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 String formatBingoRating(double bingoRating) { + return Long.toString((long) bingoRating); + } + + 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(); + } +} 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/resources/assets/bingo/lang/en_us.json b/src/main/resources/assets/bingo/lang/en_us.json index 8a1ea3cb..6e21256d 100644 --- a/src/main/resources/assets/bingo/lang/en_us.json +++ b/src/main/resources/assets/bingo/lang/en_us.json @@ -374,5 +374,7 @@ "bingo.ordinal.14": "fourteenth", "bingo.ordinal.15": "fifteenth", "bingo.ordinal.16": "sixteenth", - "bingo.ordinal.generic": "%sth" + "bingo.ordinal.generic": "%sth", + "bingo.rating_changed.increase": "Your Bingo Rating has increased from %s to %s.", + "bingo.rating_changed.decrease": "Your Bingo Rating has decreased from %s to %s." } \ No newline at end of file From 28ac1f3b237325d27175bfed2852d86e555309c4 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Sun, 24 May 2026 12:36:59 -0500 Subject: [PATCH 02/16] Bug fixes and --unrated --- .../github/gaming32/bingo/BingoCommand.java | 5 ++- .../github/gaming32/bingo/game/BingoGame.java | 39 ++++++++++--------- .../bingo/rating/BingoRatingEngine.java | 4 -- .../resources/assets/bingo/lang/en_us.json | 4 +- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index 0a875416..ff742548 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -136,6 +136,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()) @@ -439,6 +440,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); @@ -487,6 +489,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 +542,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())) { 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 2cb987cd..af1b71b8 100644 --- a/src/main/java/io/github/gaming32/bingo/game/BingoGame.java +++ b/src/main/java/io/github/gaming32/bingo/game/BingoGame.java @@ -92,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; @@ -111,7 +112,6 @@ public class BingoGame { private BingoBoard.Teams winningTeams = BingoBoard.Teams.NONE; private BingoBoard.Teams finishedTeams = BingoBoard.Teams.NONE; private BingoBoard.Teams nerfedTeams = BingoBoard.Teams.NONE; - private boolean rated = true; // TODO: --unrated? @SuppressWarnings("unchecked") public BingoGame( @@ -119,6 +119,7 @@ public BingoGame( BingoGameMode gameMode, boolean requireClient, boolean continueAfterWin, + boolean rated, long scheduledEndTime, int autoForfeitTicks, PlayerTeam... teams @@ -127,6 +128,7 @@ public BingoGame( this.gameMode = gameMode; this.requireClient = requireClient; this.continueAfterWin = continueAfterWin; + this.rated = rated; this.scheduledEndTime = scheduledEndTime; this.autoForfeitTicks = autoForfeitTicks; this.teams = teams; @@ -296,6 +298,9 @@ public void endGame(PlayerList playerList) { } } } + if (remainingTeams.any()) { + ranks.add(remainingTeams); + } Collections.reverse(forfeitedTeams); // Forfeited teams get a higher ranking if they forfeited later ranks.addAll(forfeitedTeams); @@ -329,7 +334,7 @@ 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) { + if (rated && (ranks.size() > 1 || winningTeams.count() != 1)) { applyRatingChanges(playerList); } @@ -373,21 +378,20 @@ private void applyRatingChanges(PlayerList playerList) { ); final var oldBingoRating = BingoRatingEngine.bingoRating(oldRating); final var newBingoRating = BingoRatingEngine.bingoRating(newRating); - final var formattedOldRating = BingoRatingEngine.formatBingoRating(oldBingoRating); - final var formattedNewRating = BingoRatingEngine.formatBingoRating(newBingoRating); Bingo.LOGGER.info( "Player {} has changed their rank {} -> {}", player != null ? player.getPlainTextName() : adjustment.playerId().toString(), - formattedOldRating, - formattedNewRating + (int) oldBingoRating, + (int) newBingoRating ); if (player != null) { - player.sendSystemMessage(Component.translatable( + player.sendSystemMessage(Bingo.translatable( newBingoRating >= oldBingoRating ? "bingo.rating_changed.increase" : "bingo.rating_changed.decrease", - formattedOldRating, - formattedNewRating + (int) oldBingoRating, + (int) newBingoRating, + Math.abs((int) newBingoRating - (int) oldBingoRating) )); } } @@ -951,6 +955,7 @@ public record PersistenceData( BingoGameMode gameMode, boolean requireClient, boolean continueAfterWin, + boolean rated, long scheduledEndTime, int autoForfeitTicks, List teamNames, @@ -961,8 +966,7 @@ public record PersistenceData( List forfeitedTeams, BingoBoard.Teams winningTeams, BingoBoard.Teams finishedTeams, - BingoBoard.Teams nerfedTeams, - boolean rated + BingoBoard.Teams nerfedTeams ) { public static final Codec CODEC = RecordCodecBuilder.create( instance -> instance.group( @@ -970,6 +974,7 @@ public record PersistenceData( 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), @@ -983,8 +988,7 @@ public record PersistenceData( 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), - Codec.BOOL.optionalFieldOf("rated", false).forGetter(PersistenceData::rated) + BingoBoard.Teams.CODEC.optionalFieldOf("nerfed_teams", BingoBoard.Teams.NONE).forGetter(PersistenceData::nerfedTeams) ).apply(instance, PersistenceData::new) ); @@ -996,7 +1000,7 @@ 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 : playersData.advancementProgress.entrySet()) { final Map subTarget = HashMap.newHashMap(entry.getValue().size()); @@ -1044,8 +1048,6 @@ public BingoGame createGame(Scoreboard scoreboard) throws IllegalStateException game.winningTeams = winningTeams; game.finishedTeams = finishedTeams; - game.rated = rated; - return game; } @@ -1073,7 +1075,7 @@ 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(), new PlayersData( @@ -1089,8 +1091,7 @@ private static PersistenceData create(BingoGame game) { game.forfeitedTeams, game.winningTeams, game.finishedTeams, - game.nerfedTeams, - game.rated + game.nerfedTeams ); } diff --git a/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java b/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java index 386b8072..fe8899ab 100644 --- a/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java +++ b/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java @@ -25,10 +25,6 @@ public final class BingoRatingEngine { private BingoRatingEngine() { } - public static String formatBingoRating(double bingoRating) { - return Long.toString((long) bingoRating); - } - public static double bingoRating(MatchMakingRating rating) { return ordinalToBingoRating(ordinal(rating)); } diff --git a/src/main/resources/assets/bingo/lang/en_us.json b/src/main/resources/assets/bingo/lang/en_us.json index 6e21256d..81550234 100644 --- a/src/main/resources/assets/bingo/lang/en_us.json +++ b/src/main/resources/assets/bingo/lang/en_us.json @@ -375,6 +375,6 @@ "bingo.ordinal.15": "fifteenth", "bingo.ordinal.16": "sixteenth", "bingo.ordinal.generic": "%sth", - "bingo.rating_changed.increase": "Your Bingo Rating has increased from %s to %s.", - "bingo.rating_changed.decrease": "Your Bingo Rating has decreased from %s to %s." + "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)." } \ No newline at end of file From 55625903a9b6d09c3366e7c17d845263e5693a5c Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Sun, 24 May 2026 14:51:42 -0500 Subject: [PATCH 03/16] Add "/bingo teams balance" command --- .../github/gaming32/bingo/BingoCommand.java | 129 ++++++++++++++++-- .../bingo/rating/BingoRatingEngine.java | 2 + .../github/gaming32/bingo/util/BingoUtil.java | 47 +++++-- .../resources/assets/bingo/lang/en_us.json | 3 + 4 files changed, 161 insertions(+), 20 deletions(-) diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index ff742548..052f227b 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -8,6 +8,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 +31,20 @@ 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 java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import net.minecraft.ChatFormatting; import net.minecraft.commands.CommandBuildContext; import net.minecraft.commands.CommandSourceStack; @@ -67,16 +81,10 @@ import net.minecraft.world.scores.PlayerTeam; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.function.TriFunction; +import org.apache.commons.lang3.mutable.MutableObject; -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.*; +import static net.minecraft.commands.Commands.argument; +import static net.minecraft.commands.Commands.literal; public class BingoCommand { private static final SimpleCommandExceptionType NO_GAME_RUNNING = @@ -123,6 +131,11 @@ public class BingoCommand { private static final SimpleCommandExceptionType NOT_NERFED = new SimpleCommandExceptionType(Bingo.translatable("bingo.not_nerfed")); + private static final DynamicCommandExceptionType DUPLICATE_BALANCE_TEAMS = + new DynamicCommandExceptionType(team -> Bingo.translatableEscape("bingo.balance.duplicate_teams", ((PlayerTeam)team).getFormattedDisplayName())); + 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) { @@ -386,6 +399,9 @@ public Component getDisplayName() { ) ) ) + .then(literal("balance") + .then(argument("players", EntityArgument.players())) + ) ) .then(literal("time-limit") .requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER)) @@ -463,6 +479,20 @@ 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 subTree = argument("team" + i, TeamArgument.team()) + .then(argument("team-size-" + i, IntegerArgumentType.integer(1)) + .executes(context -> balanceTeams(context, teamCount)) + ) + .build(); + currentCommand.addChild(subTree); + currentCommand = subTree.getChild("team-size-" + i); + } + } } private static int startGame(CommandContext context, int teamCount) throws CommandSyntaxException { @@ -661,4 +691,85 @@ private static int randomizeTeams( ); return players.size(); } + + private static int balanceTeams(CommandContext context, int teamCount) throws CommandSyntaxException { + final var server = context.getSource().getServer(); + final var players = EntityArgument.getPlayers(context, "players"); + + final var teams = LinkedHashMap.newLinkedHashMap(teamCount); + var totalPlayers = 0; + for (int i = 1; i <= teamCount; i++) { + final var team = TeamArgument.getTeam(context, "team" + i); + final var teamSize = IntegerArgumentType.getInteger(context, "team-size-" + i); + totalPlayers += teamSize; + if (teams.put(team, teamSize) != null) { + throw DUPLICATE_BALANCE_TEAMS.create(team); + } + } + if (totalPlayers != players.size()) { + throw MISMATCHED_PLAYER_COUNT.create(totalPlayers, players.size()); + } + + final var ratings = server.getDataStorage().computeIfAbsent(BingoRatings.TYPE); + + final MutableObject>>, Double>> bestChoice = new MutableObject<>(); + BingoUtil.forEachGroup(Set.copyOf(players), teams, possibility -> { + final var createdTeams = possibility.stream() + .map(possibleTeam -> + BingoRatingEngine.AGGREGATOR.computeTeamRating( + possibleTeam.getValue() + .stream() + .map(player -> ratings.getRating(player.getUUID())) + .toList() + ) + ) + .toList(); + var qualitySum = 0.0; + for (int i = 0; i < createdTeams.size() - 1; i++) { + for (int j = i + 1; j < createdTeams.size(); j++) { + var teamA = createdTeams.get(i); + var teamB = createdTeams.get(j); + if (teamB.mu() > teamA.mu()) { + final var tmp = teamB; + teamB = teamA; + teamA = tmp; + } + qualitySum += BingoRatingEngine.QUALITY_EVALUATOR.evaluateQuality(teamA, teamB) / createdTeams.size(); + } + } + if (bestChoice.get() == null || qualitySum > bestChoice.get().getValue()) { + Bingo.LOGGER.info("Found new best score: {}", qualitySum); + bestChoice.setValue(Map.entry(possibility, qualitySum)); + } + }); + + Bingo.LOGGER.info("Best score found: {}", bestChoice.get().getValue()); + Bingo.LOGGER.info( + "Teams:\n{}", + bestChoice.get() + .getKey() + .stream() + .map(entry -> entry.getKey().getName() + " -> " + entry.getValue()) + .collect(Collectors.joining("\n")) + ); + + final var scoreboard = server.getScoreboard(); + for (final var foundTeam : bestChoice.get().getKey()) { + for (final var player : foundTeam.getValue()) { + scoreboard.addPlayerToTeam(player.getScoreboardName(), foundTeam.getKey()); + } + } + + context.getSource().sendSuccess( + () -> Bingo.translatable( + "bingo.balance.success", + players.size(), + teams.size(), + Math.round(bestChoice.get().getValue() * 100.0) + ), + true + ); + + return 0; + } } diff --git a/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java b/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java index fe8899ab..c2cdfb75 100644 --- a/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java +++ b/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java @@ -1,6 +1,7 @@ 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; @@ -20,6 +21,7 @@ public final class BingoRatingEngine { public static final Adjudicator ADJUDICATOR = new Adjudicator<>(CONFIG, MODEL); public static final TeamRatingAggregator AGGREGATOR = new DefaultTeamRatingAggregator(CONFIG); + public static final QualityEvaluator QUALITY_EVALUATOR = new QualityEvaluator(CONFIG); public static final MatchMakingRating DEFAULT_RATING = new SimpleMatchMakingRating(25.0, 25.0 / 3.0); private BingoRatingEngine() { 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..e99f64ad 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,17 @@ import com.mojang.serialization.JsonOps; import io.github.gaming32.bingo.Bingo; import it.unimi.dsi.fastutil.Hash; +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.Supplier; +import java.util.stream.Collector; import net.minecraft.SharedConstants; import net.minecraft.commands.arguments.ResourceOrTagKeyArgument; import net.minecraft.core.Holder; @@ -39,17 +51,6 @@ 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 +318,28 @@ public static DataComponentPatch.Builder builderFrom(DataComponentPatch patch) { public static T sneakyThrow(Throwable t) throws T { throw (T) t; } + + public static void forEachGroup(Set items, Map groups, Consumer>>> handler) { + forEachGroupInner(items, List.copyOf(groups.entrySet()), 0, handler); + } + + private static void forEachGroupInner( + Set remaining, + List> groups, + int depth, + Consumer>>> handler + ) { + if (depth == groups.size()) { + handler.accept(new ArrayList<>()); + return; + } + final var group = groups.get(depth); + for (final var chosen : Sets.combinations(remaining, group.getValue())) { + final var rest = Sets.difference(remaining, chosen).immutableCopy(); + forEachGroupInner(rest, groups, depth + 1, (extra) -> { + extra.addFirst(Map.entry(group.getKey(), chosen)); + handler.accept(extra); + }); + } + } } diff --git a/src/main/resources/assets/bingo/lang/en_us.json b/src/main/resources/assets/bingo/lang/en_us.json index 81550234..11cdc5ec 100644 --- a/src/main/resources/assets/bingo/lang/en_us.json +++ b/src/main/resources/assets/bingo/lang/en_us.json @@ -290,6 +290,9 @@ "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.duplicate_teams": "Duplicate teams in teams list (%s)", + "bingo.balance.mismatched_player_count": "Command required exactly %s players to be online, but %s players are online.", + "bingo.balance.success": "Balanced %s players across %s teams with %s%% quality.", "bingo.formatting.black": "Black", "bingo.formatting.dark_blue": "Dark Blue", "bingo.formatting.dark_green": "Dark Green", From c65dcc6d5789bea8389e303c7a5c9a5abb1cffef Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Sun, 24 May 2026 14:57:39 -0500 Subject: [PATCH 04/16] Move bingo.rating_changed translations --- src/main/resources/assets/bingo/lang/en_us.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/resources/assets/bingo/lang/en_us.json b/src/main/resources/assets/bingo/lang/en_us.json index 11cdc5ec..8047a983 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.", @@ -293,6 +295,7 @@ "bingo.balance.duplicate_teams": "Duplicate teams in teams list (%s)", "bingo.balance.mismatched_player_count": "Command required exactly %s players to be online, but %s players are online.", "bingo.balance.success": "Balanced %s players across %s teams with %s%% quality.", + "bingo.formatting.black": "Black", "bingo.formatting.dark_blue": "Dark Blue", "bingo.formatting.dark_green": "Dark Green", @@ -377,7 +380,5 @@ "bingo.ordinal.14": "fourteenth", "bingo.ordinal.15": "fifteenth", "bingo.ordinal.16": "sixteenth", - "bingo.ordinal.generic": "%sth", - "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.ordinal.generic": "%sth" } \ No newline at end of file From 27362dc89edb7aae32f96717adef4a9fb7fcb8bb Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Sun, 24 May 2026 15:21:26 -0500 Subject: [PATCH 05/16] Fix checkstyle --- src/main/java/io/github/gaming32/bingo/BingoCommand.java | 3 +-- .../java/io/github/gaming32/bingo/rating/package-info.java | 4 ++++ 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/github/gaming32/bingo/rating/package-info.java diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index 052f227b..bdb52f60 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -83,8 +83,7 @@ import org.apache.commons.lang3.function.TriFunction; import org.apache.commons.lang3.mutable.MutableObject; -import static net.minecraft.commands.Commands.argument; -import static net.minecraft.commands.Commands.literal; +import static net.minecraft.commands.Commands.*; public class BingoCommand { private static final SimpleCommandExceptionType NO_GAME_RUNNING = 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; From cdd79d0e282469f1298e4f155d1ba8e6ac589bea Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Sun, 24 May 2026 16:47:07 -0500 Subject: [PATCH 06/16] Don't consider duplicate possibilities in group finding Consider: 10 teams all of size 1. Before, this would result in 3,628,800 (10!) possibilities being checked, now it's only 1. --- .../github/gaming32/bingo/BingoCommand.java | 95 +++++++++++-------- .../github/gaming32/bingo/util/BingoUtil.java | 43 ++++++--- .../resources/assets/bingo/lang/en_us.json | 2 +- 3 files changed, 88 insertions(+), 52 deletions(-) diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index bdb52f60..5f472506 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -1,6 +1,8 @@ package io.github.gaming32.bingo; import com.demonwav.mcdev.annotations.Translatable; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimaps; import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.IntegerArgumentType; @@ -35,16 +37,6 @@ import io.github.gaming32.bingo.rating.BingoRatings; import io.github.gaming32.bingo.util.BingoUtil; import io.github.gaming32.bingo.util.Vec2i; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; import net.minecraft.ChatFormatting; import net.minecraft.commands.CommandBuildContext; import net.minecraft.commands.CommandSourceStack; @@ -83,6 +75,17 @@ import org.apache.commons.lang3.function.TriFunction; import org.apache.commons.lang3.mutable.MutableObject; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + import static net.minecraft.commands.Commands.*; public class BingoCommand { @@ -136,7 +139,7 @@ public class BingoCommand { 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(); + final var game = ((MinecraftServerExt)context.getSource().getServer()).bingo$getGame(); if (game == null) { return builder.buildFuture(); } @@ -204,9 +207,9 @@ public static void register( ) .then(literal("stop") .requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER) - && source.getServer() != null && ((MinecraftServerExt) source.getServer()).bingo$getGame() != null) + && source.getServer() != null && ((MinecraftServerExt)source.getServer()).bingo$getGame() != null) .executes(ctx -> { - final var game = ((MinecraftServerExt) ctx.getSource().getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt)ctx.getSource().getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -219,18 +222,18 @@ public static void register( .executes(BingoCommand::resetGame) ) .then(literal("forfeit") - .requires(source -> source.getServer() != null && ((MinecraftServerExt) source.getServer()).bingo$getGame() != null) + .requires(source -> source.getServer() != null && ((MinecraftServerExt)source.getServer()).bingo$getGame() != null) .executes(ctx -> forfeit(ctx.getSource())) .then(argument("team", TeamArgument.team()) .requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER) - && source.getServer() != null && ((MinecraftServerExt) source.getServer()).bingo$getGame() != null) + && source.getServer() != null && ((MinecraftServerExt)source.getServer()).bingo$getGame() != null) .executes(ctx -> forfeit(ctx.getSource(), TeamArgument.getTeam(ctx, "team"))) ) ) .then(literal("board") - .requires(source -> source.getServer() != null && ((MinecraftServerExt) source.getServer()).bingo$getGame() != null) + .requires(source -> source.getServer() != null && ((MinecraftServerExt)source.getServer()).bingo$getGame() != null) .executes(ctx -> { - final var game = ((MinecraftServerExt) ctx.getSource().getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt)ctx.getSource().getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -282,7 +285,7 @@ public Component getDisplayName() { }) .then(literal("copy") .executes(ctx -> { - final var game = ((MinecraftServerExt) ctx.getSource().getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt)ctx.getSource().getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -299,7 +302,7 @@ public Component getDisplayName() { .then(literal("difficulties") .requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER)) .executes(ctx -> { - final var game = ((MinecraftServerExt) ctx.getSource().getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt)ctx.getSource().getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -324,7 +327,7 @@ public Component getDisplayName() { ) .then(literal("goals") .requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER) - && source.getServer() != null && ((MinecraftServerExt) source.getServer()).bingo$getGame() != null) + && source.getServer() != null && ((MinecraftServerExt)source.getServer()).bingo$getGame() != null) .then(argument("players", EntityArgument.players()) .then(literal("award") .then(argument("goal", IdentifierArgument.id()) @@ -407,7 +410,7 @@ public Component getDisplayName() { .then(literal("set") .then(argument("time-limit", TimeArgument.time(-1)) .executes(ctx -> { - final var game = ((MinecraftServerExt) ctx.getSource().getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt)ctx.getSource().getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -498,7 +501,7 @@ private static int startGame(CommandContext context, int tea final MinecraftServer server = context.getSource().getServer(); final var playerList = server.getPlayerList(); - final var existingGame = ((MinecraftServerExt) server).bingo$getGame(); + final var existingGame = ((MinecraftServerExt)server).bingo$getGame(); if (existingGame != null) { existingGame.endGame(playerList); } @@ -519,7 +522,7 @@ private static int startGame(CommandContext context, int tea 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 timeLimit = TIME_LIMIT.get(context); final int autoForfeitTicks = AUTO_FORFEIT_TIME.get(context); final Set teams = LinkedHashSet.newLinkedHashSet(teamCount); @@ -528,10 +531,10 @@ private static int startGame(CommandContext context, int tea final PlayerTeam team = TeamArgument.getTeam(context, argName); boolean teamActive = includeInactiveTeams || - playerList.getPlayers() - .stream() - .map(Player::getScoreboardName) - .anyMatch(team.getPlayers()::contains); + playerList.getPlayers() + .stream() + .map(Player::getScoreboardName) + .anyMatch(team.getPlayers()::contains); if (teamActive && !teams.add(team)) { throw DUPLICATE_TEAMS.create(team); } @@ -579,7 +582,7 @@ private static int startGame(CommandContext context, int tea } } - ((MinecraftServerExt) server).bingo$setGame(game); + ((MinecraftServerExt)server).bingo$setGame(game); Bingo.updateCommandTree(playerList); new ArrayList<>(playerList.getPlayers()).forEach(game::addPlayer); playerList.broadcastSystemMessage( @@ -591,7 +594,7 @@ private static int startGame(CommandContext context, int tea private static int resetGame(CommandContext context) { final var server = context.getSource().getServer(); - final var game = ((MinecraftServerExt) server).bingo$getGame(); + final var game = ((MinecraftServerExt)server).bingo$getGame(); if (game != null) { game.endGame(server.getPlayerList()); } @@ -601,7 +604,7 @@ private static int resetGame(CommandContext context) { } private static int forfeit(CommandSourceStack source) throws CommandSyntaxException { - final var game = ((MinecraftServerExt) source.getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt)source.getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -619,7 +622,7 @@ private static int forfeit(CommandSourceStack source) throws CommandSyntaxExcept } private static int forfeit(CommandSourceStack source, PlayerTeam team) throws CommandSyntaxException { - final var game = ((MinecraftServerExt) source.getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt)source.getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -640,7 +643,7 @@ private static int awardOrRevoke( TriFunction action, @Translatable String resultKey ) throws CommandSyntaxException { - final var game = ((MinecraftServerExt) context.getSource().getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt)context.getSource().getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -711,18 +714,19 @@ private static int balanceTeams(CommandContext context, int final var ratings = server.getDataStorage().computeIfAbsent(BingoRatings.TYPE); - final MutableObject>>, Double>> bestChoice = new MutableObject<>(); - BingoUtil.forEachGroup(Set.copyOf(players), teams, possibility -> { + final MutableObject>, Double>> bestChoice = new MutableObject<>(); + BingoUtil.forEachGroup(List.copyOf(players), teams.values(), possibility -> { final var createdTeams = possibility.stream() .map(possibleTeam -> BingoRatingEngine.AGGREGATOR.computeTeamRating( - possibleTeam.getValue() + possibleTeam .stream() .map(player -> ratings.getRating(player.getUUID())) .toList() ) ) .toList(); + final var matchupCount = (double)fullMesh(createdTeams.size()); var qualitySum = 0.0; for (int i = 0; i < createdTeams.size() - 1; i++) { for (int j = i + 1; j < createdTeams.size(); j++) { @@ -733,7 +737,7 @@ private static int balanceTeams(CommandContext context, int teamB = teamA; teamA = tmp; } - qualitySum += BingoRatingEngine.QUALITY_EVALUATOR.evaluateQuality(teamA, teamB) / createdTeams.size(); + qualitySum += BingoRatingEngine.QUALITY_EVALUATOR.evaluateQuality(teamA, teamB) / matchupCount; } } if (bestChoice.get() == null || qualitySum > bestChoice.get().getValue()) { @@ -748,14 +752,19 @@ private static int balanceTeams(CommandContext context, int bestChoice.get() .getKey() .stream() - .map(entry -> entry.getKey().getName() + " -> " + entry.getValue()) + .map(team -> " - " + team.stream().map(ServerPlayer::getPlainTextName).collect(Collectors.joining(", ", "[", "]"))) .collect(Collectors.joining("\n")) ); + final var teamsMapBack = teams.entrySet() + .stream() + .collect(Multimaps.toMultimap(Map.Entry::getValue, Map.Entry::getKey, ArrayListMultimap::create)); + final var scoreboard = server.getScoreboard(); for (final var foundTeam : bestChoice.get().getKey()) { - for (final var player : foundTeam.getValue()) { - scoreboard.addPlayerToTeam(player.getScoreboardName(), foundTeam.getKey()); + final var scoreboardTeam = teamsMapBack.get(foundTeam.size()).removeLast(); + for (final var player : foundTeam) { + scoreboard.addPlayerToTeam(player.getScoreboardName(), scoreboardTeam); } } @@ -771,4 +780,12 @@ private static int balanceTeams(CommandContext context, int return 0; } + + 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/java/io/github/gaming32/bingo/util/BingoUtil.java b/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java index e99f64ad..02282f71 100644 --- a/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java +++ b/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java @@ -1,5 +1,6 @@ package io.github.gaming32.bingo.util; +import com.google.common.collect.Comparators; import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Multimap; @@ -15,6 +16,8 @@ import io.github.gaming32.bingo.Bingo; import it.unimi.dsi.fastutil.Hash; import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -25,6 +28,8 @@ import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collector; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import net.minecraft.SharedConstants; import net.minecraft.commands.arguments.ResourceOrTagKeyArgument; import net.minecraft.core.Holder; @@ -319,26 +324,40 @@ public static T sneakyThrow(Throwable t) throws T { throw (T) t; } - public static void forEachGroup(Set items, Map groups, Consumer>>> handler) { - forEachGroupInner(items, List.copyOf(groups.entrySet()), 0, handler); + public static void forEachGroup(List items, Collection sizes, Consumer>> handler) { + final var sizesList = new ArrayList<>(sizes); + sizesList.sort(null); + final var indices = IntStream.range(0, items.size()).boxed().collect(Collectors.toSet()); + forEachGroupInner(indices, items, sizesList, 0, List.of(), handler); } - private static void forEachGroupInner( - Set remaining, - List> groups, + private static void forEachGroupInner( + Set remaining, + List items, + List sizes, int depth, - Consumer>>> handler + List prev, + Consumer>> handler ) { - if (depth == groups.size()) { + if (depth == sizes.size()) { handler.accept(new ArrayList<>()); return; } - final var group = groups.get(depth); - for (final var chosen : Sets.combinations(remaining, group.getValue())) { + + final int size = sizes.get(depth); + final var sameAsPrev = depth > 0 && sizes.get(depth - 1) == size; + + for (final var chosen : Sets.combinations(remaining, size)) { + final var chosenList = new ArrayList<>(chosen); + chosenList.sort(null); + if (sameAsPrev && Comparators.lexicographical(Comparator.naturalOrder()).compare(chosenList, prev) <= 0) { + continue; + } + final var rest = Sets.difference(remaining, chosen).immutableCopy(); - forEachGroupInner(rest, groups, depth + 1, (extra) -> { - extra.addFirst(Map.entry(group.getKey(), chosen)); - handler.accept(extra); + forEachGroupInner(rest, items, sizes, depth + 1, chosenList, results -> { + results.addFirst(chosenList.stream().map(items::get).toList()); + handler.accept(results); }); } } diff --git a/src/main/resources/assets/bingo/lang/en_us.json b/src/main/resources/assets/bingo/lang/en_us.json index 8047a983..897ba6cf 100644 --- a/src/main/resources/assets/bingo/lang/en_us.json +++ b/src/main/resources/assets/bingo/lang/en_us.json @@ -294,7 +294,7 @@ "bingo.no_teams": "No teams exist. Use \"/bingo teams create\" to create some teams first.", "bingo.balance.duplicate_teams": "Duplicate teams in teams list (%s)", "bingo.balance.mismatched_player_count": "Command required exactly %s players to be online, but %s players are online.", - "bingo.balance.success": "Balanced %s players across %s teams with %s%% quality.", + "bingo.balance.success": "Balanced %s players across %s teams with %s%% matchup quality.", "bingo.formatting.black": "Black", "bingo.formatting.dark_blue": "Dark Blue", From 136ab818395124831b568d9facf35ad0bb1a8156 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Sun, 24 May 2026 17:59:00 -0500 Subject: [PATCH 07/16] Revert accidental formatting changes --- .../github/gaming32/bingo/BingoCommand.java | 65 +++++++++---------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index 5f472506..d1020618 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -37,6 +37,16 @@ import io.github.gaming32.bingo.rating.BingoRatings; import io.github.gaming32.bingo.util.BingoUtil; import io.github.gaming32.bingo.util.Vec2i; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import net.minecraft.ChatFormatting; import net.minecraft.commands.CommandBuildContext; import net.minecraft.commands.CommandSourceStack; @@ -75,17 +85,6 @@ import org.apache.commons.lang3.function.TriFunction; import org.apache.commons.lang3.mutable.MutableObject; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - import static net.minecraft.commands.Commands.*; public class BingoCommand { @@ -139,7 +138,7 @@ public class BingoCommand { 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(); + final var game = ((MinecraftServerExt) context.getSource().getServer()).bingo$getGame(); if (game == null) { return builder.buildFuture(); } @@ -207,9 +206,9 @@ public static void register( ) .then(literal("stop") .requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER) - && source.getServer() != null && ((MinecraftServerExt)source.getServer()).bingo$getGame() != null) + && source.getServer() != null && ((MinecraftServerExt) source.getServer()).bingo$getGame() != null) .executes(ctx -> { - final var game = ((MinecraftServerExt)ctx.getSource().getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt) ctx.getSource().getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -222,18 +221,18 @@ public static void register( .executes(BingoCommand::resetGame) ) .then(literal("forfeit") - .requires(source -> source.getServer() != null && ((MinecraftServerExt)source.getServer()).bingo$getGame() != null) + .requires(source -> source.getServer() != null && ((MinecraftServerExt) source.getServer()).bingo$getGame() != null) .executes(ctx -> forfeit(ctx.getSource())) .then(argument("team", TeamArgument.team()) .requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER) - && source.getServer() != null && ((MinecraftServerExt)source.getServer()).bingo$getGame() != null) + && source.getServer() != null && ((MinecraftServerExt) source.getServer()).bingo$getGame() != null) .executes(ctx -> forfeit(ctx.getSource(), TeamArgument.getTeam(ctx, "team"))) ) ) .then(literal("board") - .requires(source -> source.getServer() != null && ((MinecraftServerExt)source.getServer()).bingo$getGame() != null) + .requires(source -> source.getServer() != null && ((MinecraftServerExt) source.getServer()).bingo$getGame() != null) .executes(ctx -> { - final var game = ((MinecraftServerExt)ctx.getSource().getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt) ctx.getSource().getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -285,7 +284,7 @@ public Component getDisplayName() { }) .then(literal("copy") .executes(ctx -> { - final var game = ((MinecraftServerExt)ctx.getSource().getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt) ctx.getSource().getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -302,7 +301,7 @@ public Component getDisplayName() { .then(literal("difficulties") .requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER)) .executes(ctx -> { - final var game = ((MinecraftServerExt)ctx.getSource().getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt) ctx.getSource().getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -327,7 +326,7 @@ public Component getDisplayName() { ) .then(literal("goals") .requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER) - && source.getServer() != null && ((MinecraftServerExt)source.getServer()).bingo$getGame() != null) + && source.getServer() != null && ((MinecraftServerExt) source.getServer()).bingo$getGame() != null) .then(argument("players", EntityArgument.players()) .then(literal("award") .then(argument("goal", IdentifierArgument.id()) @@ -410,7 +409,7 @@ public Component getDisplayName() { .then(literal("set") .then(argument("time-limit", TimeArgument.time(-1)) .executes(ctx -> { - final var game = ((MinecraftServerExt)ctx.getSource().getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt) ctx.getSource().getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -501,7 +500,7 @@ private static int startGame(CommandContext context, int tea final MinecraftServer server = context.getSource().getServer(); final var playerList = server.getPlayerList(); - final var existingGame = ((MinecraftServerExt)server).bingo$getGame(); + final var existingGame = ((MinecraftServerExt) server).bingo$getGame(); if (existingGame != null) { existingGame.endGame(playerList); } @@ -522,7 +521,7 @@ private static int startGame(CommandContext context, int tea 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 timeLimit = TIME_LIMIT.get(context); final int autoForfeitTicks = AUTO_FORFEIT_TIME.get(context); final Set teams = LinkedHashSet.newLinkedHashSet(teamCount); @@ -531,10 +530,10 @@ private static int startGame(CommandContext context, int tea final PlayerTeam team = TeamArgument.getTeam(context, argName); boolean teamActive = includeInactiveTeams || - playerList.getPlayers() - .stream() - .map(Player::getScoreboardName) - .anyMatch(team.getPlayers()::contains); + playerList.getPlayers() + .stream() + .map(Player::getScoreboardName) + .anyMatch(team.getPlayers()::contains); if (teamActive && !teams.add(team)) { throw DUPLICATE_TEAMS.create(team); } @@ -582,7 +581,7 @@ private static int startGame(CommandContext context, int tea } } - ((MinecraftServerExt)server).bingo$setGame(game); + ((MinecraftServerExt) server).bingo$setGame(game); Bingo.updateCommandTree(playerList); new ArrayList<>(playerList.getPlayers()).forEach(game::addPlayer); playerList.broadcastSystemMessage( @@ -594,7 +593,7 @@ private static int startGame(CommandContext context, int tea private static int resetGame(CommandContext context) { final var server = context.getSource().getServer(); - final var game = ((MinecraftServerExt)server).bingo$getGame(); + final var game = ((MinecraftServerExt) server).bingo$getGame(); if (game != null) { game.endGame(server.getPlayerList()); } @@ -604,7 +603,7 @@ private static int resetGame(CommandContext context) { } private static int forfeit(CommandSourceStack source) throws CommandSyntaxException { - final var game = ((MinecraftServerExt)source.getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt) source.getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -622,7 +621,7 @@ private static int forfeit(CommandSourceStack source) throws CommandSyntaxExcept } private static int forfeit(CommandSourceStack source, PlayerTeam team) throws CommandSyntaxException { - final var game = ((MinecraftServerExt)source.getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt) source.getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } @@ -643,7 +642,7 @@ private static int awardOrRevoke( TriFunction action, @Translatable String resultKey ) throws CommandSyntaxException { - final var game = ((MinecraftServerExt)context.getSource().getServer()).bingo$getGame(); + final var game = ((MinecraftServerExt) context.getSource().getServer()).bingo$getGame(); if (game == null) { throw NO_GAME_RUNNING.create(); } From a4f9e80c10989654bb027401d391ad85bf1a3f57 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Sun, 24 May 2026 22:12:36 -0500 Subject: [PATCH 08/16] Add /bingo rate and /bingo leaderboard --- .../github/gaming32/bingo/BingoCommand.java | 64 +++++++++++++++++-- .../github/gaming32/bingo/util/BingoUtil.java | 34 ++++++++++ .../resources/assets/bingo/lang/en_us.json | 3 + 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index d1020618..a5b9d01d 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -41,6 +41,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -70,6 +71,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; @@ -449,6 +451,50 @@ 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 topPlayers = BingoUtil.findTopElements( + ratings.getRatings().entrySet(), + Comparator.comparing(entry -> BingoRatingEngine.bingoRating(entry.getValue())), + 10 + ); + final var result = Component.translatable("bingo.leaderboard.header", topPlayers.size()); + int rank = 1; + for (final var entry : topPlayers) { + final var playerEntity = playerList.getPlayer(entry.getKey()); + result.append("\n"); + result.append(Component.translatable( + "bingo.leaderboard.player", + rank, + playerEntity != null + ? playerEntity.getDisplayName() + : Component.literal( + nameIdCache.get(entry.getKey()) + .map(NameAndId::name) + .orElseGet(entry.getKey()::toString) + ), + (int) BingoRatingEngine.bingoRating(entry.getValue()) + )); + rank++; + } + context.getSource().sendSuccess(() -> result, false); + return 0; + }) + ) ); { @@ -725,7 +771,7 @@ private static int balanceTeams(CommandContext context, int ) ) .toList(); - final var matchupCount = (double)fullMesh(createdTeams.size()); + final var matchupCount = (double) BingoUtil.fullMesh(createdTeams.size()); var qualitySum = 0.0; for (int i = 0; i < createdTeams.size() - 1; i++) { for (int j = i + 1; j < createdTeams.size(); j++) { @@ -780,11 +826,17 @@ private static int balanceTeams(CommandContext context, int return 0; } - public static int fullMesh(int n) { - if ((n & 1) == 0) { - return n / 2 * (n - 1); - } else { - return (n - 1) / 2 * n; + 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/util/BingoUtil.java b/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java index 02282f71..9f3b37d0 100644 --- a/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java +++ b/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java @@ -17,12 +17,14 @@ import it.unimi.dsi.fastutil.Hash; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.Comparator; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.PriorityQueue; import java.util.Set; import java.util.function.Consumer; import java.util.function.Function; @@ -361,4 +363,36 @@ private static void forEachGroupInner( }); } } + + public static int fullMesh(int n) { + if ((n & 1) == 0) { + return n / 2 * (n - 1); + } else { + return (n - 1) / 2 * n; + } + } + + public static List findTopElements(Iterable source, Comparator comparator, int count) { + if (count < 1) { + throw new IllegalArgumentException("Cannot findTopElements() with count < 1"); + } + + final var queue = new PriorityQueue(count, comparator); + for (final var value : source) { + if (queue.size() < count) { + queue.add(value); + } else if (comparator.compare(value, queue.peek()) > 0) { + queue.poll(); + queue.offer(value); + } + } + + final var result = new ArrayList(queue.size()); + T value; + while ((value = queue.poll()) != null) { + result.add(value); + } + Collections.reverse(result); + return result; + } } diff --git a/src/main/resources/assets/bingo/lang/en_us.json b/src/main/resources/assets/bingo/lang/en_us.json index 897ba6cf..2114f7fd 100644 --- a/src/main/resources/assets/bingo/lang/en_us.json +++ b/src/main/resources/assets/bingo/lang/en_us.json @@ -295,6 +295,9 @@ "bingo.balance.duplicate_teams": "Duplicate teams in teams list (%s)", "bingo.balance.mismatched_player_count": "Command required exactly %s players to be online, but %s players are online.", "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", From c78dee455666c2949be42cd42caf211f5db1f1aa Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Mon, 25 May 2026 21:57:18 -0500 Subject: [PATCH 09/16] Change algorithm for balancing teams --- .../github/gaming32/bingo/BingoCommand.java | 30 +---- .../bingo/rating/BingoRatingEngine.java | 121 +++++++++++++++++- 2 files changed, 125 insertions(+), 26 deletions(-) diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index a5b9d01d..3f728ecb 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -762,32 +762,12 @@ private static int balanceTeams(CommandContext context, int final MutableObject>, Double>> bestChoice = new MutableObject<>(); BingoUtil.forEachGroup(List.copyOf(players), teams.values(), possibility -> { final var createdTeams = possibility.stream() - .map(possibleTeam -> - BingoRatingEngine.AGGREGATOR.computeTeamRating( - possibleTeam - .stream() - .map(player -> ratings.getRating(player.getUUID())) - .toList() - ) - ) + .map(t -> t.stream().map(p -> ratings.getRating(p.getUUID())).toList()) .toList(); - final var matchupCount = (double) BingoUtil.fullMesh(createdTeams.size()); - var qualitySum = 0.0; - for (int i = 0; i < createdTeams.size() - 1; i++) { - for (int j = i + 1; j < createdTeams.size(); j++) { - var teamA = createdTeams.get(i); - var teamB = createdTeams.get(j); - if (teamB.mu() > teamA.mu()) { - final var tmp = teamB; - teamB = teamA; - teamA = tmp; - } - qualitySum += BingoRatingEngine.QUALITY_EVALUATOR.evaluateQuality(teamA, teamB) / matchupCount; - } - } - if (bestChoice.get() == null || qualitySum > bestChoice.get().getValue()) { - Bingo.LOGGER.info("Found new best score: {}", qualitySum); - bestChoice.setValue(Map.entry(possibility, qualitySum)); + final var drawChance = BingoRatingEngine.predictDraw(createdTeams); + if (bestChoice.get() == null || drawChance > bestChoice.get().getValue()) { + Bingo.LOGGER.info("Found new best score: {}", drawChance); + bestChoice.setValue(Map.entry(possibility, drawChance)); } }); diff --git a/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java b/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java index c2cdfb75..b2f69898 100644 --- a/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java +++ b/src/main/java/io/github/gaming32/bingo/rating/BingoRatingEngine.java @@ -7,8 +7,12 @@ 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 { @@ -21,7 +25,6 @@ public final class BingoRatingEngine { public static final Adjudicator ADJUDICATOR = new Adjudicator<>(CONFIG, MODEL); public static final TeamRatingAggregator AGGREGATOR = new DefaultTeamRatingAggregator(CONFIG); - public static final QualityEvaluator QUALITY_EVALUATOR = new QualityEvaluator(CONFIG); public static final MatchMakingRating DEFAULT_RATING = new SimpleMatchMakingRating(25.0, 25.0 / 3.0); private BingoRatingEngine() { @@ -38,4 +41,120 @@ private static double ordinal(MatchMakingRating rating) { 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; + } } From 37110a8b9954368643a904c7b569e5c6acd21d85 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Mon, 25 May 2026 22:48:54 -0500 Subject: [PATCH 10/16] Parallelize balancing --- .../io/github/gaming32/bingo/BingoCommand.java | 15 ++++++++------- .../io/github/gaming32/bingo/util/BingoUtil.java | 14 +++++++------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index 3f728ecb..2804a527 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -2,6 +2,7 @@ import com.demonwav.mcdev.annotations.Translatable; import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Comparators; import com.google.common.collect.Multimaps; import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; @@ -47,6 +48,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import net.minecraft.ChatFormatting; import net.minecraft.commands.CommandBuildContext; @@ -85,7 +87,6 @@ import net.minecraft.world.scores.PlayerTeam; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.function.TriFunction; -import org.apache.commons.lang3.mutable.MutableObject; import static net.minecraft.commands.Commands.*; @@ -759,16 +760,16 @@ private static int balanceTeams(CommandContext context, int final var ratings = server.getDataStorage().computeIfAbsent(BingoRatings.TYPE); - final MutableObject>, Double>> bestChoice = new MutableObject<>(); - BingoUtil.forEachGroup(List.copyOf(players), teams.values(), possibility -> { + final var bestChoice = new AtomicReference<>(Map.entry(List.>of(), Double.NEGATIVE_INFINITY)); + BingoUtil.forEachGroupParallel(List.copyOf(players), teams.values(), possibility -> { final var createdTeams = possibility.stream() .map(t -> t.stream().map(p -> ratings.getRating(p.getUUID())).toList()) .toList(); final var drawChance = BingoRatingEngine.predictDraw(createdTeams); - if (bestChoice.get() == null || drawChance > bestChoice.get().getValue()) { - Bingo.LOGGER.info("Found new best score: {}", drawChance); - bestChoice.setValue(Map.entry(possibility, drawChance)); - } + bestChoice.getAndAccumulate( + Map.entry(possibility, drawChance), + (a, b) -> Comparators.max(a, b, Comparator.comparingDouble(Map.Entry::getValue)) + ); }); Bingo.LOGGER.info("Best score found: {}", bestChoice.get().getValue()); 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 9f3b37d0..c99c2b1f 100644 --- a/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java +++ b/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java @@ -326,14 +326,14 @@ public static T sneakyThrow(Throwable t) throws T { throw (T) t; } - public static void forEachGroup(List items, Collection sizes, Consumer>> handler) { + public static void forEachGroupParallel(List items, Collection sizes, Consumer>> handler) { final var sizesList = new ArrayList<>(sizes); sizesList.sort(null); final var indices = IntStream.range(0, items.size()).boxed().collect(Collectors.toSet()); - forEachGroupInner(indices, items, sizesList, 0, List.of(), handler); + forEachGroupParallelInner(indices, items, sizesList, 0, List.of(), handler); } - private static void forEachGroupInner( + private static void forEachGroupParallelInner( Set remaining, List items, List sizes, @@ -349,19 +349,19 @@ private static void forEachGroupInner( final int size = sizes.get(depth); final var sameAsPrev = depth > 0 && sizes.get(depth - 1) == size; - for (final var chosen : Sets.combinations(remaining, size)) { + Sets.combinations(remaining, size).parallelStream().forEach(chosen -> { final var chosenList = new ArrayList<>(chosen); chosenList.sort(null); if (sameAsPrev && Comparators.lexicographical(Comparator.naturalOrder()).compare(chosenList, prev) <= 0) { - continue; + return; } final var rest = Sets.difference(remaining, chosen).immutableCopy(); - forEachGroupInner(rest, items, sizes, depth + 1, chosenList, results -> { + forEachGroupParallelInner(rest, items, sizes, depth + 1, chosenList, results -> { results.addFirst(chosenList.stream().map(items::get).toList()); handler.accept(results); }); - } + }); } public static int fullMesh(int n) { From ff3399de72d8972e28f99f151efb43e6337ac365 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Tue, 26 May 2026 16:25:17 -0500 Subject: [PATCH 11/16] Fixed arrangement of ranks --- src/main/java/io/github/gaming32/bingo/game/BingoGame.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 af1b71b8..baa61ac9 100644 --- a/src/main/java/io/github/gaming32/bingo/game/BingoGame.java +++ b/src/main/java/io/github/gaming32/bingo/game/BingoGame.java @@ -289,12 +289,12 @@ public void endGame(PlayerList playerList) { if (!winningTeams.any()) { winningTeams = getWinner(true); if (winningTeams.any()) { - ranks.addFirst(winningTeams); + ranks.add(winningTeams); } else { winningTeams = remainingTeams; remainingTeams = BingoBoard.Teams.NONE; if (winningTeams.any()) { - ranks.addFirst(winningTeams); + ranks.add(winningTeams); } } } @@ -880,7 +880,7 @@ public void checkForWin(PlayerList playerList) { } remainingTeams = remainingTeams.andNot(newFinishers); - ranks.addFirst(newFinishers); + ranks.add(newFinishers); if (continueAfterWin) { notifyFinishedTeam(playerList, newFinishers, place); From 2fad72509288deed8a772d1a42d1fe854c7d7439 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Tue, 26 May 2026 16:59:56 -0500 Subject: [PATCH 12/16] Change /bingo teams balance command to not take in team names --- .../github/gaming32/bingo/BingoCommand.java | 40 ++++++++----------- .../resources/assets/bingo/lang/en_us.json | 2 +- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index 2804a527..5bf2bef5 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -1,9 +1,7 @@ package io.github.gaming32.bingo; import com.demonwav.mcdev.annotations.Translatable; -import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Comparators; -import com.google.common.collect.Multimaps; import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.IntegerArgumentType; @@ -43,7 +41,6 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; -import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -135,8 +132,8 @@ public class BingoCommand { private static final SimpleCommandExceptionType NOT_NERFED = new SimpleCommandExceptionType(Bingo.translatable("bingo.not_nerfed")); - private static final DynamicCommandExceptionType DUPLICATE_BALANCE_TEAMS = - new DynamicCommandExceptionType(team -> Bingo.translatableEscape("bingo.balance.duplicate_teams", ((PlayerTeam)team).getFormattedDisplayName())); + 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)); @@ -532,13 +529,11 @@ public Component getDisplayName() { var currentCommand = bingoCommand.getChild("teams").getChild("balance").getChild("players"); for (int i = 1; i <= 32; i++) { final var teamCount = i; - final var subTree = argument("team" + i, TeamArgument.team()) - .then(argument("team-size-" + i, IntegerArgumentType.integer(1)) - .executes(context -> balanceTeams(context, teamCount)) - ) + final var subCommand = argument("team-size-" + i, IntegerArgumentType.integer(1)) + .executes(context -> balanceTeams(context, teamCount)) .build(); - currentCommand.addChild(subTree); - currentCommand = subTree.getChild("team-size-" + i); + currentCommand.addChild(subCommand); + currentCommand = subCommand; } } } @@ -743,25 +738,29 @@ private static int randomizeTeams( private static int balanceTeams(CommandContext context, int teamCount) throws CommandSyntaxException { final var server = context.getSource().getServer(); final var players = EntityArgument.getPlayers(context, "players"); + final var scoreboard = server.getScoreboard(); - final var teams = LinkedHashMap.newLinkedHashMap(teamCount); + final var teams = new ArrayList(teamCount); var totalPlayers = 0; for (int i = 1; i <= teamCount; i++) { - final var team = TeamArgument.getTeam(context, "team" + i); final var teamSize = IntegerArgumentType.getInteger(context, "team-size-" + i); totalPlayers += teamSize; - if (teams.put(team, teamSize) != null) { - throw DUPLICATE_BALANCE_TEAMS.create(team); - } + teams.add(teamSize); } if (totalPlayers != players.size()) { throw MISMATCHED_PLAYER_COUNT.create(totalPlayers, players.size()); } + final var scoreBoardTeams = new ArrayList<>(scoreboard.getPlayerTeams()); + if (teams.size() > scoreBoardTeams.size()) { + throw NOT_ENOUGH_TEAMS.create(teams.size(), scoreBoardTeams.size()); + } + Collections.shuffle(scoreBoardTeams); + final var ratings = server.getDataStorage().computeIfAbsent(BingoRatings.TYPE); final var bestChoice = new AtomicReference<>(Map.entry(List.>of(), Double.NEGATIVE_INFINITY)); - BingoUtil.forEachGroupParallel(List.copyOf(players), teams.values(), possibility -> { + BingoUtil.forEachGroupParallel(List.copyOf(players), teams, possibility -> { final var createdTeams = possibility.stream() .map(t -> t.stream().map(p -> ratings.getRating(p.getUUID())).toList()) .toList(); @@ -782,13 +781,8 @@ private static int balanceTeams(CommandContext context, int .collect(Collectors.joining("\n")) ); - final var teamsMapBack = teams.entrySet() - .stream() - .collect(Multimaps.toMultimap(Map.Entry::getValue, Map.Entry::getKey, ArrayListMultimap::create)); - - final var scoreboard = server.getScoreboard(); for (final var foundTeam : bestChoice.get().getKey()) { - final var scoreboardTeam = teamsMapBack.get(foundTeam.size()).removeLast(); + final var scoreboardTeam = scoreBoardTeams.removeLast(); for (final var player : foundTeam) { scoreboard.addPlayerToTeam(player.getScoreboardName(), scoreboardTeam); } diff --git a/src/main/resources/assets/bingo/lang/en_us.json b/src/main/resources/assets/bingo/lang/en_us.json index 2114f7fd..ea8862e0 100644 --- a/src/main/resources/assets/bingo/lang/en_us.json +++ b/src/main/resources/assets/bingo/lang/en_us.json @@ -292,7 +292,7 @@ "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.duplicate_teams": "Duplicate teams in teams list (%s)", + "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 are online.", "bingo.balance.success": "Balanced %s players across %s teams with %s%% matchup quality.", "bingo.rate": "Player %s has a rating of %s", From da81b6391407622c6eaee219b4dced7b5f19d7f2 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Tue, 26 May 2026 19:34:21 -0500 Subject: [PATCH 13/16] Rewrite /bingo leaderboard command around Guava's Comparators.greatest --- .../github/gaming32/bingo/BingoCommand.java | 26 ++++++++++++------- .../github/gaming32/bingo/util/BingoUtil.java | 26 ------------------- 2 files changed, 16 insertions(+), 36 deletions(-) diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index 5bf2bef5..1143f195 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -36,6 +36,8 @@ 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.objects.ObjectDoubleImmutablePair; +import it.unimi.dsi.fastutil.objects.ObjectDoublePair; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -465,15 +467,18 @@ public Component getDisplayName() { final var playerList = server.getPlayerList(); final var nameIdCache = server.services().nameToIdCache(); - final var topPlayers = BingoUtil.findTopElements( - ratings.getRatings().entrySet(), - Comparator.comparing(entry -> BingoRatingEngine.bingoRating(entry.getValue())), - 10 - ); + 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 = Component.translatable("bingo.leaderboard.header", topPlayers.size()); int rank = 1; for (final var entry : topPlayers) { - final var playerEntity = playerList.getPlayer(entry.getKey()); + final var playerEntity = playerList.getPlayer(entry.left()); result.append("\n"); result.append(Component.translatable( "bingo.leaderboard.player", @@ -481,14 +486,15 @@ public Component getDisplayName() { playerEntity != null ? playerEntity.getDisplayName() : Component.literal( - nameIdCache.get(entry.getKey()) - .map(NameAndId::name) - .orElseGet(entry.getKey()::toString) + nameIdCache.get(entry.left()) + .map(NameAndId::name) + .orElseGet(entry.left()::toString) ), - (int) BingoRatingEngine.bingoRating(entry.getValue()) + (int) entry.rightDouble() )); rank++; } + context.getSource().sendSuccess(() -> result, false); return 0; }) 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 c99c2b1f..8b305873 100644 --- a/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java +++ b/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java @@ -17,14 +17,12 @@ import it.unimi.dsi.fastutil.Hash; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.Comparator; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.PriorityQueue; import java.util.Set; import java.util.function.Consumer; import java.util.function.Function; @@ -371,28 +369,4 @@ public static int fullMesh(int n) { return (n - 1) / 2 * n; } } - - public static List findTopElements(Iterable source, Comparator comparator, int count) { - if (count < 1) { - throw new IllegalArgumentException("Cannot findTopElements() with count < 1"); - } - - final var queue = new PriorityQueue(count, comparator); - for (final var value : source) { - if (queue.size() < count) { - queue.add(value); - } else if (comparator.compare(value, queue.peek()) > 0) { - queue.poll(); - queue.offer(value); - } - } - - final var result = new ArrayList(queue.size()); - T value; - while ((value = queue.poll()) != null) { - result.add(value); - } - Collections.reverse(result); - return result; - } } From f0d0844852e5f395dc524033c0307c44446dbe32 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Wed, 27 May 2026 14:35:37 -0500 Subject: [PATCH 14/16] Use Bingo.translatable in leaderboard command --- src/main/java/io/github/gaming32/bingo/BingoCommand.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index 1143f195..d40989fc 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -475,12 +475,12 @@ public Component getDisplayName() { .map(entry -> ObjectDoubleImmutablePair.of(entry.getKey(), BingoRatingEngine.bingoRating(entry.getValue()))) .collect(Comparators.greatest(topPlayerCount, Comparator.comparingDouble(ObjectDoublePair::valueDouble))); - final var result = Component.translatable("bingo.leaderboard.header", topPlayers.size()); + 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(Component.translatable( + result.append(Bingo.translatable( "bingo.leaderboard.player", rank, playerEntity != null From 0fb50a268f95a8b84025e137a00601c8e2116249 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Wed, 27 May 2026 17:08:30 -0500 Subject: [PATCH 15/16] Some light optimizations in /bingo teams balance (mostly fastutil) --- .../github/gaming32/bingo/BingoCommand.java | 18 ++++---- .../github/gaming32/bingo/util/BingoUtil.java | 43 ++++++++++--------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index d40989fc..22b67d61 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -36,6 +36,7 @@ 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; @@ -45,7 +46,6 @@ import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @@ -746,7 +746,7 @@ private static int balanceTeams(CommandContext context, int final var players = EntityArgument.getPlayers(context, "players"); final var scoreboard = server.getScoreboard(); - final var teams = new ArrayList(teamCount); + 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); @@ -765,29 +765,29 @@ private static int balanceTeams(CommandContext context, int final var ratings = server.getDataStorage().computeIfAbsent(BingoRatings.TYPE); - final var bestChoice = new AtomicReference<>(Map.entry(List.>of(), Double.NEGATIVE_INFINITY)); + final var bestChoice = new AtomicReference<>(ObjectDoublePair.of(List.>of(), Double.NEGATIVE_INFINITY)); BingoUtil.forEachGroupParallel(List.copyOf(players), teams, 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( - Map.entry(possibility, drawChance), - (a, b) -> Comparators.max(a, b, Comparator.comparingDouble(Map.Entry::getValue)) + ObjectDoublePair.of(possibility, drawChance), + (a, b) -> Comparators.max(a, b, Comparator.comparingDouble(ObjectDoublePair::valueDouble)) ); }); - Bingo.LOGGER.info("Best score found: {}", bestChoice.get().getValue()); + Bingo.LOGGER.info("Best score found: {}", bestChoice.get().valueDouble()); Bingo.LOGGER.info( "Teams:\n{}", bestChoice.get() - .getKey() + .key() .stream() .map(team -> " - " + team.stream().map(ServerPlayer::getPlainTextName).collect(Collectors.joining(", ", "[", "]"))) .collect(Collectors.joining("\n")) ); - for (final var foundTeam : bestChoice.get().getKey()) { + for (final var foundTeam : bestChoice.get().key()) { final var scoreboardTeam = scoreBoardTeams.removeLast(); for (final var player : foundTeam) { scoreboard.addPlayerToTeam(player.getScoreboardName(), scoreboardTeam); @@ -799,7 +799,7 @@ private static int balanceTeams(CommandContext context, int "bingo.balance.success", players.size(), teams.size(), - Math.round(bestChoice.get().getValue() * 100.0) + Math.round(bestChoice.get().valueDouble() * 100.0) ), true ); 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 8b305873..48da7990 100644 --- a/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java +++ b/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java @@ -1,6 +1,5 @@ package io.github.gaming32.bingo.util; -import com.google.common.collect.Comparators; import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Multimap; @@ -15,9 +14,13 @@ 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.Collection; -import java.util.Comparator; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -28,7 +31,6 @@ import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collector; -import java.util.stream.Collectors; import java.util.stream.IntStream; import net.minecraft.SharedConstants; import net.minecraft.commands.arguments.ResourceOrTagKeyArgument; @@ -324,39 +326,40 @@ public static T sneakyThrow(Throwable t) throws T { throw (T) t; } - public static void forEachGroupParallel(List items, Collection sizes, Consumer>> handler) { - final var sizesList = new ArrayList<>(sizes); - sizesList.sort(null); - final var indices = IntStream.range(0, items.size()).boxed().collect(Collectors.toSet()); - forEachGroupParallelInner(indices, items, sizesList, 0, List.of(), handler); + 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( - Set remaining, + IntSet remaining, List items, - List sizes, + IntList sizes, int depth, - List prev, + IntList prev, Consumer>> handler ) { if (depth == sizes.size()) { - handler.accept(new ArrayList<>()); + handler.accept(new ArrayList<>(depth)); return; } - final int size = sizes.get(depth); - final var sameAsPrev = depth > 0 && sizes.get(depth - 1) == size; + 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 ArrayList<>(chosen); - chosenList.sort(null); - if (sameAsPrev && Comparators.lexicographical(Comparator.naturalOrder()).compare(chosenList, prev) <= 0) { + final var chosenList = new IntArrayList(chosen); + chosenList.unstableSort(null); + if (sameAsPrev && chosenList.compareTo(prev) <= 0) { return; } - final var rest = Sets.difference(remaining, chosen).immutableCopy(); + final var rest = new IntOpenHashSet(remaining); + rest.removeAll(chosenList); forEachGroupParallelInner(rest, items, sizes, depth + 1, chosenList, results -> { - results.addFirst(chosenList.stream().map(items::get).toList()); + results.add(chosenList.intStream().mapToObj(items::get).toList()); handler.accept(results); }); }); From cbfcf4b81c070fffe08f4072b182c1a9b051fe56 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Wed, 27 May 2026 19:40:12 -0500 Subject: [PATCH 16/16] Add a new /bingo teams balance overload that doesn't require team sizes (works up to 14 players) Technically the code would work with any number of players, but due to factorial time complexity, I decided to cap it at 14. Just iterating 14 players with no extra work (of which we do a fair amount of extra work) takes about 3.3 seconds on my Ryzen 9 9950X CPU, so in practice, 14 players would probably take 5-10 seconds on a proper server. For contrast, The above benchmark of pure iteration on 15 players takes the time from 3.3 seconds to 19.3 seconds. --- .../github/gaming32/bingo/BingoCommand.java | 80 ++++++++++++++++--- .../github/gaming32/bingo/util/BingoUtil.java | 36 +++++++++ .../resources/assets/bingo/lang/en_us.json | 3 +- 3 files changed, 108 insertions(+), 11 deletions(-) diff --git a/src/main/java/io/github/gaming32/bingo/BingoCommand.java b/src/main/java/io/github/gaming32/bingo/BingoCommand.java index 22b67d61..725d3855 100644 --- a/src/main/java/io/github/gaming32/bingo/BingoCommand.java +++ b/src/main/java/io/github/gaming32/bingo/BingoCommand.java @@ -48,6 +48,7 @@ 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; @@ -134,6 +135,8 @@ 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 = @@ -403,7 +406,19 @@ public Component getDisplayName() { ) ) .then(literal("balance") - .then(argument("players", EntityArgument.players())) + .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") @@ -741,10 +756,37 @@ 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 server = context.getSource().getServer(); final var players = EntityArgument.getPlayers(context, "players"); - final var scoreboard = server.getScoreboard(); final var teams = new IntArrayList(teamCount); var totalPlayers = 0; @@ -757,16 +799,32 @@ private static int balanceTeams(CommandContext context, int throw MISMATCHED_PLAYER_COUNT.create(totalPlayers, players.size()); } - final var scoreBoardTeams = new ArrayList<>(scoreboard.getPlayerTeams()); - if (teams.size() > scoreBoardTeams.size()) { - throw NOT_ENOUGH_TEAMS.create(teams.size(), scoreBoardTeams.size()); + final var scoreboardTeams = context.getSource().getServer().getScoreboard().getPlayerTeams(); + if (teams.size() > scoreboardTeams.size()) { + throw NOT_ENOUGH_TEAMS.create(teams.size(), scoreboardTeams.size()); } - Collections.shuffle(scoreBoardTeams); + + 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)); - BingoUtil.forEachGroupParallel(List.copyOf(players), teams, possibility -> { + teamLister.accept(possibility -> { final var createdTeams = possibility.stream() .map(t -> t.stream().map(p -> ratings.getRating(p.getUUID())).toList()) .toList(); @@ -787,8 +845,10 @@ private static int balanceTeams(CommandContext context, int .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(); + final var scoreboardTeam = scoreboardTeams.removeLast(); for (final var player : foundTeam) { scoreboard.addPlayerToTeam(player.getScoreboardName(), scoreboardTeam); } @@ -798,7 +858,7 @@ private static int balanceTeams(CommandContext context, int () -> Bingo.translatable( "bingo.balance.success", players.size(), - teams.size(), + bestChoice.get().key().size(), Math.round(bestChoice.get().valueDouble() * 100.0) ), true 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 48da7990..ecdb84c6 100644 --- a/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java +++ b/src/main/java/io/github/gaming32/bingo/util/BingoUtil.java @@ -29,6 +29,7 @@ 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; @@ -52,6 +53,7 @@ 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; @@ -365,6 +367,40 @@ private static void forEachGroupParallelInner( }); } + // 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); diff --git a/src/main/resources/assets/bingo/lang/en_us.json b/src/main/resources/assets/bingo/lang/en_us.json index ea8862e0..662eb173 100644 --- a/src/main/resources/assets/bingo/lang/en_us.json +++ b/src/main/resources/assets/bingo/lang/en_us.json @@ -292,8 +292,9 @@ "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 are online.", + "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:",