Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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"]
Expand Down
240 changes: 231 additions & 9 deletions src/main/java/io/github/gaming32/bingo/BingoCommand.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package io.github.gaming32.bingo;

import com.demonwav.mcdev.annotations.Translatable;
import com.google.common.collect.Comparators;
import com.mojang.brigadier.Command;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.LongArgumentType;
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;
Expand All @@ -30,7 +32,24 @@
import io.github.gaming32.bingo.game.InvalidGoalException;
import io.github.gaming32.bingo.game.mode.BingoGameMode;
import io.github.gaming32.bingo.network.messages.s2c.RemoveBoardPayload;
import io.github.gaming32.bingo.rating.BingoRatingEngine;
import io.github.gaming32.bingo.rating.BingoRatings;
import io.github.gaming32.bingo.util.BingoUtil;
import io.github.gaming32.bingo.util.Vec2i;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import it.unimi.dsi.fastutil.objects.ObjectDoubleImmutablePair;
import it.unimi.dsi.fastutil.objects.ObjectDoublePair;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import net.minecraft.ChatFormatting;
import net.minecraft.commands.CommandBuildContext;
import net.minecraft.commands.CommandSourceStack;
Expand All @@ -54,6 +73,7 @@
import net.minecraft.server.ServerScoreboard;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.server.players.NameAndId;
import net.minecraft.util.RandomSource;
import net.minecraft.world.MenuProvider;
import net.minecraft.world.SimpleContainer;
Expand All @@ -68,14 +88,6 @@
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.function.TriFunction;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

import static net.minecraft.commands.Commands.*;

public class BingoCommand {
Expand Down Expand Up @@ -123,6 +135,13 @@ public class BingoCommand {
private static final SimpleCommandExceptionType NOT_NERFED =
new SimpleCommandExceptionType(Bingo.translatable("bingo.not_nerfed"));

private static final Dynamic2CommandExceptionType TOO_MANY_PLAYERS =
new Dynamic2CommandExceptionType((safe, provided) -> Bingo.translatableEscape("bingo.balance.too_many_players", safe, provided));
private static final Dynamic2CommandExceptionType NOT_ENOUGH_TEAMS =
new Dynamic2CommandExceptionType((required, found) -> Bingo.translatableEscape("bingo.balance.not_enough_teams", required, found));
private static final Dynamic2CommandExceptionType MISMATCHED_PLAYER_COUNT =
new Dynamic2CommandExceptionType((expected, actual) -> Bingo.translatableEscape("bingo.balance.mismatched_player_count", expected, actual));

private static final SuggestionProvider<CommandSourceStack> ACTIVE_GOAL_SUGGESTOR = (context, builder) -> {
final var game = ((MinecraftServerExt) context.getSource().getServer()).bingo$getGame();
if (game == null) {
Expand All @@ -136,6 +155,7 @@ public class BingoCommand {
private static final CommandSwitch<Boolean> REQUIRE_CLIENT = CommandSwitch.storeTrue("--require-client");
private static final CommandSwitch<Boolean> CONTINUE_AFTER_WIN = CommandSwitch.storeTrue("--continue-after-win");
private static final CommandSwitch<Boolean> INCLUDE_INACTIVE_TEAMS = CommandSwitch.storeTrue("--include-inactive-teams");
private static final CommandSwitch<Boolean> RATED = CommandSwitch.storeFalse("--unrated");

private static final CommandSwitch<String> SHAPE = CommandSwitch
.argument("--shape", StringArgumentType.word())
Expand Down Expand Up @@ -385,6 +405,21 @@ public Component getDisplayName() {
)
)
)
.then(literal("balance")
.executes(context -> balanceTeams(
context,
context.getSource()
.getServer()
.getPlayerList()
.getPlayers()
.stream()
.filter(p -> !p.isSpectator())
.toList()
))
.then(argument("players", EntityArgument.players())
.executes(context -> balanceTeams(context, EntityArgument.getPlayers(context, "players")))
)
)
)
.then(literal("time-limit")
.requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
Expand Down Expand Up @@ -431,6 +466,54 @@ public Component getDisplayName() {
)
)
)
.then(literal("rate")
.executes(context -> ratePlayers(context, List.of(context.getSource().getPlayerOrException())))
.then(argument("player", EntityArgument.players())
.requires(hasPermission(LEVEL_MODERATORS))
.executes(context -> ratePlayers(context, EntityArgument.getPlayers(context, "player")))
)
)
.then(literal("leaderboard")
.executes(context -> {
final var server = context.getSource().getServer();
final var ratings = server
.getDataStorage()
.computeIfAbsent(BingoRatings.TYPE);
final var playerList = server.getPlayerList();
final var nameIdCache = server.services().nameToIdCache();

final var topPlayerCount = 10;
final var topPlayers = ratings
.getRatings()
.entrySet()
.stream()
.map(entry -> ObjectDoubleImmutablePair.of(entry.getKey(), BingoRatingEngine.bingoRating(entry.getValue())))
.collect(Comparators.greatest(topPlayerCount, Comparator.comparingDouble(ObjectDoublePair::valueDouble)));

final var result = Bingo.translatable("bingo.leaderboard.header", topPlayers.size());
int rank = 1;
for (final var entry : topPlayers) {
final var playerEntity = playerList.getPlayer(entry.left());
result.append("\n");
result.append(Bingo.translatable(
"bingo.leaderboard.player",
rank,
playerEntity != null
? playerEntity.getDisplayName()
: Component.literal(
nameIdCache.get(entry.left())
.map(NameAndId::name)
.orElseGet(entry.left()::toString)
),
(int) entry.rightDouble()
));
rank++;
}

context.getSource().sendSuccess(() -> result, false);
return 0;
})
)
);

