From b2e5208ff402c24c3dd00917f12018f143ed678c Mon Sep 17 00:00:00 2001 From: rotund_tapir Date: Wed, 29 Jul 2026 21:23:27 +0100 Subject: [PATCH] cardkit-server: GameDescriptor.bot() takes the lobby config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while writing euchre's descriptor: EuchreBot(benny: Boolean) needs to know whether the Benny joker house rule is on, and bot() had no way to tell it. A bot built for the wrong ruleset does not fail loudly — it misplays, which is the worst kind of wrong for something that covers a disconnected player's hand. The room builds its bot from the config it was created with. Only the timeouts can change afterwards (RoomCommand.Configure), never the house rules, so the bot cannot go stale. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019EH6XkA2oEhv4YnMXt8hbJ Signed-off-by: rotund_tapir --- .../rotundtapir/cardkit/server/GameDescriptor.kt | 11 ++++++++--- .../io/github/rotundtapir/cardkit/server/Room.kt | 5 ++++- .../io/github/rotundtapir/cardkit/server/ToyGame.kt | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/cardkit-server/src/main/kotlin/io/github/rotundtapir/cardkit/server/GameDescriptor.kt b/cardkit-server/src/main/kotlin/io/github/rotundtapir/cardkit/server/GameDescriptor.kt index a721c30..50960ab 100644 --- a/cardkit-server/src/main/kotlin/io/github/rotundtapir/cardkit/server/GameDescriptor.kt +++ b/cardkit-server/src/main/kotlin/io/github/rotundtapir/cardkit/server/GameDescriptor.kt @@ -52,12 +52,17 @@ interface GameDescriptor { val botNames: List get() = DEFAULT_BOT_NAMES /** - * A fresh bot strategy for this room. Called once per room and shared by every bot-played seat, - * each with its own seeded [kotlin.random.Random], so the strategy must be stateless. + * A fresh bot strategy for this room's [config]. Called once per room and shared by every + * bot-played seat, each with its own seeded [kotlin.random.Random], so the strategy must be + * stateless. + * + * Takes the config because a bot is a function of the rules it plays under: a house rule that + * adds a card or a bid changes what a correct move is, and a bot built for the wrong ruleset + * misplays rather than failing loudly. * * Prefer a cheap heuristic bot: it runs on the server's single vCPU for every abandoned seat. */ - fun bot(): Strategy + fun bot(config: C): Strategy /** The rules for a lobby's negotiated [config] — the game's house-rule toggles applied. */ fun rulesFor(config: C): GameRules diff --git a/cardkit-server/src/main/kotlin/io/github/rotundtapir/cardkit/server/Room.kt b/cardkit-server/src/main/kotlin/io/github/rotundtapir/cardkit/server/Room.kt index 2a6e43a..496f308 100644 --- a/cardkit-server/src/main/kotlin/io/github/rotundtapir/cardkit/server/Room.kt +++ b/cardkit-server/src/main/kotlin/io/github/rotundtapir/cardkit/server/Room.kt @@ -65,10 +65,13 @@ class Room( ) { private val logger = LoggerFactory.getLogger("room") private val commands = Channel>(Channel.UNLIMITED) - private val bot: Strategy = descriptor.bot() private var lobbyConfig = initialConfig + // Built from the config the room was created with. Only the timeouts can change later + // (RoomCommand.Configure), never the house rules, so the bot never goes stale. + private val bot: Strategy = descriptor.bot(initialConfig) + // Read cross-thread (isPlaying/resumedState from the Ktor handler & /health), written on the actor. @Volatile private var phase = RoomPhase.LOBBY diff --git a/cardkit-server/src/test/kotlin/io/github/rotundtapir/cardkit/server/ToyGame.kt b/cardkit-server/src/test/kotlin/io/github/rotundtapir/cardkit/server/ToyGame.kt index a5ef10a..01f1753 100644 --- a/cardkit-server/src/test/kotlin/io/github/rotundtapir/cardkit/server/ToyGame.kt +++ b/cardkit-server/src/test/kotlin/io/github/rotundtapir/cardkit/server/ToyGame.kt @@ -110,7 +110,7 @@ object ToyDescriptor : GameDescriptor { override val stateSerializer = ToyState.serializer() override val configSerializer = ToyConfig.serializer() - override fun bot(): Strategy = + override fun bot(config: ToyConfig): Strategy = Strategy { view, random -> view.legalActions.ifEmpty { listOf(ToyAction.Move(1)) }.random(random) } override fun rulesFor(config: ToyConfig): GameRules = ToyRules(config.target)