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 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. Ranked battle selection uses a recorded random seed, prioritizes under-sampled pairings involving `myBots`, and falls back to distinct active catalog bots when no advice is available. Battle Runner execution, persistence, issue-ops transport, and the runtime container are added in subsequent CH-012 tasks.
The client validates configuration and can synchronize the current ranked input snapshot. Run `./gradlew run --args="--validate-config"` to check local settings, then run `./gradlew run --args="--sync"` to resolve the canonical data repository, validate its engine pin, catalog, client registration, and matchmaking advice, and prepare an immutable bot cache at the catalog's exact source commit. Every cached source tree is checked against its catalog SHA-256 before it can be used. Ranked battle selection uses a recorded random seed, prioritizes under-sampled pairings involving `myBots`, and falls back to distinct active catalog bots when no advice is available. Battle Runner execution, persistence, issue-ops transport, and the runtime container are added in subsequent CH-012 tasks.

## Configuration

Expand Down
117 changes: 117 additions & 0 deletions src/main/java/dev/robocode/rumble/client/BotCachePreparer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package dev.robocode.rumble.client;

import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

/**
* Materializes and validates the immutable bot sources pinned by a ranked snapshot.
*/
final class BotCachePreparer {
private final RepositoryReader repositoryReader;

BotCachePreparer(final RepositoryReader repositoryReader) {
this.repositoryReader = repositoryReader;
}

PreparedBotCache prepare(final RumbleSnapshot snapshot, final ClientConfiguration configuration)
throws IOException {
if (configuration.mode() != ClientMode.RANKED) {
throw new IllegalArgumentException("Bot cache preparation requires ranked mode");
}
final String sourceCommit = snapshot.catalog().sourceCommit();
final Path cacheParent = configuration.workDirectory().resolve("cache/bots");
final Path cacheDirectory = cacheParent.resolve(sourceCommit);
if (Files.exists(cacheDirectory)) {
return validateCache(snapshot.catalog(), cacheDirectory);
}

Files.createDirectories(cacheParent);
final Path stagingDirectory = Files.createTempDirectory(cacheParent, sourceCommit + "-");
try {
try (RepositoryReader.RepositoryCheckout checkout = repositoryReader.checkout(
configuration.botsRepository(), sourceCommit)) {
for (final CatalogBot bot : sortedBots(snapshot.catalog())) {
checkout.copyDirectory(bot.path(), stagingDirectory.resolve(bot.path()));
}
}
validateCache(snapshot.catalog(), stagingDirectory);
publish(stagingDirectory, cacheDirectory);
return validateCache(snapshot.catalog(), cacheDirectory);
} finally {
deleteTree(stagingDirectory);
}
}

private static PreparedBotCache validateCache(final BotCatalog catalog, final Path cacheDirectory)
throws IOException {
final Map<CatalogBot, Path> paths = new LinkedHashMap<>();
for (final CatalogBot bot : sortedBots(catalog)) {
final Path botDirectory = cacheDirectory.resolve(bot.path()).normalize();
if (!botDirectory.startsWith(cacheDirectory)) {
throw new IOException("Bot cache path escapes its source commit: " + bot.path());
}
final String actualHash = SourceTreeHash.sha256(botDirectory);
if (!actualHash.equals(bot.sourceHash())) {
throw new IllegalArgumentException("Bot source hash mismatch for " + bot.displayName()
+ ": expected " + bot.sourceHash() + " but found " + actualHash);
}
paths.put(bot, botDirectory);
}
return new PreparedBotCache(catalog.sourceCommit(), paths);
}

private static List<CatalogBot> sortedBots(final BotCatalog catalog) {
return catalog.activeBots().values().stream()
.sorted(Comparator.comparing(CatalogBot::displayName))
.toList();
}

private static void publish(final Path stagingDirectory, final Path cacheDirectory) throws IOException {
try {
Files.move(stagingDirectory, cacheDirectory, StandardCopyOption.ATOMIC_MOVE);
return;
} catch (AtomicMoveNotSupportedException ignored) {
// The staging directory has the same parent, so a regular move remains safely scoped.
} catch (FileAlreadyExistsException exception) {
validateExistingDirectory(cacheDirectory);
return;
}
try {
Files.move(stagingDirectory, cacheDirectory);
} catch (FileAlreadyExistsException exception) {
validateExistingDirectory(cacheDirectory);
}
}

private static void validateExistingDirectory(final Path cacheDirectory) throws IOException {
if (!Files.isDirectory(cacheDirectory) || Files.isSymbolicLink(cacheDirectory)) {
throw new IOException("Bot cache commit path is not a regular directory: " + cacheDirectory);
}
}

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()) {
Files.deleteIfExists(path);
}
}
}
}

record PreparedBotCache(String sourceCommit, Map<CatalogBot, Path> bots) {
PreparedBotCache {
bots = Map.copyOf(bots);
}
}
41 changes: 41 additions & 0 deletions src/main/java/dev/robocode/rumble/client/GitRepositoryReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
Expand All @@ -26,6 +27,25 @@ public RepositoryCheckout checkout(final URI repository) throws IOException {
}
}

