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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ Install JDK 17, then run:
./gradlew build
```

The first implementation stage validates configuration and mode separation. Run `./gradlew run --args="--validate-config"` after configuring the client. Ranked synchronization, Battle Runner execution, persistence, issue-ops transport, and the runtime container are added in the subsequent CH-012 tasks.
The client validates configuration and can synchronize the current ranked input snapshot. Run `./gradlew run --args="--validate-config"` to check local settings, then run `./gradlew run --args="--sync"` to resolve the canonical data repository and validate its engine pin, catalog, client registration, and matchmaking advice. Battle Runner execution, persistence, issue-ops transport, and the runtime container are added in subsequent CH-012 tasks.

## Configuration

Copy `rumble-client.example.json` to `rumble-client.json` and set the registered `clientId`. Do not commit the resulting file or any token. A submission token is supplied at runtime only when issue-ops support is available.
Copy `rumble-client.example.json` to `rumble-client.json`, set the registered `clientId`, and choose a `workDirectory` for local cache, journal, and replay evidence. Do not commit the resulting file or any token. A submission token is supplied at runtime only when issue-ops support is available.

## Contributing

Expand Down
3 changes: 2 additions & 1 deletion rumble-client.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
"myBots": [],
"gameTypes": ["1v1", "twinduel", "melee"],
"battlesPerSession": 50,
"mode": "ranked"
"mode": "ranked",
"workDirectory": ".rumble-client"
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
package dev.robocode.rumble.client;

import java.net.URI;
import java.nio.file.Path;
import java.util.Set;

/**
* Validated local settings that determine how the client may run.
*
* @param botsRepository reviewed bot catalog repository.
* @param dataRepository Rumble data repository or its canonical predecessor.
* @param clientId registered client identity.
* @param myBots local own-bot scheduling hints.
* @param gameTypes selected ranked game types.
* @param battlesPerSession maximum battles requested for one session.
* @param mode local execution mode.
* @param workDirectory local cache, journal, and evidence root.
*/
record ClientConfiguration(String clientId, ClientMode mode) {
record ClientConfiguration(URI botsRepository, URI dataRepository, String clientId, Set<String> myBots,
Set<GameType> gameTypes, int battlesPerSession, ClientMode mode, Path workDirectory) {
ClientConfiguration {
myBots = Set.copyOf(myBots);
gameTypes = Set.copyOf(gameTypes);
workDirectory = workDirectory.toAbsolutePath().normalize();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Set;

Expand All @@ -20,7 +21,6 @@
*/
final class ClientConfigurationLoader {
private static final int SUPPORTED_SCHEMA_VERSION = 1;
private static final Set<String> SUPPORTED_GAME_TYPES = Set.of("1v1", "twinduel", "melee");
private static final String EXAMPLE_CLIENT_ID = "replace-with-registered-client-id";

/**
Expand All @@ -34,16 +34,20 @@ final class ClientConfigurationLoader {
ClientConfiguration load(final Path configurationPath) throws IOException {
final JsonObject configuration = parse(configurationPath);
validateSchemaVersion(configuration);
validateHttpsUri(configuration, "botsRepo");
validateHttpsUri(configuration, "dataRepo");
final URI botsRepository = parseHttpsUri(configuration, "botsRepo");
final URI dataRepository = parseHttpsUri(configuration, "dataRepo");
final String clientId = requiredString(configuration, "clientId");
if (clientId.equals(EXAMPLE_CLIENT_ID)) {
throw new IllegalArgumentException("clientId must replace the example value");
}
validateStringArray(configuration, "myBots", Set.of(), false);
validateStringArray(configuration, "gameTypes", SUPPORTED_GAME_TYPES, true);
validatePositiveInteger(configuration, "battlesPerSession");
return new ClientConfiguration(clientId, parseMode(requiredString(configuration, "mode")));
final Set<String> myBots = parseStringSet(configuration, "myBots", false);
final Set<GameType> gameTypes = parseGameTypes(configuration);
final int battlesPerSession = parsePositiveInteger(configuration, "battlesPerSession");
final ClientMode mode = parseMode(requiredString(configuration, "mode"));
final Path workDirectory = parseWorkDirectory(configurationPath,
optionalString(configuration, "workDirectory", ".rumble-client"));
return new ClientConfiguration(botsRepository, dataRepository, clientId, myBots, gameTypes,
battlesPerSession, mode, workDirectory);
}

private static JsonObject parse(final Path configurationPath) throws IOException {
Expand All @@ -65,7 +69,7 @@ private static void validateSchemaVersion(final JsonObject configuration) {
}
}

private static void validateHttpsUri(final JsonObject configuration, final String fieldName) {
private static URI parseHttpsUri(final JsonObject configuration, final String fieldName) {
final String value = requiredString(configuration, fieldName);
try {
final URI uri = new URI(value);
Expand All @@ -75,13 +79,14 @@ private static void validateHttpsUri(final JsonObject configuration, final Strin
if (uri.getRawUserInfo() != null) {
throw new IllegalArgumentException(fieldName + " must not contain user credentials");
}
return uri;
} catch (URISyntaxException exception) {
throw new IllegalArgumentException(fieldName + " must be an absolute HTTPS URL", exception);
}
}

private static void validateStringArray(final JsonObject configuration, final String fieldName,
final Set<String> allowedValues, final boolean required) {
private static Set<String> parseStringSet(final JsonObject configuration, final String fieldName,
final boolean required) {
final JsonElement element = requiredElement(configuration, fieldName);
if (!element.isJsonArray()) {
throw new IllegalArgumentException(fieldName + " must be an array of strings");
Expand All @@ -90,25 +95,46 @@ private static void validateStringArray(final JsonObject configuration, final St
if (required && values.isEmpty()) {
throw new IllegalArgumentException(fieldName + " must contain at least one value");
}
final Set<String> uniqueValues = new HashSet<>();
final Set<String> uniqueValues = new LinkedHashSet<>();
for (final JsonElement value : values) {
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank()) {
throw new IllegalArgumentException(fieldName + " must be an array of non-blank strings");
}
if (!uniqueValues.add(value.getAsString())) {
throw new IllegalArgumentException(fieldName + " must not contain duplicate values");
}
if (!allowedValues.isEmpty() && !allowedValues.contains(value.getAsString())) {
throw new IllegalArgumentException(fieldName + " contains unsupported value: " + value.getAsString());
}
return Set.copyOf(uniqueValues);
}

private static Set<GameType> parseGameTypes(final JsonObject configuration) {
final Set<GameType> gameTypes = new HashSet<>();
for (final String value : parseStringSet(configuration, "gameTypes", true)) {
final GameType gameType = GameType.fromContractName(value);
if (!gameTypes.add(gameType)) {
throw new IllegalArgumentException("gameTypes must not contain duplicate values");
}
}
return Set.copyOf(gameTypes);
}

private static void validatePositiveInteger(final JsonObject configuration, final String fieldName) {
private static int parsePositiveInteger(final JsonObject configuration, final String fieldName) {
final JsonElement element = requiredElement(configuration, fieldName);
if (integerValue(element, fieldName) < 1) {
final int value = integerValue(element, fieldName);
if (value < 1) {
throw new IllegalArgumentException(fieldName + " must be a positive integer");
}
return value;
}

private static Path parseWorkDirectory(final Path configurationPath, final String value) {
final Path configured = Path.of(value);
final Path parent = configurationPath.toAbsolutePath().normalize().getParent();
final Path resolved = configured.isAbsolute() ? configured.normalize() : parent.resolve(configured).normalize();
if (resolved.getParent() == null) {
throw new IllegalArgumentException("workDirectory must not be a filesystem root");
}
return resolved;
}

private static int integerValue(final JsonElement element, final String fieldName) {
Expand All @@ -130,6 +156,15 @@ private static String requiredString(final JsonObject configuration, final Strin
return element.getAsString();
}

private static String optionalString(final JsonObject configuration, final String fieldName,
final String defaultValue) {
final JsonElement element = configuration.get(fieldName);
if (element == null || element.isJsonNull()) {
return defaultValue;
}
return requiredString(configuration, fieldName);
}

private static JsonElement requiredElement(final JsonObject configuration, final String fieldName) {
final JsonElement element = configuration.get(fieldName);
if (element == null || element.isJsonNull()) {
Expand Down
29 changes: 29 additions & 0 deletions src/main/java/dev/robocode/rumble/client/GameType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package dev.robocode.rumble.client;

import java.util.Arrays;

/**
* Ranked game types published by the Rumble engine pin.
*/
enum GameType {
ONE_VS_ONE("1v1"),
TWIN_DUEL("twinduel"),
MELEE("melee");

private final String contractName;

GameType(final String contractName) {
this.contractName = contractName;
}

String contractName() {
return contractName;
}

static GameType fromContractName(final String value) {
return Arrays.stream(values())
.filter(gameType -> gameType.contractName.equals(value))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unsupported game type: " + value));
}
}
100 changes: 100 additions & 0 deletions src/main/java/dev/robocode/rumble/client/GitRepositoryReader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package dev.robocode.rumble.client;

import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;

/**
* Reads one remote repository revision through an isolated shallow Git clone.
*/
final class GitRepositoryReader implements RepositoryReader {
@Override
public RepositoryCheckout checkout(final URI repository) throws IOException {
final Path directory = Files.createTempDirectory("rumble-client-repository-");
try {
runGit("clone", "--quiet", "--depth", "1", "--no-tags", repository.toString(), directory.toString());
final String revision = runGit("-C", directory.toString(), "rev-parse", "HEAD").trim();
return new Checkout(repository, directory, revision);
} catch (IOException exception) {
deleteTree(directory);
throw exception;
}
}

private static String runGit(final String... arguments) throws IOException {
final Process process = new ProcessBuilder(prependGit(arguments)).redirectErrorStream(true).start();
final String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
try {
final int exitCode = process.waitFor();
if (exitCode != 0) {
throw new IOException("Git command failed with exit code " + exitCode + ": " + output.strip());
}
return output;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while waiting for Git", exception);
}
}

private static String[] prependGit(final String[] arguments) {
final String[] command = new String[arguments.length + 1];
command[0] = "git";
System.arraycopy(arguments, 0, command, 1, arguments.length);
return command;
}

private static void deleteTree(final Path directory) throws IOException {
if (!Files.exists(directory)) {
return;
}
try (Stream<Path> paths = Files.walk(directory)) {
for (final Path path : paths.sorted(Comparator.reverseOrder()).toList()) {
if (!Files.isSymbolicLink(path)) {
path.toFile().setWritable(true);
}
Files.deleteIfExists(path);
}
}
}

private record Checkout(URI repository, Path directory, String revision) implements RepositoryCheckout {
@Override
public String read(final String relativePath) throws IOException {
return Files.readString(resolveInsideCheckout(relativePath));
}

@Override
public List<String> listFiles(final String relativeDirectory) throws IOException {
final Path directoryPath = resolveInsideCheckout(relativeDirectory);
try (Stream<Path> paths = Files.list(directoryPath)) {
return paths.filter(Files::isRegularFile)
.map(path -> directory.relativize(path).toString().replace('\\', '/'))
.sorted()
.toList();
}
}

private Path resolveInsideCheckout(final String relativePath) throws IOException {
final Path requested = Path.of(relativePath);
if (requested.isAbsolute()) {
throw new IOException("Repository path must be relative: " + relativePath);
}
final Path root = directory.toRealPath();
final Path resolved = directory.resolve(requested).normalize().toRealPath();
if (!resolved.startsWith(root)) {
throw new IOException("Repository path escapes checkout: " + relativePath);
}
return resolved;
}

@Override
public void close() throws IOException {
deleteTree(directory);
}
}
}
Loading
Loading