From 0b9594dd8b8e2b905c8b5b37d1ef33fd7e743d38 Mon Sep 17 00:00:00 2001 From: rabestro Date: Sun, 26 Jul 2026 00:16:45 +0300 Subject: [PATCH] perf: skip the search on the opponent's dice rolls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The game stream carries both sides' rolls, and the bot searched on every one of them, de-duplicating only by event version. Half of all search work therefore produced an off-turn submission that the server silently rejects. Resolve the bot's own seat once per game from GET /bot/games — the only endpoint that answers from the caller's perspective, since every stream event reports the seat it is *about* — and return early when the roll belongs to the opponent. This stays an optimisation, not a correctness dependency: an unresolved seat (game not yet listed, network blip, older server) falls back to the historical behaviour of searching every roll and letting the server reject the off-turn ones, so the bot can never freeze for want of its colour. Co-Authored-By: Claude Opus 5 --- README.md | 11 ++-- .../scala/dicechess/refbot/Protocol.scala | 12 ++++ .../scala/dicechess/refbot/ReferenceBot.scala | 66 +++++++++++++++---- .../dicechess/refbot/ProtocolSuite.scala | 14 ++++ .../dicechess/refbot/ReferenceBotSuite.scala | 18 +++++ 5 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 src/test/scala/dicechess/refbot/ReferenceBotSuite.scala diff --git a/README.md b/README.md index feb440c..57b8fe0 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,15 @@ have your own bot. JVM (Scala 3), wraps the - Authenticates with a bot token and listens on `GET /bot/stream/event`. - Accepts incoming challenges, and on `gameStart` streams the game (`GET /bot/game/stream/{id}`) and, - on each dice roll, computes a turn and submits it (`POST /bot/game/{id}/move`). + on each of its own dice rolls, computes a turn and submits it (`POST /bot/game/{id}/move`). - Reconnects the long-lived streams; the move call is fire-and-forget (the outcome arrives on the stream). -It never needs to know its colour: the move endpoint resolves the bot's seat **server-side**, so the -bot reacts to every roll and the server applies the move only on this bot's turn (off-turn submissions -are harmlessly rejected). +It never needs to know its colour **to be correct**: the move endpoint resolves the bot's seat +server-side, so reacting to every roll is always safe — the server applies the move only on this bot's +turn (off-turn submissions are harmlessly rejected). Knowing the colour is only an optimisation: the +game stream carries both sides' rolls, so the bot reads its own seat once per game from +`GET /bot/games` and skips the search on the opponent's rolls — halving the search work. If that +lookup fails the bot simply searches on every roll again, as it always did. ## Quickstart (play the house bot, no registration) diff --git a/src/main/scala/dicechess/refbot/Protocol.scala b/src/main/scala/dicechess/refbot/Protocol.scala index 4043e8a..35a056e 100644 --- a/src/main/scala/dicechess/refbot/Protocol.scala +++ b/src/main/scala/dicechess/refbot/Protocol.scala @@ -68,6 +68,16 @@ object Protocol: final case class Challenge(id: String, challenger: Principal, target: Principal) final case class BotGame(gameId: String) + + /** One entry of `GET /bot/games`. The server sends more (`activeSeat`, `dicePending`, `timeControl`, `clocks`, + * `version`) — deliberately not modelled, because the game stream already carries all of it live. What only this + * listing knows is `seat`: which side the *caller* holds, the one thing the event stream never says. + */ + final case class BotActiveGame(gameId: String, seat: Seat) + + /** `GET /bot/games` 200: every live game the caller is seated in. */ + final case class BotGames(games: List[BotActiveGame]) + final case class ChallengeTarget(team: String, name: String) final case class BotMove(moves: List[String]) @@ -107,6 +117,8 @@ object Protocol: given Codec[BotEvent] = deriveCodec given Codec[Challenge] = deriveCodec given Codec[BotGame] = deriveCodec + given Codec[BotActiveGame] = deriveCodec + given Codec[BotGames] = deriveCodec given Codec[ChallengeTarget] = deriveCodec given Codec[BotMove] = deriveCodec given Codec[BotSeed] = deriveCodec diff --git a/src/main/scala/dicechess/refbot/ReferenceBot.scala b/src/main/scala/dicechess/refbot/ReferenceBot.scala index e823e49..145a091 100644 --- a/src/main/scala/dicechess/refbot/ReferenceBot.scala +++ b/src/main/scala/dicechess/refbot/ReferenceBot.scala @@ -19,17 +19,20 @@ import org.http4s.{AuthScheme, Credentials, Request, Status} import java.security.SecureRandom import scala.concurrent.duration.* -/** Per-game memory: the highest game-event version already handled (turn de-duplication) plus the time control, which - * rides only on the Snapshot yet is needed to budget the increment on later DiceRolled turns. +/** Per-game memory: the highest game-event version already handled (turn de-duplication), the time control, which rides + * only on the Snapshot yet is needed to budget the increment on later DiceRolled turns, and the seat this bot holds — + * `None` when it could not be resolved (see [[ReferenceBot.ownSeat]]). */ -final private case class GameMemory(handled: Long, timeControl: Option[TimeControl]) +final private case class GameMemory(handled: Long, timeControl: Option[TimeControl], ownSeat: Option[Seat]) /** A Lichess-bot-style client of the Dice Chess Bot API: it listens on the account stream, accepts incoming challenges, * and plays each game with the engine. * - * It never needs to know which colour it holds: the move endpoint resolves the bot's seat server-side, so the bot - * simply reacts to every dice roll by computing and submitting a move — the server applies it only when it is in fact - * this bot's turn (off-turn submissions are harmlessly rejected). + * It never needs to know which colour it holds *to be correct*: the move endpoint resolves the bot's seat server-side, + * so reacting to every dice roll is always safe — the server applies the move only when it is in fact this bot's turn + * (off-turn submissions are harmlessly rejected). Knowing the seat is purely an optimisation, and the code treats it + * as one: it looks the seat up once per game to skip the search on the opponent's rolls, and if the lookup fails it + * falls back to searching on every roll rather than freezing. */ final class ReferenceBot(config: Config, client: Client[IO], supervisor: Supervisor[IO], strategy: Strategy): @@ -137,18 +140,37 @@ final class ReferenceBot(config: Config, client: Client[IO], supervisor: Supervi private def describe(error: Throwable): String = error.toString.take(120) /** Stream one game to its terminal, submitting a move on each fresh dice roll for our turn. Contributes this bot's - * dice seed first so the server's opening-roll gate can open promptly (otherwise it waits out the grace). + * dice seed first so the server's opening-roll gate can open promptly (otherwise it waits out the grace), then + * resolves our seat once so the opponent's rolls can be skipped. Both run before the subscription without losing + * anything: the stream opens with a Snapshot of the current position. */ private def playGame(gameId: String): IO[Unit] = submitSeed(gameId) *> - Ref - .of[IO, GameMemory](GameMemory(handled = -1L, timeControl = None)) + ownSeat(gameId) + .flatMap(seat => Ref.of[IO, GameMemory](GameMemory(handled = -1L, timeControl = None, ownSeat = seat))) .flatMap: mem => ndjson[GameEvent](Request[IO](GET, config.baseUri / "bot" / "game" / "stream" / gameId).putHeaders(auth)) .evalMap(event => onGameEvent(gameId, mem, event)) .compile .drain + /** Which seat this bot holds in `gameId`. The game stream never says — every event reports the seat it is *about* — + * so ask `GET /bot/games`, the one endpoint that answers from the caller's perspective. + * + * Best-effort by design: any failure (a game not yet in the listing, a blip, an older server) yields `None`, which + * degrades to the historical behaviour of searching on every roll. Never let this optimisation stop the bot from + * playing. + */ + private def ownSeat(gameId: String): IO[Option[Seat]] = + fetch[BotGames](Request[IO](GET, config.baseUri / "bot" / "games").putHeaders(auth)).flatMap: + case Right(listing) => + val seat = listing.games.collectFirst { case game if game.gameId == gameId => game.seat } + seat match + case Some(s) => IO.println(s"[refbot] game $gameId seated as $s").as(seat) + case None => IO.println(s"[refbot] game $gameId seat unknown — searching every roll").as(None) + case Left(error) => + IO.println(s"[refbot] game $gameId seat lookup failed (searching every roll): ${describe(error)}").as(None) + /** Contribute this bot's post-commit dice entropy (provably-fair, #13) before the opening roll. Best-effort: if it * fails, the server force-starts after its grace and this seat falls back to its id, so the game still proceeds. */ @@ -166,12 +188,16 @@ final class ReferenceBot(config: Config, client: Client[IO], supervisor: Supervi private def onGameEvent(gameId: String, mem: Ref[IO, GameMemory], event: GameEvent): IO[Unit] = event match case GameEvent.DiceRolled(v, seat, _, dfen, clocks) => - mem.get.flatMap(m => maybeMove(gameId, mem, v, dfen, turnClock(seat, clocks, m.timeControl))) + mem.get.flatMap: m => + if !ReferenceBot.shouldSearch(m.ownSeat, seat) then IO.unit + else maybeMove(gameId, mem, v, dfen, turnClock(seat, clocks, m.timeControl)) case GameEvent.Snapshot(v, ps) => // The time control rides only on the Snapshot; remember it so later DiceRolled turns can carry the increment. - mem.update(_.copy(timeControl = ps.timeControl)) *> - (if ps.dicePending then maybeMove(gameId, mem, v, ps.dfen, turnClock(ps.activeSeat, ps.clocks, ps.timeControl)) - else IO.unit) + mem + .updateAndGet(_.copy(timeControl = ps.timeControl)) + .flatMap: m => + if !(ps.dicePending && ReferenceBot.shouldSearch(m.ownSeat, ps.activeSeat)) then IO.unit + else maybeMove(gameId, mem, v, ps.dfen, turnClock(ps.activeSeat, ps.clocks, ps.timeControl)) case GameEvent.GameEnded(_, over) => IO.println(s"[refbot] game $gameId ended: ${over.result} (${over.termination})") case _ => IO.unit @@ -218,3 +244,17 @@ final class ReferenceBot(config: Config, client: Client[IO], supervisor: Supervi .filter(_.nonEmpty) .map(decode[A]) .collect { case Right(value) => value } + +object ReferenceBot: + + /** Is a position with `toMove` on the move worth searching? + * + * The dice are rolled for both sides on the same stream, and a search costs real CPU — under a strategy that + * serializes concurrent games behind one model session it also costs queue time that the other games' clocks pay + * for. So skip the opponent's rolls: the submission they produce is rejected server-side anyway. + * + * `ownSeat = None` means the seat could not be resolved, and then this must answer `true`. Correctness never depends + * on knowing our colour (the server arbitrates); an unknown seat may only cost the wasted search it was meant to + * save, never a missed turn. + */ + private[refbot] def shouldSearch(ownSeat: Option[Seat], toMove: Seat): Boolean = ownSeat.forall(_ == toMove) diff --git a/src/test/scala/dicechess/refbot/ProtocolSuite.scala b/src/test/scala/dicechess/refbot/ProtocolSuite.scala index 1542af3..3afdc9f 100644 --- a/src/test/scala/dicechess/refbot/ProtocolSuite.scala +++ b/src/test/scala/dicechess/refbot/ProtocolSuite.scala @@ -65,6 +65,20 @@ class ProtocolSuite extends munit.FunSuite: assertEquals(ChallengeTarget("acme", "bob").asJson.noSpaces, """{"team":"acme","name":"bob"}""") assertEquals(BotSeed("deadbeefdeadbeef").asJson.noSpaces, """{"seed":"deadbeefdeadbeef"}""") + // GET /bot/games — the only place the wire says which seat WE hold. Play-api's BotActiveGame carries five more + // fields the bot deliberately ignores; pinning the real payload proves the subset still decodes. + test("decodes the caller's own seat out of the /bot/games listing"): + assertEquals( + decode[BotGames]( + """{"games":[{"gameId":"g1","seat":"Black","activeSeat":"White","dicePending":true,""" + + """"timeControl":{"Fischer":{"initialSeconds":300,"incrementSeconds":3}},""" + + """"clocks":{"white":300000,"black":300000},"version":3}]}""" + ), + Right(BotGames(List(BotActiveGame("g1", Seat.Black)))) + ) + // No live games (e.g. the listing raced ahead of the game) — an empty list, not an error. + assertEquals(decode[BotGames]("""{"games":[]}"""), Right(BotGames(Nil))) + test("decodes the seek-keeper wire (#14): the created seek and both status shapes"): // POST /bot/seeks 201 — exactly what play-api's CreatedSeek emits. assertEquals( diff --git a/src/test/scala/dicechess/refbot/ReferenceBotSuite.scala b/src/test/scala/dicechess/refbot/ReferenceBotSuite.scala new file mode 100644 index 0000000..3d8a7cf --- /dev/null +++ b/src/test/scala/dicechess/refbot/ReferenceBotSuite.scala @@ -0,0 +1,18 @@ +package dicechess.refbot + +import dicechess.refbot.Protocol.Seat + +class ReferenceBotSuite extends munit.FunSuite: + + test("searches only on our own rolls once the seat is known"): + assert(ReferenceBot.shouldSearch(Some(Seat.White), Seat.White)) + assert(ReferenceBot.shouldSearch(Some(Seat.Black), Seat.Black)) + assert(!ReferenceBot.shouldSearch(Some(Seat.White), Seat.Black)) + assert(!ReferenceBot.shouldSearch(Some(Seat.Black), Seat.White)) + + // The invariant the whole optimisation hangs on: the bot must never need its colour to keep playing. An unresolved + // seat has to degrade to the historical behaviour — search every roll, let the server reject the off-turn ones — + // rather than sit out its own turns. + test("an unknown seat falls back to searching every roll"): + assert(ReferenceBot.shouldSearch(None, Seat.White)) + assert(ReferenceBot.shouldSearch(None, Seat.Black))