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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,23 @@ 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
java-version: |
11
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:
Expand Down
10 changes: 9 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +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 ./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 \
-Dorg.gradle.java.installations.paths=/opt/java/openjdk,/opt/java/openjdk-11 \
-PtankRoyaleSource=/tank-royale installDist

FROM ubuntu:24.04
ARG TARGETARCH
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@ 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.
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

Expand All @@ -26,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

Expand Down
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -30,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"))
}
1 change: 1 addition & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
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"))
}
}
}
15 changes: 15 additions & 0 deletions src/main/java/dev/robocode/rumble/client/BattleExecutor.java
Original file line number Diff line number Diff line change
@@ -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) {
}
21 changes: 17 additions & 4 deletions src/main/java/dev/robocode/rumble/client/GameType.java
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
130 changes: 130 additions & 0 deletions src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java
Original file line number Diff line number Diff line change
@@ -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<SubmissionReceipt> 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<SubmissionReceipt> 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<String> 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<UUID> 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<SubmissionReceipt> 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();
}
}
Loading
Loading