@Override
public RepositoryCheckout checkout(final URI repository, final String revision) throws IOException {
final Path directory = Files.createTempDirectory("rumble-client-repository-");
try {
runGit("init", "--quiet", directory.toString());
runGit("-C", directory.toString(), "remote", "add", "origin", repository.toString());
runGit("-C", directory.toString(), "fetch", "--quiet", "--depth", "1", "origin", revision);
runGit("-C", directory.toString(), "checkout", "--quiet", "--detach", "FETCH_HEAD");
final String actualRevision = runGit("-C", directory.toString(), "rev-parse", "HEAD").trim();
if (!actualRevision.equals(revision)) {
throw new IOException("Repository returned " + actualRevision + " for requested commit " + revision);
}
return new Checkout(repository, directory, actualRevision);
} 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);
Expand Down Expand Up @@ -79,6 +99,27 @@ public List<String> listFiles(final String relativeDirectory) throws IOException
}
}

@Override
public void copyDirectory(final String relativeDirectory, final Path destination) throws IOException {
final Path source = resolveInsideCheckout(relativeDirectory);
if (!Files.isDirectory(source)) {
throw new IOException("Repository path is not a directory: " + relativeDirectory);
}
try (Stream<Path> paths = Files.walk(source)) {
for (final Path path : paths.sorted().toList()) {
if (Files.isSymbolicLink(path)) {
throw new IOException("Bot source must not contain symbolic links: " + relativeDirectory);
}
final Path target = destination.resolve(source.relativize(path).toString());
if (Files.isDirectory(path)) {
Files.createDirectories(target);
} else if (Files.isRegularFile(path)) {
Files.copy(path, target, StandardCopyOption.COPY_ATTRIBUTES);
}
}
}
}

private Path resolveInsideCheckout(final String relativePath) throws IOException {
final Path requested = Path.of(relativePath);
if (requested.isAbsolute()) {
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/dev/robocode/rumble/client/RepositoryReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.util.List;

/**
Expand All @@ -10,6 +11,15 @@
interface RepositoryReader {
RepositoryCheckout checkout(URI repository) throws IOException;

default RepositoryCheckout checkout(final URI repository, final String revision) throws IOException {
final RepositoryCheckout checkout = checkout(repository);
if (!checkout.revision().equals(revision)) {
checkout.close();
throw new IOException("Repository revision does not match requested commit " + revision);
}
return checkout;
}

/**
* A read-only repository revision. Implementations are not required to be thread-safe.
*/
Expand All @@ -22,6 +32,10 @@ interface RepositoryCheckout extends AutoCloseable {

List<String> listFiles(String relativeDirectory) throws IOException;

default void copyDirectory(final String relativeDirectory, final Path destination) throws IOException {
throw new IOException("Repository checkout does not support directory export");
}

@Override
void close() throws IOException;
}
Expand Down
12 changes: 7 additions & 5 deletions src/main/java/dev/robocode/rumble/client/RumbleClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ static void run(final String[] arguments, final PrintStream output) throws IOExc
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);
final GitRepositoryReader repositoryReader = new GitRepositoryReader();
final RumbleSnapshot snapshot = new RumbleSynchronizer(repositoryReader).synchronize(configuration);
final PreparedBotCache botCache = new BotCachePreparer(repositoryReader).prepare(snapshot, 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());
output.printf("Accepted behavior version %d, cached %d active bots at %s, and advice for %d game types.%n",
snapshot.engine().behaviorVersion(), botCache.bots().size(), botCache.sourceCommit(),
snapshot.advice().size());
return;
}
output.printf("Configuration %s is valid for %s mode.%n", configurationPath, configuration.mode().displayName());
Expand All @@ -67,6 +69,6 @@ private static void printHelp(final PrintStream output) {
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.");
output.println("Use --sync to validate the current ranked snapshot and prepare its immutable bot cache.");
}
}
68 changes: 68 additions & 0 deletions src/main/java/dev/robocode/rumble/client/SourceTreeHash.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package dev.robocode.rumble.client;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.stream.Stream;

/**
* Computes the canonical source-tree identity shared with the Rumble bot catalog.
*/
final class SourceTreeHash {
private SourceTreeHash() {
}

static String sha256(final Path directory) throws IOException {
final MessageDigest digest = sha256Digest();
for (final Path file : sourceFiles(directory)) {
final String relativePath = directory.relativize(file).toString().replace('\\', '/');
digest.update(relativePath.getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0);
digest.update(Files.readAllBytes(file));
digest.update((byte) 0);
}
return "sha256:" + HexFormat.of().formatHex(digest.digest());
}

private static List<Path> sourceFiles(final Path directory) throws IOException {
if (!Files.isDirectory(directory) || Files.isSymbolicLink(directory)) {
throw new IOException("Bot cache path is not a regular directory: " + directory);
}
try (Stream<Path> paths = Files.walk(directory)) {
final List<Path> allPaths = paths.sorted(Comparator.comparing(
path -> directory.relativize(path).toString().replace('\\', '/'))).toList();
for (final Path path : allPaths) {
if (Files.isSymbolicLink(path)) {
throw new IOException("Bot source must not contain symbolic links: " + path);
}
}
return allPaths.stream()
.filter(Files::isRegularFile)
.filter(path -> !containsPycache(directory.relativize(path)))
.toList();
}
}

private static boolean containsPycache(final Path relativePath) {
for (final Path segment : relativePath) {
if (segment.toString().equals("__pycache__")) {
return true;
}
}
return false;
}

private static MessageDigest sha256Digest() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
}
Loading
Loading