From fd299ab48e7ceca65718a6bf1e5188310a9cd65e Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Tue, 4 Aug 2026 18:35:50 +0200 Subject: [PATCH 1/2] feat: select ranked battles --- README.md | 2 +- .../rumble/client/BattleSelection.java | 12 +++ .../rumble/client/RankedBattleSelector.java | 72 +++++++++++++++ .../client/RankedBattleSelectorTest.java | 91 +++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/main/java/dev/robocode/rumble/client/BattleSelection.java create mode 100644 src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java create mode 100644 src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java diff --git a/README.md b/README.md index 85cfb92..7fbaf44 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Install JDK 17, then run: ./gradlew build ``` -The client validates configuration and can synchronize the current ranked input snapshot. Run `./gradlew run --args="--validate-config"` to check local settings, then run `./gradlew run --args="--sync"` to resolve the canonical data repository and validate its engine pin, catalog, client registration, and matchmaking advice. Battle Runner execution, persistence, issue-ops transport, and the runtime container are added in subsequent CH-012 tasks. +The client validates configuration and can synchronize the current ranked input snapshot. Run `./gradlew run --args="--validate-config"` to check local settings, then run `./gradlew run --args="--sync"` to resolve the canonical data repository and validate its engine pin, catalog, client registration, and matchmaking advice. Ranked battle selection uses a recorded random seed, prioritizes under-sampled pairings involving `myBots`, and falls back to distinct active catalog bots when no advice is available. Battle Runner execution, persistence, issue-ops transport, and the runtime container are added in subsequent CH-012 tasks. ## Configuration diff --git a/src/main/java/dev/robocode/rumble/client/BattleSelection.java b/src/main/java/dev/robocode/rumble/client/BattleSelection.java new file mode 100644 index 0000000..772ad06 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/BattleSelection.java @@ -0,0 +1,12 @@ +package dev.robocode.rumble.client; + +import java.util.List; + +/** + * Immutable ranked battle selection reproducible from its recorded random seed. + */ +record BattleSelection(GameType gameType, long randomSeed, List participants) { + BattleSelection { + participants = List.copyOf(participants); + } +} diff --git a/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java b/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java new file mode 100644 index 0000000..94d31ec --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java @@ -0,0 +1,72 @@ +package dev.robocode.rumble.client; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.Set; + +/** + * Turns non-exclusive matchmaking advice into a reproducible ranked battle. + */ +final class RankedBattleSelector { + BattleSelection select(final RumbleSnapshot snapshot, final ClientConfiguration configuration, + final GameType gameType, final long randomSeed) { + if (configuration.mode() != ClientMode.RANKED) { + throw new IllegalArgumentException("Ranked battle selection requires ranked mode"); + } + final GameTypeSettings settings = requireSettings(snapshot, gameType); + final MatchAdvice advice = requireAdvice(snapshot, gameType); + final List availableBots = snapshot.catalog().activeBots().values().stream() + .sorted(Comparator.comparing(CatalogBot::displayName)) + .toList(); + if (availableBots.size() < settings.participants()) { + throw new IllegalArgumentException("Game type " + gameType.contractName() + " requires " + + settings.participants() + " distinct active bots, but the catalog contains " + + availableBots.size()); + } + + final Random random = new Random(randomSeed); + final List participants = new ArrayList<>(settings.participants()); + chooseAdviceAnchor(advice.priorityPairs(), configuration.myBots(), random).ifPresent(pair -> + participants.addAll(pair.bots())); + + final Set selected = new HashSet<>(participants); + final List remaining = new ArrayList<>(availableBots.stream() + .filter(bot -> !selected.contains(bot)) + .toList()); + java.util.Collections.shuffle(remaining, random); + participants.addAll(remaining.subList(0, settings.participants() - participants.size())); + return new BattleSelection(gameType, randomSeed, participants); + } + + private static Optional chooseAdviceAnchor(final List priorityPairs, + final Set ownBots, final Random random) { + final List ownBotPairs = priorityPairs.stream() + .filter(pair -> pair.bots().stream().anyMatch(bot -> ownBots.contains(bot.name()))) + .toList(); + final List preferredPairs = ownBotPairs.isEmpty() ? priorityPairs : ownBotPairs; + if (preferredPairs.isEmpty()) { + return Optional.empty(); + } + return Optional.of(preferredPairs.get(random.nextInt(preferredPairs.size()))); + } + + private static GameTypeSettings requireSettings(final RumbleSnapshot snapshot, final GameType gameType) { + final GameTypeSettings settings = snapshot.engine().gameTypes().get(gameType); + if (settings == null) { + throw new IllegalArgumentException("Engine pin has no settings for " + gameType.contractName()); + } + return settings; + } + + private static MatchAdvice requireAdvice(final RumbleSnapshot snapshot, final GameType gameType) { + final MatchAdvice advice = snapshot.advice().get(gameType); + if (advice == null) { + throw new IllegalArgumentException("Snapshot has no matchmaking advice for " + gameType.contractName()); + } + return advice; + } +} diff --git a/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java b/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java new file mode 100644 index 0000000..cd0a794 --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java @@ -0,0 +1,91 @@ +package dev.robocode.rumble.client; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RankedBattleSelectorTest { + private static final long RANDOM_SEED = 482193L; + + @Test + @Tag("RCL-003") + void testRCL003_UnitPositive_prefersOwnBotAdviceAndSelectsEachPinnedParticipantCount() { + final RumbleSnapshot snapshot = snapshot(12, true); + final ClientConfiguration configuration = configuration(Set.of("Bot 03")); + final RankedBattleSelector selector = new RankedBattleSelector(); + + for (final GameType gameType : GameType.values()) { + final BattleSelection selection = selector.select(snapshot, configuration, gameType, RANDOM_SEED); + + assertEquals(snapshot.engine().gameTypes().get(gameType).participants(), selection.participants().size()); + assertEquals(selection.participants().size(), Set.copyOf(selection.participants()).size()); + assertTrue(selection.participants().stream().anyMatch(bot -> bot.name().equals("Bot 03"))); + assertEquals(RANDOM_SEED, selection.randomSeed()); + } + } + + @Test + @Tag("RCL-003") + void testRCL003_UnitPositive_usesSeededCatalogFallbackWhenAdviceIsEmpty() { + final RumbleSnapshot snapshot = snapshot(12, false); + final RankedBattleSelector selector = new RankedBattleSelector(); + + final BattleSelection first = selector.select(snapshot, configuration(Set.of()), GameType.MELEE, RANDOM_SEED); + final BattleSelection repeated = selector.select(snapshot, configuration(Set.of()), GameType.MELEE, RANDOM_SEED); + + assertEquals(first, repeated); + assertEquals(10, first.participants().size()); + } + + @Test + @Tag("RCL-003") + void testRCL003_UnitNegative_rejectsASelectionWithoutEnoughDistinctActiveBots() { + final RumbleSnapshot snapshot = snapshot(9, false); + + assertThrows(IllegalArgumentException.class, () -> new RankedBattleSelector() + .select(snapshot, configuration(Set.of()), GameType.MELEE, RANDOM_SEED)); + } + + private static RumbleSnapshot snapshot(final int botCount, final boolean withAdvice) { + final Map bots = new LinkedHashMap<>(); + for (int index = 1; index <= botCount; index++) { + final String name = "Bot %02d".formatted(index); + final CatalogBot bot = new CatalogBot(name, "1.0", "Java", "bots/java/" + name, + "sha256:" + "%064x".formatted(index)); + bots.put(bot.displayName(), bot); + } + final Map settings = Map.of( + GameType.ONE_VS_ONE, new GameTypeSettings(35, 800, 600, 2), + GameType.TWIN_DUEL, new GameTypeSettings(75, 800, 800, 4), + GameType.MELEE, new GameTypeSettings(35, 1000, 1000, 10)); + final List pairs = withAdvice ? List.of( + new PriorityPair(List.of(bots.get("Bot 01 1.0"), bots.get("Bot 02 1.0")), 0, "new-bot"), + new PriorityPair(List.of(bots.get("Bot 03 1.0"), bots.get("Bot 04 1.0")), 5, "under-sampled")) + : List.of(); + final Map advice = new LinkedHashMap<>(); + for (final GameType gameType : GameType.values()) { + advice.put(gameType, new MatchAdvice(gameType, "a".repeat(64), 6, pairs)); + } + return new RumbleSnapshot(URI.create("https://github.com/example/rumble-data"), "b".repeat(40), + new EnginePin(1, "unreleased", "example/image", settings), + new BotCatalog(URI.create("https://github.com/example/rumble-bots"), "c".repeat(40), bots), + new ClientRegistration("alice", "alice-desktop"), advice); + } + + private static ClientConfiguration configuration(final Set ownBots) { + return new ClientConfiguration(URI.create("https://github.com/example/rumble-bots"), + URI.create("https://github.com/example/rumble-data"), Optional.of("alice-desktop"), ownBots, + Set.of(GameType.values()), 10, ClientMode.RANKED, Path.of("work")); + } +} From 5b09f1f28f4b6b49945e5fd6fdbfd4f1c48e6159 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Tue, 4 Aug 2026 18:41:56 +0200 Subject: [PATCH 2/2] fix: bound global matchmaking candidates --- .../rumble/client/RankedBattleSelector.java | 6 +++- .../client/RankedBattleSelectorTest.java | 34 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java b/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java index 94d31ec..8b19564 100644 --- a/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java +++ b/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java @@ -12,6 +12,8 @@ * Turns non-exclusive matchmaking advice into a reproducible ranked battle. */ final class RankedBattleSelector { + private static final int GLOBAL_PRIORITY_CANDIDATE_LIMIT = 10; + BattleSelection select(final RumbleSnapshot snapshot, final ClientConfiguration configuration, final GameType gameType, final long randomSeed) { if (configuration.mode() != ClientMode.RANKED) { @@ -47,7 +49,9 @@ private static Optional chooseAdviceAnchor(final List ownBotPairs = priorityPairs.stream() .filter(pair -> pair.bots().stream().anyMatch(bot -> ownBots.contains(bot.name()))) .toList(); - final List preferredPairs = ownBotPairs.isEmpty() ? priorityPairs : ownBotPairs; + final List preferredPairs = ownBotPairs.isEmpty() + ? priorityPairs.stream().limit(GLOBAL_PRIORITY_CANDIDATE_LIMIT).toList() + : ownBotPairs; if (preferredPairs.isEmpty()) { return Optional.empty(); } diff --git a/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java b/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java index cd0a794..2a9b5da 100644 --- a/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java +++ b/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java @@ -5,17 +5,21 @@ import java.net.URI; import java.nio.file.Path; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class RankedBattleSelectorTest { + private static final int GLOBAL_PRIORITY_CANDIDATE_LIMIT = 10; private static final long RANDOM_SEED = 482193L; @Test @@ -30,11 +34,39 @@ void testRCL003_UnitPositive_prefersOwnBotAdviceAndSelectsEachPinnedParticipantC assertEquals(snapshot.engine().gameTypes().get(gameType).participants(), selection.participants().size()); assertEquals(selection.participants().size(), Set.copyOf(selection.participants()).size()); - assertTrue(selection.participants().stream().anyMatch(bot -> bot.name().equals("Bot 03"))); + assertTrue(selection.participants().stream().map(CatalogBot::name) + .toList().containsAll(List.of("Bot 03", "Bot 04"))); assertEquals(RANDOM_SEED, selection.randomSeed()); } } + @Test + @Tag("RCL-003") + void testRCL003_UnitPositive_selectsGlobalAdviceOnlyFromTheHighPriorityWindow() { + final RumbleSnapshot baseSnapshot = snapshot(12, false); + final List bots = baseSnapshot.catalog().activeBots().values().stream() + .sorted(Comparator.comparing(CatalogBot::displayName)) + .toList(); + final List pairs = IntStream.range(1, bots.size()) + .mapToObj(index -> new PriorityPair(List.of(bots.get(0), bots.get(index)), index, "under-sampled")) + .toList(); + final MatchAdvice advice = new MatchAdvice(GameType.ONE_VS_ONE, "a".repeat(64), 12, pairs); + final RumbleSnapshot snapshot = new RumbleSnapshot(baseSnapshot.canonicalDataRepository(), + baseSnapshot.dataRevision(), baseSnapshot.engine(), baseSnapshot.catalog(), + baseSnapshot.registration(), Map.of(GameType.ONE_VS_ONE, advice)); + final Set> highPriorityPairs = pairs.stream().limit(GLOBAL_PRIORITY_CANDIDATE_LIMIT) + .map(pair -> Set.copyOf(pair.bots())) + .collect(Collectors.toSet()); + final RankedBattleSelector selector = new RankedBattleSelector(); + + for (long seed = 0; seed < 100; seed++) { + final BattleSelection selection = selector.select(snapshot, configuration(Set.of()), + GameType.ONE_VS_ONE, seed); + + assertTrue(highPriorityPairs.contains(Set.copyOf(selection.participants()))); + } + } + @Test @Tag("RCL-003") void testRCL003_UnitPositive_usesSeededCatalogFallbackWhenAdviceIsEmpty() {