Skip to content

Commit a55f26c

Browse files
Merge pull request #7 from robocode-dev/ch-031-complete-rumble-client
Complete ranked Rumble client execution
2 parents 62568a0 + cec7c8b commit a55f26c

28 files changed

Lines changed: 1550 additions & 60 deletions

.github/workflows/build.yml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,23 @@ jobs:
1616
runs-on: ${{ matrix.os }}
1717
steps:
1818
- uses: actions/checkout@v4
19+
- uses: actions/checkout@v4
20+
with:
21+
repository: robocode-dev/tank-royale
22+
ref: fd06b97a61c9aa264e6964520a30262f8f8be751
23+
path: tank-royale-source
24+
persist-credentials: false
1925
- uses: actions/setup-java@v5
2026
with:
2127
distribution: temurin
22-
java-version: 17
28+
java-version: |
29+
11
30+
17
2331
cache: gradle
2432
- if: runner.os != 'Windows'
25-
run: ./gradlew build
33+
run: ./gradlew --no-configuration-cache -PtankRoyaleSource=tank-royale-source build
2634
- if: runner.os == 'Windows'
27-
run: .\gradlew.bat build
35+
run: .\gradlew.bat --no-configuration-cache "-PtankRoyaleSource=tank-royale-source" build
2836
- if: runner.os == 'Linux'
2937
uses: actions/upload-artifact@v4
3038
with:

Dockerfile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
# syntax=docker/dockerfile:1
22

3+
FROM eclipse-temurin:11-jdk AS jdk11
4+
35
FROM gradle:8.14.3-jdk17 AS build
6+
ARG TANK_ROYALE_COMMIT=fd06b97a61c9aa264e6964520a30262f8f8be751
47
WORKDIR /workspace
8+
COPY --from=jdk11 /opt/java/openjdk /opt/java/openjdk-11
59
COPY gradle gradle
610
COPY gradlew gradlew.bat build.gradle.kts settings.gradle.kts gradle.properties ./
711
COPY src src
8-
RUN ./gradlew --no-daemon installDist
12+
RUN git clone --filter=blob:none https://github.com/robocode-dev/tank-royale.git /tank-royale \
13+
&& git -C /tank-royale checkout "$TANK_ROYALE_COMMIT" \
14+
&& ./gradlew --no-daemon --no-configuration-cache \
15+
-Dorg.gradle.java.installations.paths=/opt/java/openjdk,/opt/java/openjdk-11 \
16+
-PtankRoyaleSource=/tank-royale installDist
917

1018
FROM ubuntu:24.04
1119
ARG TARGETARCH

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,19 @@ Contributors may use the supported native distribution or the recommended Docker
88

99
## Build
1010

11-
Install JDK 17, then run:
11+
Install JDK 17 and keep a Tank Royale checkout containing BR-049 beside this repository, then run:
1212

1313
```shell
14-
./gradlew build
14+
./gradlew --no-configuration-cache -PtankRoyaleSource=../tank-royale build
1515
```
1616

17+
On PowerShell, quote the property argument: `.\gradlew.bat --no-configuration-cache "-PtankRoyaleSource=../tank-royale" build`.
18+
19+
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.
20+
1721
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.
1822

19-
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.
23+
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.
2024

2125
## Configuration
2226

@@ -26,7 +30,7 @@ Copy `rumble-client.example.json` to `rumble-client.json`. Ranked mode requires
2630

2731
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.
2832

29-
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.
33+
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.
3034

3135
## Contributing
3236

