Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions src/main/java/dev/robocode/rumble/client/BattleSelection.java
Original file line number Diff line number Diff line change
@@ -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<CatalogBot> participants) {
BattleSelection {
participants = List.copyOf(participants);
}
}
76 changes: 76 additions & 0 deletions src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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 {
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) {
throw new IllegalArgumentException("Ranked battle selection requires ranked mode");
}
final GameTypeSettings settings = requireSettings(snapshot, gameType);
final MatchAdvice advice = requireAdvice(snapshot, gameType);
final List<CatalogBot> 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<CatalogBot> participants = new ArrayList<>(settings.participants());
chooseAdviceAnchor(advice.priorityPairs(), configuration.myBots(), random).ifPresent(pair ->
participants.addAll(pair.bots()));

final Set<CatalogBot> selected = new HashSet<>(participants);
final List<CatalogBot> 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<PriorityPair> chooseAdviceAnchor(final List<PriorityPair> priorityPairs,
final Set<String> ownBots, final Random random) {
final List<PriorityPair> ownBotPairs = priorityPairs.stream()
.filter(pair -> pair.bots().stream().anyMatch(bot -> ownBots.contains(bot.name())))
.toList();
final List<PriorityPair> preferredPairs = ownBotPairs.isEmpty()
? priorityPairs.stream().limit(GLOBAL_PRIORITY_CANDIDATE_LIMIT).toList()
: 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;
}
}
123 changes: 123 additions & 0 deletions src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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.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
@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().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<CatalogBot> bots = baseSnapshot.catalog().activeBots().values().stream()
.sorted(Comparator.comparing(CatalogBot::displayName))
.toList();
final List<PriorityPair> 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<Set<CatalogBot>> 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() {
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<String, CatalogBot> 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<GameType, GameTypeSettings> 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<PriorityPair> 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<GameType, MatchAdvice> 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<String> 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"));
}
}
Loading