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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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. |

Expand Down
32 changes: 20 additions & 12 deletions src/main/java/lv/id/jc/dicechess/runtime/TurnContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,34 @@

/**
* 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
* has no legal move); {@code null} means the legal moves are not known — either the envelope
* 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<List<String>> legalMoves) {}
public record TurnContext(String gameId, String dfen, Clock clock, List<List<String>> 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) {}
}
60 changes: 51 additions & 9 deletions src/main/java/lv/id/jc/dicechess/runtime/WebhookHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@
/** 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);
Expand Down Expand Up @@ -144,14 +149,21 @@
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<List<String>> legalMoves = null;
Expand All @@ -166,7 +178,7 @@
}
}

var context = new TurnContext(gameId, dfen, remainingMillis, opponentRemainingMillis, legalMoves);
var context = new TurnContext(gameId, dfen, clock, legalMoves);

List<String> moves;
try {
Expand Down Expand Up @@ -227,6 +239,36 @@
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) {

Check warning on line 264 in src/main/java/lv/id/jc/dicechess/runtime/WebhookHandler.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace "e" with an unnamed pattern.

See more on https://sonarcloud.io/project/issues?id=rabestro_dicechess-bot-runtime&issues=AZ-LJzyu8vBDyLa2FYSu&open=AZ-LJzyu8vBDyLa2FYSu&pullRequest=8
// 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;
}
Expand Down
80 changes: 75 additions & 5 deletions src/test/java/lv/id/jc/dicechess/runtime/WebhookHandlerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<TurnContext>();
Function<TurnContext, List<String>> strategy = ctx -> {
seenContext.set(ctx);
Expand All @@ -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<TurnContext>();
Function<TurnContext, List<String>> 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<TurnContext>();
Function<TurnContext, List<String>> 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<TurnContext>();
Function<TurnContext, List<String>> 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<TurnContext>();
Function<TurnContext, List<String>> 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
Expand Down
Loading