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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Install JDK 17, then run:
./gradlew build
```

The first implementation stage validates configuration and mode separation. Ranked synchronization, Battle Runner execution, persistence, issue-ops transport, and the runtime container are added in the subsequent CH-012 tasks.
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.

## Configuration

Expand Down
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ repositories {
}

dependencies {
implementation("com.google.code.gson:gson:2.13.2")

testImplementation(platform("org.junit:junit-bom:5.11.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
Expand Down
Empty file modified gradlew
100644 → 100755
Empty file.
10 changes: 10 additions & 0 deletions src/main/java/dev/robocode/rumble/client/ClientConfiguration.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package dev.robocode.rumble.client;

/**
* Validated local settings that determine how the client may run.
*
* @param clientId registered client identity.
* @param mode local execution mode.
*/
record ClientConfiguration(String clientId, ClientMode mode) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package dev.robocode.rumble.client;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;

/**
* Loads and validates the version-one local client configuration.
*/
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";

/**
* Loads a configuration file and rejects malformed or unsupported settings.
*
* @param configurationPath configuration file to load.
* @return validated local configuration.
* @throws IOException if the configuration file cannot be read.
* @throws IllegalArgumentException if the configuration is invalid.
*/
ClientConfiguration load(final Path configurationPath) throws IOException {
final JsonObject configuration = parse(configurationPath);
validateSchemaVersion(configuration);
validateHttpsUri(configuration, "botsRepo");
validateHttpsUri(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")));
}

private static JsonObject parse(final Path configurationPath) throws IOException {
try {
final JsonElement configuration = JsonParser.parseString(Files.readString(configurationPath));
if (!configuration.isJsonObject()) {
throw new IllegalArgumentException("Configuration must be a JSON object");
}
return configuration.getAsJsonObject();
} catch (JsonParseException exception) {
throw new IllegalArgumentException("Configuration must contain valid JSON", exception);
}
}

private static void validateSchemaVersion(final JsonObject configuration) {
final JsonElement schemaVersion = requiredElement(configuration, "schemaVersion");
if (integerValue(schemaVersion, "schemaVersion") != SUPPORTED_SCHEMA_VERSION) {
throw new IllegalArgumentException("schemaVersion must be " + SUPPORTED_SCHEMA_VERSION);
}
}

private static void validateHttpsUri(final JsonObject configuration, final String fieldName) {
final String value = requiredString(configuration, fieldName);
try {
final URI uri = new URI(value);
if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null) {
throw new IllegalArgumentException(fieldName + " must be an absolute HTTPS URL");
}
if (uri.getRawUserInfo() != null) {
throw new IllegalArgumentException(fieldName + " must not contain user credentials");
}
} 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) {
final JsonElement element = requiredElement(configuration, fieldName);
if (!element.isJsonArray()) {
throw new IllegalArgumentException(fieldName + " must be an array of strings");
}
final JsonArray values = element.getAsJsonArray();
if (required && values.isEmpty()) {
throw new IllegalArgumentException(fieldName + " must contain at least one value");
}
final Set<String> uniqueValues = new HashSet<>();
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());
}
}
}

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

private static int integerValue(final JsonElement element, final String fieldName) {
if (!element.isJsonPrimitive() || !element.getAsJsonPrimitive().isNumber()) {
throw new IllegalArgumentException(fieldName + " must be an integer");
}
try {
return element.getAsBigDecimal().intValueExact();
} catch (ArithmeticException exception) {
throw new IllegalArgumentException(fieldName + " must be an integer", exception);
}
}

private static String requiredString(final JsonObject configuration, final String fieldName) {
final JsonElement element = requiredElement(configuration, fieldName);
if (!element.isJsonPrimitive() || !element.getAsJsonPrimitive().isString() || element.getAsString().isBlank()) {
throw new IllegalArgumentException(fieldName + " must be a non-blank string");
}
return element.getAsString();
}

private static JsonElement requiredElement(final JsonObject configuration, final String fieldName) {
final JsonElement element = configuration.get(fieldName);
if (element == null || element.isJsonNull()) {
throw new IllegalArgumentException("Configuration is missing " + fieldName);
}
return element;
}

private static ClientMode parseMode(final String value) {
try {
return ClientMode.valueOf(value.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("mode must be ranked or practice", exception);
}
}
}
9 changes: 9 additions & 0 deletions src/main/java/dev/robocode/rumble/client/ClientMode.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,13 @@ public enum ClientMode {
public boolean permitsRankedJournal() {
return this == RANKED;
}

/**
* Returns the lowercase name accepted by the configuration file.
*
* @return configuration-facing mode name.
*/
public String displayName() {
return name().toLowerCase(java.util.Locale.ROOT);
}
}
49 changes: 45 additions & 4 deletions src/main/java/dev/robocode/rumble/client/RumbleClient.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,60 @@
package dev.robocode.rumble.client;

import java.io.IOException;
import java.io.PrintStream;
import java.nio.file.Path;

/**
* Command-line entry point for Tank Royale Rumble battle contribution.
*/
public final class RumbleClient {
private static final String HELP_OPTION = "--help";
private static final String VALIDATE_CONFIG_OPTION = "--validate-config";
private static final Path DEFAULT_CONFIGURATION_PATH = Path.of("rumble-client.json");

private RumbleClient() {
}

/**
* Prints the currently available command-line help.
* Starts the Rumble client command-line interface.
*
* @param arguments command-line arguments, currently unused.
* @param arguments command-line arguments.
*/
public static void main(final String[] arguments) {
System.out.println("Tank Royale Rumble Client");
System.out.println("Ranked synchronization and battle execution are under CH-012 implementation.");
try {
run(arguments, System.out);
} catch (IllegalArgumentException | IOException exception) {
System.err.println("Error: " + exception.getMessage());
System.err.println("Run with --help for usage.");
System.exit(1);
}
}

static void run(final String[] arguments, final PrintStream output) throws IOException {
if (arguments.length == 0 || hasOnlyArgument(arguments, HELP_OPTION)) {
printHelp(output);
return;
}

if (!arguments[0].equals(VALIDATE_CONFIG_OPTION) || arguments.length > 2) {
throw new IllegalArgumentException("Expected --validate-config [path] or --help");
}

final Path configurationPath = arguments.length == 2 ? Path.of(arguments[1]) : DEFAULT_CONFIGURATION_PATH;
final ClientConfiguration configuration = new ClientConfigurationLoader().load(configurationPath);
output.printf("Configuration %s is valid for %s mode.%n", configurationPath, configuration.mode().displayName());
output.println("Ranked synchronization and battle execution are not available yet.");
}

private static boolean hasOnlyArgument(final String[] arguments, final String option) {
return arguments.length == 1 && arguments[0].equals(option);
}

private static void printHelp(final PrintStream output) {
output.println("Tank Royale Rumble Client");
output.println("Usage: rumble-client --validate-config [path]");
output.println(" rumble-client --help");
output.println();
output.println("Use --validate-config to check a local ranked or practice configuration.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package dev.robocode.rumble.client;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

class ClientConfigurationLoaderTest {
private final ClientConfigurationLoader loader = new ClientConfigurationLoader();

@Test
@Tag("Unit")
void testUnitPositive_loadsValidPracticeConfiguration() throws IOException {
final ClientConfiguration configuration = loader.load(writeConfiguration("practice", "registered-client"));

assertEquals("registered-client", configuration.clientId());
assertEquals(ClientMode.PRACTICE, configuration.mode());
}

@Test
@Tag("Unit")
void testUnitNegative_rejectsExampleClientId() throws IOException {
final Path configurationPath = writeConfiguration("ranked", "replace-with-registered-client-id");

assertThrows(IllegalArgumentException.class, () -> loader.load(configurationPath));
}

@Test
@Tag("Unit")
void testUnitNegative_rejectsUnsupportedGameType() throws IOException {
final Path configurationPath = Files.createTempFile("rumble-client", ".json");
Files.writeString(configurationPath, validConfiguration("ranked", "registered-client")
.replace("\"melee\"", "\"team\""));

assertThrows(IllegalArgumentException.class, () -> loader.load(configurationPath));
}

@Test
@Tag("Unit")
void testUnitNegative_rejectsFractionalBattleCount() throws IOException {
final Path configurationPath = Files.createTempFile("rumble-client", ".json");
Files.writeString(configurationPath, validConfiguration("ranked", "registered-client")
.replace("50", "1.5"));

assertThrows(IllegalArgumentException.class, () -> loader.load(configurationPath));
}

@Test
@Tag("Unit")
void testUnitNegative_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"));

assertThrows(IllegalArgumentException.class, () -> loader.load(configurationPath));
}

@Test
@Tag("Unit")
void testUnitNegative_rejectsEmptyGameTypes() throws IOException {
final Path configurationPath = Files.createTempFile("rumble-client", ".json");
Files.writeString(configurationPath, validConfiguration("ranked", "registered-client")
.replace("[\"1v1\", \"twinduel\", \"melee\"]", "[]"));

assertThrows(IllegalArgumentException.class, () -> loader.load(configurationPath));
}

private static Path writeConfiguration(final String mode, final String clientId) throws IOException {
final Path configurationPath = Files.createTempFile("rumble-client", ".json");
Files.writeString(configurationPath, validConfiguration(mode, clientId));
return configurationPath;
}

private static String validConfiguration(final String mode, final String clientId) {
return """
{
"schemaVersion": 1,
"botsRepo": "https://github.com/robocode-dev/rumble-bots",
"dataRepo": "https://github.com/robocode-dev/rumble-data",
"clientId": "%s",
"myBots": [],
"gameTypes": ["1v1", "twinduel", "melee"],
"battlesPerSession": 50,
"mode": "%s"
}
""".formatted(clientId, mode);
}
}
29 changes: 29 additions & 0 deletions src/test/java/dev/robocode/rumble/client/RumbleClientTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package dev.robocode.rumble.client;

import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;

class RumbleClientTest {
@Test
@Tag("Unit")
void testUnitPositive_printsHelpWithoutConfiguration() throws IOException {
final ByteArrayOutputStream bytes = new ByteArrayOutputStream();

RumbleClient.run(new String[] {"--help"}, new PrintStream(bytes));

assertTrue(bytes.toString().contains("Usage: rumble-client --validate-config [path]"));
}

@Test
@Tag("Unit")
void testUnitNegative_rejectsUnknownCommand() {
assertThrows(IllegalArgumentException.class, () -> RumbleClient.run(new String[] {"--submit"}, System.out));
}
}
Loading