{
Expand All @@ -439,6 +522,7 @@ public Component getDisplayName() {
REQUIRE_CLIENT.addTo(startCommand);
CONTINUE_AFTER_WIN.addTo(startCommand);
INCLUDE_INACTIVE_TEAMS.addTo(startCommand);
RATED.addTo(startCommand);

SHAPE.addTo(startCommand);
SIZE.addTo(startCommand);
Expand All @@ -461,6 +545,18 @@ public Component getDisplayName() {
currentCommand = subCommand;
}
}

{
var currentCommand = bingoCommand.getChild("teams").getChild("balance").getChild("players");
for (int i = 1; i <= 32; i++) {
final var teamCount = i;
final var subCommand = argument("team-size-" + i, IntegerArgumentType.integer(1))
.executes(context -> balanceTeams(context, teamCount))
.build();
currentCommand.addChild(subCommand);
currentCommand = subCommand;
}
}
}

private static int startGame(CommandContext<CommandSourceStack> context, int teamCount) throws CommandSyntaxException {
Expand All @@ -487,6 +583,7 @@ private static int startGame(CommandContext<CommandSourceStack> 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);

Expand Down Expand Up @@ -539,7 +636,7 @@ private static int startGame(CommandContext<CommandSourceStack> 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())) {
Expand Down Expand Up @@ -658,4 +755,129 @@ private static int randomizeTeams(
);
return players.size();
}

private static int balanceTeams(CommandContext<CommandSourceStack> context, Collection<ServerPlayer> 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<CommandSourceStack> context, int teamCount) throws CommandSyntaxException {
final var players = EntityArgument.getPlayers(context, "players");

final var teams = new IntArrayList(teamCount);
var totalPlayers = 0;
for (int i = 1; i <= teamCount; i++) {
final var teamSize = IntegerArgumentType.getInteger(context, "team-size-" + i);
totalPlayers += teamSize;
teams.add(teamSize);
}
if (totalPlayers != players.size()) {
throw MISMATCHED_PLAYER_COUNT.create(totalPlayers, players.size());
}

final var scoreboardTeams = context.getSource().getServer().getScoreboard().getPlayerTeams();
if (teams.size() > scoreboardTeams.size()) {
throw NOT_ENOUGH_TEAMS.create(teams.size(), scoreboardTeams.size());
}

return basicBalanceTeams(
context,
players,
scoreboardTeams,
scoreComputer -> BingoUtil.forEachGroupParallel(List.copyOf(players), teams, scoreComputer)
);
}

private static int basicBalanceTeams(
CommandContext<CommandSourceStack> context,
Collection<ServerPlayer> players,
Collection<PlayerTeam> teams,
Consumer<Consumer<List<List<ServerPlayer>>>> 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.<List<ServerPlayer>>of(), Double.NEGATIVE_INFINITY));
teamLister.accept(possibility -> {
final var createdTeams = possibility.stream()
.map(t -> t.stream().map(p -> ratings.getRating(p.getUUID())).toList())
.toList();
final var drawChance = BingoRatingEngine.predictDraw(createdTeams);
bestChoice.getAndAccumulate(
ObjectDoublePair.of(possibility, drawChance),
(a, b) -> Comparators.max(a, b, Comparator.comparingDouble(ObjectDoublePair::valueDouble))
);
});

Bingo.LOGGER.info("Best score found: {}", bestChoice.get().valueDouble());
Bingo.LOGGER.info(
"Teams:\n{}",
bestChoice.get()
.key()
.stream()
.map(team -> " - " + team.stream().map(ServerPlayer::getPlainTextName).collect(Collectors.joining(", ", "[", "]")))
.collect(Collectors.joining("\n"))
);

final var scoreboardTeams = new ArrayList<>(teams);
Collections.shuffle(scoreboardTeams);
for (final var foundTeam : bestChoice.get().key()) {
final var scoreboardTeam = scoreboardTeams.removeLast();
for (final var player : foundTeam) {
scoreboard.addPlayerToTeam(player.getScoreboardName(), scoreboardTeam);
}
}

context.getSource().sendSuccess(
() -> Bingo.translatable(
"bingo.balance.success",
players.size(),
bestChoice.get().key().size(),
Math.round(bestChoice.get().valueDouble() * 100.0)
),
true
);

return 0;
}

private static int ratePlayers(CommandContext<CommandSourceStack> context, Collection<ServerPlayer> 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;
}
}
Loading
Loading