From ea506cb6678c09f8b89663ddeb1095ad333a05ba Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Mon, 3 Aug 2026 21:23:03 +0200 Subject: [PATCH 1/2] feat: validate Rumble client configuration --- README.md | 2 +- build.gradle.kts | 2 + .../rumble/client/ClientConfiguration.java | 10 ++ .../client/ClientConfigurationLoader.java | 148 ++++++++++++++++++ .../robocode/rumble/client/ClientMode.java | 9 ++ .../robocode/rumble/client/RumbleClient.java | 49 +++++- .../client/ClientConfigurationLoaderTest.java | 93 +++++++++++ .../rumble/client/RumbleClientTest.java | 29 ++++ 8 files changed, 337 insertions(+), 5 deletions(-) create mode 100644 src/main/java/dev/robocode/rumble/client/ClientConfiguration.java create mode 100644 src/main/java/dev/robocode/rumble/client/ClientConfigurationLoader.java create mode 100644 src/test/java/dev/robocode/rumble/client/ClientConfigurationLoaderTest.java create mode 100644 src/test/java/dev/robocode/rumble/client/RumbleClientTest.java diff --git a/README.md b/README.md index d170d8b..f708089 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.gradle.kts b/build.gradle.kts index 816ccf6..596f23a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") diff --git a/src/main/java/dev/robocode/rumble/client/ClientConfiguration.java b/src/main/java/dev/robocode/rumble/client/ClientConfiguration.java new file mode 100644 index 0000000..9317649 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/ClientConfiguration.java @@ -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) { +} diff --git a/src/main/java/dev/robocode/rumble/client/ClientConfigurationLoader.java b/src/main/java/dev/robocode/rumble/client/ClientConfigurationLoader.java new file mode 100644 index 0000000..e93e582 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/ClientConfigurationLoader.java @@ -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 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 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 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); + } + } +} diff --git a/src/main/java/dev/robocode/rumble/client/ClientMode.java b/src/main/java/dev/robocode/rumble/client/ClientMode.java index f0a21d2..509e0b8 100644 --- a/src/main/java/dev/robocode/rumble/client/ClientMode.java +++ b/src/main/java/dev/robocode/rumble/client/ClientMode.java @@ -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); + } } diff --git a/src/main/java/dev/robocode/rumble/client/RumbleClient.java b/src/main/java/dev/robocode/rumble/client/RumbleClient.java index d27f3cf..69f9ce5 100644 --- a/src/main/java/dev/robocode/rumble/client/RumbleClient.java +++ b/src/main/java/dev/robocode/rumble/client/RumbleClient.java @@ -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."); } } diff --git a/src/test/java/dev/robocode/rumble/client/ClientConfigurationLoaderTest.java b/src/test/java/dev/robocode/rumble/client/ClientConfigurationLoaderTest.java new file mode 100644 index 0000000..d6e37ae --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/ClientConfigurationLoaderTest.java @@ -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); + } +} diff --git a/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java b/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java new file mode 100644 index 0000000..3ae72ac --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java @@ -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)); + } +} From bcbda0f105a9ab419f6757af0c91062575e3f66a Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Mon, 3 Aug 2026 21:30:09 +0200 Subject: [PATCH 2/2] fix: make Gradle wrapper executable --- gradlew | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 gradlew diff --git a/gradlew b/gradlew old mode 100644 new mode 100755