build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ repositories {
1818

1919
dependencies {
2020
implementation("com.google.code.gson:gson:2.13.2")
21+
implementation("dev.robocode.tankroyale:robocode-tankroyale-runner:${providers.gradleProperty("tankRoyaleRunnerVersion").get()}")
2122

2223
testImplementation(platform("org.junit:junit-bom:5.11.4"))
2324
testImplementation("org.junit.jupiter:junit-jupiter")
@@ -30,4 +31,7 @@ application {
3031

3132
tasks.test {
3233
useJUnitPlatform()
34+
val tankRoyaleSource = providers.gradleProperty("tankRoyaleSource").orElse("../tank-royale").get()
35+
dependsOn(gradle.includedBuild("tank-royale").task(":sample-bots:java:build"))
36+
systemProperty("tankRoyaleSampleBotsJava", file(tankRoyaleSource).resolve("sample-bots/java/build/archive"))
3337
}

gradle.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
group=dev.robocode.rumble
22
version=0.1.0-SNAPSHOT
3+
tankRoyaleRunnerVersion=1.2.0
34
org.gradle.configuration-cache=true
45
org.gradle.caching=true

settings.gradle.kts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,11 @@
11
rootProject.name = "rumble-client"
2+
3+
providers.gradleProperty("tankRoyaleSource").orNull?.let { sourcePath ->
4+
includeBuild(file(sourcePath)) {
5+
name = "tank-royale"
6+
dependencySubstitution {
7+
substitute(module("dev.robocode.tankroyale:robocode-tankroyale-runner"))
8+
.using(project(":runner"))
9+
}
10+
}
11+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package dev.robocode.rumble.client;
2+
3+
import dev.robocode.tankroyale.runner.BattleResults;
4+
5+
import java.io.IOException;
6+
import java.nio.file.Path;
7+
8+
/** Executes one prepared battle and returns its complete Runner result and recording. */
9+
interface BattleExecutor {
10+
CompletedBattle execute(BattleSelection selection, PreparedBotCache cache, EnginePin engine,
11+
GameTypeSettings settings, Path recordingDirectory) throws IOException;
12+
}
13+
14+
record CompletedBattle(BattleResults results, Path replay) {
15+
}

src/main/java/dev/robocode/rumble/client/GameType.java

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,33 @@
66
* Ranked game types published by the Rumble engine pin.
77
*/
88
enum GameType {
9-
ONE_VS_ONE("1v1"),
10-
TWIN_DUEL("twinduel"),
11-
MELEE("melee");
9+
ONE_VS_ONE("1v1", 1),
10+
TWIN_DUEL("twinduel", 2),
11+
MELEE("melee", 1);
1212

1313
private final String contractName;
14+
private final int teamSize;
1415

15-
GameType(final String contractName) {
16+
GameType(final String contractName, final int teamSize) {
1617
this.contractName = contractName;
18+
this.teamSize = teamSize;
1719
}
1820

1921
String contractName() {
2022
return contractName;
2123
}
2224

25+
/**
26+
* Number of bots each catalog entry of this game type expands to when the battle is booted.
27+
*/
28+
int teamSize() {
29+
return teamSize;
30+
}
31+
32+
boolean isTeamGame() {
33+
return teamSize > 1;
34+
}
35+
2336
static GameType fromContractName(final String value) {
2437
return Arrays.stream(values())
2538
.filter(gameType -> gameType.contractName.equals(value))
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package dev.robocode.rumble.client;
2+
3+
import com.google.gson.JsonArray;
4+
import com.google.gson.JsonElement;
5+
import com.google.gson.JsonObject;
6+
import com.google.gson.JsonParser;
7+
8+
import java.io.IOException;
9+
import java.net.URI;
10+
import java.net.http.HttpClient;
11+
import java.net.http.HttpRequest;
12+
import java.net.http.HttpResponse;
13+
import java.nio.charset.StandardCharsets;
14+
import java.time.Duration;
15+
import java.util.ArrayList;
16+
import java.util.List;
17+
import java.util.UUID;
18+
19+
/** GitHub REST adapter limited to creating result issues and reading their receipt comments. */
20+
final class GitHubIssueOpsTransport implements IssueOpsTransport {
21+
private static final URI API_ROOT = URI.create("https://api.github.com/");
22+
private static final String API_VERSION = "2026-03-10";
23+
24+
private final HttpClient client;
25+
private final String token;
26+
27+
GitHubIssueOpsTransport(final String token) {
28+
this(HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(), token);
29+
}
30+
31+
GitHubIssueOpsTransport(final HttpClient client, final String token) {
32+
if (token == null || token.isBlank()) {
33+
throw new IllegalArgumentException("RUMBLE_CLIENT_TOKEN must contain an Issues-only GitHub token");
34+
}
35+
this.client = client;
36+
this.token = token;
37+
}
38+
39+
@Override
40+
public SubmittedBatch createIssue(final URI repository, final String body, final String title) throws IOException {
41+
final JsonObject request = new JsonObject();
42+
request.addProperty("title", title);
43+
request.addProperty("body", body);
44+
final JsonArray labels = new JsonArray();
45+
labels.add("result-submission");
46+
request.add("labels", labels);
47+
final JsonObject response = request("POST", endpoint(repository, "issues"), request.toString());
48+
final int issueNumber = response.get("number").getAsInt();
49+
final String issueUrl = response.get("html_url").getAsString();
50+
return new SubmittedBatch(issueNumber, issueUrl, battleIds(body));
51+
}
52+
53+
@Override
54+
public List<SubmissionReceipt> receipts(final URI repository, final SubmittedBatch batch) throws IOException {
55+
final JsonArray comments = request("GET", endpoint(repository, "issues/" + batch.issueNumber()
56+
+ "/comments?per_page=100"), null).getAsJsonArray("comments");
57+
final List<SubmissionReceipt> receipts = new ArrayList<>();
58+
for (final JsonElement comment : comments) {
59+
final JsonElement body = comment.getAsJsonObject().get("body");
60+
if (body != null && body.isJsonPrimitive()) {
61+
receipts.addAll(receipts(body.getAsString(), batch.issueUrl()));
62+
}
63+
}
64+
return receipts;
65+
}
66+
67+
private JsonObject request(final String method, final URI endpoint, final String body) throws IOException {
68+
final HttpRequest.Builder request = HttpRequest.newBuilder(endpoint).timeout(Duration.ofSeconds(30))
69+
.header("Accept", "application/vnd.github+json")
70+
.header("Authorization", "Bearer " + token)
71+
.header("X-GitHub-Api-Version", API_VERSION);
72+
if (body == null) {
73+
request.GET();
74+
} else {
75+
request.header("Content-Type", "application/json")
76+
.method(method, HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
77+
}
78+
try {
79+
final HttpResponse<String> response = client.send(request.build(), HttpResponse.BodyHandlers.ofString());
80+
if (response.statusCode() < 200 || response.statusCode() >= 300) {
81+
throw new IOException("GitHub Issues API returned HTTP " + response.statusCode());
82+
}
83+
final JsonElement parsed = JsonParser.parseString(response.body());
84+
if (parsed.isJsonObject()) {
85+
return parsed.getAsJsonObject();
86+
}
87+
final JsonObject wrapper = new JsonObject();
88+
wrapper.add("comments", parsed.getAsJsonArray());
89+
return wrapper;
90+
} catch (InterruptedException exception) {
91+
Thread.currentThread().interrupt();
92+
throw new IOException("Interrupted while calling the GitHub Issues API", exception);
93+
} catch (RuntimeException exception) {
94+
throw new IOException("GitHub Issues API returned invalid JSON", exception);
95+
}
96+
}
97+
98+
private static URI endpoint(final URI repository, final String suffix) {
99+
if (!"github.com".equalsIgnoreCase(repository.getHost())) {
100+
throw new IllegalArgumentException("Issues-only submission requires a github.com canonical repository");
101+
}
102+
final String[] segments = repository.getPath().replaceFirst("/$", "").replaceFirst("\\.git$", "")
103+
.split("/");
104+
if (segments.length != 3 || segments[1].isBlank() || segments[2].isBlank()) {
105+
throw new IllegalArgumentException("Canonical repository must identify a GitHub owner and repository");
106+
}
107+
return API_ROOT.resolve("repos/" + segments[1] + "/" + segments[2] + "/" + suffix);
108+
}
109+
110+
private static List<UUID> battleIds(final String body) {
111+
final int begin = body.indexOf('{');
112+
final int end = body.lastIndexOf('}');
113+
final JsonArray results = JsonParser.parseString(body.substring(begin, end + 1)).getAsJsonObject()
114+
.getAsJsonArray("results");
115+
return results.asList().stream().map(result -> UUID.fromString(result.getAsJsonObject()
116+
.get("battleId").getAsString())).toList();
117+
}
118+
119+
private static List<SubmissionReceipt> receipts(final String comment, final String issueUrl) {
120+
return comment.lines().map(String::trim).filter(line -> line.endsWith(": accepted"))
121+
.map(line -> line.substring(0, line.length() - ": accepted".length()))
122+
.flatMap(value -> {
123+
try {
124+
return java.util.stream.Stream.of(new SubmissionReceipt(UUID.fromString(value), issueUrl));
125+
} catch (IllegalArgumentException exception) {
126+
return java.util.stream.Stream.empty();
127+
}
128+
}).toList();
129+
}
130+
}

0 commit comments

Comments
 (0)