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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
12 changes: 12 additions & 0 deletions src/main/scala/dicechess/refbot/Protocol.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down Expand Up @@ -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
Expand Down
66 changes: 53 additions & 13 deletions src/main/scala/dicechess/refbot/ReferenceBot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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.
*/
Expand All @@ -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
Expand Down Expand Up @@ -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)
14 changes: 14 additions & 0 deletions src/test/scala/dicechess/refbot/ProtocolSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions src/test/scala/dicechess/refbot/ReferenceBotSuite.scala
Original file line number Diff line number Diff line change
@@ -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))