From 7c5c1475006e60dc68db0160022c086fca29a83d Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 30 Aug 2026 12:50:31 +0200 Subject: [PATCH 1/9] build: consume local BR-049 Runner source --- .github/workflows/build.yml | 10 ++++++++-- Dockerfile | 5 ++++- README.md | 8 ++++++-- build.gradle.kts | 1 + gradle.properties | 1 + settings.gradle.kts | 9 +++++++++ .../rumble/client/RunnerDependencyTest.java | 19 +++++++++++++++++++ 7 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 src/test/java/dev/robocode/rumble/client/RunnerDependencyTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5742535..82f0d06 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,15 +16,21 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: robocode-dev/tank-royale + ref: fd06b97a61c9aa264e6964520a30262f8f8be751 + path: tank-royale-source + persist-credentials: false - uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 cache: gradle - if: runner.os != 'Windows' - run: ./gradlew build + run: ./gradlew --no-configuration-cache -PtankRoyaleSource=tank-royale-source build - if: runner.os == 'Windows' - run: .\gradlew.bat build + run: .\gradlew.bat --no-configuration-cache "-PtankRoyaleSource=tank-royale-source" build - if: runner.os == 'Linux' uses: actions/upload-artifact@v4 with: diff --git a/Dockerfile b/Dockerfile index 35510cf..79b68f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,14 @@ # syntax=docker/dockerfile:1 FROM gradle:8.14.3-jdk17 AS build +ARG TANK_ROYALE_COMMIT=fd06b97a61c9aa264e6964520a30262f8f8be751 WORKDIR /workspace COPY gradle gradle COPY gradlew gradlew.bat build.gradle.kts settings.gradle.kts gradle.properties ./ COPY src src -RUN ./gradlew --no-daemon installDist +RUN git clone --filter=blob:none https://github.com/robocode-dev/tank-royale.git /tank-royale \ + && git -C /tank-royale checkout "$TANK_ROYALE_COMMIT" \ + && ./gradlew --no-daemon --no-configuration-cache -PtankRoyaleSource=/tank-royale installDist FROM ubuntu:24.04 ARG TARGETARCH diff --git a/README.md b/README.md index 3a3380d..4d0de5f 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,16 @@ Contributors may use the supported native distribution or the recommended Docker ## Build -Install JDK 17, then run: +Install JDK 17 and keep a Tank Royale checkout containing BR-049 beside this repository, then run: ```shell -./gradlew build +./gradlew --no-configuration-cache -PtankRoyaleSource=../tank-royale build ``` +On PowerShell, quote the property argument: `.\gradlew.bat --no-configuration-cache "-PtankRoyaleSource=../tank-royale" build`. + +The source substitution is the development dependency path until the Runner API is part of a value-bearing Tank Royale release. It compiles the client against `dev.robocode.tankroyale:robocode-tankroyale-runner` without publishing an interim artifact. CI and the Docker build pin the accepted Tank Royale merge commit rather than following a moving branch. Configuration caching is disabled for source-substituted builds because the included Tank Royale build does not support it. + The build produces native ZIP and TAR archives under `build/distributions/`. Run `./gradlew run --args="--check-runtimes"` to verify the required Java 17, .NET 8 SDK, Python 3.12, and Node.js 22 installations; the check never installs or changes them. 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, validate its engine pin, catalog, client registration, and matchmaking advice, and prepare an immutable bot cache at the catalog's exact source commit. Every cached source tree is checked against its catalog SHA-256 before it can be used. 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. diff --git a/build.gradle.kts b/build.gradle.kts index 596f23a..1cff1fd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,7 @@ repositories { dependencies { implementation("com.google.code.gson:gson:2.13.2") + implementation("dev.robocode.tankroyale:robocode-tankroyale-runner:${providers.gradleProperty("tankRoyaleRunnerVersion").get()}") testImplementation(platform("org.junit:junit-bom:5.11.4")) testImplementation("org.junit.jupiter:junit-jupiter") diff --git a/gradle.properties b/gradle.properties index f1c749e..2f52580 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ group=dev.robocode.rumble version=0.1.0-SNAPSHOT +tankRoyaleRunnerVersion=1.2.0 org.gradle.configuration-cache=true org.gradle.caching=true diff --git a/settings.gradle.kts b/settings.gradle.kts index 3b10662..cee5978 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,10 @@ rootProject.name = "rumble-client" + +providers.gradleProperty("tankRoyaleSource").orNull?.let { sourcePath -> + includeBuild(file(sourcePath)) { + dependencySubstitution { + substitute(module("dev.robocode.tankroyale:robocode-tankroyale-runner")) + .using(project(":runner")) + } + } +} diff --git a/src/test/java/dev/robocode/rumble/client/RunnerDependencyTest.java b/src/test/java/dev/robocode/rumble/client/RunnerDependencyTest.java new file mode 100644 index 0000000..0285e76 --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/RunnerDependencyTest.java @@ -0,0 +1,19 @@ +package dev.robocode.rumble.client; + +import dev.robocode.tankroyale.runner.BattleRunner; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +final class RunnerDependencyTest { + @Test + @Tag("Unit") + void testUnitPositive_localRunnerProvidesBehaviorVersionPrecondition() { + assertDoesNotThrow(() -> { + try (BattleRunner ignored = BattleRunner.create(builder -> builder.requireBehaviorVersion(1))) { + // Constructing the Runner proves the Java-facing BR-049 API is present. + } + }); + } +} From c56229f6fbcdc27248c084f686fb999f3a696977 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 30 Aug 2026 12:53:01 +0200 Subject: [PATCH 2/9] ci: install Tank Royale build toolchains --- .github/workflows/build.yml | 4 +++- Dockerfile | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82f0d06..63f6661 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,9 @@ jobs: - uses: actions/setup-java@v5 with: distribution: temurin - java-version: 17 + java-version: | + 11 + 17 cache: gradle - if: runner.os != 'Windows' run: ./gradlew --no-configuration-cache -PtankRoyaleSource=tank-royale-source build diff --git a/Dockerfile b/Dockerfile index 79b68f8..bdfe0b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,19 @@ # syntax=docker/dockerfile:1 +FROM eclipse-temurin:11-jdk AS jdk11 + FROM gradle:8.14.3-jdk17 AS build ARG TANK_ROYALE_COMMIT=fd06b97a61c9aa264e6964520a30262f8f8be751 WORKDIR /workspace +COPY --from=jdk11 /opt/java/openjdk /opt/java/openjdk-11 COPY gradle gradle COPY gradlew gradlew.bat build.gradle.kts settings.gradle.kts gradle.properties ./ COPY src src RUN git clone --filter=blob:none https://github.com/robocode-dev/tank-royale.git /tank-royale \ && git -C /tank-royale checkout "$TANK_ROYALE_COMMIT" \ - && ./gradlew --no-daemon --no-configuration-cache -PtankRoyaleSource=/tank-royale installDist + && ./gradlew --no-daemon --no-configuration-cache \ + -Dorg.gradle.java.installations.paths=/opt/java/openjdk,/opt/java/openjdk-11 \ + -PtankRoyaleSource=/tank-royale installDist FROM ubuntu:24.04 ARG TARGETARCH From 965b4a2e9b0ae324fe5ceb60d5036179d6f8b435 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 30 Aug 2026 13:32:02 +0200 Subject: [PATCH 3/9] feat: select TwinDuel teams from catalog metadata --- .../robocode/rumble/client/JsonContract.java | 11 +++++ .../rumble/client/RankedBattleSelector.java | 29 +++++++++-- .../rumble/client/RumbleSnapshot.java | 20 +++++++- .../rumble/client/RumbleSnapshotParser.java | 39 ++++++++++++++- .../client/RankedBattleSelectorTest.java | 49 +++++++++++++------ .../rumble/client/RumbleSynchronizerTest.java | 13 +++++ 6 files changed, 141 insertions(+), 20 deletions(-) diff --git a/src/main/java/dev/robocode/rumble/client/JsonContract.java b/src/main/java/dev/robocode/rumble/client/JsonContract.java index dcf7ad6..bff7249 100644 --- a/src/main/java/dev/robocode/rumble/client/JsonContract.java +++ b/src/main/java/dev/robocode/rumble/client/JsonContract.java @@ -93,6 +93,17 @@ JsonArray array(final String field) { return value.getAsJsonArray(); } + JsonArray optionalArray(final String field) { + final JsonElement value = object.get(field); + if (value == null || value.isJsonNull()) { + return new JsonArray(); + } + if (!value.isJsonArray()) { + throw invalid(document + "." + field + " must be an array"); + } + return value.getAsJsonArray(); + } + JsonObject object(final String field) { final JsonElement value = required(field); if (!value.isJsonObject()) { diff --git a/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java b/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java index 8b19564..c968831 100644 --- a/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java +++ b/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java @@ -22,16 +22,19 @@ BattleSelection select(final RumbleSnapshot snapshot, final ClientConfiguration final GameTypeSettings settings = requireSettings(snapshot, gameType); final MatchAdvice advice = requireAdvice(snapshot, gameType); final List availableBots = snapshot.catalog().activeBots().values().stream() + .filter(bot -> bot.isTeam() == (gameType == GameType.TWIN_DUEL)) .sorted(Comparator.comparing(CatalogBot::displayName)) .toList(); - if (availableBots.size() < settings.participants()) { + final int requiredEntries = requiredEntries(gameType, settings); + if (availableBots.size() < requiredEntries) { throw new IllegalArgumentException("Game type " + gameType.contractName() + " requires " - + settings.participants() + " distinct active bots, but the catalog contains " + + requiredEntries + " distinct active " + + (gameType == GameType.TWIN_DUEL ? "teams" : "bots") + ", but the catalog contains " + availableBots.size()); } final Random random = new Random(randomSeed); - final List participants = new ArrayList<>(settings.participants()); + final List participants = new ArrayList<>(requiredEntries); chooseAdviceAnchor(advice.priorityPairs(), configuration.myBots(), random).ifPresent(pair -> participants.addAll(pair.bots())); @@ -40,10 +43,28 @@ BattleSelection select(final RumbleSnapshot snapshot, final ClientConfiguration .filter(bot -> !selected.contains(bot)) .toList()); java.util.Collections.shuffle(remaining, random); - participants.addAll(remaining.subList(0, settings.participants() - participants.size())); + participants.addAll(remaining.subList(0, requiredEntries - participants.size())); + final int expandedParticipants = participants.stream() + .mapToInt(CatalogBot::expandedParticipantCount) + .sum(); + if (expandedParticipants != settings.participants()) { + throw new IllegalArgumentException("Game type " + gameType.contractName() + " requires " + + settings.participants() + " expanded participants, but selection contains " + + expandedParticipants); + } return new BattleSelection(gameType, randomSeed, participants); } + private static int requiredEntries(final GameType gameType, final GameTypeSettings settings) { + if (gameType != GameType.TWIN_DUEL) { + return settings.participants(); + } + if (settings.participants() % 2 != 0) { + throw new IllegalArgumentException("TwinDuel requires an even expanded participant count"); + } + return settings.participants() / 2; + } + private static Optional chooseAdviceAnchor(final List priorityPairs, final Set ownBots, final Random random) { final List ownBotPairs = priorityPairs.stream() diff --git a/src/main/java/dev/robocode/rumble/client/RumbleSnapshot.java b/src/main/java/dev/robocode/rumble/client/RumbleSnapshot.java index 5634c97..03bc985 100644 --- a/src/main/java/dev/robocode/rumble/client/RumbleSnapshot.java +++ b/src/main/java/dev/robocode/rumble/client/RumbleSnapshot.java @@ -32,10 +32,28 @@ record BotCatalog(URI source, String sourceCommit, Map activ } } -record CatalogBot(String name, String version, String platform, String path, String sourceHash) { +record CatalogBot(String name, String version, String platform, String path, String sourceHash, + List teamMembers) { + CatalogBot { + teamMembers = List.copyOf(teamMembers); + } + + CatalogBot(final String name, final String version, final String platform, final String path, + final String sourceHash) { + this(name, version, platform, path, sourceHash, List.of()); + } + String displayName() { return name + " " + version; } + + boolean isTeam() { + return !teamMembers.isEmpty(); + } + + int expandedParticipantCount() { + return isTeam() ? teamMembers.size() : 1; + } } record ClientRegistration(String account, String clientId) { diff --git a/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java b/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java index 6445afd..aef25c2 100644 --- a/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java +++ b/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java @@ -90,7 +90,8 @@ private static BotCatalog parseCatalog(final String json, final URI expectedBots final CatalogBot entry = new CatalogBot(bot.string("name"), bot.string("version"), bot.string("platform"), validatedBotPath(bot.string("path")), matching(bot.string("sourceHash"), SHA_256, - "catalog bot sourceHash must be sha256:<64 lowercase hex>")); + "catalog bot sourceHash must be sha256:<64 lowercase hex>"), + parseTeamMembers(bot.optionalArray("teamMembers"))); if (activeBots.putIfAbsent(entry.displayName(), entry) != null) { throw JsonContract.invalid("catalog.json contains duplicate active bot " + entry.displayName()); } @@ -98,9 +99,41 @@ private static BotCatalog parseCatalog(final String json, final URI expectedBots if (activeBots.isEmpty()) { throw JsonContract.invalid("catalog.json contains no active bots"); } + validateTeamMembers(activeBots); return new BotCatalog(source, sourceCommit, activeBots); } + private static List parseTeamMembers(final JsonArray values) { + if (!values.isEmpty() && values.size() != 2) { + throw JsonContract.invalid("catalog bot teamMembers must be empty or contain exactly two identities"); + } + final List members = new ArrayList<>(); + for (final JsonElement value : values) { + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() + || value.getAsString().isBlank()) { + throw JsonContract.invalid("catalog bot teamMembers must contain non-blank strings"); + } + members.add(value.getAsString()); + } + return members; + } + + private static void validateTeamMembers(final Map activeBots) { + for (final CatalogBot team : activeBots.values().stream().filter(CatalogBot::isTeam).toList()) { + for (final String memberIdentity : team.teamMembers()) { + final CatalogBot member = activeBots.get(memberIdentity); + if (member == null) { + throw JsonContract.invalid("catalog team " + team.displayName() + + " references an inactive or unknown member: " + memberIdentity); + } + if (member.isTeam()) { + throw JsonContract.invalid("catalog team " + team.displayName() + + " references another team: " + memberIdentity); + } + } + } + } + private static ClientRegistration parseRegistration(final RepositoryReader.RepositoryCheckout checkout, final String clientId) throws java.io.IOException { ClientRegistration match = null; @@ -170,6 +203,10 @@ private static MatchAdvice parseAdvice(final String json, final String path, fin if (catalogBot == null) { throw JsonContract.invalid(path + " references an inactive or unknown bot: " + bot.getAsString()); } + if (catalogBot.isTeam() != (expectedGameType == GameType.TWIN_DUEL)) { + throw JsonContract.invalid(path + " references a bot that is ineligible for " + + expectedGameType.contractName() + ": " + bot.getAsString()); + } catalogBots.add(catalogBot); } if (catalogBots.get(0).equals(catalogBots.get(1))) { diff --git a/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java b/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java index b633a10..29aeaab 100644 --- a/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java +++ b/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java @@ -23,28 +23,35 @@ class RankedBattleSelectorTest { private static final long RANDOM_SEED = 482193L; @Test - @Tag("RCL-003") - void testRCL003_UnitPositive_prefersOwnBotAdviceAndSelectsEachPinnedParticipantCount() { + @Tag("RCL-010") + void testRCL010_UnitPositive_prefersOwnEntryAdviceAndSelectsEachPinnedParticipantCount() { final RumbleSnapshot snapshot = snapshot(12, true); - final ClientConfiguration configuration = configuration(Set.of("Bot 03")); + final ClientConfiguration configuration = configuration(Set.of("Bot 03", "Team 02")); 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()); + final int expectedEntries = gameType == GameType.TWIN_DUEL ? 2 + : snapshot.engine().gameTypes().get(gameType).participants(); + assertEquals(expectedEntries, 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"))); + final List expectedAdvice = gameType == GameType.TWIN_DUEL + ? List.of("Team 02", "Team 03") : List.of("Bot 03", "Bot 04"); + assertTrue(selection.participants().stream().map(CatalogBot::name).toList() + .containsAll(expectedAdvice)); + assertEquals(snapshot.engine().gameTypes().get(gameType).participants(), + selection.participants().stream().mapToInt(CatalogBot::expandedParticipantCount).sum()); assertEquals(RANDOM_SEED, selection.randomSeed()); } } @Test - @Tag("RCL-003") - void testRCL003_UnitPositive_selectsGlobalAdviceOnlyFromTheHighPriorityWindow() { + @Tag("RCL-010") + void testRCL010_UnitPositive_selectsGlobalAdviceOnlyFromTheHighPriorityWindow() { final RumbleSnapshot baseSnapshot = snapshot(12, false); final List bots = baseSnapshot.catalog().activeBots().values().stream() + .filter(bot -> !bot.isTeam()) .sorted(Comparator.comparing(CatalogBot::displayName)) .toList(); final List pairs = IntStream.range(1, bots.size()) @@ -68,8 +75,8 @@ void testRCL003_UnitPositive_selectsGlobalAdviceOnlyFromTheHighPriorityWindow() } @Test - @Tag("RCL-003") - void testRCL003_UnitPositive_usesSeededCatalogFallbackWhenAdviceIsEmpty() { + @Tag("RCL-010") + void testRCL010_UnitPositive_usesSeededCatalogFallbackWhenAdviceIsEmpty() { final RumbleSnapshot snapshot = snapshot(12, false); final RankedBattleSelector selector = new RankedBattleSelector(); @@ -81,8 +88,8 @@ void testRCL003_UnitPositive_usesSeededCatalogFallbackWhenAdviceIsEmpty() { } @Test - @Tag("RCL-003") - void testRCL003_UnitNegative_rejectsASelectionWithoutEnoughDistinctActiveBots() { + @Tag("RCL-010") + void testRCL010_UnitNegative_rejectsASelectionWithoutEnoughDistinctActiveBots() { final RumbleSnapshot snapshot = snapshot(9, false); assertThrows(IllegalArgumentException.class, () -> new RankedBattleSelector() @@ -97,17 +104,31 @@ private static RumbleSnapshot snapshot(final int botCount, final boolean withAdv "sha256:" + "%064x".formatted(index)); bots.put(bot.displayName(), bot); } + final List individuals = bots.values().stream().toList(); + for (int index = 0; index + 1 < individuals.size(); index += 2) { + final String name = "Team %02d".formatted(index / 2 + 1); + final CatalogBot team = new CatalogBot(name, "1.0", "JVM", "bots/java/" + name, + "sha256:" + "%064x".formatted(botCount + index + 1), + List.of(individuals.get(index).displayName(), individuals.get(index + 1).displayName())); + bots.put(team.displayName(), team); + } 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( + final List individualPairs = 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 List teamPairs = withAdvice ? List.of( + new PriorityPair(List.of(bots.get("Team 01 1.0"), bots.get("Team 04 1.0")), 0, "new-bot"), + new PriorityPair(List.of(bots.get("Team 02 1.0"), bots.get("Team 03 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)); + advice.put(gameType, new MatchAdvice(gameType, "a".repeat(64), 6, + gameType == GameType.TWIN_DUEL ? teamPairs : individualPairs)); } return new RumbleSnapshot(URI.create("https://github.com/example/rumble-data"), "b".repeat(40), new EnginePin(1, "unreleased", "example/image", java.util.Optional.empty(), settings), diff --git a/src/test/java/dev/robocode/rumble/client/RumbleSynchronizerTest.java b/src/test/java/dev/robocode/rumble/client/RumbleSynchronizerTest.java index 88e2321..ac01a78 100644 --- a/src/test/java/dev/robocode/rumble/client/RumbleSynchronizerTest.java +++ b/src/test/java/dev/robocode/rumble/client/RumbleSynchronizerTest.java @@ -127,6 +127,19 @@ void testRCL002_IntegrationNegative_rejectsCatalogBotPathTraversal() { () -> new RumbleSynchronizer(repositories).synchronize(configuration())); } + @Test + @Tag("RCL-002") + void testRCL002_IntegrationNegative_rejectsUnknownCatalogTeamMember() { + final InMemoryRepositoryReader repositories = validRepositories(); + repositories.replace(CANONICAL_REPOSITORY, "catalog.json", + repositories.read(CANONICAL_REPOSITORY, "catalog.json") + .replace("\"status\": \"active\"}", + "\"status\": \"active\", \"teamMembers\": [\"Missing 1.0\", \"Bravo 1.0\"]}")); + + assertThrows(IllegalArgumentException.class, + () -> new RumbleSynchronizer(repositories).synchronize(configuration())); + } + @Test @Tag("Unit") void testUnitNegative_rejectsSynchronizationInPracticeModeBeforeRepositoryAccess() { From 8bcbbcd33bca5e971ca728c5ee80e7f120d4b13e Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 30 Aug 2026 14:04:48 +0200 Subject: [PATCH 4/9] fix: never select two entries that share a member bot Selection checked distinctness by team identity only, so two teams sharing a member could both be picked and the battle would boot the same bot on both sides, making result attribution meaningless. Selection now tracks booked member identities, skips colliding candidates, drops an advice anchor whose pair overlaps, and fails loudly when the battle cannot be filled. Team-ness was hardcoded as a TWIN_DUEL comparison in three places with a magic divisor. GameType carries teamSize, the client-side twin of rumble-data's TEAM_SIZE, and the required entry count, catalog filter, error wording, and parser eligibility check all derive from it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Hag2ofqnbadWJqvnboJU7s --- README.md | 2 +- .../dev/robocode/rumble/client/GameType.java | 21 +++++-- .../rumble/client/RankedBattleSelector.java | 58 ++++++++++++++----- .../rumble/client/RumbleSnapshotParser.java | 2 +- .../client/RankedBattleSelectorTest.java | 34 +++++++++-- 5 files changed, 94 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4d0de5f..c712841 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The source substitution is the development dependency path until the Runner API The build produces native ZIP and TAR archives under `build/distributions/`. Run `./gradlew run --args="--check-runtimes"` to verify the required Java 17, .NET 8 SDK, Python 3.12, and Node.js 22 installations; the check never installs or changes them. -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, validate its engine pin, catalog, client registration, and matchmaking advice, and prepare an immutable bot cache at the catalog's exact source commit. Every cached source tree is checked against its catalog SHA-256 before it can be used. 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. +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, validate its engine pin, catalog, client registration, and matchmaking advice, and prepare an immutable bot cache at the catalog's exact source commit. Every cached source tree is checked against its catalog SHA-256 before it can be used. 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. Each game type declares how many bots one catalog entry expands to, so TwinDuel selects two team entries for its four pinned participants while `1v1` and melee select individual bots, and a selection never contains two entries that share a member bot. 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/GameType.java b/src/main/java/dev/robocode/rumble/client/GameType.java index c1c1cdb..4a068c5 100644 --- a/src/main/java/dev/robocode/rumble/client/GameType.java +++ b/src/main/java/dev/robocode/rumble/client/GameType.java @@ -6,20 +6,33 @@ * Ranked game types published by the Rumble engine pin. */ enum GameType { - ONE_VS_ONE("1v1"), - TWIN_DUEL("twinduel"), - MELEE("melee"); + ONE_VS_ONE("1v1", 1), + TWIN_DUEL("twinduel", 2), + MELEE("melee", 1); private final String contractName; + private final int teamSize; - GameType(final String contractName) { + GameType(final String contractName, final int teamSize) { this.contractName = contractName; + this.teamSize = teamSize; } String contractName() { return contractName; } + /** + * Number of bots each catalog entry of this game type expands to when the battle is booted. + */ + int teamSize() { + return teamSize; + } + + boolean isTeamGame() { + return teamSize > 1; + } + static GameType fromContractName(final String value) { return Arrays.stream(values()) .filter(gameType -> gameType.contractName.equals(value)) diff --git a/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java b/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java index c968831..a6b8dc5 100644 --- a/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java +++ b/src/main/java/dev/robocode/rumble/client/RankedBattleSelector.java @@ -22,28 +22,41 @@ BattleSelection select(final RumbleSnapshot snapshot, final ClientConfiguration final GameTypeSettings settings = requireSettings(snapshot, gameType); final MatchAdvice advice = requireAdvice(snapshot, gameType); final List availableBots = snapshot.catalog().activeBots().values().stream() - .filter(bot -> bot.isTeam() == (gameType == GameType.TWIN_DUEL)) + .filter(bot -> bot.isTeam() == gameType.isTeamGame()) .sorted(Comparator.comparing(CatalogBot::displayName)) .toList(); final int requiredEntries = requiredEntries(gameType, settings); if (availableBots.size() < requiredEntries) { throw new IllegalArgumentException("Game type " + gameType.contractName() + " requires " + requiredEntries + " distinct active " - + (gameType == GameType.TWIN_DUEL ? "teams" : "bots") + ", but the catalog contains " + + (gameType.isTeamGame() ? "teams" : "bots") + ", but the catalog contains " + availableBots.size()); } final Random random = new Random(randomSeed); final List participants = new ArrayList<>(requiredEntries); - chooseAdviceAnchor(advice.priorityPairs(), configuration.myBots(), random).ifPresent(pair -> - participants.addAll(pair.bots())); + final Set bookedMembers = new HashSet<>(); + chooseAdviceAnchor(advice.priorityPairs(), configuration.myBots(), random) + .filter(pair -> disjoint(pair.bots())) + .ifPresent(pair -> pair.bots().forEach(bot -> book(bot, participants, bookedMembers))); - final Set selected = new HashSet<>(participants); final List remaining = new ArrayList<>(availableBots.stream() - .filter(bot -> !selected.contains(bot)) + .filter(bot -> !participants.contains(bot)) .toList()); java.util.Collections.shuffle(remaining, random); - participants.addAll(remaining.subList(0, requiredEntries - participants.size())); + for (final CatalogBot candidate : remaining) { + if (participants.size() == requiredEntries) { + break; + } + if (java.util.Collections.disjoint(bookedMembers, memberIdentities(candidate))) { + book(candidate, participants, bookedMembers); + } + } + if (participants.size() != requiredEntries) { + throw new IllegalArgumentException("Game type " + gameType.contractName() + " requires " + + requiredEntries + " entries that share no member bot, but only " + + participants.size() + " could be selected"); + } final int expandedParticipants = participants.stream() .mapToInt(CatalogBot::expandedParticipantCount) .sum(); @@ -55,14 +68,33 @@ BattleSelection select(final RumbleSnapshot snapshot, final ClientConfiguration return new BattleSelection(gameType, randomSeed, participants); } + private static void book(final CatalogBot bot, final List participants, + final Set bookedMembers) { + participants.add(bot); + bookedMembers.addAll(memberIdentities(bot)); + } + + /** + * Distinct identities of the bots one catalog entry boots, so that no bot ever appears on both + * sides of a single battle. A team that lists the same member twice still boots that one bot. + */ + private static Set memberIdentities(final CatalogBot bot) { + return bot.isTeam() ? Set.copyOf(bot.teamMembers()) : Set.of(bot.displayName()); + } + + private static boolean disjoint(final List bots) { + final Set union = new HashSet<>(); + return bots.stream().allMatch(bot -> java.util.Collections.disjoint(union, memberIdentities(bot)) + && union.addAll(memberIdentities(bot))); + } + private static int requiredEntries(final GameType gameType, final GameTypeSettings settings) { - if (gameType != GameType.TWIN_DUEL) { - return settings.participants(); - } - if (settings.participants() % 2 != 0) { - throw new IllegalArgumentException("TwinDuel requires an even expanded participant count"); + if (settings.participants() % gameType.teamSize() != 0) { + throw new IllegalArgumentException("Game type " + gameType.contractName() + + " pins " + settings.participants() + " participants, which is not divisible by its team size " + + gameType.teamSize()); } - return settings.participants() / 2; + return settings.participants() / gameType.teamSize(); } private static Optional chooseAdviceAnchor(final List priorityPairs, diff --git a/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java b/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java index aef25c2..561f439 100644 --- a/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java +++ b/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java @@ -203,7 +203,7 @@ private static MatchAdvice parseAdvice(final String json, final String path, fin if (catalogBot == null) { throw JsonContract.invalid(path + " references an inactive or unknown bot: " + bot.getAsString()); } - if (catalogBot.isTeam() != (expectedGameType == GameType.TWIN_DUEL)) { + if (catalogBot.isTeam() != expectedGameType.isTeamGame()) { throw JsonContract.invalid(path + " references a bot that is ineligible for " + expectedGameType.contractName() + ": " + bot.getAsString()); } diff --git a/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java b/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java index 29aeaab..6d53542 100644 --- a/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java +++ b/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java @@ -32,11 +32,11 @@ void testRCL010_UnitPositive_prefersOwnEntryAdviceAndSelectsEachPinnedParticipan for (final GameType gameType : GameType.values()) { final BattleSelection selection = selector.select(snapshot, configuration, gameType, RANDOM_SEED); - final int expectedEntries = gameType == GameType.TWIN_DUEL ? 2 - : snapshot.engine().gameTypes().get(gameType).participants(); + final int expectedEntries = snapshot.engine().gameTypes().get(gameType).participants() + / gameType.teamSize(); assertEquals(expectedEntries, selection.participants().size()); assertEquals(selection.participants().size(), Set.copyOf(selection.participants()).size()); - final List expectedAdvice = gameType == GameType.TWIN_DUEL + final List expectedAdvice = gameType.isTeamGame() ? List.of("Team 02", "Team 03") : List.of("Bot 03", "Bot 04"); assertTrue(selection.participants().stream().map(CatalogBot::name).toList() .containsAll(expectedAdvice)); @@ -87,6 +87,32 @@ void testRCL010_UnitPositive_usesSeededCatalogFallbackWhenAdviceIsEmpty() { assertEquals(10, first.participants().size()); } + @Test + @Tag("RCL-010") + void testRCL010_UnitNegative_neverSelectsTwoTeamsThatShareAMemberBot() { + final RumbleSnapshot base = snapshot(12, false); + final Map bots = new LinkedHashMap<>(); + base.catalog().activeBots().values().stream().filter(bot -> !bot.isTeam()) + .forEach(bot -> bots.put(bot.displayName(), bot)); + final List individuals = List.copyOf(bots.values()); + for (int index = 1; index < individuals.size(); index++) { + final String name = "Overlap %02d".formatted(index); + final CatalogBot team = new CatalogBot(name, "1.0", "JVM", "bots/java/" + name, + "sha256:" + "%064x".formatted(100 + index), + List.of(individuals.get(0).displayName(), individuals.get(index).displayName())); + bots.put(team.displayName(), team); + } + final RumbleSnapshot snapshot = new RumbleSnapshot(base.canonicalDataRepository(), base.dataRevision(), + base.engine(), new BotCatalog(base.catalog().source(), base.catalog().sourceCommit(), bots), + base.registration(), base.advice()); + + final IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> new RankedBattleSelector() + .select(snapshot, configuration(Set.of()), GameType.TWIN_DUEL, RANDOM_SEED)); + + assertTrue(failure.getMessage().contains("share no member bot"), failure.getMessage()); + } + @Test @Tag("RCL-010") void testRCL010_UnitNegative_rejectsASelectionWithoutEnoughDistinctActiveBots() { @@ -128,7 +154,7 @@ GameType.TWIN_DUEL, new GameTypeSettings(75, 800, 800, 4), final Map advice = new LinkedHashMap<>(); for (final GameType gameType : GameType.values()) { advice.put(gameType, new MatchAdvice(gameType, "a".repeat(64), 6, - gameType == GameType.TWIN_DUEL ? teamPairs : individualPairs)); + gameType.isTeamGame() ? teamPairs : individualPairs)); } return new RumbleSnapshot(URI.create("https://github.com/example/rumble-data"), "b".repeat(40), new EnginePin(1, "unreleased", "example/image", java.util.Optional.empty(), settings), From 2ca0f1cb1eee9fa6d52196107e4a7b6ec460d75a Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 30 Aug 2026 14:14:02 +0200 Subject: [PATCH 5/9] feat: execute ranked battles with replay evidence --- README.md | 4 +- .../rumble/client/BattleExecutor.java | 15 +++ .../rumble/client/RankedBattleExecution.java | 99 ++++++++++++++ .../rumble/client/RankedBattleRecord.java | 26 ++++ .../robocode/rumble/client/RumbleClient.java | 30 ++++- .../rumble/client/RunnerBattleExecutor.java | 56 ++++++++ .../client/RankedBattleExecutionTest.java | 124 ++++++++++++++++++ .../rumble/client/RumbleClientTest.java | 1 + 8 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 src/main/java/dev/robocode/rumble/client/BattleExecutor.java create mode 100644 src/main/java/dev/robocode/rumble/client/RankedBattleExecution.java create mode 100644 src/main/java/dev/robocode/rumble/client/RankedBattleRecord.java create mode 100644 src/main/java/dev/robocode/rumble/client/RunnerBattleExecutor.java create mode 100644 src/test/java/dev/robocode/rumble/client/RankedBattleExecutionTest.java diff --git a/README.md b/README.md index c712841..2c61caa 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The source substitution is the development dependency path until the Runner API The build produces native ZIP and TAR archives under `build/distributions/`. Run `./gradlew run --args="--check-runtimes"` to verify the required Java 17, .NET 8 SDK, Python 3.12, and Node.js 22 installations; the check never installs or changes them. -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, validate its engine pin, catalog, client registration, and matchmaking advice, and prepare an immutable bot cache at the catalog's exact source commit. Every cached source tree is checked against its catalog SHA-256 before it can be used. 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. Each game type declares how many bots one catalog entry expands to, so TwinDuel selects two team entries for its four pinned participants while `1v1` and melee select individual bots, and a selection never contains two entries that share a member bot. 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, validate its engine pin, catalog, client registration, and matchmaking advice, and prepare an immutable bot cache at the catalog's exact source commit. Every cached source tree is checked against its catalog SHA-256 before it can be used. 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. Each game type declares how many bots one catalog entry expands to, so TwinDuel selects two team entries for its four pinned participants while `1v1` and melee select individual bots, and a selection never contains two entries that share a member bot. Run `./gradlew run --args="--run"` to execute one pinned ranked battle through Battle Runner and retain its replay evidence locally. Journal persistence, issue-ops transport, and the runtime container are added in subsequent CH-012 tasks. ## Configuration @@ -30,7 +30,7 @@ Copy `rumble-client.example.json` to `rumble-client.json`. Ranked mode requires Docker Engine or Docker Desktop is required. Build the current non-published development image with `docker build --tag rumble-client:dev .`, then use `docker/rumble.sh` or `docker/rumble.ps1` to validate configuration, check the bundled runtimes, or synchronize the ranked snapshot. Docker execution uses the default `.rumble-client` work directory beside the configuration file. The launchers expose only that configuration file and state directory to the container and apply a read-only root filesystem, dropped capabilities, finite resource limits, and no external network for the runtime check. -Battle and submission commands remain unavailable until their later CH-012 implementation tasks land. Their Docker launcher phases will run battles offline without a submission credential and submission online without starting bot code. +Submission commands remain unavailable until their later CH-012 implementation tasks land. Their Docker launcher phases will run battles offline without a submission credential and submission online without starting bot code. ## Contributing diff --git a/src/main/java/dev/robocode/rumble/client/BattleExecutor.java b/src/main/java/dev/robocode/rumble/client/BattleExecutor.java new file mode 100644 index 0000000..99947ba --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/BattleExecutor.java @@ -0,0 +1,15 @@ +package dev.robocode.rumble.client; + +import dev.robocode.tankroyale.runner.BattleResults; + +import java.io.IOException; +import java.nio.file.Path; + +/** Executes one prepared battle and returns its complete Runner result and recording. */ +interface BattleExecutor { + CompletedBattle execute(BattleSelection selection, PreparedBotCache cache, EnginePin engine, + GameTypeSettings settings, Path recordingDirectory) throws IOException; +} + +record CompletedBattle(BattleResults results, Path replay) { +} diff --git a/src/main/java/dev/robocode/rumble/client/RankedBattleExecution.java b/src/main/java/dev/robocode/rumble/client/RankedBattleExecution.java new file mode 100644 index 0000000..1fcf217 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/RankedBattleExecution.java @@ -0,0 +1,99 @@ +package dev.robocode.rumble.client; + +import dev.robocode.tankroyale.runner.BotResult; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; + +/** Validates one completed ranked battle and binds its immutable result to retained replay evidence. */ +final class RankedBattleExecution { + private final BattleExecutor executor; + private final Clock clock; + private final Supplier battleIds; + + RankedBattleExecution(final BattleExecutor executor, final Clock clock, final Supplier battleIds) { + this.executor = executor; + this.clock = clock; + this.battleIds = battleIds; + } + + RankedBattleRecord execute(final BattleSelection selection, final PreparedBotCache cache, + final RumbleSnapshot snapshot, final ClientConfiguration configuration, + final String clientVersion) throws IOException { + if (configuration.mode() != ClientMode.RANKED) { + throw new IllegalArgumentException("Ranked battle execution requires ranked mode"); + } + final GameTypeSettings settings = requireSettings(snapshot, selection.gameType()); + final UUID battleId = battleIds.get(); + final Path recordingDirectory = configuration.workDirectory().resolve("recordings").resolve(battleId.toString()); + Files.createDirectories(recordingDirectory); + final CompletedBattle completed = executor.execute(selection, cache, snapshot.engine(), settings, recordingDirectory); + validate(completed, selection, settings); + final Path evidence = configuration.workDirectory().resolve("evidence").resolve(battleId + ".battle.gz"); + Files.createDirectories(evidence.getParent()); + Files.move(completed.replay(), evidence); + return new RankedBattleRecord(battleId, clock.instant(), + new ClientIdentity(snapshot.registration().clientId(), clientVersion), + new EngineIdentity(snapshot.engine().behaviorVersion()), selection.gameType().contractName(), + settings.rounds(), settings.arenaWidth(), settings.arenaHeight(), + completed.results().getResults().stream().map(RankedBattleExecution::participant).toList(), + sha256(evidence)); + } + + private static void validate(final CompletedBattle completed, final BattleSelection selection, + final GameTypeSettings settings) { + if (completed.results().getNumberOfRounds() != settings.rounds()) { + throw new IllegalArgumentException("Battle completed " + completed.results().getNumberOfRounds() + + " rounds, but the engine pin requires " + settings.rounds()); + } + if (!Files.isRegularFile(completed.replay())) { + throw new IllegalArgumentException("Completed battle has no replay recording"); + } + final Set expected = selection.participants().stream() + .map(bot -> identity(bot.name(), bot.version(), bot.isTeam())).collect(java.util.stream.Collectors.toSet()); + final Set actual = new HashSet<>(); + for (final BotResult result : completed.results().getResults()) { + actual.add(identity(result.getName(), result.getVersion(), result.isTeam())); + } + if (!actual.equals(expected) || actual.size() != completed.results().getResults().size()) { + throw new IllegalArgumentException("Battle results do not match the ranked selection"); + } + } + + 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 String identity(final String name, final String version, final boolean isTeam) { + return name + "\n" + version + "\n" + isTeam; + } + + private static RankedParticipant participant(final BotResult result) { + return new RankedParticipant(result.getName(), result.getVersion(), result.isTeam(), result.getRank(), + result.getTotalScore(), result.getSurvival(), result.getLastSurvivorBonus(), result.getBulletDamage(), + result.getBulletKillBonus(), result.getRamDamage(), result.getRamKillBonus(), result.getFirstPlaces(), + result.getSecondPlaces(), result.getThirdPlaces()); + } + + private static String sha256(final Path file) throws IOException { + try { + return "sha256:" + java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(Files.readAllBytes(file))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } +} diff --git a/src/main/java/dev/robocode/rumble/client/RankedBattleRecord.java b/src/main/java/dev/robocode/rumble/client/RankedBattleRecord.java new file mode 100644 index 0000000..d9555fd --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/RankedBattleRecord.java @@ -0,0 +1,26 @@ +package dev.robocode.rumble.client; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** Immutable result record ready for the Rumble result-data envelope. */ +record RankedBattleRecord(UUID battleId, Instant completedAt, ClientIdentity client, EngineIdentity engine, + String gameType, int rounds, int arenaWidth, int arenaHeight, + List participants, String replayHash) { + RankedBattleRecord { + participants = List.copyOf(participants); + } +} + +record ClientIdentity(String id, String version) { +} + +record EngineIdentity(int behaviorVersion) { +} + +record RankedParticipant(String name, String version, boolean isTeam, int rank, int totalScore, + int survival, int lastSurvivorBonus, int bulletDamage, int bulletKillBonus, + int ramDamage, int ramKillBonus, int firstPlaces, int secondPlaces, + int thirdPlaces) { +} diff --git a/src/main/java/dev/robocode/rumble/client/RumbleClient.java b/src/main/java/dev/robocode/rumble/client/RumbleClient.java index c6c6c5f..a52685d 100644 --- a/src/main/java/dev/robocode/rumble/client/RumbleClient.java +++ b/src/main/java/dev/robocode/rumble/client/RumbleClient.java @@ -3,6 +3,9 @@ import java.io.IOException; import java.io.PrintStream; import java.nio.file.Path; +import java.time.Clock; +import java.util.Comparator; +import java.util.UUID; /** * Command-line entry point for Tank Royale Rumble battle contribution. @@ -12,6 +15,7 @@ public final class RumbleClient { private static final String VALIDATE_CONFIG_OPTION = "--validate-config"; private static final String CHECK_RUNTIMES_OPTION = "--check-runtimes"; private static final String SYNCHRONIZE_OPTION = "--sync"; + private static final String RUN_OPTION = "--run"; private static final Path DEFAULT_CONFIGURATION_PATH = Path.of("rumble-client.json"); private RumbleClient() { @@ -49,17 +53,30 @@ static void run(final String[] arguments, final PrintStream output, final Runtim } if (arguments.length > 2 - || (!arguments[0].equals(VALIDATE_CONFIG_OPTION) && !arguments[0].equals(SYNCHRONIZE_OPTION))) { + || (!arguments[0].equals(VALIDATE_CONFIG_OPTION) && !arguments[0].equals(SYNCHRONIZE_OPTION) + && !arguments[0].equals(RUN_OPTION))) { throw new IllegalArgumentException( - "Expected --validate-config [path], --check-runtimes, --sync [path], or --help"); + "Expected --validate-config [path], --check-runtimes, --sync [path], --run [path], or --help"); } final Path configurationPath = arguments.length == 2 ? Path.of(arguments[1]) : DEFAULT_CONFIGURATION_PATH; final ClientConfiguration configuration = new ClientConfigurationLoader().load(configurationPath); - if (arguments[0].equals(SYNCHRONIZE_OPTION)) { + if (arguments[0].equals(SYNCHRONIZE_OPTION) || arguments[0].equals(RUN_OPTION)) { final GitRepositoryReader repositoryReader = new GitRepositoryReader(); final RumbleSnapshot snapshot = new RumbleSynchronizer(repositoryReader).synchronize(configuration); final PreparedBotCache botCache = new BotCachePreparer(repositoryReader).prepare(snapshot, configuration); + if (arguments[0].equals(RUN_OPTION)) { + final GameType gameType = configuration.gameTypes().stream() + .min(Comparator.comparing(GameType::contractName)).orElseThrow(); + final BattleSelection selection = new RankedBattleSelector().select(snapshot, configuration, gameType, + UUID.randomUUID().getMostSignificantBits()); + final RankedBattleRecord record = new RankedBattleExecution(new RunnerBattleExecutor(), + Clock.systemUTC(), UUID::randomUUID).execute(selection, botCache, snapshot, configuration, + clientVersion()); + output.printf("Completed ranked %s battle %s; replay evidence is retained at %s.%n", + record.gameType(), record.battleId(), configuration.workDirectory().resolve("evidence")); + return; + } output.printf("Synchronized %s at %s.%n", snapshot.canonicalDataRepository(), snapshot.dataRevision()); output.printf("Accepted behavior version %d, cached %d active bots at %s, and advice for %d game types.%n", snapshot.engine().behaviorVersion(), botCache.bots().size(), botCache.sourceCommit(), @@ -79,11 +96,13 @@ private static void printHelp(final PrintStream output) { output.println("Usage: rumble-client --validate-config [path]"); output.println(" rumble-client --check-runtimes"); output.println(" rumble-client --sync [path]"); + output.println(" rumble-client --run [path]"); output.println(" rumble-client --help"); output.println(); output.println("Use --validate-config to check a local ranked or practice configuration."); output.println("Use --check-runtimes to verify native Java, .NET, Python, and Node.js prerequisites."); output.println("Use --sync to validate the current ranked snapshot and prepare its immutable bot cache."); + output.println("Use --run to execute one ranked battle and retain its local replay evidence."); } private static void printRuntimeReport(final RuntimeReport report, final PrintStream output) { @@ -101,4 +120,9 @@ private static void printRuntimeReport(final RuntimeReport report, final PrintSt interface RuntimeCheck { RuntimeReport check() throws IOException; } + + private static String clientVersion() { + final String version = RumbleClient.class.getPackage().getImplementationVersion(); + return version == null ? "development" : version; + } } diff --git a/src/main/java/dev/robocode/rumble/client/RunnerBattleExecutor.java b/src/main/java/dev/robocode/rumble/client/RunnerBattleExecutor.java new file mode 100644 index 0000000..9b8bb3d --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/RunnerBattleExecutor.java @@ -0,0 +1,56 @@ +package dev.robocode.rumble.client; + +import dev.robocode.tankroyale.runner.BattleResults; +import dev.robocode.tankroyale.runner.BattleRunner; +import dev.robocode.tankroyale.runner.BattleSetup; +import dev.robocode.tankroyale.runner.BotEntry; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** Production adapter that executes one pinned battle through Battle Runner. */ +final class RunnerBattleExecutor implements BattleExecutor { + @Override + public CompletedBattle execute(final BattleSelection selection, final PreparedBotCache cache, + final EnginePin engine, final GameTypeSettings settings, + final Path recordingDirectory) throws IOException { + final List bots = selection.participants().stream() + .map(cache.bots()::get) + .map(path -> { + if (path == null) { + throw new IllegalArgumentException("Selection bot is absent from the prepared cache"); + } + return BotEntry.of(path); + }) + .toList(); + final BattleResults results; + try (BattleRunner runner = BattleRunner.create(builder -> builder.embeddedServer() + .enableRecording(recordingDirectory).requireBehaviorVersion(engine.behaviorVersion()))) { + results = runner.runBattle(setup(selection.gameType(), settings), bots); + } + try (var files = Files.list(recordingDirectory)) { + final List recordings = files.filter(path -> path.getFileName().toString().endsWith(".battle.gz")) + .toList(); + if (recordings.size() != 1) { + throw new IOException("Battle Runner produced " + recordings.size() + " replay recordings"); + } + return new CompletedBattle(results, recordings.get(0)); + } + } + + private static BattleSetup setup(final GameType gameType, final GameTypeSettings settings) { + return switch (gameType) { + case ONE_VS_ONE -> BattleSetup.oneVsOne(builder -> configure(builder, settings)); + case TWIN_DUEL -> BattleSetup.twinDuel(builder -> configure(builder, settings)); + case MELEE -> BattleSetup.melee(builder -> configure(builder, settings)); + }; + } + + private static void configure(final BattleSetup.Builder builder, final GameTypeSettings settings) { + builder.setNumberOfRounds(settings.rounds()); + builder.setArenaWidth(settings.arenaWidth()); + builder.setArenaHeight(settings.arenaHeight()); + } +} diff --git a/src/test/java/dev/robocode/rumble/client/RankedBattleExecutionTest.java b/src/test/java/dev/robocode/rumble/client/RankedBattleExecutionTest.java new file mode 100644 index 0000000..3214a56 --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/RankedBattleExecutionTest.java @@ -0,0 +1,124 @@ +package dev.robocode.rumble.client; + +import dev.robocode.tankroyale.runner.BattleResults; +import dev.robocode.tankroyale.runner.BotResult; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RankedBattleExecutionTest { + @TempDir + Path temporaryDirectory; + + @Test + @Tag("RCL-005") + void testRCL005_IntegrationPositive_completedPinnedBattleCreatesReplayBoundRecord() throws IOException { + final UUID battleId = UUID.fromString("2a1e154d-9e16-4cd3-81c6-5e5d4092c731"); + final RankedBattleRecord record = execution(validExecutor(), battleId).execute(selection(), cache(), snapshot(), + configuration(ClientMode.RANKED), "0.1.0"); + + assertEquals(battleId, record.battleId()); + assertEquals("1v1", record.gameType()); + assertEquals(35, record.rounds()); + assertEquals(2, record.participants().size()); + assertTrue(record.replayHash().startsWith("sha256:")); + assertTrue(Files.isRegularFile(temporaryDirectory.resolve("work/evidence").resolve(battleId + ".battle.gz"))); + } + + @Test + @Tag("RCL-004") + void testRCL004_IntegrationNegative_practiceModeCannotCreateRankedResult() { + assertThrows(IllegalArgumentException.class, () -> execution(validExecutor(), UUID.randomUUID()).execute(selection(), cache(), + snapshot(), configuration(ClientMode.PRACTICE), "0.1.0")); + assertFalse(Files.exists(temporaryDirectory.resolve("work/evidence"))); + } + + @Test + @Tag("RCL-005") + void testRCL005_IntegrationNegative_incompleteBattleCreatesNoEvidence() { + final BattleExecutor incomplete = (selection, cache, engine, settings, recordings) -> { + Files.createDirectories(recordings); + final Path replay = recordings.resolve("partial.battle.gz"); + Files.writeString(replay, "partial"); + return new CompletedBattle(new BattleResults(34, results()), replay); + }; + + assertThrows(IllegalArgumentException.class, () -> execution(incomplete, UUID.randomUUID()).execute(selection(), cache(), snapshot(), + configuration(ClientMode.RANKED), "0.1.0")); + assertFalse(Files.exists(temporaryDirectory.resolve("work/evidence"))); + } + + private RankedBattleExecution execution(final BattleExecutor executor, final UUID battleId) { + return new RankedBattleExecution(executor, Clock.fixed(Instant.parse("2026-08-30T12:00:00Z"), ZoneOffset.UTC), + () -> battleId); + } + + private BattleExecutor validExecutor() { + return (selection, cache, engine, settings, recordings) -> { + Files.createDirectories(recordings); + final Path replay = recordings.resolve("game.battle.gz"); + Files.writeString(replay, "replay"); + return new CompletedBattle(new BattleResults(settings.rounds(), results()), replay); + }; + } + + private static List results() { + return List.of(result(1, "Alpha", 80, 35, 0), result(2, "Bravo", 20, 0, 35)); + } + + private static BotResult result(final int rank, final String name, final int score, final int firstPlaces, + final int secondPlaces) { + return new BotResult(rank, name, "1.0", false, rank, score, 0, 0, 0, 0, 0, 0, firstPlaces, + secondPlaces, 0); + } + + private static BattleSelection selection() { + return new BattleSelection(GameType.ONE_VS_ONE, 7L, List.of(alpha(), bravo())); + } + + private static PreparedBotCache cache() { + return new PreparedBotCache("a".repeat(40), Map.of(alpha(), Path.of("alpha"), bravo(), Path.of("bravo"))); + } + + private static RumbleSnapshot snapshot() { + final Map bots = Map.of(alpha().displayName(), alpha(), bravo().displayName(), bravo()); + final EnginePin engine = new EnginePin(1, "unreleased", "image", Optional.empty(), + Map.of(GameType.ONE_VS_ONE, new GameTypeSettings(35, 800, 600, 2))); + return new RumbleSnapshot(URI.create("https://github.com/example/data"), "b".repeat(40), engine, + new BotCatalog(URI.create("https://github.com/example/bots"), "a".repeat(40), bots), + new ClientRegistration("alice", "alice-client"), Map.of()); + } + + private ClientConfiguration configuration(final ClientMode mode) { + return new ClientConfiguration(URI.create("https://github.com/example/bots"), + URI.create("https://github.com/example/data"), mode == ClientMode.RANKED + ? Optional.of("alice-client") : Optional.empty(), Set.of(), Set.of(GameType.ONE_VS_ONE), 1, mode, + temporaryDirectory.resolve("work")); + } + + private static CatalogBot alpha() { + return new CatalogBot("Alpha", "1.0", "JVM", "bots/java/Alpha", "sha256:" + "a".repeat(64)); + } + + private static CatalogBot bravo() { + return new CatalogBot("Bravo", "1.0", "JVM", "bots/java/Bravo", "sha256:" + "b".repeat(64)); + } +} diff --git a/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java b/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java index 81e8707..530f9e5 100644 --- a/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java +++ b/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java @@ -22,6 +22,7 @@ void testUnitPositive_printsHelpWithoutConfiguration() throws IOException { assertTrue(bytes.toString().contains("rumble-client --validate-config [path]")); assertTrue(bytes.toString().contains("rumble-client --check-runtimes")); assertTrue(bytes.toString().contains("rumble-client --sync [path]")); + assertTrue(bytes.toString().contains("rumble-client --run [path]")); } @Test From 0294ee86d8bb94672a3a681c125ff39da2986ae9 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 30 Aug 2026 14:18:02 +0200 Subject: [PATCH 6/9] feat: journal ranked result records durably --- .../rumble/client/RankedBattleExecution.java | 1 + .../rumble/client/RankedBattleRecord.java | 2 +- .../robocode/rumble/client/RankedJournal.java | 266 ++++++++++++++++++ .../robocode/rumble/client/RumbleClient.java | 6 + .../client/RankedBattleExecutionTest.java | 1 + .../rumble/client/RankedJournalTest.java | 57 ++++ 6 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 src/main/java/dev/robocode/rumble/client/RankedJournal.java create mode 100644 src/test/java/dev/robocode/rumble/client/RankedJournalTest.java diff --git a/src/main/java/dev/robocode/rumble/client/RankedBattleExecution.java b/src/main/java/dev/robocode/rumble/client/RankedBattleExecution.java index 1fcf217..c14a70b 100644 --- a/src/main/java/dev/robocode/rumble/client/RankedBattleExecution.java +++ b/src/main/java/dev/robocode/rumble/client/RankedBattleExecution.java @@ -45,6 +45,7 @@ RankedBattleRecord execute(final BattleSelection selection, final PreparedBotCac new ClientIdentity(snapshot.registration().clientId(), clientVersion), new EngineIdentity(snapshot.engine().behaviorVersion()), selection.gameType().contractName(), settings.rounds(), settings.arenaWidth(), settings.arenaHeight(), + selection.randomSeed(), completed.results().getResults().stream().map(RankedBattleExecution::participant).toList(), sha256(evidence)); } diff --git a/src/main/java/dev/robocode/rumble/client/RankedBattleRecord.java b/src/main/java/dev/robocode/rumble/client/RankedBattleRecord.java index d9555fd..d0ccb0d 100644 --- a/src/main/java/dev/robocode/rumble/client/RankedBattleRecord.java +++ b/src/main/java/dev/robocode/rumble/client/RankedBattleRecord.java @@ -7,7 +7,7 @@ /** Immutable result record ready for the Rumble result-data envelope. */ record RankedBattleRecord(UUID battleId, Instant completedAt, ClientIdentity client, EngineIdentity engine, String gameType, int rounds, int arenaWidth, int arenaHeight, - List participants, String replayHash) { + long selectionSeed, List participants, String replayHash) { RankedBattleRecord { participants = List.copyOf(participants); } diff --git a/src/main/java/dev/robocode/rumble/client/RankedJournal.java b/src/main/java/dev/robocode/rumble/client/RankedJournal.java new file mode 100644 index 0000000..5d99817 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/RankedJournal.java @@ -0,0 +1,266 @@ +package dev.robocode.rumble.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Durable append-only storage for completed ranked records and their subsequent disposition. + */ +final class RankedJournal { + private static final int SCHEMA_VERSION = 1; + private static final String RECORDS_FILE = "records.jsonl"; + private static final String ACKNOWLEDGEMENTS_FILE = "acknowledgements.jsonl"; + private static final String QUARANTINE_FILE = "quarantine.jsonl"; + + private final Path directory; + + RankedJournal(final Path workDirectory) { + directory = workDirectory.resolve("journal"); + } + + void append(final RankedBattleRecord record) throws IOException { + appendLine(RECORDS_FILE, recordJson(record)); + } + + List pending() throws IOException { + final Map records = records(); + acknowledged().forEach(records::remove); + quarantined().forEach(records::remove); + return List.copyOf(records.values()); + } + + void acknowledge(final Collection receipts) throws IOException { + for (final SubmissionReceipt receipt : receipts) { + appendLine(ACKNOWLEDGEMENTS_FILE, dispositionJson(receipt.battleId(), "receipt", receipt.reference())); + } + } + + List quarantineObsolete(final int behaviorVersion) throws IOException { + final List obsolete = pending().stream() + .filter(record -> record.engine().behaviorVersion() != behaviorVersion) + .toList(); + for (final RankedBattleRecord record : obsolete) { + appendLine(QUARANTINE_FILE, dispositionJson(record.battleId(), "reason", + "behavior version " + record.engine().behaviorVersion() + " is incompatible with " + behaviorVersion)); + } + return obsolete; + } + + private Map records() throws IOException { + final Map records = new LinkedHashMap<>(); + for (final JsonObject entry : entries(RECORDS_FILE)) { + final RankedBattleRecord record = record(entry); + if (records.putIfAbsent(record.battleId(), record) != null) { + throw new IllegalArgumentException("Ranked journal contains duplicate battle ID " + record.battleId()); + } + } + return records; + } + + private Set acknowledged() throws IOException { + return dispositionIds(ACKNOWLEDGEMENTS_FILE, "receipt"); + } + + private Set quarantined() throws IOException { + return dispositionIds(QUARANTINE_FILE, "reason"); + } + + private Set dispositionIds(final String file, final String requiredField) throws IOException { + final Set result = new LinkedHashSet<>(); + for (final JsonObject entry : entries(file)) { + requiredString(entry, requiredField, file); + result.add(UUID.fromString(requiredString(entry, "battleId", file))); + } + return result; + } + + private List entries(final String file) throws IOException { + final Path path = directory.resolve(file); + if (!Files.exists(path)) { + return List.of(); + } + try (var lines = Files.lines(path, StandardCharsets.UTF_8)) { + return lines.filter(line -> !line.isBlank()).map(line -> parseEntry(line, file)).toList(); + } + } + + private void appendLine(final String file, final JsonObject entry) throws IOException { + Files.createDirectories(directory); + final ByteBuffer bytes = StandardCharsets.UTF_8.encode(entry + System.lineSeparator()); + try (FileChannel channel = FileChannel.open(directory.resolve(file), StandardOpenOption.CREATE, + StandardOpenOption.WRITE, StandardOpenOption.APPEND)) { + while (bytes.hasRemaining()) { + channel.write(bytes); + } + channel.force(true); + } + } + + private static JsonObject parseEntry(final String line, final String file) { + try { + final JsonElement parsed = JsonParser.parseString(line); + if (!parsed.isJsonObject()) { + throw new IllegalArgumentException("Ranked journal " + file + " contains a non-object entry"); + } + final JsonObject entry = parsed.getAsJsonObject(); + if (integer(entry, "schemaVersion", file) != SCHEMA_VERSION) { + throw new IllegalArgumentException("Ranked journal " + file + " has an unsupported schema version"); + } + return entry; + } catch (RuntimeException exception) { + throw new IllegalArgumentException("Ranked journal " + file + " contains invalid JSON", exception); + } + } + + private static JsonObject recordJson(final RankedBattleRecord record) { + final JsonObject result = new JsonObject(); + result.addProperty("schemaVersion", SCHEMA_VERSION); + result.addProperty("battleId", record.battleId().toString()); + result.addProperty("completedAt", record.completedAt().toString()); + result.add("client", clientJson(record.client())); + final JsonObject engine = new JsonObject(); + engine.addProperty("behaviorVersion", record.engine().behaviorVersion()); + result.add("engine", engine); + result.addProperty("gameType", record.gameType()); + result.addProperty("rounds", record.rounds()); + result.addProperty("arenaWidth", record.arenaWidth()); + result.addProperty("arenaHeight", record.arenaHeight()); + result.addProperty("selectionSeed", record.selectionSeed()); + final JsonArray participants = new JsonArray(); + record.participants().forEach(participant -> participants.add(participantJson(participant))); + result.add("participants", participants); + result.addProperty("replayHash", record.replayHash()); + return result; + } + + private static JsonObject clientJson(final ClientIdentity client) { + final JsonObject result = new JsonObject(); + result.addProperty("id", client.id()); + result.addProperty("version", client.version()); + return result; + } + + private static JsonObject participantJson(final RankedParticipant participant) { + final JsonObject result = new JsonObject(); + result.addProperty("name", participant.name()); + result.addProperty("version", participant.version()); + result.addProperty("isTeam", participant.isTeam()); + result.addProperty("rank", participant.rank()); + result.addProperty("totalScore", participant.totalScore()); + result.addProperty("survival", participant.survival()); + result.addProperty("lastSurvivorBonus", participant.lastSurvivorBonus()); + result.addProperty("bulletDamage", participant.bulletDamage()); + result.addProperty("bulletKillBonus", participant.bulletKillBonus()); + result.addProperty("ramDamage", participant.ramDamage()); + result.addProperty("ramKillBonus", participant.ramKillBonus()); + result.addProperty("firstPlaces", participant.firstPlaces()); + result.addProperty("secondPlaces", participant.secondPlaces()); + result.addProperty("thirdPlaces", participant.thirdPlaces()); + return result; + } + + private static JsonObject dispositionJson(final UUID battleId, final String field, final String value) { + final JsonObject result = new JsonObject(); + result.addProperty("schemaVersion", SCHEMA_VERSION); + result.addProperty("battleId", battleId.toString()); + result.addProperty(field, value); + return result; + } + + private static RankedBattleRecord record(final JsonObject json) { + final JsonObject client = object(json, "client", RECORDS_FILE); + final JsonObject engine = object(json, "engine", RECORDS_FILE); + final List participants = array(json, "participants", RECORDS_FILE).asList().stream() + .map(element -> participant(element.getAsJsonObject())).toList(); + return new RankedBattleRecord(UUID.fromString(requiredString(json, "battleId", RECORDS_FILE)), + Instant.parse(requiredString(json, "completedAt", RECORDS_FILE)), + new ClientIdentity(requiredString(client, "id", RECORDS_FILE), requiredString(client, "version", RECORDS_FILE)), + new EngineIdentity(integer(engine, "behaviorVersion", RECORDS_FILE)), + requiredString(json, "gameType", RECORDS_FILE), integer(json, "rounds", RECORDS_FILE), + integer(json, "arenaWidth", RECORDS_FILE), integer(json, "arenaHeight", RECORDS_FILE), + longValue(json, "selectionSeed", RECORDS_FILE), participants, + requiredString(json, "replayHash", RECORDS_FILE)); + } + + private static RankedParticipant participant(final JsonObject json) { + return new RankedParticipant(requiredString(json, "name", RECORDS_FILE), + requiredString(json, "version", RECORDS_FILE), booleanValue(json, "isTeam", RECORDS_FILE), + integer(json, "rank", RECORDS_FILE), integer(json, "totalScore", RECORDS_FILE), + integer(json, "survival", RECORDS_FILE), integer(json, "lastSurvivorBonus", RECORDS_FILE), + integer(json, "bulletDamage", RECORDS_FILE), integer(json, "bulletKillBonus", RECORDS_FILE), + integer(json, "ramDamage", RECORDS_FILE), integer(json, "ramKillBonus", RECORDS_FILE), + integer(json, "firstPlaces", RECORDS_FILE), integer(json, "secondPlaces", RECORDS_FILE), + integer(json, "thirdPlaces", RECORDS_FILE)); + } + + private static JsonObject object(final JsonObject json, final String field, final String file) { + final JsonElement value = json.get(field); + if (value == null || !value.isJsonObject()) { + throw new IllegalArgumentException("Ranked journal " + file + " has invalid " + field); + } + return value.getAsJsonObject(); + } + + private static JsonArray array(final JsonObject json, final String field, final String file) { + final JsonElement value = json.get(field); + if (value == null || !value.isJsonArray()) { + throw new IllegalArgumentException("Ranked journal " + file + " has invalid " + field); + } + return value.getAsJsonArray(); + } + + private static String requiredString(final JsonObject json, final String field, final String file) { + final JsonElement value = json.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() + || value.getAsString().isBlank()) { + throw new IllegalArgumentException("Ranked journal " + file + " has invalid " + field); + } + return value.getAsString(); + } + + private static int integer(final JsonObject json, final String field, final String file) { + try { + return json.get(field).getAsInt(); + } catch (RuntimeException exception) { + throw new IllegalArgumentException("Ranked journal " + file + " has invalid " + field, exception); + } + } + + private static long longValue(final JsonObject json, final String field, final String file) { + try { + return json.get(field).getAsLong(); + } catch (RuntimeException exception) { + throw new IllegalArgumentException("Ranked journal " + file + " has invalid " + field, exception); + } + } + + private static boolean booleanValue(final JsonObject json, final String field, final String file) { + final JsonElement value = json.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) { + throw new IllegalArgumentException("Ranked journal " + file + " has invalid " + field); + } + return value.getAsBoolean(); + } +} + +/** One durable acknowledgement published by the result-data ingestion workflow. */ +record SubmissionReceipt(UUID battleId, String reference) { +} diff --git a/src/main/java/dev/robocode/rumble/client/RumbleClient.java b/src/main/java/dev/robocode/rumble/client/RumbleClient.java index a52685d..7e2731f 100644 --- a/src/main/java/dev/robocode/rumble/client/RumbleClient.java +++ b/src/main/java/dev/robocode/rumble/client/RumbleClient.java @@ -66,6 +66,8 @@ static void run(final String[] arguments, final PrintStream output, final Runtim final RumbleSnapshot snapshot = new RumbleSynchronizer(repositoryReader).synchronize(configuration); final PreparedBotCache botCache = new BotCachePreparer(repositoryReader).prepare(snapshot, configuration); if (arguments[0].equals(RUN_OPTION)) { + final RankedJournal journal = new RankedJournal(configuration.workDirectory()); + final int quarantined = journal.quarantineObsolete(snapshot.engine().behaviorVersion()).size(); final GameType gameType = configuration.gameTypes().stream() .min(Comparator.comparing(GameType::contractName)).orElseThrow(); final BattleSelection selection = new RankedBattleSelector().select(snapshot, configuration, gameType, @@ -73,6 +75,10 @@ static void run(final String[] arguments, final PrintStream output, final Runtim final RankedBattleRecord record = new RankedBattleExecution(new RunnerBattleExecutor(), Clock.systemUTC(), UUID::randomUUID).execute(selection, botCache, snapshot, configuration, clientVersion()); + journal.append(record); + if (quarantined > 0) { + output.printf("Quarantined %d records from an obsolete behavior-version epoch.%n", quarantined); + } output.printf("Completed ranked %s battle %s; replay evidence is retained at %s.%n", record.gameType(), record.battleId(), configuration.workDirectory().resolve("evidence")); return; diff --git a/src/test/java/dev/robocode/rumble/client/RankedBattleExecutionTest.java b/src/test/java/dev/robocode/rumble/client/RankedBattleExecutionTest.java index 3214a56..ebcdd5c 100644 --- a/src/test/java/dev/robocode/rumble/client/RankedBattleExecutionTest.java +++ b/src/test/java/dev/robocode/rumble/client/RankedBattleExecutionTest.java @@ -38,6 +38,7 @@ void testRCL005_IntegrationPositive_completedPinnedBattleCreatesReplayBoundRecor assertEquals(battleId, record.battleId()); assertEquals("1v1", record.gameType()); assertEquals(35, record.rounds()); + assertEquals(7L, record.selectionSeed()); assertEquals(2, record.participants().size()); assertTrue(record.replayHash().startsWith("sha256:")); assertTrue(Files.isRegularFile(temporaryDirectory.resolve("work/evidence").resolve(battleId + ".battle.gz"))); diff --git a/src/test/java/dev/robocode/rumble/client/RankedJournalTest.java b/src/test/java/dev/robocode/rumble/client/RankedJournalTest.java new file mode 100644 index 0000000..fa561a2 --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/RankedJournalTest.java @@ -0,0 +1,57 @@ +package dev.robocode.rumble.client; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RankedJournalTest { + @TempDir + Path temporaryDirectory; + + @Test + @Tag("RCL-006") + void testRCL006_IntegrationPositive_reopensFsyncedRecordAndKeepsUnacknowledgedResults() throws IOException { + final RankedJournal journal = new RankedJournal(temporaryDirectory); + final RankedBattleRecord first = record("9f4ee719-d4a3-4387-8964-f52877c414b0", 7); + final RankedBattleRecord second = record("89b530c6-d06e-4730-9eb1-565efb99d877", 7); + + journal.append(first); + journal.append(second); + new RankedJournal(temporaryDirectory).acknowledge(List.of(new SubmissionReceipt(first.battleId(), "issue-42"))); + + assertEquals(List.of(second), new RankedJournal(temporaryDirectory).pending()); + assertTrue(Files.size(temporaryDirectory.resolve("journal/records.jsonl")) > 0); + } + + @Test + @Tag("RCL-006") + void testRCL006_IntegrationPositive_quarantinesObsoleteBehaviorVersionWithoutDeletingEvidence() throws IOException { + final RankedJournal journal = new RankedJournal(temporaryDirectory); + final RankedBattleRecord obsolete = record("44177440-36c4-4eb7-8949-5984648a1665", 6); + final RankedBattleRecord current = record("39a63005-4d76-4d23-9d37-d378ab62f5c0", 7); + journal.append(obsolete); + journal.append(current); + + assertEquals(List.of(obsolete), journal.quarantineObsolete(7)); + assertEquals(List.of(current), journal.pending()); + assertTrue(Files.readString(temporaryDirectory.resolve("journal/quarantine.jsonl")) + .contains("behavior version 6 is incompatible with 7")); + } + + private static RankedBattleRecord record(final String battleId, final int behaviorVersion) { + return new RankedBattleRecord(UUID.fromString(battleId), Instant.parse("2026-08-30T12:00:00Z"), + new ClientIdentity("alice-client", "0.1.0"), new EngineIdentity(behaviorVersion), "1v1", 35, + 800, 600, 7L, List.of(new RankedParticipant("Alpha", "1.0", false, 1, 35, 35, + 0, 0, 0, 0, 0, 35, 0, 0)), "sha256:" + "a".repeat(64)); + } +} From ee6b1d281bac1206a82e0b3407e779b3772125ed Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 30 Aug 2026 14:21:57 +0200 Subject: [PATCH 7/9] feat: submit ranked batches through issue ops --- README.md | 2 +- .../client/GitHubIssueOpsTransport.java | 130 ++++++++++++++++++ .../rumble/client/IssueOpsSubmission.java | 114 +++++++++++++++ .../robocode/rumble/client/RankedJournal.java | 41 +++++- .../robocode/rumble/client/RumbleClient.java | 25 +++- .../rumble/client/IssueOpsSubmissionTest.java | 83 +++++++++++ .../rumble/client/RumbleClientTest.java | 3 +- 7 files changed, 391 insertions(+), 7 deletions(-) create mode 100644 src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java create mode 100644 src/main/java/dev/robocode/rumble/client/IssueOpsSubmission.java create mode 100644 src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java diff --git a/README.md b/README.md index 2c61caa..2e6a2ce 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The source substitution is the development dependency path until the Runner API The build produces native ZIP and TAR archives under `build/distributions/`. Run `./gradlew run --args="--check-runtimes"` to verify the required Java 17, .NET 8 SDK, Python 3.12, and Node.js 22 installations; the check never installs or changes them. -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, validate its engine pin, catalog, client registration, and matchmaking advice, and prepare an immutable bot cache at the catalog's exact source commit. Every cached source tree is checked against its catalog SHA-256 before it can be used. 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. Each game type declares how many bots one catalog entry expands to, so TwinDuel selects two team entries for its four pinned participants while `1v1` and melee select individual bots, and a selection never contains two entries that share a member bot. Run `./gradlew run --args="--run"` to execute one pinned ranked battle through Battle Runner and retain its replay evidence locally. Journal 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, validate its engine pin, catalog, client registration, and matchmaking advice, and prepare an immutable bot cache at the catalog's exact source commit. Every cached source tree is checked against its catalog SHA-256 before it can be used. 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. Each game type declares how many bots one catalog entry expands to, so TwinDuel selects two team entries for its four pinned participants while `1v1` and melee select individual bots, and a selection never contains two entries that share a member bot. Run `./gradlew run --args="--run"` to execute one pinned ranked battle through Battle Runner and retain its replay evidence locally. Run `./gradlew run --args="--submit"` to post pending records through the `rumble-data` issue inbox. It reads `RUMBLE_CLIENT_TOKEN` only at runtime; use a GitHub fine-grained token limited to read and write Issues access for that repository. The client records posted batches locally and removes records only after their result-data receipt comments appear. The runtime container is added in a subsequent CH-012 task. ## Configuration diff --git a/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java b/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java new file mode 100644 index 0000000..56772f9 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java @@ -0,0 +1,130 @@ +package dev.robocode.rumble.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** GitHub REST adapter limited to creating result issues and reading their receipt comments. */ +final class GitHubIssueOpsTransport implements IssueOpsTransport { + private static final URI API_ROOT = URI.create("https://api.github.com/"); + private static final String API_VERSION = "2026-03-10"; + + private final HttpClient client; + private final String token; + + GitHubIssueOpsTransport(final String token) { + this(HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(), token); + } + + GitHubIssueOpsTransport(final HttpClient client, final String token) { + if (token == null || token.isBlank()) { + throw new IllegalArgumentException("RUMBLE_CLIENT_TOKEN must contain an Issues-only GitHub token"); + } + this.client = client; + this.token = token; + } + + @Override + public SubmittedBatch createIssue(final URI repository, final String body, final String title) throws IOException { + final JsonObject request = new JsonObject(); + request.addProperty("title", title); + request.addProperty("body", body); + final JsonArray labels = new JsonArray(); + labels.add("result-submission"); + request.add("labels", labels); + final JsonObject response = request("POST", endpoint(repository, "issues"), request.toString()); + final int issueNumber = response.get("number").getAsInt(); + final String issueUrl = response.get("html_url").getAsString(); + return new SubmittedBatch(issueNumber, issueUrl, battleIds(body)); + } + + @Override + public List receipts(final URI repository, final SubmittedBatch batch) throws IOException { + final JsonArray comments = request("GET", endpoint(repository, "issues/" + batch.issueNumber() + + "/comments?per_page=100"), null).getAsJsonArray("comments"); + final List receipts = new ArrayList<>(); + for (final JsonElement comment : comments) { + final JsonElement body = comment.getAsJsonObject().get("body"); + if (body != null && body.isJsonPrimitive()) { + receipts.addAll(receipts(body.getAsString(), batch.issueUrl())); + } + } + return receipts; + } + + private JsonObject request(final String method, final URI endpoint, final String body) throws IOException { + final HttpRequest.Builder request = HttpRequest.newBuilder(endpoint).timeout(Duration.ofSeconds(30)) + .header("Accept", "application/vnd.github+json") + .header("Authorization", "Bearer " + token) + .header("X-GitHub-Api-Version", API_VERSION); + if (body == null) { + request.GET(); + } else { + request.header("Content-Type", "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)); + } + try { + final HttpResponse response = client.send(request.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("GitHub Issues API returned HTTP " + response.statusCode()); + } + final JsonElement parsed = JsonParser.parseString(response.body()); + if (parsed.isJsonObject()) { + return parsed.getAsJsonObject(); + } + final JsonObject wrapper = new JsonObject(); + wrapper.add("comments", parsed.getAsJsonArray()); + return wrapper; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while calling the GitHub Issues API", exception); + } catch (RuntimeException exception) { + throw new IOException("GitHub Issues API returned invalid JSON", exception); + } + } + + private static URI endpoint(final URI repository, final String suffix) { + if (!"github.com".equalsIgnoreCase(repository.getHost())) { + throw new IllegalArgumentException("Issues-only submission requires a github.com canonical repository"); + } + final String[] segments = repository.getPath().replaceFirst("/$", "").replaceFirst("\\.git$", "") + .split("/"); + if (segments.length != 3 || segments[1].isBlank() || segments[2].isBlank()) { + throw new IllegalArgumentException("Canonical repository must identify a GitHub owner and repository"); + } + return API_ROOT.resolve("repos/" + segments[1] + "/" + segments[2] + "/" + suffix); + } + + private static List battleIds(final String body) { + final int begin = body.indexOf('{'); + final int end = body.lastIndexOf('}'); + final JsonArray results = JsonParser.parseString(body.substring(begin, end + 1)).getAsJsonObject() + .getAsJsonArray("results"); + return results.asList().stream().map(result -> UUID.fromString(result.getAsJsonObject() + .get("battleId").getAsString())).toList(); + } + + private static List receipts(final String comment, final String issueUrl) { + return comment.lines().map(String::trim).filter(line -> line.endsWith(": accepted")) + .map(line -> line.substring(0, line.length() - ": accepted".length())) + .flatMap(value -> { + try { + return java.util.stream.Stream.of(new SubmissionReceipt(UUID.fromString(value), issueUrl)); + } catch (IllegalArgumentException exception) { + return java.util.stream.Stream.empty(); + } + }).toList(); + } +} diff --git a/src/main/java/dev/robocode/rumble/client/IssueOpsSubmission.java b/src/main/java/dev/robocode/rumble/client/IssueOpsSubmission.java new file mode 100644 index 0000000..b47306e --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/IssueOpsSubmission.java @@ -0,0 +1,114 @@ +package dev.robocode.rumble.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.io.IOException; +import java.net.URI; +import java.time.Clock; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Sends bounded result envelopes through the result-data Issues-only inbox and retains them until receipted. + */ +final class IssueOpsSubmission { + private static final int MAX_RESULTS_PER_ISSUE = 60; + + private final IssueOpsTransport transport; + private final Clock clock; + + IssueOpsSubmission(final IssueOpsTransport transport, final Clock clock) { + this.transport = transport; + this.clock = clock; + } + + SubmissionReport submit(final RankedJournal journal, final RumbleSnapshot snapshot) throws IOException { + final List receipts = collectReceipts(journal.unacknowledgedSubmissions(), snapshot); + journal.acknowledge(receipts); + final Set inFlight = journal.unacknowledgedSubmissions().stream().flatMap(batch -> batch.battleIds().stream()) + .collect(Collectors.toSet()); + final List pending = journal.pending().stream().filter(record -> !inFlight.contains(record.battleId())) + .toList(); + final List submitted = new ArrayList<>(); + for (final List batch : batches(pending)) { + final SubmittedBatch issue = transport.createIssue(snapshot.canonicalDataRepository(), envelope(batch), + title(batch.get(0))); + journal.recordSubmission(issue); + submitted.add(issue); + } + return new SubmissionReport(receipts, submitted); + } + + private List collectReceipts(final List submitted, + final RumbleSnapshot snapshot) throws IOException { + final List receipts = new ArrayList<>(); + for (final SubmittedBatch batch : submitted) { + final Set expected = Set.copyOf(batch.battleIds()); + for (final SubmissionReceipt receipt : transport.receipts(snapshot.canonicalDataRepository(), batch)) { + if (expected.contains(receipt.battleId())) { + receipts.add(receipt); + } + } + } + return receipts; + } + + private static List> batches(final List records) { + final Map> grouped = records.stream() + .collect(Collectors.groupingBy(RankedBattleRecord::client, java.util.LinkedHashMap::new, + Collectors.toList())); + final List> batches = new ArrayList<>(); + for (final List group : grouped.values()) { + for (int start = 0; start < group.size(); start += MAX_RESULTS_PER_ISSUE) { + batches.add(List.copyOf(group.subList(start, Math.min(group.size(), start + MAX_RESULTS_PER_ISSUE)))); + } + } + return batches; + } + + private String title(final RankedBattleRecord record) { + return "[result] " + record.client().id() + " " + DateTimeFormatter.ISO_INSTANT.format(clock.instant()); + } + + private static String envelope(final Collection records) { + final RankedBattleRecord first = records.stream().findFirst().orElseThrow(); + if (records.stream().anyMatch(record -> !record.client().equals(first.client()))) { + throw new IllegalArgumentException("A submission envelope may contain only one client identity"); + } + final JsonObject envelope = new JsonObject(); + envelope.addProperty("schemaVersion", 1); + envelope.addProperty("clientId", first.client().id()); + envelope.addProperty("clientVersion", first.client().version()); + final JsonArray results = new JsonArray(); + for (final RankedBattleRecord record : records) { + final JsonObject result = RankedJournal.recordJson(record); + result.remove("schemaVersion"); + results.add(result); + } + envelope.add("results", results); + return "```json%n%s%n```".formatted(envelope); + } +} + +/** Reports both newly observed receipts and newly posted issue batches. */ +record SubmissionReport(List receipts, List submitted) { + SubmissionReport { + receipts = List.copyOf(receipts); + submitted = List.copyOf(submitted); + } +} + +/** Issues-only boundary used by the client to post result envelopes and observe receipt comments. */ +interface IssueOpsTransport { + SubmittedBatch createIssue(URI repository, String body, String title) throws IOException; + + List receipts(URI repository, SubmittedBatch batch) throws IOException; +} diff --git a/src/main/java/dev/robocode/rumble/client/RankedJournal.java b/src/main/java/dev/robocode/rumble/client/RankedJournal.java index 5d99817..5fb4f61 100644 --- a/src/main/java/dev/robocode/rumble/client/RankedJournal.java +++ b/src/main/java/dev/robocode/rumble/client/RankedJournal.java @@ -29,6 +29,7 @@ final class RankedJournal { private static final String RECORDS_FILE = "records.jsonl"; private static final String ACKNOWLEDGEMENTS_FILE = "acknowledgements.jsonl"; private static final String QUARANTINE_FILE = "quarantine.jsonl"; + private static final String SUBMISSIONS_FILE = "submissions.jsonl"; private final Path directory; @@ -53,6 +54,24 @@ void acknowledge(final Collection receipts) throws IOExceptio } } + void recordSubmission(final SubmittedBatch batch) throws IOException { + final JsonObject entry = new JsonObject(); + entry.addProperty("schemaVersion", SCHEMA_VERSION); + entry.addProperty("issueNumber", batch.issueNumber()); + entry.addProperty("issueUrl", batch.issueUrl()); + final JsonArray battleIds = new JsonArray(); + batch.battleIds().forEach(battleId -> battleIds.add(battleId.toString())); + entry.add("battleIds", battleIds); + appendLine(SUBMISSIONS_FILE, entry); + } + + List unacknowledgedSubmissions() throws IOException { + final Set pending = pending().stream().map(RankedBattleRecord::battleId) + .collect(java.util.stream.Collectors.toSet()); + return entries(SUBMISSIONS_FILE).stream().map(RankedJournal::submittedBatch) + .filter(batch -> batch.battleIds().stream().anyMatch(pending::contains)).toList(); + } + List quarantineObsolete(final int behaviorVersion) throws IOException { final List obsolete = pending().stream() .filter(record -> record.engine().behaviorVersion() != behaviorVersion) @@ -130,7 +149,7 @@ private static JsonObject parseEntry(final String line, final String file) { } } - private static JsonObject recordJson(final RankedBattleRecord record) { + static JsonObject recordJson(final RankedBattleRecord record) { final JsonObject result = new JsonObject(); result.addProperty("schemaVersion", SCHEMA_VERSION); result.addProperty("battleId", record.battleId().toString()); @@ -200,6 +219,19 @@ private static RankedBattleRecord record(final JsonObject json) { requiredString(json, "replayHash", RECORDS_FILE)); } + private static SubmittedBatch submittedBatch(final JsonObject json) { + final int issueNumber = integer(json, "issueNumber", SUBMISSIONS_FILE); + if (issueNumber < 1) { + throw new IllegalArgumentException("Ranked journal submissions.jsonl has invalid issueNumber"); + } + final List battleIds = array(json, "battleIds", SUBMISSIONS_FILE).asList().stream() + .map(JsonElement::getAsString).map(UUID::fromString).toList(); + if (battleIds.isEmpty() || new LinkedHashSet<>(battleIds).size() != battleIds.size()) { + throw new IllegalArgumentException("Ranked journal submissions.jsonl has invalid battleIds"); + } + return new SubmittedBatch(issueNumber, requiredString(json, "issueUrl", SUBMISSIONS_FILE), battleIds); + } + private static RankedParticipant participant(final JsonObject json) { return new RankedParticipant(requiredString(json, "name", RECORDS_FILE), requiredString(json, "version", RECORDS_FILE), booleanValue(json, "isTeam", RECORDS_FILE), @@ -264,3 +296,10 @@ private static boolean booleanValue(final JsonObject json, final String field, f /** One durable acknowledgement published by the result-data ingestion workflow. */ record SubmissionReceipt(UUID battleId, String reference) { } + +/** One locally recorded issue containing a batch awaiting result-data receipts. */ +record SubmittedBatch(int issueNumber, String issueUrl, List battleIds) { + SubmittedBatch { + battleIds = List.copyOf(battleIds); + } +} diff --git a/src/main/java/dev/robocode/rumble/client/RumbleClient.java b/src/main/java/dev/robocode/rumble/client/RumbleClient.java index 7e2731f..4f309cc 100644 --- a/src/main/java/dev/robocode/rumble/client/RumbleClient.java +++ b/src/main/java/dev/robocode/rumble/client/RumbleClient.java @@ -16,6 +16,7 @@ public final class RumbleClient { private static final String CHECK_RUNTIMES_OPTION = "--check-runtimes"; private static final String SYNCHRONIZE_OPTION = "--sync"; private static final String RUN_OPTION = "--run"; + private static final String SUBMIT_OPTION = "--submit"; private static final Path DEFAULT_CONFIGURATION_PATH = Path.of("rumble-client.json"); private RumbleClient() { @@ -54,18 +55,19 @@ static void run(final String[] arguments, final PrintStream output, final Runtim if (arguments.length > 2 || (!arguments[0].equals(VALIDATE_CONFIG_OPTION) && !arguments[0].equals(SYNCHRONIZE_OPTION) - && !arguments[0].equals(RUN_OPTION))) { + && !arguments[0].equals(RUN_OPTION) && !arguments[0].equals(SUBMIT_OPTION))) { throw new IllegalArgumentException( - "Expected --validate-config [path], --check-runtimes, --sync [path], --run [path], or --help"); + "Expected --validate-config [path], --check-runtimes, --sync [path], --run [path], --submit [path], or --help"); } final Path configurationPath = arguments.length == 2 ? Path.of(arguments[1]) : DEFAULT_CONFIGURATION_PATH; final ClientConfiguration configuration = new ClientConfigurationLoader().load(configurationPath); - if (arguments[0].equals(SYNCHRONIZE_OPTION) || arguments[0].equals(RUN_OPTION)) { + if (arguments[0].equals(SYNCHRONIZE_OPTION) || arguments[0].equals(RUN_OPTION) + || arguments[0].equals(SUBMIT_OPTION)) { final GitRepositoryReader repositoryReader = new GitRepositoryReader(); final RumbleSnapshot snapshot = new RumbleSynchronizer(repositoryReader).synchronize(configuration); - final PreparedBotCache botCache = new BotCachePreparer(repositoryReader).prepare(snapshot, configuration); if (arguments[0].equals(RUN_OPTION)) { + final PreparedBotCache botCache = new BotCachePreparer(repositoryReader).prepare(snapshot, configuration); final RankedJournal journal = new RankedJournal(configuration.workDirectory()); final int quarantined = journal.quarantineObsolete(snapshot.engine().behaviorVersion()).size(); final GameType gameType = configuration.gameTypes().stream() @@ -83,6 +85,19 @@ static void run(final String[] arguments, final PrintStream output, final Runtim record.gameType(), record.battleId(), configuration.workDirectory().resolve("evidence")); return; } + if (arguments[0].equals(SUBMIT_OPTION)) { + final RankedJournal journal = new RankedJournal(configuration.workDirectory()); + final int quarantined = journal.quarantineObsolete(snapshot.engine().behaviorVersion()).size(); + final SubmissionReport report = new IssueOpsSubmission(new GitHubIssueOpsTransport( + System.getenv("RUMBLE_CLIENT_TOKEN")), Clock.systemUTC()).submit(journal, snapshot); + output.printf("Observed %d accepted result receipts and created %d Issues-only submission batches.%n", + report.receipts().size(), report.submitted().size()); + if (quarantined > 0) { + output.printf("Quarantined %d records from an obsolete behavior-version epoch.%n", quarantined); + } + return; + } + final PreparedBotCache botCache = new BotCachePreparer(repositoryReader).prepare(snapshot, configuration); output.printf("Synchronized %s at %s.%n", snapshot.canonicalDataRepository(), snapshot.dataRevision()); output.printf("Accepted behavior version %d, cached %d active bots at %s, and advice for %d game types.%n", snapshot.engine().behaviorVersion(), botCache.bots().size(), botCache.sourceCommit(), @@ -103,12 +118,14 @@ private static void printHelp(final PrintStream output) { output.println(" rumble-client --check-runtimes"); output.println(" rumble-client --sync [path]"); output.println(" rumble-client --run [path]"); + output.println(" rumble-client --submit [path]"); output.println(" rumble-client --help"); output.println(); output.println("Use --validate-config to check a local ranked or practice configuration."); output.println("Use --check-runtimes to verify native Java, .NET, Python, and Node.js prerequisites."); output.println("Use --sync to validate the current ranked snapshot and prepare its immutable bot cache."); output.println("Use --run to execute one ranked battle and retain its local replay evidence."); + output.println("Use --submit to send pending ranked records with the RUMBLE_CLIENT_TOKEN Issues-only credential."); } private static void printRuntimeReport(final RuntimeReport report, final PrintStream output) { diff --git a/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java b/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java new file mode 100644 index 0000000..7741a95 --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java @@ -0,0 +1,83 @@ +package dev.robocode.rumble.client; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class IssueOpsSubmissionTest { + @TempDir + Path temporaryDirectory; + + @Test + @Tag("RCL-007") + void testRCL007_IntegrationPositive_postsBoundedIssueEnvelopeAndAcknowledgesOnlyReceipt() throws IOException { + final RankedBattleRecord first = record("d5ef6066-6a22-467f-88fe-08ff73540e18"); + final RankedBattleRecord second = record("fc86dfad-f03f-4ca8-b6f2-b8eb2e327a2c"); + final RankedJournal journal = new RankedJournal(temporaryDirectory); + journal.append(first); + journal.append(second); + final RecordingTransport transport = new RecordingTransport(first.battleId()); + final IssueOpsSubmission submission = new IssueOpsSubmission(transport, + Clock.fixed(Instant.parse("2026-08-30T12:00:00Z"), ZoneOffset.UTC)); + + final SubmissionReport posted = submission.submit(journal, snapshot()); + final SubmissionReport receipted = submission.submit(journal, snapshot()); + + assertEquals(1, posted.submitted().size()); + assertEquals(1, transport.bodies.size()); + assertTrue(transport.bodies.get(0).startsWith("```json")); + assertTrue(transport.bodies.get(0).contains("\"clientId\":\"alice-client\"")); + assertEquals(List.of(first.battleId()), receipted.receipts().stream().map(SubmissionReceipt::battleId).toList()); + assertEquals(List.of(second), journal.pending()); + } + + private static RumbleSnapshot snapshot() { + return new RumbleSnapshot(URI.create("https://github.com/example/rumble-data"), "a".repeat(40), + new EnginePin(7, "1.2.0", "image", Optional.empty(), Map.of()), + new BotCatalog(URI.create("https://github.com/example/rumble-bots"), "b".repeat(40), Map.of()), + new ClientRegistration("alice", "alice-client"), Map.of()); + } + + private static RankedBattleRecord record(final String battleId) { + return new RankedBattleRecord(UUID.fromString(battleId), Instant.parse("2026-08-30T12:00:00Z"), + new ClientIdentity("alice-client", "0.1.0"), new EngineIdentity(7), "1v1", 35, 800, 600, + 7L, List.of(new RankedParticipant("Alpha", "1.0", false, 1, 35, 35, 0, 0, 0, 0, 0, 35, 0, 0)), + "sha256:" + "a".repeat(64)); + } + + private static final class RecordingTransport implements IssueOpsTransport { + private final UUID accepted; + private final List bodies = new ArrayList<>(); + + private RecordingTransport(final UUID accepted) { + this.accepted = accepted; + } + + @Override + public SubmittedBatch createIssue(final URI repository, final String body, final String title) { + bodies.add(body); + return new SubmittedBatch(42, "https://github.com/example/rumble-data/issues/42", List.of(accepted, + UUID.fromString("fc86dfad-f03f-4ca8-b6f2-b8eb2e327a2c"))); + } + + @Override + public List receipts(final URI repository, final SubmittedBatch batch) { + return List.of(new SubmissionReceipt(accepted, batch.issueUrl())); + } + } +} diff --git a/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java b/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java index 530f9e5..904bc9b 100644 --- a/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java +++ b/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java @@ -23,12 +23,13 @@ void testUnitPositive_printsHelpWithoutConfiguration() throws IOException { assertTrue(bytes.toString().contains("rumble-client --check-runtimes")); assertTrue(bytes.toString().contains("rumble-client --sync [path]")); assertTrue(bytes.toString().contains("rumble-client --run [path]")); + assertTrue(bytes.toString().contains("rumble-client --submit [path]")); } @Test @Tag("Unit") void testUnitNegative_rejectsUnknownCommand() { - assertThrows(IllegalArgumentException.class, () -> RumbleClient.run(new String[] {"--submit"}, System.out)); + assertThrows(IllegalArgumentException.class, () -> RumbleClient.run(new String[] {"--unknown"}, System.out)); } @Test From e1789bc80a1f9f7c5d9dbf141b29279a7158722c Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 30 Aug 2026 21:05:42 +0200 Subject: [PATCH 8/9] test: tag ranked configuration and team selection evidence --- .../client/ClientConfigurationLoaderTest.java | 40 +++++++++---------- .../client/RankedBattleSelectorTest.java | 16 +++++++- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/test/java/dev/robocode/rumble/client/ClientConfigurationLoaderTest.java b/src/test/java/dev/robocode/rumble/client/ClientConfigurationLoaderTest.java index 7e25e4e..dabe40b 100644 --- a/src/test/java/dev/robocode/rumble/client/ClientConfigurationLoaderTest.java +++ b/src/test/java/dev/robocode/rumble/client/ClientConfigurationLoaderTest.java @@ -17,8 +17,8 @@ class ClientConfigurationLoaderTest { private final ClientConfigurationLoader loader = new ClientConfigurationLoader(); @Test - @Tag("Unit") - void testUnitPositive_loadsValidPracticeConfiguration() throws IOException { + @Tag("RCL-001") + void testRCL001_IntegrationPositive_loadsValidPracticeConfiguration() throws IOException { final Path configurationPath = writeConfiguration("practice", "registered-client"); final ClientConfiguration configuration = loader.load(configurationPath); @@ -30,16 +30,16 @@ void testUnitPositive_loadsValidPracticeConfiguration() throws IOException { } @Test - @Tag("Unit") - void testUnitNegative_rejectsExampleClientId() throws IOException { + @Tag("RCL-001") + void testRCL001_IntegrationNegative_rejectsExampleClientId() throws IOException { final Path configurationPath = writeConfiguration("ranked", "replace-with-registered-client-id"); assertThrows(IllegalArgumentException.class, () -> loader.load(configurationPath)); } @Test - @Tag("Unit") - void testUnitPositive_allowsPracticeConfigurationWithoutClientId() throws IOException { + @Tag("RCL-001") + void testRCL001_IntegrationPositive_allowsPracticeConfigurationWithoutClientId() throws IOException { final Path configurationPath = Files.createTempFile("rumble-client", ".json"); Files.writeString(configurationPath, validConfiguration("practice", "registered-client") .replace(" \"clientId\": \"registered-client\",\n", "")); @@ -50,8 +50,8 @@ void testUnitPositive_allowsPracticeConfigurationWithoutClientId() throws IOExce } @Test - @Tag("Unit") - void testUnitNegative_rejectsRankedConfigurationWithoutClientId() throws IOException { + @Tag("RCL-001") + void testRCL001_IntegrationNegative_rejectsRankedConfigurationWithoutClientId() throws IOException { final Path configurationPath = Files.createTempFile("rumble-client", ".json"); Files.writeString(configurationPath, validConfiguration("ranked", "registered-client") .replace(" \"clientId\": \"registered-client\",\n", "")); @@ -60,8 +60,8 @@ void testUnitNegative_rejectsRankedConfigurationWithoutClientId() throws IOExcep } @Test - @Tag("Unit") - void testUnitNegative_rejectsUnsupportedGameType() throws IOException { + @Tag("RCL-001") + void testRCL001_IntegrationNegative_rejectsUnsupportedGameType() throws IOException { final Path configurationPath = Files.createTempFile("rumble-client", ".json"); Files.writeString(configurationPath, validConfiguration("ranked", "registered-client") .replace("\"melee\"", "\"team\"")); @@ -70,8 +70,8 @@ void testUnitNegative_rejectsUnsupportedGameType() throws IOException { } @Test - @Tag("Unit") - void testUnitNegative_rejectsFractionalBattleCount() throws IOException { + @Tag("RCL-001") + void testRCL001_IntegrationNegative_rejectsFractionalBattleCount() throws IOException { final Path configurationPath = Files.createTempFile("rumble-client", ".json"); Files.writeString(configurationPath, validConfiguration("ranked", "registered-client") .replace("50", "1.5")); @@ -80,8 +80,8 @@ void testUnitNegative_rejectsFractionalBattleCount() throws IOException { } @Test - @Tag("Unit") - void testUnitNegative_rejectsCredentialedRepositoryUrl() throws IOException { + @Tag("RCL-001") + void testRCL001_IntegrationNegative_rejectsCredentialedRepositoryUrl() throws IOException { final Path configurationPath = Files.createTempFile("rumble-client", ".json"); Files.writeString(configurationPath, validConfiguration("ranked", "registered-client") .replace("https://github.com/robocode-dev/rumble-bots", "https://credential@github.com/robocode-dev/rumble-bots")); @@ -90,8 +90,8 @@ void testUnitNegative_rejectsCredentialedRepositoryUrl() throws IOException { } @Test - @Tag("Unit") - void testUnitNegative_rejectsRepositoryUrlQueryThatCouldCarryCredentials() throws IOException { + @Tag("RCL-001") + void testRCL001_IntegrationNegative_rejectsRepositoryUrlQueryThatCouldCarryCredentials() throws IOException { final Path configurationPath = Files.createTempFile("rumble-client", ".json"); Files.writeString(configurationPath, validConfiguration("ranked", "registered-client") .replace("https://github.com/robocode-dev/rumble-data", @@ -101,8 +101,8 @@ void testUnitNegative_rejectsRepositoryUrlQueryThatCouldCarryCredentials() throw } @Test - @Tag("Unit") - void testUnitNegative_rejectsEmptyGameTypes() throws IOException { + @Tag("RCL-001") + void testRCL001_IntegrationNegative_rejectsEmptyGameTypes() throws IOException { final Path configurationPath = Files.createTempFile("rumble-client", ".json"); Files.writeString(configurationPath, validConfiguration("ranked", "registered-client") .replace("[\"1v1\", \"twinduel\", \"melee\"]", "[]")); @@ -111,8 +111,8 @@ void testUnitNegative_rejectsEmptyGameTypes() throws IOException { } @Test - @Tag("Unit") - void testUnitPositive_defaultsWorkDirectoryForExistingSchemaOneConfiguration() throws IOException { + @Tag("RCL-001") + void testRCL001_IntegrationPositive_defaultsWorkDirectoryForExistingSchemaOneConfiguration() throws IOException { final Path configurationPath = Files.createTempFile("rumble-client", ".json"); final String legacyConfiguration = validConfiguration("ranked", "registered-client") .replace(",\n \"workDirectory\": \".rumble-client\"", ""); diff --git a/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java b/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java index 6d53542..a84f387 100644 --- a/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java +++ b/src/test/java/dev/robocode/rumble/client/RankedBattleSelectorTest.java @@ -88,8 +88,8 @@ void testRCL010_UnitPositive_usesSeededCatalogFallbackWhenAdviceIsEmpty() { } @Test - @Tag("RCL-010") - void testRCL010_UnitNegative_neverSelectsTwoTeamsThatShareAMemberBot() { + @Tag("RCL-011") + void testRCL011_UnitNegative_rejectsTwinDuelWhenEveryTeamSharesAMemberBot() { final RumbleSnapshot base = snapshot(12, false); final Map bots = new LinkedHashMap<>(); base.catalog().activeBots().values().stream().filter(bot -> !bot.isTeam()) @@ -113,6 +113,18 @@ void testRCL010_UnitNegative_neverSelectsTwoTeamsThatShareAMemberBot() { assertTrue(failure.getMessage().contains("share no member bot"), failure.getMessage()); } + @Test + @Tag("RCL-011") + void testRCL011_UnitPositive_selectsTwinDuelTeamsWithDisjointMembers() { + final BattleSelection selection = new RankedBattleSelector().select(snapshot(12, false), configuration(Set.of()), + GameType.TWIN_DUEL, RANDOM_SEED); + + final Set members = selection.participants().stream().flatMap(team -> team.teamMembers().stream()) + .collect(Collectors.toSet()); + + assertEquals(4, members.size()); + } + @Test @Tag("RCL-010") void testRCL010_UnitNegative_rejectsASelectionWithoutEnoughDistinctActiveBots() { From cec7c8bc35d650954f577f0763399d6435e03e3b Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 30 Aug 2026 21:11:10 +0200 Subject: [PATCH 9/9] test: exercise real Runner battle execution --- build.gradle.kts | 3 + settings.gradle.kts | 1 + .../rumble/client/RunnerBattleExecutor.java | 3 + .../rumble/client/IssueOpsSubmissionTest.java | 61 ++++++++++++ .../client/RankedBattleExecutionTest.java | 12 +++ .../RunnerBattleExecutorIntegrationTest.java | 96 +++++++++++++++++++ 6 files changed, 176 insertions(+) create mode 100644 src/test/java/dev/robocode/rumble/client/RunnerBattleExecutorIntegrationTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 1cff1fd..d6e12c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,4 +31,7 @@ application { tasks.test { useJUnitPlatform() + val tankRoyaleSource = providers.gradleProperty("tankRoyaleSource").orElse("../tank-royale").get() + dependsOn(gradle.includedBuild("tank-royale").task(":sample-bots:java:build")) + systemProperty("tankRoyaleSampleBotsJava", file(tankRoyaleSource).resolve("sample-bots/java/build/archive")) } diff --git a/settings.gradle.kts b/settings.gradle.kts index cee5978..eff6282 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,6 +2,7 @@ rootProject.name = "rumble-client" providers.gradleProperty("tankRoyaleSource").orNull?.let { sourcePath -> includeBuild(file(sourcePath)) { + name = "tank-royale" dependencySubstitution { substitute(module("dev.robocode.tankroyale:robocode-tankroyale-runner")) .using(project(":runner")) diff --git a/src/main/java/dev/robocode/rumble/client/RunnerBattleExecutor.java b/src/main/java/dev/robocode/rumble/client/RunnerBattleExecutor.java index 9b8bb3d..1eeaa8a 100644 --- a/src/main/java/dev/robocode/rumble/client/RunnerBattleExecutor.java +++ b/src/main/java/dev/robocode/rumble/client/RunnerBattleExecutor.java @@ -12,6 +12,8 @@ /** Production adapter that executes one pinned battle through Battle Runner. */ final class RunnerBattleExecutor implements BattleExecutor { + private static final int SOURCE_BOT_READY_TIMEOUT_MICROS = 10_000_000; + @Override public CompletedBattle execute(final BattleSelection selection, final PreparedBotCache cache, final EnginePin engine, final GameTypeSettings settings, @@ -52,5 +54,6 @@ private static void configure(final BattleSetup.Builder builder, final GameTypeS builder.setNumberOfRounds(settings.rounds()); builder.setArenaWidth(settings.arenaWidth()); builder.setArenaHeight(settings.arenaHeight()); + builder.setReadyTimeoutMicros(SOURCE_BOT_READY_TIMEOUT_MICROS); } } diff --git a/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java b/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java index 7741a95..c84df5c 100644 --- a/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java +++ b/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java @@ -17,6 +17,7 @@ import java.util.UUID; 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 IssueOpsSubmissionTest { @@ -46,6 +47,50 @@ void testRCL007_IntegrationPositive_postsBoundedIssueEnvelopeAndAcknowledgesOnly assertEquals(List.of(second), journal.pending()); } + @Test + @Tag("RCL-006") + void testRCL006_IntegrationNegative_submissionFailureLeavesEveryRecordRetryable() throws IOException { + final RankedBattleRecord record = record("67158518-7dd0-4d0c-af40-e1981f8a348f"); + final RankedJournal journal = new RankedJournal(temporaryDirectory); + journal.append(record); + final IssueOpsTransport unavailable = new IssueOpsTransport() { + @Override + public SubmittedBatch createIssue(final URI repository, final String body, final String title) throws IOException { + throw new IOException("offline"); + } + + @Override + public List receipts(final URI repository, final SubmittedBatch batch) { + return List.of(); + } + }; + + assertThrows(IOException.class, () -> new IssueOpsSubmission(unavailable, Clock.systemUTC()).submit(journal, snapshot())); + assertEquals(List.of(record), journal.pending()); + } + + @Test + @Tag("RCL-007") + void testRCL007_IntegrationPositive_splitsSixtyOneRecordsAcrossBoundedIssueBatches() throws IOException { + final RankedJournal journal = new RankedJournal(temporaryDirectory); + for (int index = 0; index < 61; index++) { + journal.append(record(UUID.randomUUID().toString())); + } + final BatchRecordingTransport transport = new BatchRecordingTransport(); + + final SubmissionReport report = new IssueOpsSubmission(transport, Clock.systemUTC()).submit(journal, snapshot()); + + assertEquals(2, report.submitted().size()); + assertEquals(List.of(60, 1), transport.bodies.stream() + .map(body -> body.split("\"battleId\"", -1).length - 1).toList()); + } + + @Test + @Tag("RCL-007") + void testRCL007_IntegrationNegative_rejectsMissingIssuesOnlyCredentialBeforeAnyRequest() { + assertThrows(IllegalArgumentException.class, () -> new GitHubIssueOpsTransport("")); + } + private static RumbleSnapshot snapshot() { return new RumbleSnapshot(URI.create("https://github.com/example/rumble-data"), "a".repeat(40), new EnginePin(7, "1.2.0", "image", Optional.empty(), Map.of()), @@ -80,4 +125,20 @@ public List receipts(final URI repository, final SubmittedBat return List.of(new SubmissionReceipt(accepted, batch.issueUrl())); } } + + private static final class BatchRecordingTransport implements IssueOpsTransport { + private final List bodies = new ArrayList<>(); + + @Override + public SubmittedBatch createIssue(final URI repository, final String body, final String title) { + bodies.add(body); + return new SubmittedBatch(bodies.size(), "https://github.com/example/rumble-data/issues/" + bodies.size(), + List.of(UUID.randomUUID())); + } + + @Override + public List receipts(final URI repository, final SubmittedBatch batch) { + return List.of(); + } + } } diff --git a/src/test/java/dev/robocode/rumble/client/RankedBattleExecutionTest.java b/src/test/java/dev/robocode/rumble/client/RankedBattleExecutionTest.java index ebcdd5c..8f83399 100644 --- a/src/test/java/dev/robocode/rumble/client/RankedBattleExecutionTest.java +++ b/src/test/java/dev/robocode/rumble/client/RankedBattleExecutionTest.java @@ -52,6 +52,18 @@ void testRCL004_IntegrationNegative_practiceModeCannotCreateRankedResult() { assertFalse(Files.exists(temporaryDirectory.resolve("work/evidence"))); } + @Test + @Tag("RCL-004") + void testRCL004_IntegrationPositive_rankedResultCanEnterOnlyTheRankedJournal() throws IOException { + final RankedBattleRecord record = execution(validExecutor(), UUID.randomUUID()).execute(selection(), cache(), + snapshot(), configuration(ClientMode.RANKED), "0.1.0"); + final RankedJournal journal = new RankedJournal(temporaryDirectory.resolve("work")); + + journal.append(record); + + assertEquals(List.of(record), journal.pending()); + } + @Test @Tag("RCL-005") void testRCL005_IntegrationNegative_incompleteBattleCreatesNoEvidence() { diff --git a/src/test/java/dev/robocode/rumble/client/RunnerBattleExecutorIntegrationTest.java b/src/test/java/dev/robocode/rumble/client/RunnerBattleExecutorIntegrationTest.java new file mode 100644 index 0000000..e23ec37 --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/RunnerBattleExecutorIntegrationTest.java @@ -0,0 +1,96 @@ +package dev.robocode.rumble.client; + +import dev.robocode.tankroyale.runner.BattleException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RunnerBattleExecutorIntegrationTest { + private static final Path TANK_ROYALE_SAMPLE_BOTS = Path.of(System.getProperty("tankRoyaleSampleBotsJava")); + + @TempDir + Path temporaryDirectory; + + @Test + @Tag("RCL-005") + void testRCL005_IntegrationPositive_realRunnerCompletesPinnedBattleAndRetainsReplay() throws Exception { + final UUID battleId = UUID.fromString("ecb24b79-0c0c-497d-8b51-404e0edcc295"); + + final RankedBattleRecord record = execution(battleId, 1).execute(selection(), cache(), snapshot(1), + configuration(), "0.1.0"); + + assertEquals(1, record.rounds()); + assertEquals(Set.of("Walls", "Spin Bot"), record.participants().stream() + .map(RankedParticipant::name).collect(java.util.stream.Collectors.toSet())); + assertTrue(Files.isRegularFile(temporaryDirectory.resolve("work/evidence").resolve(battleId + ".battle.gz"))); + } + + @Test + @Tag("RCL-005") + void testRCL005_IntegrationNegative_behaviorMismatchCreatesNoReplayEvidence() { + assertThrows(BattleException.class, () -> execution(UUID.randomUUID(), 2).execute(selection(), cache(), + snapshot(2), configuration(), "0.1.0")); + + assertFalse(Files.exists(temporaryDirectory.resolve("work/evidence"))); + } + + private RankedBattleExecution execution(final UUID battleId, final int behaviorVersion) { + return new RankedBattleExecution(new RunnerBattleExecutor(), + Clock.fixed(Instant.parse("2026-08-30T12:00:00Z"), ZoneOffset.UTC), () -> battleId); + } + + private static BattleSelection selection() { + return new BattleSelection(GameType.ONE_VS_ONE, 7L, List.of(walls(), spinBot())); + } + + private static PreparedBotCache cache() { + return new PreparedBotCache("a".repeat(40), Map.of(walls(), botDirectory("Walls"), spinBot(), botDirectory("SpinBot"))); + } + + private RumbleSnapshot snapshot(final int behaviorVersion) { + final Map bots = Map.of(walls().displayName(), walls(), spinBot().displayName(), spinBot()); + final EnginePin engine = new EnginePin(behaviorVersion, "1.2.0", "image", Optional.empty(), + Map.of(GameType.ONE_VS_ONE, new GameTypeSettings(1, 800, 600, 2))); + return new RumbleSnapshot(java.net.URI.create("https://github.com/example/data"), "b".repeat(40), engine, + new BotCatalog(java.net.URI.create("https://github.com/example/bots"), "a".repeat(40), bots), + new ClientRegistration("alice", "alice-client"), Map.of()); + } + + private ClientConfiguration configuration() { + return new ClientConfiguration(java.net.URI.create("https://github.com/example/bots"), + java.net.URI.create("https://github.com/example/data"), Optional.of("alice-client"), Set.of(), + Set.of(GameType.ONE_VS_ONE), 1, ClientMode.RANKED, temporaryDirectory.resolve("work")); + } + + private static Path botDirectory(final String name) { + final Path directory = TANK_ROYALE_SAMPLE_BOTS.resolve(name); + if (!Files.isDirectory(directory)) { + throw new IllegalStateException("Tank Royale Java sample bot is unavailable: " + directory); + } + return directory; + } + + private static CatalogBot walls() { + return new CatalogBot("Walls", "1.0", "JVM", "bots/java/Walls", "sha256:" + "a".repeat(64)); + } + + private static CatalogBot spinBot() { + return new CatalogBot("Spin Bot", "1.0", "JVM", "bots/java/SpinBot", "sha256:" + "b".repeat(64)); + } +}