diff --git a/README.md b/README.md index cc0d782..f871935 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # DiceChess Bot Runtime +[![CI](https://github.com/rabestro/dicechess-bot-runtime/actions/workflows/ci.yml/badge.svg)](https://github.com/rabestro/dicechess-bot-runtime/actions/workflows/ci.yml) +[![Code Quality](https://github.com/rabestro/dicechess-bot-runtime/actions/workflows/qodana.yml/badge.svg)](https://github.com/rabestro/dicechess-bot-runtime/actions/workflows/qodana.yml) +[![Javadoc](https://img.shields.io/badge/Javadoc-jc.id.lv-1E90FF)](https://jc.id.lv/dicechess-bot-runtime/) +[![Release](https://img.shields.io/github/v/tag/rabestro/dicechess-bot-runtime?label=release&sort=semver)](https://github.com/rabestro/dicechess-bot-runtime/packages) +![Java](https://img.shields.io/badge/Java-25-orange) +[![License: MIT](https://img.shields.io/badge/License-MIT-lightgrey)](./LICENSE) + The transport/protocol plumbing shared by DiceChess webhook bots: HMAC-SHA256 signature verification, the one-time ownership handshake, and (optionally) an HTTP server for the Azure Functions custom-handler model. A bot author supplies one thing — a function from a @@ -17,7 +24,7 @@ bot depending on it: engine-linked, so it reads only `ctx.dfen()` and ignores ev | --- | --- | | `Signatures` | HMAC-SHA256 sign/verify, ±5 minute replay window, constant-time comparison. | | `WebhookHandler` | Orchestrates one delivery: handshake, signature check, dispatch to the strategy function. Never throws. | -| `TurnContext` | What the strategy function sees: `gameId`, `dfen`, both clocks in milliseconds (`null` for an untimed game), and every complete legal turn already walked out (`null` if unknown). | +| `TurnContext` | What the strategy function sees: `gameId`, `dfen`, the game `clock` (both sides' remaining time plus the per-turn Fischer increment, all in ms — the whole `clock` is `null` for an untimed game), and every complete legal turn already walked out (`null` if unknown). | | `CustomHandlerServer` | A JDK `HttpServer` wrapper reading `FUNCTIONS_CUSTOMHANDLER_PORT` — optional; bring your own HTTP layer if you'd rather. | | `JsonFiles` | Generic JSON-object-of-strings file loader (an opening book, or any similar lookup table), degrades gracefully when the file is absent. | diff --git a/src/main/java/lv/id/jc/dicechess/runtime/TurnContext.java b/src/main/java/lv/id/jc/dicechess/runtime/TurnContext.java index 4072c55..dae337b 100644 --- a/src/main/java/lv/id/jc/dicechess/runtime/TurnContext.java +++ b/src/main/java/lv/id/jc/dicechess/runtime/TurnContext.java @@ -4,16 +4,13 @@ /** * What a strategy is told about the turn it must play — everything the webhook envelope's - * {@code state} carries that a move-choosing function plausibly needs, beyond the position - * itself. + * {@code state} carries that a move-choosing function plausibly needs, beyond the position itself. * * @param gameId the game's id — lets a strategy keep per-game state (a search tree, a * transposition table) or tag its own logs * @param dfen the position plus the rolled dice for the side to move - * @param remainingMillis milliseconds left on the mover's own clock, or {@code null} for an - * untimed game - * @param opponentRemainingMillis milliseconds left on the opponent's clock, or {@code null} for - * an untimed game + * @param clock the game clock as of this turn, or {@code null} for an untimed game — see {@link + * Clock}, whose presence is itself the "is this game timed?" answer * @param legalMoves every complete legal turn, each as its sequence of UCI micro-moves — the * server's prefix tree, already walked root-to-leaf, so a strategy with no engine of its own * can play by picking one of these directly. An empty list is a genuine auto-pass (the roll @@ -21,9 +18,20 @@ * omitted them (past the server's inline cap) and this handler was not given a play-api base * URL to fetch the fallback from, or that fetch failed */ -public record TurnContext( - String gameId, - String dfen, - Long remainingMillis, - Long opponentRemainingMillis, - List> legalMoves) {} +public record TurnContext(String gameId, String dfen, Clock clock, List> legalMoves) { + + /** + * The game clock as of this turn. Present only for a timed game — {@link TurnContext#clock()} is + * {@code null} when the game is untimed, so a single null check answers "is this game timed?". + * Both remaining times are always present together (a game has clocks for both sides or + * neither), so they are primitive; only the increment can be absent. + * + * @param remainingMillis milliseconds left on the mover's own clock + * @param opponentRemainingMillis milliseconds left on the opponent's clock + * @param incrementMillis the per-turn Fischer increment in milliseconds, credited after each + * completed turn, or {@code null} when the control has none (a sudden-death or per-move + * game). Feed it to the engine's time manager as the increment term — a {@code null} + * naturally coalesces to a zero increment + */ + public record Clock(long remainingMillis, long opponentRemainingMillis, Long incrementMillis) {} +} diff --git a/src/main/java/lv/id/jc/dicechess/runtime/WebhookHandler.java b/src/main/java/lv/id/jc/dicechess/runtime/WebhookHandler.java index 8d72493..9653214 100644 --- a/src/main/java/lv/id/jc/dicechess/runtime/WebhookHandler.java +++ b/src/main/java/lv/id/jc/dicechess/runtime/WebhookHandler.java @@ -30,6 +30,11 @@ public final class WebhookHandler { /** Header carrying the hex HMAC-SHA256 signature (see {@link Signatures}). */ public static final String SIGNATURE_HEADER = "x-dicechess-signature"; + // Webhook-state field names reused across parsing (extracted to avoid duplicated literals). + private static final String FIELD_CLOCKS = "clocks"; + private static final String FIELD_TIME_CONTROL = "timeControl"; + private static final String VARIANT_FISCHER = "Fischer"; + private static final Gson GSON = new Gson(); private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); private static final Duration FALLBACK_TIMEOUT = Duration.ofSeconds(5); @@ -144,14 +149,21 @@ private Response yourTurn(Map headers, String rawBody, JsonObjec var state = envelope.getAsJsonObject("state"); var dfen = state.get("dfen").getAsString(); - Long remainingMillis = null; - Long opponentRemainingMillis = null; - if (state.has("clocks") && !state.get("clocks").isJsonNull()) { - var clocks = state.getAsJsonObject("clocks"); - var white = clocks.get("white").getAsLong(); - var black = clocks.get("black").getAsLong(); - remainingMillis = seat.equals("White") ? white : black; - opponentRemainingMillis = seat.equals("White") ? black : white; + // The game clock for this turn (null for an untimed game): play-api sends both sides' + // remaining time under "clocks" only for a timed control. The per-turn increment lives in + // "timeControl" and only its Fischer variant carries one — parsed defensively by + // fischerIncrementMillis, so a null increment on a present clock is a valid sudden-death or + // per-move game. + TurnContext.Clock clock = null; + if (state.has(FIELD_CLOCKS) && state.get(FIELD_CLOCKS).isJsonObject()) { + var clocks = state.getAsJsonObject(FIELD_CLOCKS); + if (clocks.has("white") && clocks.has("black")) { + var white = clocks.get("white").getAsLong(); + var black = clocks.get("black").getAsLong(); + var own = seat.equals("White") ? white : black; + var opponent = seat.equals("White") ? black : white; + clock = new TurnContext.Clock(own, opponent, fischerIncrementMillis(state)); + } } List> legalMoves = null; @@ -166,7 +178,7 @@ private Response yourTurn(Map headers, String rawBody, JsonObjec } } - var context = new TurnContext(gameId, dfen, remainingMillis, opponentRemainingMillis, legalMoves); + var context = new TurnContext(gameId, dfen, clock, legalMoves); List moves; try { @@ -227,6 +239,36 @@ private static List> flattenLegalMoves(JsonObject tree) { return paths; } + /** + * The per-turn Fischer increment in milliseconds from the envelope's {@code timeControl}, or + * {@code null} when the control is not Fischer or the field is missing or malformed. Fully + * defensive: only a numeric, non-negative {@code incrementSeconds} within {@code int} range is + * accepted, so no input can throw (which {@link #handle} would turn into a 400) or overflow the + * conversion to milliseconds. + */ + private static Long fischerIncrementMillis(JsonObject state) { + if (!state.has(FIELD_TIME_CONTROL) || !state.get(FIELD_TIME_CONTROL).isJsonObject()) { + return null; + } + var timeControl = state.getAsJsonObject(FIELD_TIME_CONTROL); + if (!timeControl.has(VARIANT_FISCHER) || !timeControl.get(VARIANT_FISCHER).isJsonObject()) { + return null; + } + var increment = timeControl.getAsJsonObject(VARIANT_FISCHER).get("incrementSeconds"); + if (increment == null || !increment.isJsonPrimitive() || !increment.getAsJsonPrimitive().isNumber()) { + return null; + } + try { + var seconds = increment.getAsLong(); + return seconds >= 0 && seconds <= Integer.MAX_VALUE ? seconds * 1000L : null; + } catch (NumberFormatException e) { + // getAsLong() documents this throw for untrusted input. The isNumber() guard already + // routes to the non-throwing path in current Gson, but catching keeps the "no input can + // throw" contract true against Gson's documented API rather than its internals. + return null; + } + } + private static String stripTrailingSlash(String url) { return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; } diff --git a/src/test/java/lv/id/jc/dicechess/runtime/WebhookHandlerTest.java b/src/test/java/lv/id/jc/dicechess/runtime/WebhookHandlerTest.java index 07cf630..14f7802 100644 --- a/src/test/java/lv/id/jc/dicechess/runtime/WebhookHandlerTest.java +++ b/src/test/java/lv/id/jc/dicechess/runtime/WebhookHandlerTest.java @@ -84,12 +84,12 @@ void theStrategyReceivesItsOwnClockAsRemainingAndTheOpponentsAsOpponentRemaining handler.handle(signedHeaders(body, NOW), body, NOW); - assertThat(seenContext.get().remainingMillis()).isEqualTo(300000L); - assertThat(seenContext.get().opponentRemainingMillis()).isEqualTo(295000L); + assertThat(seenContext.get().clock().remainingMillis()).isEqualTo(300000L); + assertThat(seenContext.get().clock().opponentRemainingMillis()).isEqualTo(295000L); } @Test - void anUntimedGameLeavesBothClockFieldsNull() { + void anUntimedGameLeavesTheClockNull() { var seenContext = new AtomicReference(); Function> strategy = ctx -> { seenContext.set(ctx); @@ -100,8 +100,78 @@ void anUntimedGameLeavesBothClockFieldsNull() { handler.handle(signedHeaders(body, NOW), body, NOW); - assertThat(seenContext.get().remainingMillis()).isNull(); - assertThat(seenContext.get().opponentRemainingMillis()).isNull(); + assertThat(seenContext.get().clock()).isNull(); + } + + @Test + void aFischerControlSurfacesThePerTurnIncrementInMillis() { + var seenContext = new AtomicReference(); + Function> strategy = ctx -> { + seenContext.set(ctx); + return List.of(); + }; + var handler = new WebhookHandler(SECRET, strategy); + var body = "{\"type\":\"yourTurn\",\"gameId\":\"g1\",\"seat\":\"White\",\"state\":{\"dfen\":\"x\"," + + "\"clocks\":{\"white\":300000,\"black\":300000}," + + "\"timeControl\":{\"Fischer\":{\"initialSeconds\":300,\"incrementSeconds\":3}}}}"; + + handler.handle(signedHeaders(body, NOW), body, NOW); + + assertThat(seenContext.get().clock().incrementMillis()).isEqualTo(3000L); + } + + @Test + void aNonFischerControlLeavesTheIncrementNull() { + var seenContext = new AtomicReference(); + Function> strategy = ctx -> { + seenContext.set(ctx); + return List.of(); + }; + var handler = new WebhookHandler(SECRET, strategy); + var body = "{\"type\":\"yourTurn\",\"gameId\":\"g1\",\"seat\":\"White\",\"state\":{\"dfen\":\"x\"," + + "\"clocks\":{\"white\":60000,\"black\":60000}," + + "\"timeControl\":{\"SuddenDeath\":{\"initialSeconds\":60}}}}"; + + handler.handle(signedHeaders(body, NOW), body, NOW); + + assertThat(seenContext.get().clock()).isNotNull(); + assertThat(seenContext.get().clock().incrementMillis()).isNull(); + } + + @Test + void anAbsentTimeControlLeavesTheIncrementNull() { + var seenContext = new AtomicReference(); + Function> strategy = ctx -> { + seenContext.set(ctx); + return List.of(); + }; + var handler = new WebhookHandler(SECRET, strategy); + var body = "{\"type\":\"yourTurn\",\"gameId\":\"g1\",\"seat\":\"White\",\"state\":{\"dfen\":\"x\"," + + "\"clocks\":{\"white\":60000,\"black\":60000}}}"; + + handler.handle(signedHeaders(body, NOW), body, NOW); + + assertThat(seenContext.get().clock().incrementMillis()).isNull(); + } + + @Test + void aMalformedIncrementDegradesToNullNotA400() { + var seenContext = new AtomicReference(); + Function> strategy = ctx -> { + seenContext.set(ctx); + return List.of(); + }; + var handler = new WebhookHandler(SECRET, strategy); + // incrementSeconds present but not a number: the increment is optional guidance, so this + // must degrade to a null increment on a valid clock, never fail the turn with a 400. + var body = "{\"type\":\"yourTurn\",\"gameId\":\"g1\",\"seat\":\"White\",\"state\":{\"dfen\":\"x\"," + + "\"clocks\":{\"white\":300000,\"black\":300000}," + + "\"timeControl\":{\"Fischer\":{\"initialSeconds\":300,\"incrementSeconds\":\"soon\"}}}}"; + + var response = handler.handle(signedHeaders(body, NOW), body, NOW); + + assertThat(response.status()).isEqualTo(200); + assertThat(seenContext.get().clock().incrementMillis()).isNull(); } @Test