diff --git a/README.md b/README.md index f708089..8fa1305 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/rumble-client.example.json b/rumble-client.example.json index 70b93fd..72332b8 100644 --- a/rumble-client.example.json +++ b/rumble-client.example.json @@ -6,5 +6,6 @@ "myBots": [], "gameTypes": ["1v1", "twinduel", "melee"], "battlesPerSession": 50, - "mode": "ranked" + "mode": "ranked", + "workDirectory": ".rumble-client" } diff --git a/src/main/java/dev/robocode/rumble/client/ClientConfiguration.java b/src/main/java/dev/robocode/rumble/client/ClientConfiguration.java index 9317649..e6bb4c4 100644 --- a/src/main/java/dev/robocode/rumble/client/ClientConfiguration.java +++ b/src/main/java/dev/robocode/rumble/client/ClientConfiguration.java @@ -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 myBots, + Set gameTypes, int battlesPerSession, ClientMode mode, Path workDirectory) { + ClientConfiguration { + myBots = Set.copyOf(myBots); + gameTypes = Set.copyOf(gameTypes); + workDirectory = workDirectory.toAbsolutePath().normalize(); + } } diff --git a/src/main/java/dev/robocode/rumble/client/ClientConfigurationLoader.java b/src/main/java/dev/robocode/rumble/client/ClientConfigurationLoader.java index e93e582..8135d36 100644 --- a/src/main/java/dev/robocode/rumble/client/ClientConfigurationLoader.java +++ b/src/main/java/dev/robocode/rumble/client/ClientConfigurationLoader.java @@ -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; @@ -20,7 +21,6 @@ */ 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"; /** @@ -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 myBots = parseStringSet(configuration, "myBots", false); + final Set 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 { @@ -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); @@ -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 allowedValues, final boolean required) { + private static Set 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"); @@ -90,7 +95,7 @@ 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 uniqueValues = new HashSet<>(); + final Set 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"); @@ -98,17 +103,38 @@ private static void validateStringArray(final JsonObject configuration, final St 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 parseGameTypes(final JsonObject configuration) { + final Set 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) { @@ -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()) { diff --git a/src/main/java/dev/robocode/rumble/client/GameType.java b/src/main/java/dev/robocode/rumble/client/GameType.java new file mode 100644 index 0000000..c1c1cdb --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/GameType.java @@ -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)); + } +} diff --git a/src/main/java/dev/robocode/rumble/client/GitRepositoryReader.java b/src/main/java/dev/robocode/rumble/client/GitRepositoryReader.java new file mode 100644 index 0000000..947f561 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/GitRepositoryReader.java @@ -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 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 listFiles(final String relativeDirectory) throws IOException { + final Path directoryPath = resolveInsideCheckout(relativeDirectory); + try (Stream 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); + } + } +} diff --git a/src/main/java/dev/robocode/rumble/client/JsonContract.java b/src/main/java/dev/robocode/rumble/client/JsonContract.java new file mode 100644 index 0000000..b3e56f2 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/JsonContract.java @@ -0,0 +1,122 @@ +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.net.URI; +import java.net.URISyntaxException; + +/** + * Fail-fast access to one versioned JSON contract document. + */ +final class JsonContract { + private static final int SUPPORTED_SCHEMA_VERSION = 1; + + private final JsonObject object; + private final String document; + + private JsonContract(final JsonObject object, final String document) { + this.object = object; + this.document = document; + } + + static JsonContract parse(final String json, final String document) { + try { + final JsonElement root = JsonParser.parseString(json); + if (!root.isJsonObject()) { + throw invalid(document + " must contain a JSON object"); + } + final JsonContract contract = new JsonContract(root.getAsJsonObject(), document); + if (contract.integer("schemaVersion", 1) != SUPPORTED_SCHEMA_VERSION) { + throw invalid(document + " has an unsupported schemaVersion"); + } + return contract; + } catch (JsonParseException exception) { + throw invalid(document + " contains invalid JSON", exception); + } + } + + String string(final String field) { + final JsonElement value = required(field); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank()) { + throw invalid(document + "." + field + " must be a non-blank string"); + } + return value.getAsString(); + } + + String nullableString(final String field) { + final JsonElement value = object.get(field); + if (value == null || value.isJsonNull()) { + return null; + } + return string(field); + } + + int integer(final String field, final int minimum) { + final JsonElement value = required(field); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { + throw invalid(document + "." + field + " must be an integer"); + } + try { + final int result = value.getAsBigDecimal().intValueExact(); + if (result < minimum) { + throw invalid(document + "." + field + " must be at least " + minimum); + } + return result; + } catch (ArithmeticException exception) { + throw invalid(document + "." + field + " must be an integer", exception); + } + } + + URI httpsUri(final String field) { + final String value = string(field); + try { + final URI uri = new URI(value); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getRawUserInfo() != null) { + throw invalid(document + "." + field + " must be an absolute credential-free HTTPS URL"); + } + return uri; + } catch (URISyntaxException exception) { + throw invalid(document + "." + field + " must be an absolute credential-free HTTPS URL", exception); + } + } + + JsonArray array(final String field) { + final JsonElement value = required(field); + if (!value.isJsonArray()) { + throw invalid(document + "." + field + " must be an array"); + } + return value.getAsJsonArray(); + } + + JsonObject object(final String field) { + final JsonElement value = required(field); + if (!value.isJsonObject()) { + throw invalid(document + "." + field + " must be an object"); + } + return value.getAsJsonObject(); + } + + static JsonContract nested(final JsonObject object, final String document) { + return new JsonContract(object, document); + } + + private JsonElement required(final String field) { + final JsonElement value = object.get(field); + if (value == null || value.isJsonNull()) { + throw invalid(document + " is missing " + field); + } + return value; + } + + static IllegalArgumentException invalid(final String message) { + return new IllegalArgumentException(message); + } + + static IllegalArgumentException invalid(final String message, final Exception cause) { + return new IllegalArgumentException(message, cause); + } +} diff --git a/src/main/java/dev/robocode/rumble/client/RepositoryReader.java b/src/main/java/dev/robocode/rumble/client/RepositoryReader.java new file mode 100644 index 0000000..78e9b98 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/RepositoryReader.java @@ -0,0 +1,28 @@ +package dev.robocode.rumble.client; + +import java.io.IOException; +import java.net.URI; +import java.util.List; + +/** + * Opens immutable repository views for one synchronization attempt. + */ +interface RepositoryReader { + RepositoryCheckout checkout(URI repository) throws IOException; + + /** + * A read-only repository revision. Implementations are not required to be thread-safe. + */ + interface RepositoryCheckout extends AutoCloseable { + URI repository(); + + String revision(); + + String read(String relativePath) throws IOException; + + List listFiles(String relativeDirectory) throws IOException; + + @Override + void close() throws IOException; + } +} diff --git a/src/main/java/dev/robocode/rumble/client/RumbleClient.java b/src/main/java/dev/robocode/rumble/client/RumbleClient.java index 69f9ce5..b0d651b 100644 --- a/src/main/java/dev/robocode/rumble/client/RumbleClient.java +++ b/src/main/java/dev/robocode/rumble/client/RumbleClient.java @@ -10,6 +10,7 @@ public final class RumbleClient { private static final String HELP_OPTION = "--help"; private static final String VALIDATE_CONFIG_OPTION = "--validate-config"; + private static final String SYNCHRONIZE_OPTION = "--sync"; private static final Path DEFAULT_CONFIGURATION_PATH = Path.of("rumble-client.json"); private RumbleClient() { @@ -36,14 +37,23 @@ static void run(final String[] arguments, final PrintStream output) throws IOExc return; } - if (!arguments[0].equals(VALIDATE_CONFIG_OPTION) || arguments.length > 2) { - throw new IllegalArgumentException("Expected --validate-config [path] or --help"); + if (arguments.length > 2 + || (!arguments[0].equals(VALIDATE_CONFIG_OPTION) && !arguments[0].equals(SYNCHRONIZE_OPTION))) { + throw new IllegalArgumentException("Expected --validate-config [path], --sync [path], or --help"); } final Path configurationPath = arguments.length == 2 ? Path.of(arguments[1]) : DEFAULT_CONFIGURATION_PATH; final ClientConfiguration configuration = new ClientConfigurationLoader().load(configurationPath); + if (arguments[0].equals(SYNCHRONIZE_OPTION)) { + final RumbleSnapshot snapshot = new RumbleSynchronizer(new GitRepositoryReader()) + .synchronize(configuration); + output.printf("Synchronized %s at %s.%n", snapshot.canonicalDataRepository(), snapshot.dataRevision()); + output.printf("Accepted behavior version %d, %d active bots, and advice for %d game types.%n", + snapshot.engine().behaviorVersion(), snapshot.catalog().activeBots().size(), snapshot.advice().size()); + return; + } 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."); + output.println("Battle execution is not available yet."); } private static boolean hasOnlyArgument(final String[] arguments, final String option) { @@ -53,8 +63,10 @@ private static boolean hasOnlyArgument(final String[] arguments, final String op 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 --sync [path]"); output.println(" rumble-client --help"); output.println(); output.println("Use --validate-config to check a local ranked or practice configuration."); + output.println("Use --sync to validate the current canonical ranked input snapshot."); } } diff --git a/src/main/java/dev/robocode/rumble/client/RumbleSnapshot.java b/src/main/java/dev/robocode/rumble/client/RumbleSnapshot.java new file mode 100644 index 0000000..fddbeb8 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/RumbleSnapshot.java @@ -0,0 +1,53 @@ +package dev.robocode.rumble.client; + +import java.net.URI; +import java.util.List; +import java.util.Map; + +/** + * Immutable ranked input snapshot accepted from one Rumble data revision. + */ +record RumbleSnapshot(URI canonicalDataRepository, String dataRevision, EnginePin engine, BotCatalog catalog, + ClientRegistration registration, Map advice) { + RumbleSnapshot { + advice = Map.copyOf(advice); + } +} + +record EnginePin(int behaviorVersion, String tankRoyaleVersion, String image, + Map gameTypes) { + EnginePin { + gameTypes = Map.copyOf(gameTypes); + } +} + +record GameTypeSettings(int rounds, int arenaWidth, int arenaHeight, int participants) { +} + +record BotCatalog(URI source, String sourceCommit, Map activeBots) { + BotCatalog { + activeBots = Map.copyOf(activeBots); + } +} + +record CatalogBot(String name, String version, String platform, String path, String sourceHash) { + String displayName() { + return name + " " + version; + } +} + +record ClientRegistration(String account, String clientId) { +} + +record MatchAdvice(GameType gameType, String projectionId, int targetSamplesPerPairing, + List priorityPairs) { + MatchAdvice { + priorityPairs = List.copyOf(priorityPairs); + } +} + +record PriorityPair(List bots, int existingSamples, String reason) { + PriorityPair { + bots = List.copyOf(bots); + } +} diff --git a/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java b/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java new file mode 100644 index 0000000..ef871ba --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/RumbleSnapshotParser.java @@ -0,0 +1,234 @@ +package dev.robocode.rumble.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Validates the mutually dependent documents in one Rumble data checkout. + */ +final class RumbleSnapshotParser { + private static final Pattern COMMIT = Pattern.compile("[0-9a-f]{40}"); + private static final Pattern SHA_256 = Pattern.compile("sha256:[0-9a-f]{64}"); + private static final Pattern PROJECTION_ID = Pattern.compile("[0-9a-f]{64}"); + private static final Set ADVICE_REASONS = Set.of("new-bot", "under-sampled"); + + RumbleSnapshot parse(final RepositoryReader.RepositoryCheckout checkout, + final ClientConfiguration configuration) throws java.io.IOException { + final EnginePin engine = parseEngine(checkout.read("engine.json"), configuration.gameTypes()); + final BotCatalog catalog = parseCatalog(checkout.read("catalog.json"), configuration.botsRepository()); + final ClientRegistration registration = parseRegistration(checkout, configuration.clientId()); + final Map advice = new HashMap<>(); + for (final GameType gameType : configuration.gameTypes()) { + final String path = "matchmaking/matches_needed-" + gameType.contractName() + ".json"; + advice.put(gameType, parseAdvice(checkout.read(path), path, gameType, catalog)); + } + return new RumbleSnapshot(checkout.repository(), checkout.revision(), engine, catalog, registration, advice); + } + + private static EnginePin parseEngine(final String json, final Set selectedGameTypes) { + final JsonContract contract = JsonContract.parse(json, "engine.json"); + final int behaviorVersion = contract.integer("behaviorVersion", 1); + final String tankRoyaleVersion = contract.string("tankRoyaleVersion"); + final String image = contract.string("image"); + final JsonObject gameTypesObject = contract.object("gameTypes"); + final Map gameTypes = new HashMap<>(); + for (final GameType gameType : selectedGameTypes) { + final JsonElement value = gameTypesObject.get(gameType.contractName()); + if (value == null || !value.isJsonObject()) { + throw JsonContract.invalid("engine.json has no settings for " + gameType.contractName()); + } + final JsonContract settings = JsonContract.nested(value.getAsJsonObject(), + "engine.json.gameTypes." + gameType.contractName()); + final int rounds = settings.integer("rounds", 1); + final int participants = settings.integer("participants", 2); + final JsonArray battlefield = settings.array("battlefield"); + if (battlefield.size() != 2) { + throw JsonContract.invalid("engine.json battlefield must contain width and height"); + } + final int width = arrayInteger(battlefield, 0, "engine.json battlefield width", 1); + final int height = arrayInteger(battlefield, 1, "engine.json battlefield height", 1); + gameTypes.put(gameType, new GameTypeSettings(rounds, width, height, participants)); + } + return new EnginePin(behaviorVersion, tankRoyaleVersion, image, gameTypes); + } + + private static BotCatalog parseCatalog(final String json, final URI expectedBotsRepository) { + final JsonContract contract = JsonContract.parse(json, "catalog.json"); + final URI source = contract.httpsUri("source"); + if (!sourceBelongsToRepository(source, expectedBotsRepository)) { + throw JsonContract.invalid("catalog.json.source does not belong to the configured bots repository"); + } + final String sourceCommit = matching(contract.string("sourceCommit"), COMMIT, + "catalog.json.sourceCommit must be a full lowercase Git commit"); + final Map activeBots = new HashMap<>(); + for (final JsonElement element : contract.array("bots")) { + if (!element.isJsonObject()) { + throw JsonContract.invalid("catalog.json.bots must contain objects"); + } + final JsonContract bot = JsonContract.nested(element.getAsJsonObject(), "catalog.json bot"); + final String status = bot.string("status"); + if (!status.equals("active")) { + continue; + } + final CatalogBot entry = new CatalogBot(bot.string("name"), bot.string("version"), + bot.string("platform"), bot.string("path"), matching(bot.string("sourceHash"), SHA_256, + "catalog bot sourceHash must be sha256:<64 lowercase hex>")); + if (activeBots.putIfAbsent(entry.displayName(), entry) != null) { + throw JsonContract.invalid("catalog.json contains duplicate active bot " + entry.displayName()); + } + } + if (activeBots.isEmpty()) { + throw JsonContract.invalid("catalog.json contains no active bots"); + } + return new BotCatalog(source, sourceCommit, activeBots); + } + + private static ClientRegistration parseRegistration(final RepositoryReader.RepositoryCheckout checkout, + final String clientId) throws java.io.IOException { + ClientRegistration match = null; + for (final String path : checkout.listFiles("clients")) { + if (!path.endsWith(".json")) { + continue; + } + final JsonContract registration = JsonContract.parse(checkout.read(path), path); + final String account = registration.string("account"); + if (!path.equals("clients/" + account + ".json")) { + throw JsonContract.invalid(path + ".account must match its filename"); + } + final JsonArray clientIds = registration.array("clientIds"); + if (clientIds.isEmpty()) { + throw JsonContract.invalid(path + ".clientIds must not be empty"); + } + final Set uniqueClientIds = new HashSet<>(); + for (final JsonElement element : clientIds) { + if (!element.isJsonPrimitive() || !element.getAsJsonPrimitive().isString() + || element.getAsString().isBlank()) { + throw JsonContract.invalid(path + ".clientIds must contain non-blank strings"); + } + if (!uniqueClientIds.add(element.getAsString())) { + throw JsonContract.invalid(path + ".clientIds must not contain duplicates"); + } + if (element.getAsString().equals(clientId)) { + if (match != null) { + throw JsonContract.invalid("clientId is registered to more than one account: " + clientId); + } + match = new ClientRegistration(account, clientId); + } + } + } + if (match == null) { + throw JsonContract.invalid("clientId is not registered: " + clientId); + } + return match; + } + + private static MatchAdvice parseAdvice(final String json, final String path, final GameType expectedGameType, + final BotCatalog catalog) { + final JsonContract contract = JsonContract.parse(json, path); + final GameType actualGameType = GameType.fromContractName(contract.string("gameType")); + if (actualGameType != expectedGameType) { + throw JsonContract.invalid(path + " declares the wrong gameType"); + } + final String projectionId = matching(contract.string("projectionId"), PROJECTION_ID, + path + ".projectionId must be 64 lowercase hex characters"); + final int target = contract.integer("targetSamplesPerPairing", 1); + final List pairs = new ArrayList<>(); + final Set uniquePairs = new HashSet<>(); + for (final JsonElement element : contract.array("priorityPairs")) { + if (!element.isJsonObject()) { + throw JsonContract.invalid(path + ".priorityPairs must contain objects"); + } + final JsonContract pair = JsonContract.nested(element.getAsJsonObject(), path + " priority pair"); + final JsonArray bots = pair.array("bots"); + if (bots.size() != 2) { + throw JsonContract.invalid(path + " priority pair must identify two bots"); + } + final List catalogBots = new ArrayList<>(); + for (final JsonElement bot : bots) { + if (!bot.isJsonPrimitive() || !bot.getAsJsonPrimitive().isString()) { + throw JsonContract.invalid(path + " priority pair bot must be a string"); + } + final CatalogBot catalogBot = catalog.activeBots().get(bot.getAsString()); + if (catalogBot == null) { + throw JsonContract.invalid(path + " references an inactive or unknown bot: " + bot.getAsString()); + } + catalogBots.add(catalogBot); + } + if (catalogBots.get(0).equals(catalogBots.get(1))) { + throw JsonContract.invalid(path + " priority pair must contain distinct bots"); + } + final String pairIdentity = catalogBots.stream().map(CatalogBot::displayName).sorted() + .reduce((left, right) -> left + "\n" + right).orElseThrow(); + if (!uniquePairs.add(pairIdentity)) { + throw JsonContract.invalid(path + " contains a duplicate priority pair"); + } + final int have = pair.integer("have", 0); + if (have >= target) { + throw JsonContract.invalid(path + " priority pair is not under-sampled"); + } + final String reason = pair.string("reason"); + if (!ADVICE_REASONS.contains(reason)) { + throw JsonContract.invalid(path + " priority pair has unsupported reason: " + reason); + } + pairs.add(new PriorityPair(catalogBots, have, reason)); + } + return new MatchAdvice(actualGameType, projectionId, target, pairs); + } + + private static int arrayInteger(final JsonArray array, final int index, final String description, + final int minimum) { + final JsonElement value = array.get(index); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { + throw JsonContract.invalid(description + " must be an integer"); + } + try { + final int result = value.getAsBigDecimal().intValueExact(); + if (result < minimum) { + throw JsonContract.invalid(description + " must be at least " + minimum); + } + return result; + } catch (ArithmeticException exception) { + throw JsonContract.invalid(description + " must be an integer", exception); + } + } + + private static String matching(final String value, final Pattern pattern, final String message) { + if (!pattern.matcher(value).matches()) { + throw JsonContract.invalid(message); + } + return value; + } + + private static boolean sourceBelongsToRepository(final URI source, final URI repository) { + final String repositoryPath = stripGitSuffix(stripTrailingSlash(repository.getPath())); + final String sourcePath = source.getPath(); + if (repository.getHost().equalsIgnoreCase("github.com") + && source.getHost().equalsIgnoreCase("raw.githubusercontent.com")) { + return sourcePath.startsWith(repositoryPath + "/"); + } + return repository.getHost().equalsIgnoreCase(source.getHost()) + && sourcePath.startsWith(repositoryPath + "/"); + } + + private static String stripGitSuffix(final String value) { + return value.endsWith(".git") ? value.substring(0, value.length() - 4) : value; + } + + private static String stripTrailingSlash(final String value) { + String result = value; + while (result.endsWith("/")) { + result = result.substring(0, result.length() - 1); + } + return result; + } +} diff --git a/src/main/java/dev/robocode/rumble/client/RumbleSynchronizer.java b/src/main/java/dev/robocode/rumble/client/RumbleSynchronizer.java new file mode 100644 index 0000000..57ee0b4 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/RumbleSynchronizer.java @@ -0,0 +1,84 @@ +package dev.robocode.rumble.client; + +import java.io.IOException; +import java.net.URI; +import java.util.HashSet; +import java.util.Set; + +/** + * Resolves the canonical data repository and validates one ranked input snapshot. + */ +final class RumbleSynchronizer { + private static final int MAX_CANONICAL_HOPS = 5; + + private final RepositoryReader repositoryReader; + private final RumbleSnapshotParser parser; + + RumbleSynchronizer(final RepositoryReader repositoryReader) { + this(repositoryReader, new RumbleSnapshotParser()); + } + + RumbleSynchronizer(final RepositoryReader repositoryReader, final RumbleSnapshotParser parser) { + this.repositoryReader = repositoryReader; + this.parser = parser; + } + + RumbleSnapshot synchronize(final ClientConfiguration configuration) throws IOException { + try (RepositoryReader.RepositoryCheckout checkout = openCanonical(configuration.dataRepository())) { + return parser.parse(checkout, configuration); + } + } + + private RepositoryReader.RepositoryCheckout openCanonical(final URI initialRepository) throws IOException { + final Set visited = new HashSet<>(); + URI current = initialRepository; + for (int hop = 0; hop < MAX_CANONICAL_HOPS; hop++) { + final String identity = repositoryIdentity(current); + if (!visited.add(identity)) { + throw new IllegalArgumentException("Canonical repository pointer contains a cycle at " + current); + } + final RepositoryReader.RepositoryCheckout checkout = repositoryReader.checkout(current); + try { + final JsonContract pointer = JsonContract.parse(checkout.read("wellknown/rumble.json"), + "wellknown/rumble.json"); + final URI canonical = pointer.httpsUri("canonical"); + final String movedTo = pointer.nullableString("movedTo"); + final URI target = movedTo == null ? canonical : parseHttpsUri(movedTo, "wellknown/rumble.json.movedTo"); + if (repositoryIdentity(target).equals(identity)) { + return checkout; + } + current = target; + } catch (IOException | RuntimeException exception) { + checkout.close(); + throw exception; + } + checkout.close(); + } + throw new IllegalArgumentException("Canonical repository pointer exceeds " + MAX_CANONICAL_HOPS + " hops"); + } + + private static URI parseHttpsUri(final String value, final String field) { + final JsonContract wrapper = JsonContract.parse( + "{\"schemaVersion\":1,\"value\":\"" + escapeJson(value) + "\"}", field); + return wrapper.httpsUri("value"); + } + + private static String escapeJson(final String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private static String repositoryIdentity(final URI repository) { + final URI normalized = repository.normalize(); + String path = normalized.getPath(); + while (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + if (path.endsWith(".git")) { + path = path.substring(0, path.length() - 4); + } + final int port = normalized.getPort(); + return normalized.getScheme().toLowerCase(java.util.Locale.ROOT) + "://" + + normalized.getHost().toLowerCase(java.util.Locale.ROOT) + + (port < 0 ? "" : ":" + port) + path; + } +} diff --git a/src/test/java/dev/robocode/rumble/client/ClientConfigurationLoaderTest.java b/src/test/java/dev/robocode/rumble/client/ClientConfigurationLoaderTest.java index d6e37ae..5ff845f 100644 --- a/src/test/java/dev/robocode/rumble/client/ClientConfigurationLoaderTest.java +++ b/src/test/java/dev/robocode/rumble/client/ClientConfigurationLoaderTest.java @@ -1,6 +1,7 @@ package dev.robocode.rumble.client; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Tag; @@ -9,6 +10,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Set; class ClientConfigurationLoaderTest { private final ClientConfigurationLoader loader = new ClientConfigurationLoader(); @@ -16,10 +18,14 @@ class ClientConfigurationLoaderTest { @Test @Tag("Unit") void testUnitPositive_loadsValidPracticeConfiguration() throws IOException { - final ClientConfiguration configuration = loader.load(writeConfiguration("practice", "registered-client")); + final Path configurationPath = writeConfiguration("practice", "registered-client"); + final ClientConfiguration configuration = loader.load(configurationPath); assertEquals("registered-client", configuration.clientId()); assertEquals(ClientMode.PRACTICE, configuration.mode()); + assertEquals(Set.of(GameType.ONE_VS_ONE, GameType.TWIN_DUEL, GameType.MELEE), configuration.gameTypes()); + assertEquals(configurationPath.getParent().resolve(".rumble-client").toAbsolutePath().normalize(), + configuration.workDirectory()); } @Test @@ -70,6 +76,21 @@ void testUnitNegative_rejectsEmptyGameTypes() throws IOException { assertThrows(IllegalArgumentException.class, () -> loader.load(configurationPath)); } + @Test + @Tag("Unit") + void testUnitPositive_defaultsWorkDirectoryForExistingSchemaOneConfiguration() throws IOException { + final Path configurationPath = Files.createTempFile("rumble-client", ".json"); + final String legacyConfiguration = validConfiguration("ranked", "registered-client") + .replace(",\n \"workDirectory\": \".rumble-client\"", ""); + assertFalse(legacyConfiguration.contains("workDirectory")); + Files.writeString(configurationPath, legacyConfiguration); + + final ClientConfiguration configuration = loader.load(configurationPath); + + assertEquals(configurationPath.getParent().resolve(".rumble-client").toAbsolutePath().normalize(), + configuration.workDirectory()); + } + 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)); @@ -86,7 +107,8 @@ private static String validConfiguration(final String mode, final String clientI "myBots": [], "gameTypes": ["1v1", "twinduel", "melee"], "battlesPerSession": 50, - "mode": "%s" + "mode": "%s", + "workDirectory": ".rumble-client" } """.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 index 3ae72ac..0d1e29d 100644 --- a/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java +++ b/src/test/java/dev/robocode/rumble/client/RumbleClientTest.java @@ -18,7 +18,8 @@ void testUnitPositive_printsHelpWithoutConfiguration() throws IOException { RumbleClient.run(new String[] {"--help"}, new PrintStream(bytes)); - assertTrue(bytes.toString().contains("Usage: rumble-client --validate-config [path]")); + assertTrue(bytes.toString().contains("rumble-client --validate-config [path]")); + assertTrue(bytes.toString().contains("rumble-client --sync [path]")); } @Test diff --git a/src/test/java/dev/robocode/rumble/client/RumbleSynchronizerTest.java b/src/test/java/dev/robocode/rumble/client/RumbleSynchronizerTest.java new file mode 100644 index 0000000..0f535bc --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/RumbleSynchronizerTest.java @@ -0,0 +1,195 @@ +package dev.robocode.rumble.client; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RumbleSynchronizerTest { + private static final URI PREVIOUS_REPOSITORY = URI.create("https://github.com/example/previous-data"); + private static final URI CANONICAL_REPOSITORY = URI.create("https://github.com/example/rumble-data"); + private static final URI BOTS_REPOSITORY = URI.create("https://github.com/example/rumble-bots"); + + @Test + @Tag("RCL-002") + void testRCL002_IntegrationPositive_followsCanonicalPointerAndValidatesSnapshot() throws IOException { + final InMemoryRepositoryReader repositories = validRepositories(); + + final RumbleSnapshot snapshot = new RumbleSynchronizer(repositories).synchronize(configuration()); + + assertEquals(CANONICAL_REPOSITORY, snapshot.canonicalDataRepository()); + assertEquals("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", snapshot.dataRevision()); + assertEquals(1, snapshot.engine().behaviorVersion()); + assertEquals("alice", snapshot.registration().account()); + assertEquals(List.of(PREVIOUS_REPOSITORY, CANONICAL_REPOSITORY), repositories.requestedRepositories()); + assertEquals(1, snapshot.advice().get(GameType.ONE_VS_ONE).priorityPairs().size()); + } + + @Test + @Tag("RCL-002") + void testRCL002_IntegrationNegative_rejectsUnknownAdviceSchemaBeforeReturningSnapshot() { + final InMemoryRepositoryReader repositories = validRepositories(); + repositories.replace(CANONICAL_REPOSITORY, "matchmaking/matches_needed-1v1.json", + validAdvice().replace("\"schemaVersion\": 1", "\"schemaVersion\": 2")); + + assertThrows(IllegalArgumentException.class, + () -> new RumbleSynchronizer(repositories).synchronize(configuration())); + } + + @Test + @Tag("RCL-002") + void testRCL002_IntegrationNegative_rejectsUnregisteredClientIdentity() { + final InMemoryRepositoryReader repositories = validRepositories(); + repositories.replace(CANONICAL_REPOSITORY, "clients/alice.json", + """ + {"schemaVersion": 1, "account": "alice", "clientIds": ["another-client"]} + """); + + assertThrows(IllegalArgumentException.class, + () -> new RumbleSynchronizer(repositories).synchronize(configuration())); + } + + @Test + @Tag("RCL-002") + void testRCL002_IntegrationNegative_rejectsCatalogFromDifferentBotRepository() { + final InMemoryRepositoryReader repositories = validRepositories(); + repositories.replace(CANONICAL_REPOSITORY, "catalog.json", + repositories.read(CANONICAL_REPOSITORY, "catalog.json") + .replace("raw.githubusercontent.com/example/rumble-bots", + "raw.githubusercontent.com/example/unreviewed-bots")); + + assertThrows(IllegalArgumentException.class, + () -> new RumbleSynchronizer(repositories).synchronize(configuration())); + } + + private static ClientConfiguration configuration() { + return new ClientConfiguration(BOTS_REPOSITORY, PREVIOUS_REPOSITORY, "alice-desktop", Set.of(), + Set.of(GameType.ONE_VS_ONE), 10, ClientMode.RANKED, Path.of("work")); + } + + private static InMemoryRepositoryReader validRepositories() { + final InMemoryRepositoryReader repositories = new InMemoryRepositoryReader(); + repositories.add(PREVIOUS_REPOSITORY, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Map.of( + "wellknown/rumble.json", """ + {"schemaVersion": 1, "canonical": "https://github.com/example/previous-data", "movedTo": "https://github.com/example/rumble-data"} + """)); + final Map canonicalFiles = new LinkedHashMap<>(); + canonicalFiles.put("wellknown/rumble.json", """ + {"schemaVersion": 1, "canonical": "https://github.com/example/rumble-data", "movedTo": null} + """); + canonicalFiles.put("engine.json", """ + { + "schemaVersion": 1, + "behaviorVersion": 1, + "tankRoyaleVersion": "unreleased", + "image": "ghcr.io/example/tank-royale:unreleased", + "gameTypes": {"1v1": {"rounds": 35, "battlefield": [800, 600], "participants": 2}} + } + """); + canonicalFiles.put("catalog.json", """ + { + "schemaVersion": 1, + "source": "https://raw.githubusercontent.com/example/rumble-bots/main/bots/index.json", + "sourceCommit": "cccccccccccccccccccccccccccccccccccccccc", + "bots": [ + {"name": "Alpha", "version": "1.0", "platform": "Java", "path": "bots/java/Alpha", "sourceHash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "status": "active"}, + {"name": "Bravo", "version": "1.0", "platform": "Python", "path": "bots/python/Bravo", "sourceHash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "status": "active"} + ] + } + """); + canonicalFiles.put("clients/alice.json", """ + {"schemaVersion": 1, "account": "alice", "clientIds": ["alice-desktop"]} + """); + canonicalFiles.put("matchmaking/matches_needed-1v1.json", validAdvice()); + repositories.add(CANONICAL_REPOSITORY, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", canonicalFiles); + return repositories; + } + + private static String validAdvice() { + return """ + { + "schemaVersion": 1, + "projectionId": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "gameType": "1v1", + "targetSamplesPerPairing": 6, + "priorityPairs": [{"bots": ["Alpha 1.0", "Bravo 1.0"], "have": 0, "reason": "new-bot"}] + } + """; + } + + private static final class InMemoryRepositoryReader implements RepositoryReader { + private final Map repositories = new LinkedHashMap<>(); + private final List requestedRepositories = new ArrayList<>(); + + void add(final URI repository, final String revision, final Map files) { + repositories.put(repository, new RepositoryData(revision, new LinkedHashMap<>(files))); + } + + void replace(final URI repository, final String path, final String content) { + repositories.get(repository).files().put(path, content); + } + + List requestedRepositories() { + return List.copyOf(requestedRepositories); + } + + String read(final URI repository, final String path) { + return repositories.get(repository).files().get(path); + } + + @Override + public RepositoryCheckout checkout(final URI repository) throws IOException { + final RepositoryData data = repositories.get(repository); + if (data == null) { + throw new IOException("Unknown repository " + repository); + } + requestedRepositories.add(repository); + return new RepositoryCheckout() { + @Override + public URI repository() { + return repository; + } + + @Override + public String revision() { + return data.revision(); + } + + @Override + public String read(final String relativePath) throws IOException { + final String content = data.files().get(relativePath); + if (content == null) { + throw new IOException("Missing " + relativePath); + } + return content; + } + + @Override + public List listFiles(final String relativeDirectory) { + final String prefix = relativeDirectory + "/"; + return data.files().keySet().stream() + .filter(path -> path.startsWith(prefix) && !path.substring(prefix.length()).contains("/")) + .sorted() + .toList(); + } + + @Override + public void close() { + } + }; + } + } + + private record RepositoryData(String revision, Map files) { + } +}