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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
31 changes: 23 additions & 8 deletions src/main/scala/dicechess/refbot/Config.scala
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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('+').map(_.trim) match
case Array(initialMin, incSec) =>
for
initSec <- initialMin.toIntOption.map(_ * 60)
inc <- incSec.toIntOption
yield TimeControl.Fischer(initSec, inc)
Comment on lines +39 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the environment variable contains spaces around the + sign (e.g., "10 + 10"), the split parts will contain leading/trailing spaces (e.g., "10 " and " 10"). Since String.toIntOption does not ignore surrounding whitespace, parsing will fail and return None. Trimming each part before parsing resolves this issue.

        case Array(initialMin, incSec) =>
          for
            initSec <- initialMin.trim.toIntOption.map(_ * 60)
            inc     <- incSec.trim.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))
6 changes: 5 additions & 1 deletion src/main/scala/dicechess/refbot/ReferenceBot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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))
Expand Down
27 changes: 27 additions & 0 deletions src/test/scala/dicechess/refbot/ConfigSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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))),
("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)
)
Comment on lines +8 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add test cases to verify that spaces around the + sign (e.g., "10 + 10") and invalid formats are handled 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))),
      ("10", Some(TimeControl.SuddenDeath(600))),
      ("5", Some(TimeControl.SuddenDeath(300))),
      ("", None),
      ("   ", None),
      ("invalid", None),
      ("10+invalid", 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)