Add opt-in unattended camera capture without pause menu UI
This commit is contained in:
+28
-5
@@ -29,7 +29,23 @@ export MCB_CAMERA_PORT=8766
|
||||
|
||||
Set the same secret in the Paper plugin's camera configuration. Paper accepts 32–512 characters from `A–Z`, `a–z`, `0–9`, `.`, `_`, `~`, and `-`, without spaces or line breaks; the automatically generated value already meets these requirements. All three Paper tokens must differ. Without `MCB_CAMERA_TOKEN`, the HTTP service is disabled. `MCB_CAMERA_PORT` is optional; allowed ports are 1024–65535. The address is always `127.0.0.1`, with no option to bind a public interface.
|
||||
|
||||
Launch a separate observer, connect to the intended Paper server, and switch it to spectator using an authorized server mechanism. Configure the observer's UUID in the Paper plugin. A valid separate game session is required if the builder stays on the server simultaneously; the mod does not bypass authentication or account restrictions. Keep client menus closed and do not spectate another entity. A minimized window may stop rendering and cause a timeout.
|
||||
Launch a separate observer, connect to the intended Paper server, and switch it to spectator using an authorized server mechanism. Configure the observer's UUID in the Paper plugin. A valid separate game session is required if the builder stays on the server simultaneously; the mod does not bypass authentication or account restrictions. In the default interactive mode, keep client menus closed and do not spectate another entity. A minimized window may stop rendering and cause a timeout.
|
||||
|
||||
### Unattended observer
|
||||
|
||||
Set `MCB_CAMERA_UNATTENDED=1` **in the Minecraft child process environment** and restart that client. Only the literal value `1` enables this mode; it is off by default. Authenticated `GET /health` reports `unattended`, and completed captures include the same flag.
|
||||
|
||||
In this mode, an ordinary multiplayer pause menu may remain open and the window may be unfocused. During an active capture the mod clears and skips GUI extraction, including menu blur, HUD, chat, toasts and debug UI. It does not close the menu, grab the mouse, change focus, or change `pauseOnLostFocus`. The menu reappears normally after capture. The capture still hides the HUD and restores its prior visibility, FOV, bobbing, FOV effects, perspective and rotation on success or failure.
|
||||
|
||||
Only the exact vanilla `PauseScreen` is allowed. Other screens, including chat, settings, death, loading/resource-pack prompts, disconnect screens, modded pause-screen subclasses and real overlays, remain obstructions. Paused single-player worlds remain blocked. Spectator mode, the observer's own camera, position/view stability, authenticated loopback access, loading checks and the 20-second timeout remain required. If the graphics driver stops rendering, opting in cannot manufacture a fresh frame.
|
||||
|
||||
The mod does not create a display or launch another client. On Linux, use an existing virtual graphics display for the dedicated observer. Prism may forward launch requests to an already running launcher and inherit its real display; set the display in the instance's **WrapperCommand**, before Java starts. For example, with a prepared Xvfb display `:99`:
|
||||
|
||||
```text
|
||||
env DISPLAY=:99 MCB_CAMERA_UNATTENDED=1 python3 /path/to/camera-wrapper.py
|
||||
```
|
||||
|
||||
Use a wrapper/config that supplies the intended token and a separate port for each simultaneous observer. Verify the Java process's display and the authenticated health `playerId`/`unattended` before routing captures to it. Keep test captures and machine-specific launcher settings outside this repository.
|
||||
|
||||
### One client through Prism
|
||||
|
||||
@@ -65,7 +81,7 @@ When positioning through the vanilla console, write explicit decimal X/Z coordin
|
||||
|
||||
All requests, including health checks and image retrieval, require `Authorization: Bearer <MCB_CAMERA_TOKEN>`. JSON is not written to the log.
|
||||
|
||||
- `GET /health` — cached state from the latest client tick: `status`, `connected`, `spectator`, `busy`, `dimension`, `playerId`, and `updatedAt`. A stale `updatedAt` means the client has stopped updating.
|
||||
- `GET /health` — cached state from the latest client tick: `status`, `connected`, `spectator`, `busy`, `unattended`, `dimension`, `playerId`, and `updatedAt`. A stale `updatedAt` means the client has stopped updating.
|
||||
- `POST /v1/capture` — queue one capture. HTTP 202 response: `{"status":"pending","captureId":"<uuid>"}`. A busy camera returns HTTP 409 and `camera_busy`.
|
||||
- `GET /v1/captures/<uuid>` — retrieve `pending`, `completed`, or `error`. Unknown/expired IDs return HTTP 404. A terminal `error` contains `error` and `message`, without an image.
|
||||
|
||||
@@ -97,15 +113,15 @@ Before capture, the mod checks spectator mode, position agreement (±0.05 blocks
|
||||
2. Three frames with an initialized camera, available chunks, and an empty geometry preparation queue.
|
||||
3. The pose and window to remain suitable until framebuffer readback.
|
||||
|
||||
PNG capture uses `Screenshot.takeScreenshot` after the frame renders, with GPU readback through Blaze3D and no direct OpenGL code. PNG encoding and downscaling run on a separate thread. The HUD, FOV, perspective, view bobbing, and rotation saved when the mod starts handling the capture are restored on the client thread after success or error. Saving starts after the server position arrives; this does not return the player to the position before teleportation. The server remains authoritative over the post-teleport position.
|
||||
PNG capture uses `Screenshot.takeScreenshot` after the frame renders, with GPU readback through Blaze3D and no direct OpenGL code. Unattended captures count only frames whose GUI extraction was actually suppressed. PNG encoding and downscaling run on a separate thread. The HUD, FOV, perspective, view bobbing, and rotation saved when the mod starts handling the capture are restored on the client thread after success or error. Saving starts after the server position arrives; this does not return the player to the position before teleportation. The server remains authoritative over the post-teleport position.
|
||||
|
||||
This is a **checked loading heuristic**, not confirmation of a specific server revision. The response always contains `readiness: "local_chunks_and_render_queue_stable"` and `serverRevisionVerified: false`. `afterOperationId` is for correlation; by itself it does not prove the client received every update from that operation. Do not present this result as revision verification. Strict freshness requires an additional server marker and acknowledgment that the corresponding packets were processed. Distant geometry outside the checked chunks and changes after capture remain limitations.
|
||||
|
||||
Loading failures, disconnection, world/position changes, open menus, rotation interference, and missing framebuffer data return an error instead of a stale image. A separate thread enforces the 20-second timeout even if rendering hangs. Another capture is allowed after state restoration on the client thread. At most four results are retained for up to two minutes; PNGs are limited to 8 MiB and request bodies to 8192 bytes. Images remain in memory and are not written to the shared screenshots directory.
|
||||
Loading failures, disconnection, world/position changes, blocking menus, rotation interference, and missing framebuffer data return an error instead of a stale image. All open menus block the default interactive mode; only the unattended multiplayer pause menu is exempt. A separate thread enforces the 20-second timeout even if rendering hangs. Another capture is allowed after state restoration on the client thread. At most four results are retained for up to two minutes; PNGs are limited to 8 MiB and request bodies to 8192 bytes. Images remain in memory and are not written to the shared screenshots directory.
|
||||
|
||||
## Verification and prototype limits
|
||||
|
||||
`./gradlew build` compiles the mod against real Minecraft 26.2 dependencies; tests cover HTTP bearer authentication, request-size limits, and parameter validation. These tests do not launch the client or sign in to an account.
|
||||
`./gradlew build` compiles the mod against real Minecraft 26.2 dependencies; 17 tests cover HTTP bearer authentication, token/request-size limits, parameter validation, auto-connect and the attended/unattended obstruction policy. These unit tests do not launch the client or sign in to an account.
|
||||
|
||||
A real graphical test ran on September 12, 2026: one Prism client, the project owner in spectator mode, matching owner and camera UUIDs, Paper 26.2, and a completed 575-block tower. Mixin loading, client connection, server teleportation, framebuffer readback, and PNG delivery through Paper HTTP were verified. The image shows the built tower without the HUD.
|
||||
|
||||
@@ -120,3 +136,10 @@ Set `camera-auto-connect: '127.0.0.1:25575'` in the private Paper config used by
|
||||
Create `config/minecraft-builder-camera.autojoin-disabled` inside the Minecraft instance to pause retries without restarting; remove it to resume. The health endpoint reports enabled/paused state, target and attempt count. Remove the opt-in setting and restart the client to disable permanently. Opt-in deliberately includes reconnecting after a manual disconnect from the configured server.
|
||||
|
||||
Auto-connect uses Minecraft's normal connection flow. It does not authenticate new accounts, launch Prism, execute chat commands, change game mode, move the player, or grant editing rights. A trusted local operator can separately use the Paper console for `lobby <player>` and `gamemode spectator <player>`. Graphical rendering is still required for photographs; menus and user input can invalidate a capture.
|
||||
|
||||
|
||||
### Unattended local verification (2026-09-21)
|
||||
|
||||
A separate real Minecraft 26.2 spectator client captured a 1280×720 PNG on a virtual display while its window was unfocused and the vanilla pause menu remained open. The image was visually checked for a genuine world frame without menu, HUD or chat. In multiplayer `isPaused()` was false, even with `PauseScreen` open; the menu state, not an assumed paused simulation, was exercised.
|
||||
|
||||
Both initially visible and initially hidden HUD states were restored. FOV, view bobbing, FOV effects, perspective and rotation returned to their saved values. A rotation change and a blocking screen introduced during capture produced explicit errors and still restored view settings. The original pause menu, focus state and `pauseOnLostFocus` were left untouched. With unattended mode disabled, the same paused/unfocused client returned `view_obstructed`; after its menu was closed, the default mode still produced a clean 1280×720 image. Machine-specific fixtures, images and receipts stay outside the repository in the local test workspace.
|
||||
|
||||
@@ -12,6 +12,7 @@ import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.client.gui.screens.ConnectScreen;
|
||||
import net.minecraft.client.gui.screens.DisconnectedScreen;
|
||||
import net.minecraft.client.gui.screens.PauseScreen;
|
||||
import net.minecraft.client.gui.screens.TitleScreen;
|
||||
import net.minecraft.client.gui.screens.multiplayer.JoinMultiplayerScreen;
|
||||
import net.minecraft.client.multiplayer.ServerData;
|
||||
@@ -52,6 +53,7 @@ public final class CameraClient implements ClientModInitializer, CameraHttpServe
|
||||
private boolean autoConnectSuspended;
|
||||
private boolean autoConnectPaused;
|
||||
private long nextPauseCheck;
|
||||
private final boolean unattended = CaptureViewPolicy.unattended(System.getenv("MCB_CAMERA_UNATTENDED"));
|
||||
|
||||
@Override public void onInitializeClient() {
|
||||
String token = System.getenv("MCB_CAMERA_TOKEN");
|
||||
@@ -112,6 +114,7 @@ public final class CameraClient implements ClientModInitializer, CameraHttpServe
|
||||
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("unattended", unattended);
|
||||
health.addProperty("autoConnectEnabled", autoConnect != null);
|
||||
if (autoConnect != null) {
|
||||
health.addProperty("autoConnectTarget", autoConnect.target());
|
||||
@@ -126,7 +129,7 @@ public final class CameraClient implements ClientModInitializer, CameraHttpServe
|
||||
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()) {
|
||||
if (!permitsView(client)) {
|
||||
job.fail("view_obstructed", "Close menus and overlays in the observer client"); return;
|
||||
}
|
||||
if (!job.initialized) {
|
||||
@@ -137,7 +140,7 @@ public final class CameraClient implements ClientModInitializer, CameraHttpServe
|
||||
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();
|
||||
setHudHidden(client, true);
|
||||
client.options.fov().set(job.request.fov());
|
||||
client.options.bobView().set(false);
|
||||
client.options.fovEffectScale().set(0.0);
|
||||
@@ -180,16 +183,50 @@ public final class CameraClient implements ClientModInitializer, CameraHttpServe
|
||||
/** 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());
|
||||
if (worker != null) worker.rendered(Minecraft.getInstance(), renderWorld);
|
||||
}
|
||||
|
||||
private void rendered(Minecraft client) {
|
||||
/** Read on the render thread before GUI extraction; never closes a screen or grabs the mouse. */
|
||||
public static boolean suppressCaptureGui() {
|
||||
CameraClient worker = instance;
|
||||
if (worker == null || !worker.unattended) return false;
|
||||
Minecraft client = Minecraft.getInstance();
|
||||
Job job = worker.active.get();
|
||||
boolean suppress = job != null && !job.done && job.initialized
|
||||
&& client.player == job.player && client.level == job.level
|
||||
&& client.player.isSpectator() && client.getCameraEntity() == client.player
|
||||
&& worker.permitsView(client) && matchesPosition(client, job.request) && matchesView(client, job.request);
|
||||
if (suppress) job.guiSuppressed = true;
|
||||
return suppress;
|
||||
}
|
||||
|
||||
private boolean permitsView(Minecraft client) {
|
||||
var screen = client.gui.screen();
|
||||
// Do not treat arbitrary modded subclasses, settings, chat, death, loading,
|
||||
// resource-pack prompts or disconnect screens as a dismissible pause menu.
|
||||
var kind = screen == null ? CaptureViewPolicy.Screen.NONE
|
||||
: screen.getClass() == PauseScreen.class ? CaptureViewPolicy.Screen.PAUSE : CaptureViewPolicy.Screen.BLOCKING;
|
||||
return CaptureViewPolicy.permits(unattended, !client.hasSingleplayerServer(), kind,
|
||||
client.gui.overlay() != null, client.isPaused());
|
||||
}
|
||||
|
||||
private void rendered(Minecraft client, boolean renderWorld) {
|
||||
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)) {
|
||||
if (job == null) return;
|
||||
boolean guiSuppressed = job.guiSuppressed;
|
||||
job.guiSuppressed = false;
|
||||
if (!renderWorld || job.done || !job.initialized || job.readbackStarted || job.stableTicks < 20) return;
|
||||
if (client.player != job.player || client.level != job.level || !permitsView(client)
|
||||
|| !matchesPosition(client, job.request) || !matchesView(client, job.request)) {
|
||||
job.fail("view_changed", "Observer view changed before frame capture"); return;
|
||||
}
|
||||
if (!client.player.isSpectator()) { job.fail("spectator_required", "Camera account must be in spectator mode"); return; }
|
||||
if (client.getCameraEntity() != client.player) {
|
||||
job.fail("spectating_entity", "Observer must use its own camera, not another entity"); return;
|
||||
}
|
||||
// An allowed pause screen alone is not evidence of a clean framebuffer:
|
||||
// require GUI suppression in this exact frame before counting readiness.
|
||||
if (unattended && !guiSuppressed) { job.readyFrames = 0; return; }
|
||||
if (!client.gameRenderer.mainCamera().isInitialized() || !chunksLoaded(client)
|
||||
|| client.levelRenderer.sectionRenderDispatcher() == null || !client.levelRenderer.hasRenderedAllSections()) {
|
||||
job.readyFrames = 0; return;
|
||||
@@ -202,6 +239,7 @@ public final class CameraClient implements ClientModInitializer, CameraHttpServe
|
||||
job.readbackStarted = true;
|
||||
JsonObject metadata = new JsonObject();
|
||||
metadata.addProperty("capturedAt", Instant.now().toString());
|
||||
metadata.addProperty("unattended", unattended);
|
||||
metadata.addProperty("dimension", dimension(client));
|
||||
metadata.addProperty("x", client.player.getX());
|
||||
metadata.addProperty("y", client.player.getY());
|
||||
@@ -286,7 +324,7 @@ public final class CameraClient implements ClientModInitializer, CameraHttpServe
|
||||
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();
|
||||
setHudHidden(client, saved.hudHidden);
|
||||
client.options.fov().set(saved.fov);
|
||||
client.options.bobView().set(saved.bobView);
|
||||
client.options.fovEffectScale().set(saved.fovEffectScale);
|
||||
@@ -299,6 +337,10 @@ public final class CameraClient implements ClientModInitializer, CameraHttpServe
|
||||
job.saved = null;
|
||||
}
|
||||
|
||||
private static void setHudHidden(Minecraft client, boolean hidden) {
|
||||
if (client.gui.hud.isHidden() != hidden) client.gui.hud.toggle();
|
||||
}
|
||||
|
||||
private void stop(Minecraft client) {
|
||||
instance = null;
|
||||
Job job = active.getAndSet(null);
|
||||
@@ -317,7 +359,7 @@ public final class CameraClient implements ClientModInitializer, CameraHttpServe
|
||||
final long createdNanos = System.nanoTime();
|
||||
volatile JsonObject result;
|
||||
volatile boolean done;
|
||||
boolean initialized, readbackStarted;
|
||||
boolean initialized, readbackStarted, guiSuppressed;
|
||||
int stableTicks, readyFrames;
|
||||
SavedView saved;
|
||||
LocalPlayer player;
|
||||
|
||||
@@ -26,8 +26,8 @@ public final class CameraHttpServer implements AutoCloseable {
|
||||
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");
|
||||
if (token == null || !token.matches("[A-Za-z0-9._~-]{32,512}"))
|
||||
throw new IllegalArgumentException("MCB_CAMERA_TOKEN must contain 32..512 characters from A-Za-z0-9._~-");
|
||||
this.authorization = ("Bearer " + token).getBytes(StandardCharsets.UTF_8);
|
||||
this.backend = backend;
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), port), 8);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.minecraftbuilder.camera;
|
||||
|
||||
/** Shared tick/readback policy. Only an ordinary multiplayer pause menu is suppressible. */
|
||||
final class CaptureViewPolicy {
|
||||
enum Screen { NONE, PAUSE, BLOCKING }
|
||||
|
||||
static boolean unattended(String value) { return "1".equals(value); }
|
||||
|
||||
static boolean permits(boolean unattended, boolean multiplayer, Screen screen, boolean overlay, boolean paused) {
|
||||
if (overlay || screen == Screen.BLOCKING) return false;
|
||||
if (unattended && multiplayer) return true;
|
||||
return screen == Screen.NONE && !paused;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package dev.minecraftbuilder.camera.mixin;
|
||||
|
||||
import dev.minecraftbuilder.camera.CameraClient;
|
||||
import net.minecraft.client.DeltaTracker;
|
||||
import net.minecraft.client.gui.Gui;
|
||||
import net.minecraft.client.renderer.state.gui.GuiRenderState;
|
||||
import org.spongepowered.asm.mixin.Final;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** Suppresses capture UI without closing screens or changing mouse focus. */
|
||||
@Mixin(Gui.class)
|
||||
abstract class GuiMixin {
|
||||
@Shadow @Final private GuiRenderState guiRenderState;
|
||||
|
||||
@Inject(method = "extractRenderState", at = @At("HEAD"), cancellable = true)
|
||||
private void mcb$extractCaptureGui(DeltaTracker deltaTracker, boolean renderWorld,
|
||||
boolean finishedLoading, CallbackInfo callback) {
|
||||
if (!renderWorld || !CameraClient.suppressCaptureGui()) return;
|
||||
// Reset also clears the previous frame's menu blur and UI draw commands.
|
||||
guiRenderState.reset();
|
||||
callback.cancel();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,6 @@
|
||||
"required": true,
|
||||
"package": "dev.minecraftbuilder.camera.mixin",
|
||||
"compatibilityLevel": "JAVA_25",
|
||||
"client": ["GameRendererMixin"],
|
||||
"client": ["GameRendererMixin", "GuiMixin"],
|
||||
"injectors": { "defaultRequire": 1 }
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class CameraHttpServerTest {
|
||||
@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 health() { return JsonParser.parseString("{\"status\":\"ok\",\"unattended\":true}").getAsJsonObject(); }
|
||||
public JsonObject submit(CaptureRequest request) {
|
||||
queued.incrementAndGet();
|
||||
return JsonParser.parseString("{\"status\":\"pending\",\"captureId\":\"00000000-0000-0000-0000-000000000000\"}").getAsJsonObject();
|
||||
@@ -37,12 +37,17 @@ class CameraHttpServerTest {
|
||||
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());
|
||||
assertEquals(401, send(client, base + "/health", "GET", "", "wrong").statusCode());
|
||||
assertTrue(JsonParser.parseString(send(client, base + "/health", "GET", "", TOKEN).body())
|
||||
.getAsJsonObject().get("unattended").getAsBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsTokenlessOrWeakService() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new CameraHttpServer(0, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new CameraHttpServer(0, "weak", null));
|
||||
for (String token : new String[]{"a".repeat(513), "a".repeat(31) + "é", "a".repeat(32) + ":", "a".repeat(32) + "\n"})
|
||||
assertThrows(IllegalArgumentException.class, () -> new CameraHttpServer(0, token, null));
|
||||
}
|
||||
|
||||
private static HttpResponse<String> send(HttpClient client, String url, String method, String body, String token) throws Exception {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package dev.minecraftbuilder.camera;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static dev.minecraftbuilder.camera.CaptureViewPolicy.Screen.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CaptureViewPolicyTest {
|
||||
@Test void onlyExplicitOneEnablesUnattendedMode() {
|
||||
assertTrue(CaptureViewPolicy.unattended("1"));
|
||||
for (String value : new String[]{null, "", "0", "true", " 1", "yes"})
|
||||
assertFalse(CaptureViewPolicy.unattended(value));
|
||||
}
|
||||
|
||||
@Test void attendedContractRejectsEveryScreenPauseAndOverlay() {
|
||||
for (boolean multiplayer : new boolean[]{false, true}) {
|
||||
assertTrue(CaptureViewPolicy.permits(false, multiplayer, NONE, false, false));
|
||||
assertFalse(CaptureViewPolicy.permits(false, multiplayer, NONE, false, true));
|
||||
for (var screen : CaptureViewPolicy.Screen.values()) {
|
||||
assertFalse(CaptureViewPolicy.permits(false, multiplayer, screen, true, false));
|
||||
if (screen != NONE) assertFalse(CaptureViewPolicy.permits(false, multiplayer, screen, false, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unattendedAllowsMultiplayerPauseButNotBlockingScreensOrOverlays() {
|
||||
for (boolean paused : new boolean[]{false, true}) {
|
||||
assertTrue(CaptureViewPolicy.permits(true, true, PAUSE, false, paused));
|
||||
assertTrue(CaptureViewPolicy.permits(true, true, NONE, false, paused));
|
||||
assertFalse(CaptureViewPolicy.permits(true, true, BLOCKING, false, paused));
|
||||
for (var screen : CaptureViewPolicy.Screen.values())
|
||||
assertFalse(CaptureViewPolicy.permits(true, true, screen, true, paused));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void optInDoesNotSilentlyUnpauseAnIntegratedServer() {
|
||||
assertFalse(CaptureViewPolicy.permits(true, false, PAUSE, false, true));
|
||||
assertFalse(CaptureViewPolicy.permits(true, false, NONE, false, true));
|
||||
assertTrue(CaptureViewPolicy.permits(true, false, NONE, false, false));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user