From 8e3c54bfb82434580973a87e9fbe043934c3412d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jegors=20=C4=8Cemisovs?= Date: Thu, 9 Jul 2026 08:58:05 +0300 Subject: [PATCH 1/5] feat: support configuring seek time control via env var (#32) Configure standing seeks with a time control using the BOT_SEEK_TIME_CONTROL env var. Closes #32 --- src/main/scala/dicechess/refbot/Config.scala | 31 ++++++++++++++----- .../scala/dicechess/refbot/ReferenceBot.scala | 6 +++- .../scala/dicechess/refbot/ConfigSuite.scala | 24 ++++++++++++++ 3 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 src/test/scala/dicechess/refbot/ConfigSuite.scala diff --git a/src/main/scala/dicechess/refbot/Config.scala b/src/main/scala/dicechess/refbot/Config.scala index 94b739a..6c460c5 100644 --- a/src/main/scala/dicechess/refbot/Config.scala +++ b/src/main/scala/dicechess/refbot/Config.scala @@ -1,6 +1,7 @@ package dicechess.refbot import cats.effect.IO +import dicechess.refbot.Protocol.TimeControl import org.http4s.Uri /** Runtime configuration, from the environment. @@ -10,25 +11,39 @@ import org.http4s.Uri * - `BOT_ALGORITHM` — engine search algorithm (default `greedy`) * - `BOT_CHALLENGE` — optional `team|name` to challenge on startup (for bot-vs-bot demos) * - `BOT_OPEN_SEEKS` — standing lobby seeks to hold open so humans always find this bot (default `0` = none) + * - `BOT_SEEK_TIME_CONTROL` — optional seek time control, e.g. `10+10` (Fischer) or `10` (Sudden Death) */ final case class Config( baseUri: Uri, token: String, algorithm: String, challenge: Option[(String, String)], - openSeeks: Int + openSeeks: Int, + seekTimeControl: Option[TimeControl] ) object Config: - def fromEnv: IO[Config] = IO: - val base = sys.env.getOrElse("PLAY_API_BASE_URL", "http://localhost:8080") - val token = sys.env.getOrElse("BOT_TOKEN", "") - val algorithm = sys.env.getOrElse("BOT_ALGORITHM", "greedy") - val challenge = sys.env.get("BOT_CHALLENGE").filter(_.nonEmpty).flatMap { spec => + def fromMap(env: Map[String, String]): Config = + val base = env.getOrElse("PLAY_API_BASE_URL", "http://localhost:8080") + val token = env.getOrElse("BOT_TOKEN", "") + val algorithm = env.getOrElse("BOT_ALGORITHM", "greedy") + val challenge = env.get("BOT_CHALLENGE").filter(_.nonEmpty).flatMap { spec => spec.split('|') match case Array(team, name) if team.nonEmpty && name.nonEmpty => Some(team -> name) case _ => None } - val openSeeks = sys.env.get("BOT_OPEN_SEEKS").flatMap(_.toIntOption).filter(_ > 0).getOrElse(0) - Config(Uri.unsafeFromString(base), token, algorithm, challenge, openSeeks) + val openSeeks = env.get("BOT_OPEN_SEEKS").flatMap(_.toIntOption).filter(_ > 0).getOrElse(0) + val seekTimeControl = env.get("BOT_SEEK_TIME_CONTROL").filter(_.nonEmpty).flatMap { spec => + spec.trim.split('+') match + case Array(initialMin, incSec) => + for + initSec <- initialMin.toIntOption.map(_ * 60) + inc <- incSec.toIntOption + yield TimeControl.Fischer(initSec, inc) + case _ => + spec.trim.toIntOption.map(min => TimeControl.SuddenDeath(min * 60)) + } + Config(Uri.unsafeFromString(base), token, algorithm, challenge, openSeeks, seekTimeControl) + + def fromEnv: IO[Config] = IO(fromMap(sys.env)) diff --git a/src/main/scala/dicechess/refbot/ReferenceBot.scala b/src/main/scala/dicechess/refbot/ReferenceBot.scala index b843ac0..e823e49 100644 --- a/src/main/scala/dicechess/refbot/ReferenceBot.scala +++ b/src/main/scala/dicechess/refbot/ReferenceBot.scala @@ -8,6 +8,7 @@ import dicechess.refbot.Protocol.given import fs2.Stream import io.circe.Decoder import io.circe.parser.decode +import io.circe.syntax.* import org.http4s.Method.* import org.http4s.circe.CirceEntityCodec.given import org.http4s.client.{Client, UnexpectedStatus} @@ -114,7 +115,10 @@ final class ReferenceBot(config: Config, client: Client[IO], supervisor: Supervi private def topUpSeeks(held: Ref[IO, Map[String, String]]): IO[Unit] = held.get.flatMap { current => List.fill(config.openSeeks - current.size)(()).traverse_ { _ => - fetch[CreatedSeek](POST(io.circe.Json.obj(), config.baseUri / "bot" / "seeks").putHeaders(auth)).flatMap { + val body = config.seekTimeControl match + case Some(tc) => io.circe.Json.obj("timeControl" -> tc.asJson) + case None => io.circe.Json.obj() + fetch[CreatedSeek](POST(body, config.baseUri / "bot" / "seeks").putHeaders(auth)).flatMap { case Right(created) => IO.println(s"[refbot] standing seek ${created.seekId} posted") *> held.update(_.updated(created.seekId, created.secret)) diff --git a/src/test/scala/dicechess/refbot/ConfigSuite.scala b/src/test/scala/dicechess/refbot/ConfigSuite.scala new file mode 100644 index 0000000..6885639 --- /dev/null +++ b/src/test/scala/dicechess/refbot/ConfigSuite.scala @@ -0,0 +1,24 @@ +package dicechess.refbot + +import dicechess.refbot.Protocol.TimeControl + +class ConfigSuite extends munit.FunSuite: + + test("parses BOT_SEEK_TIME_CONTROL correctly"): + val cases = List( + ("10+10", Some(TimeControl.Fischer(600, 10))), + ("5+3", Some(TimeControl.Fischer(300, 3))), + ("10", Some(TimeControl.SuddenDeath(600))), + ("5", Some(TimeControl.SuddenDeath(300))), + ("", None), + (" ", None) + ) + + cases.foreach { case (envValue, expected) => + val config = Config.fromMap(Map("BOT_SEEK_TIME_CONTROL" -> envValue)) + assertEquals(config.seekTimeControl, expected) + } + + test("defaults to None if BOT_SEEK_TIME_CONTROL is absent"): + val config = Config.fromMap(Map.empty) + assertEquals(config.seekTimeControl, None) From d19cb8a2758e276ca0997a6937229a6040a5a3aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jegors=20=C4=8Cemisovs?= Date: Thu, 9 Jul 2026 08:59:06 +0300 Subject: [PATCH 2/5] chore: configure seek time control env var in compose --- docker-compose.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index c8edc7b..59bfd81 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,4 +16,5 @@ services: BOT_ALGORITHM: ${BOT_ALGORITHM:-greedy} # Standing lobby seeks (#14): keep N open offers so humans always find the house bot to play. BOT_OPEN_SEEKS: ${BOT_OPEN_SEEKS:-2} + BOT_SEEK_TIME_CONTROL: ${BOT_SEEK_TIME_CONTROL:-10+10} JAVA_OPTS: "-Dcats.effect.warnOnNonMainThreadDetected=false --sun-misc-unsafe-memory-access=allow" From 8612c756ecf7b51c927a16c8e48106248ff2aca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jegors=20=C4=8Cemisovs?= Date: Thu, 9 Jul 2026 09:02:03 +0300 Subject: [PATCH 3/5] chore: bump dicechess-engine-scala version to 1.7.0 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index f38a564..a687fb7 100644 --- a/build.sbt +++ b/build.sbt @@ -21,7 +21,7 @@ credentials ++= (for { user = sys.env.get("GITHUB_ACTOR").filter(_.nonEmpty).getOrElse("git") } yield Credentials("GitHub Package Registry", "maven.pkg.github.com", user, token)).toSeq -val DiceChessEngineVersion = "1.6.1" +val DiceChessEngineVersion = "1.7.0" val CatsEffectVersion = "3.7-4972921" val Fs2Version = "3.13.0" val Http4sVersion = "0.23.30" From a07f309bbed853720eee4b3e716faeb4d181b1ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jegors=20=C4=8Cemisovs?= Date: Thu, 9 Jul 2026 09:03:07 +0300 Subject: [PATCH 4/5] feat: trim whitespace around seek time control delimiters --- src/main/scala/dicechess/refbot/Config.scala | 2 +- src/test/scala/dicechess/refbot/ConfigSuite.scala | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/scala/dicechess/refbot/Config.scala b/src/main/scala/dicechess/refbot/Config.scala index 6c460c5..a0f0fac 100644 --- a/src/main/scala/dicechess/refbot/Config.scala +++ b/src/main/scala/dicechess/refbot/Config.scala @@ -35,7 +35,7 @@ object Config: } val openSeeks = env.get("BOT_OPEN_SEEKS").flatMap(_.toIntOption).filter(_ > 0).getOrElse(0) val seekTimeControl = env.get("BOT_SEEK_TIME_CONTROL").filter(_.nonEmpty).flatMap { spec => - spec.trim.split('+') match + spec.trim.split('+').map(_.trim) match case Array(initialMin, incSec) => for initSec <- initialMin.toIntOption.map(_ * 60) diff --git a/src/test/scala/dicechess/refbot/ConfigSuite.scala b/src/test/scala/dicechess/refbot/ConfigSuite.scala index 6885639..37844bb 100644 --- a/src/test/scala/dicechess/refbot/ConfigSuite.scala +++ b/src/test/scala/dicechess/refbot/ConfigSuite.scala @@ -7,8 +7,11 @@ class ConfigSuite extends munit.FunSuite: test("parses BOT_SEEK_TIME_CONTROL correctly"): val cases = List( ("10+10", Some(TimeControl.Fischer(600, 10))), + ("10 + 10", Some(TimeControl.Fischer(600, 10))), + (" 5 + 3 ", Some(TimeControl.Fischer(300, 3))), ("5+3", Some(TimeControl.Fischer(300, 3))), ("10", Some(TimeControl.SuddenDeath(600))), + (" 10 ", Some(TimeControl.SuddenDeath(600))), ("5", Some(TimeControl.SuddenDeath(300))), ("", None), (" ", None) From a81ea7f7369fe7964f83931c9f684b7379e6fd15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jegors=20=C4=8Cemisovs?= Date: Thu, 9 Jul 2026 09:04:58 +0300 Subject: [PATCH 5/5] docs: document BOT_SEEK_TIME_CONTROL env var in README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cea50ae..feb440c 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ server validates with, so a move your `Strategy` returns is accepted iff it's le | `BOT_ALGORITHM` | `greedy` | engine search algorithm (used by the default `EngineStrategy`) | | `BOT_CHALLENGE` | — | optional `team\|name` to challenge on startup (e.g. `house\|greedy`) | | `BOT_OPEN_SEEKS` | `0` | standing lobby seeks to keep open, so humans browsing the lobby always find this bot to play | +| `BOT_SEEK_TIME_CONTROL` | — | optional seek time control, e.g. `10+10` (Fischer) or `10` (Sudden Death) | ## Self-play (local)