Initial minecraft-builder-mcp prototype and Gothic hall build

This commit is contained in:
Emil
2026-09-12 23:22:20 +03:00
commit 6682668c16
103 changed files with 10064 additions and 0 deletions
@@ -0,0 +1,300 @@
package dev.minecraftbuilder.camera;
import com.google.gson.JsonObject;
import com.mojang.blaze3d.platform.NativeImage;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.minecraft.client.CameraType;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/** One observer, one active request, bounded retained screenshots. No world writes or client teleport hacks. */
public final class CameraClient implements ClientModInitializer, CameraHttpServer.Backend {
private static final Logger LOGGER = LoggerFactory.getLogger("minecraft-builder-camera");
private static final long TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(20);
private static volatile CameraClient instance;
private final AtomicReference<Job> active = new AtomicReference<>();
private final LinkedHashMap<String, Job> jobs = new LinkedHashMap<>();
private final ExecutorService encoder = Executors.newSingleThreadExecutor(Thread.ofPlatform()
.daemon(true).name("mcb-camera-encoder").factory());
private final ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform()
.daemon(true).name("mcb-camera-watchdog").factory());
private volatile JsonObject cachedHealth = CameraHttpServer.error("starting", "Waiting for client tick");
private CameraHttpServer http;
@Override public void onInitializeClient() {
String token = System.getenv("MCB_CAMERA_TOKEN");
if (token == null || token.isBlank()) {
LOGGER.info("Camera HTTP disabled: set MCB_CAMERA_TOKEN to enable the dedicated observer service");
encoder.shutdownNow();
watchdog.shutdownNow();
return;
}
try {
int port = Integer.parseInt(System.getenv().getOrDefault("MCB_CAMERA_PORT", "8766"));
if (port < 1024 || port > 65535) throw new IllegalArgumentException("Invalid camera port");
http = new CameraHttpServer(port, token, this);
instance = this;
ClientTickEvents.END_CLIENT_TICK.register(this::tick);
ClientLifecycleEvents.CLIENT_STOPPING.register(this::stop);
watchdog.scheduleAtFixedRate(this::expire, 1, 1, TimeUnit.SECONDS);
http.start();
LOGGER.info("Camera HTTP listening on 127.0.0.1:{} (authenticated)", port);
} catch (Exception exception) {
LOGGER.error("Camera service could not start: {}", exception.getClass().getSimpleName());
encoder.shutdownNow();
watchdog.shutdownNow();
}
}
@Override public JsonObject health() { return cachedHealth.deepCopy(); }
@Override public synchronized JsonObject submit(CaptureRequest request) {
Job job = new Job(request);
if (!active.compareAndSet(null, job)) return CameraHttpServer.error("camera_busy", "One capture is already active");
jobs.put(job.id, job);
while (jobs.size() > 4) jobs.remove(jobs.keySet().iterator().next());
return job.result.deepCopy();
}
@Override public synchronized JsonObject poll(String captureId) {
Job job = jobs.get(captureId);
return job == null ? null : job.result.deepCopy();
}
private synchronized void expire() {
Job job = active.get();
if (job != null && System.nanoTime() - job.createdNanos >= TIMEOUT_NANOS)
job.fail("capture_timeout", "Scene did not become ready within 20 seconds; no fresh image returned");
// Keep an expired active job until a client tick can restore its view safely.
jobs.values().removeIf(value -> value != active.get()
&& System.nanoTime() - value.createdNanos > TimeUnit.MINUTES.toNanos(2));
}
private void tick(Minecraft client) {
Job job = active.get();
JsonObject health = new JsonObject();
health.addProperty("status", "ok");
health.addProperty("connected", client.level != null && client.player != null);
health.addProperty("spectator", client.player != null && client.player.isSpectator());
health.addProperty("busy", job != null);
health.addProperty("updatedAt", Instant.now().toString());
if (client.level != null) health.addProperty("dimension", dimension(client));
if (client.player != null) health.addProperty("playerId", client.player.getUUID().toString());
cachedHealth = health;
if (job == null) return;
if (job.done) { restore(client, job); active.compareAndSet(job, null); return; }
if (client.player == null || client.level == null) { job.fail("disconnected", "Camera client is not in a world"); return; }
if (!client.player.isSpectator()) { job.fail("spectator_required", "Camera account must be in spectator mode"); return; }
if (client.gui.screen() != null || client.gui.overlay() != null || client.isPaused()) {
job.fail("view_obstructed", "Close menus and overlays in the observer client"); return;
}
if (!job.initialized) {
// Paper owns teleports. Wait for its position/dimension packet before adjusting the view.
if (!matchesPosition(client, job.request)) return;
job.player = client.player;
job.level = client.level;
job.saved = new SavedView(client.gui.hud.isHidden(), client.options.fov().get(),
client.options.bobView().get(), client.options.fovEffectScale().get(),
client.options.getCameraType(), client.player.getYRot(), client.player.getXRot());
if (!client.gui.hud.isHidden()) client.gui.hud.toggle();
client.options.fov().set(job.request.fov());
client.options.bobView().set(false);
client.options.fovEffectScale().set(0.0);
client.options.setCameraType(CameraType.FIRST_PERSON);
client.player.setYRot(job.request.yaw());
client.player.setXRot(job.request.pitch());
client.player.setOldRot();
job.initialized = true;
}
if (client.player != job.player || client.level != job.level || !matchesPosition(client, job.request)) {
job.fail("camera_moved", "Observer moved or changed world during capture"); return;
}
if (client.getCameraEntity() != client.player) {
job.fail("spectating_entity", "Observer must use its own camera, not another entity"); return;
}
if (!matchesView(client, job.request)) { job.fail("view_changed", "Observer view changed during capture"); return; }
if (chunksLoaded(client)) job.stableTicks++; else { job.stableTicks = 0; job.readyFrames = 0; }
}
/** Called after GameRenderer.render, on Minecraft's render thread. Uses the supported GPU screenshot API. */
public static void afterRender(boolean renderWorld) {
CameraClient worker = instance;
if (worker != null && renderWorld) worker.rendered(Minecraft.getInstance());
}
private void rendered(Minecraft client) {
Job job = active.get();
if (job == null || job.done || !job.initialized || job.readbackStarted || job.stableTicks < 20) return;
if (client.player != job.player || client.level != job.level || client.gui.screen() != null
|| client.gui.overlay() != null || !matchesPosition(client, job.request) || !matchesView(client, job.request)) {
job.fail("view_changed", "Observer view changed before frame capture"); return;
}
if (!client.gameRenderer.mainCamera().isInitialized() || !chunksLoaded(client)
|| client.levelRenderer.sectionRenderDispatcher() == null || !client.levelRenderer.hasRenderedAllSections()) {
job.readyFrames = 0; return;
}
if (++job.readyFrames < 3) return;
var target = client.gameRenderer.mainRenderTarget();
if (target.width <= 0 || target.height <= 0 || (long) target.width * target.height > 16_777_216) {
job.fail("framebuffer_size", "Observer framebuffer is empty or exceeds 16 megapixels"); return;
}
job.readbackStarted = true;
JsonObject metadata = new JsonObject();
metadata.addProperty("capturedAt", Instant.now().toString());
metadata.addProperty("dimension", dimension(client));
metadata.addProperty("x", client.player.getX());
metadata.addProperty("y", client.player.getY());
metadata.addProperty("z", client.player.getZ());
metadata.addProperty("eyeY", client.gameRenderer.mainCamera().position().y);
metadata.addProperty("yaw", client.gameRenderer.mainCamera().yRot());
metadata.addProperty("pitch", client.gameRenderer.mainCamera().xRot());
metadata.addProperty("fov", job.request.fov());
metadata.addProperty("readiness", "local_chunks_and_render_queue_stable");
metadata.addProperty("serverRevisionVerified", false);
metadata.addProperty("loadedChunkRadius", 1);
metadata.addProperty("stabilizationTicks", job.stableTicks);
metadata.addProperty("stabilizationFrames", job.readyFrames);
if (job.request.afterOperationId() != null) metadata.addProperty("afterOperationId", job.request.afterOperationId());
try {
Screenshot.takeScreenshot(target, image -> {
if (job.done || encoder.isShutdown()) { image.close(); return; }
try { encoder.execute(() -> encode(job, image, metadata)); }
catch (RuntimeException error) { image.close(); job.fail("encoding_unavailable", "Image encoder unavailable"); }
});
} catch (Exception exception) {
job.fail("readback_failed", "Could not read observer framebuffer");
}
}
private static void encode(Job job, NativeImage image, JsonObject metadata) {
try (image; ByteArrayOutputStream bytes = new ByteArrayOutputStream()) {
if (job.done) return;
int sourceWidth = image.getWidth(), sourceHeight = image.getHeight();
double scale = Math.min(1, Math.min((double) job.request.width() / sourceWidth,
(double) job.request.height() / sourceHeight));
int width = Math.max(1, (int) Math.round(sourceWidth * scale));
int height = Math.max(1, (int) Math.round(sourceHeight * scale));
BufferedImage source = new BufferedImage(sourceWidth, sourceHeight, BufferedImage.TYPE_INT_ARGB);
source.setRGB(0, 0, sourceWidth, sourceHeight, image.getPixels(), 0, sourceWidth);
BufferedImage output = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = output.createGraphics();
try {
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
graphics.drawImage(source, 0, 0, width, height, null);
} finally { graphics.dispose(); source.flush(); }
if (!ImageIO.write(output, "png", bytes)) throw new IllegalStateException("PNG writer unavailable");
output.flush();
if (bytes.size() > 8 * 1024 * 1024) { job.fail("image_too_large", "PNG exceeds 8 MiB limit"); return; }
JsonObject result = metadata.deepCopy();
result.addProperty("status", "completed");
result.addProperty("mimeType", "image/png");
result.addProperty("width", width);
result.addProperty("height", height);
result.addProperty("sourceWidth", sourceWidth);
result.addProperty("sourceHeight", sourceHeight);
result.addProperty("imageBase64", Base64.getEncoder().encodeToString(bytes.toByteArray()));
job.finish(result);
} catch (Exception exception) { job.fail("encoding_failed", "Could not encode observer screenshot"); }
}
private static String dimension(Minecraft client) { return client.level.dimension().identifier().toString(); }
private static boolean matchesPosition(Minecraft client, CaptureRequest request) {
return client.player != null && client.level != null
&& (request.dimension() == null || request.dimension().equals(dimension(client)))
&& Math.abs(client.player.getX() - request.x()) <= 0.05
&& Math.abs(client.player.getY() - request.y()) <= 0.05
&& Math.abs(client.player.getZ() - request.z()) <= 0.05;
}
private static boolean matchesView(Minecraft client, CaptureRequest request) {
return client.gui.hud.isHidden() && client.options.getCameraType() == CameraType.FIRST_PERSON
&& client.options.fov().get() == request.fov()
&& Math.abs(Math.IEEEremainder(client.player.getYRot() - request.yaw(), 360)) <= 0.1
&& Math.abs(client.player.getXRot() - request.pitch()) <= 0.1;
}
private static boolean chunksLoaded(Minecraft client) {
int cx = Math.floorDiv(client.player.blockPosition().getX(), 16);
int cz = Math.floorDiv(client.player.blockPosition().getZ(), 16);
for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++)
if (client.level.getChunkSource().getChunk(cx + dx, cz + dz, ChunkStatus.FULL, false) == null) return false;
return true;
}
private static void restore(Minecraft client, Job job) {
SavedView saved = job.saved;
if (saved == null) return;
if (client.gui.hud.isHidden() != saved.hudHidden) client.gui.hud.toggle();
client.options.fov().set(saved.fov);
client.options.bobView().set(saved.bobView);
client.options.fovEffectScale().set(saved.fovEffectScale);
client.options.setCameraType(saved.cameraType);
if (client.player == job.player) {
client.player.setYRot(saved.yaw);
client.player.setXRot(saved.pitch);
client.player.setOldRot();
}
job.saved = null;
}
private void stop(Minecraft client) {
instance = null;
Job job = active.getAndSet(null);
if (job != null) { job.fail("client_stopping", "Camera client is stopping"); restore(client, job); }
if (http != null) http.close();
watchdog.shutdownNow();
encoder.shutdown();
}
private record SavedView(boolean hudHidden, int fov, boolean bobView, double fovEffectScale,
CameraType cameraType, float yaw, float pitch) {}
private static final class Job {
final String id = UUID.randomUUID().toString();
final CaptureRequest request;
final long createdNanos = System.nanoTime();
volatile JsonObject result;
volatile boolean done;
boolean initialized, readbackStarted;
int stableTicks, readyFrames;
SavedView saved;
LocalPlayer player;
ClientLevel level;
Job(CaptureRequest request) {
this.request = request;
result = new JsonObject();
result.addProperty("status", "pending");
result.addProperty("captureId", id);
}
synchronized void finish(JsonObject value) {
if (done) return;
value.addProperty("captureId", id);
result = value;
done = true;
}
void fail(String code, String message) { finish(CameraHttpServer.error(code, message)); }
}
}
@@ -0,0 +1,98 @@
package dev.minecraftbuilder.camera;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** No Minecraft APIs are called by HTTP threads. Backend only queues work and reads cached results. */
public final class CameraHttpServer implements AutoCloseable {
public interface Backend {
JsonObject health();
JsonObject submit(CaptureRequest request);
JsonObject poll(String captureId);
}
private final HttpServer server;
private final ExecutorService executor = Executors.newFixedThreadPool(2, Thread.ofPlatform()
.daemon(true).name("mcb-camera-http-", 0).factory());
private final byte[] authorization;
private final Backend backend;
public CameraHttpServer(int port, String token, Backend backend) throws IOException {
if (token == null || token.length() < 32 || token.length() > 512 || token.chars().anyMatch(Character::isWhitespace))
throw new IllegalArgumentException("MCB_CAMERA_TOKEN must contain 32..512 non-whitespace characters");
this.authorization = ("Bearer " + token).getBytes(StandardCharsets.UTF_8);
this.backend = backend;
server = HttpServer.create(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), port), 8);
server.setExecutor(executor);
server.createContext("/", this::handle);
}
public void start() { server.start(); }
public int port() { return server.getAddress().getPort(); }
private void handle(HttpExchange exchange) throws IOException {
try (exchange) {
String bearer = exchange.getRequestHeaders().getFirst("Authorization");
if (bearer == null || !MessageDigest.isEqual(authorization, bearer.getBytes(StandardCharsets.UTF_8))) {
send(exchange, 401, error("unauthorized", "Valid camera bearer token required"));
return;
}
String path = exchange.getRequestURI().getPath();
String method = exchange.getRequestMethod();
if (path.equals("/health") && method.equals("GET")) {
send(exchange, 200, backend.health());
} else if (path.equals("/v1/capture") && method.equals("POST")) {
byte[] body = exchange.getRequestBody().readNBytes(8193);
if (body.length > 8192) {
send(exchange, 413, error("request_too_large", "Capture request exceeds 8192 bytes"));
return;
}
try {
JsonObject result = backend.submit(CaptureRequest.parse(JsonParser.parseString(
new String(body, StandardCharsets.UTF_8)).getAsJsonObject()));
send(exchange, result.has("error") ? 409 : 202, result);
} catch (RuntimeException exception) {
send(exchange, 400, error("invalid_request", "Invalid capture JSON or capture parameters"));
}
} else if (path.startsWith("/v1/captures/") && method.equals("GET")) {
String id = path.substring("/v1/captures/".length());
if (!id.matches("[0-9a-f-]{36}")) {
send(exchange, 400, error("invalid_capture_id", "Expected a capture UUID"));
return;
}
JsonObject result = backend.poll(id);
send(exchange, result == null ? 404 : 200,
result == null ? error("capture_not_found", "Capture expired or does not exist") : result);
} else {
send(exchange, 404, error("not_found", "Unknown camera endpoint or method"));
}
}
}
public static JsonObject error(String code, String message) {
JsonObject result = new JsonObject();
result.addProperty("status", "error");
result.addProperty("error", code);
result.addProperty("message", message);
return result;
}
private static void send(HttpExchange exchange, int code, JsonObject result) throws IOException {
byte[] bytes = result.toString().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff");
exchange.sendResponseHeaders(code, bytes.length);
exchange.getResponseBody().write(bytes);
}
@Override public void close() { server.stop(0); executor.shutdownNow(); }
}
@@ -0,0 +1,51 @@
package dev.minecraftbuilder.camera;
import com.google.gson.JsonObject;
import java.util.Set;
/** Coordinates are the observer's feet, matching Paper teleports; the reply also gives eye position. */
public record CaptureRequest(double x, double y, double z, float yaw, float pitch, int fov,
int width, int height, String dimension, String afterOperationId) {
private static final Set<String> FIELDS = Set.of("x", "y", "z", "yaw", "pitch", "fov", "width",
"height", "dimension", "world", "world_id", "afterOperationId");
public static CaptureRequest parse(JsonObject body) {
if (!FIELDS.containsAll(body.keySet())) throw new IllegalArgumentException("Unknown capture field");
double x = number(body, "x"), y = number(body, "y"), z = number(body, "z");
double yaw = number(body, "yaw"), pitch = number(body, "pitch");
if (Math.abs(x) > 29_999_984 || Math.abs(z) > 29_999_984 || y < -2048 || y > 2048)
throw new IllegalArgumentException("Position is outside the camera coordinate limits");
if (Math.abs(yaw) > 360 || Math.abs(pitch) > 90)
throw new IllegalArgumentException("yaw must be -360..360 and pitch -90..90");
int fov = integer(body, "fov", 70, 30, 110);
int width = integer(body, "width", 1280, 320, 1920);
int height = integer(body, "height", 720, 180, 1080);
return new CaptureRequest(x, y, z, (float) yaw, (float) pitch, fov, width, height,
string(body, "dimension", 128), string(body, "afterOperationId", 128));
}
private static double number(JsonObject body, String name) {
if (!body.has(name) || !body.get(name).isJsonPrimitive() || !body.getAsJsonPrimitive(name).isNumber())
throw new IllegalArgumentException(name + " must be a number");
double value = body.get(name).getAsDouble();
if (!Double.isFinite(value)) throw new IllegalArgumentException(name + " must be finite");
return value;
}
private static int integer(JsonObject body, String name, int fallback, int min, int max) {
if (!body.has(name)) return fallback;
double value = number(body, name);
if (value != Math.rint(value) || value < min || value > max)
throw new IllegalArgumentException(name + " must be an integer in " + min + ".." + max);
return (int) value;
}
private static String string(JsonObject body, String name, int max) {
if (!body.has(name)) return null;
if (!body.get(name).isJsonPrimitive() || !body.getAsJsonPrimitive(name).isString())
throw new IllegalArgumentException(name + " must be a string");
String value = body.get(name).getAsString();
if (value.isBlank() || value.length() > max) throw new IllegalArgumentException(name + " has invalid length");
return value;
}
}
@@ -0,0 +1,17 @@
package dev.minecraftbuilder.camera.mixin;
import dev.minecraftbuilder.camera.CameraClient;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.renderer.GameRenderer;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(GameRenderer.class)
abstract class GameRendererMixin {
@Inject(method = "render", at = @At("TAIL"))
private void mcb$afterFrame(DeltaTracker deltaTracker, boolean renderWorld, CallbackInfo callback) {
CameraClient.afterRender(renderWorld);
}
}
@@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"id": "minecraft_builder_camera",
"version": "${version}",
"name": "Minecraft Builder Camera",
"description": "Authenticated loopback camera worker for minecraft-builder-mcp.",
"environment": "client",
"entrypoints": { "client": ["dev.minecraftbuilder.camera.CameraClient"] },
"mixins": ["minecraft-builder-camera.mixins.json"],
"depends": {
"fabricloader": ">=0.19.5",
"minecraft": "26.2",
"java": ">=25",
"fabric-api": "0.160.0+26.2"
}
}
@@ -0,0 +1,7 @@
{
"required": true,
"package": "dev.minecraftbuilder.camera.mixin",
"compatibilityLevel": "JAVA_25",
"client": ["GameRendererMixin"],
"injectors": { "defaultRequire": 1 }
}
@@ -0,0 +1,53 @@
package dev.minecraftbuilder.camera;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class CameraHttpServerTest {
private static final String TOKEN = "test-camera-token-with-at-least-32-characters";
private static final String VALID = "{\"x\":1,\"y\":64,\"z\":-2,\"yaw\":0,\"pitch\":15}";
@Test void authenticatesBeforeQueueingAndEnforcesRequestBudget() throws Exception {
AtomicInteger queued = new AtomicInteger();
CameraHttpServer.Backend backend = new CameraHttpServer.Backend() {
public JsonObject health() { return JsonParser.parseString("{\"status\":\"ok\"}").getAsJsonObject(); }
public JsonObject submit(CaptureRequest request) {
queued.incrementAndGet();
return JsonParser.parseString("{\"status\":\"pending\",\"captureId\":\"00000000-0000-0000-0000-000000000000\"}").getAsJsonObject();
}
public JsonObject poll(String id) { return null; }
};
try (CameraHttpServer server = new CameraHttpServer(0, TOKEN, backend); HttpClient client = HttpClient.newHttpClient()) {
server.start();
String base = "http://127.0.0.1:" + server.port();
assertEquals(401, send(client, base + "/v1/capture", "POST", VALID, "wrong").statusCode());
assertEquals(0, queued.get());
assertEquals(202, send(client, base + "/v1/capture", "POST", VALID, TOKEN).statusCode());
assertEquals(1, queued.get());
assertEquals(413, send(client, base + "/v1/capture", "POST", " ".repeat(8193), TOKEN).statusCode());
assertEquals(400, send(client, base + "/v1/capture", "POST", "{\"x\":true}", TOKEN).statusCode());
assertEquals(1, queued.get());
assertEquals(404, send(client, base + "/v1/captures/00000000-0000-0000-0000-000000000000", "GET", "", TOKEN).statusCode());
assertEquals("no-store", send(client, base + "/health", "GET", "", TOKEN).headers().firstValue("Cache-Control").orElseThrow());
}
}
@Test void rejectsTokenlessOrWeakService() {
assertThrows(IllegalArgumentException.class, () -> new CameraHttpServer(0, null, null));
assertThrows(IllegalArgumentException.class, () -> new CameraHttpServer(0, "weak", null));
}
private static HttpResponse<String> send(HttpClient client, String url, String method, String body, String token) throws Exception {
return client.send(HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(5))
.header("Authorization", "Bearer " + token).method(method, HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofString());
}
}
@@ -0,0 +1,33 @@
package dev.minecraftbuilder.camera;
import com.google.gson.JsonParser;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class CaptureRequestTest {
private static final String POSE = "\"x\":1,\"y\":64,\"z\":-2,\"yaw\":0,\"pitch\":15";
@Test void suppliesBoundedDefaultsAndKeepsCorrelation() {
CaptureRequest request = parse("{" + POSE + ",\"afterOperationId\":\"op-42\",\"dimension\":\"minecraft:overworld\"}");
assertEquals(1280, request.width());
assertEquals(720, request.height());
assertEquals(70, request.fov());
assertEquals("op-42", request.afterOperationId());
}
@ParameterizedTest @ValueSource(strings = {"\"width\":1921", "\"width\":320.5", "\"height\":0",
"\"fov\":111", "\"width\":\"640\"", "\"afterOperationId\":null", "\"shell\":\"noop\""})
void rejectsUnsafeOrAmbiguousParameters(String extra) {
assertThrows(IllegalArgumentException.class, () -> parse("{" + POSE + "," + extra + "}"));
}
@Test void rejectsNonFiniteAndOutOfWorldPose() {
assertThrows(IllegalArgumentException.class, () -> parse("{" + POSE.replace("\"x\":1", "\"x\":1e999") + "}"));
assertThrows(IllegalArgumentException.class, () -> parse("{" + POSE.replace("\"pitch\":15", "\"pitch\":91") + "}"));
assertThrows(IllegalArgumentException.class, () -> parse("{" + POSE.replace("\"y\":64", "\"y\":4096") + "}"));
}
private static CaptureRequest parse(String input) { return CaptureRequest.parse(JsonParser.parseString(input).getAsJsonObject()); }
}