diff options
12 files changed, 239 insertions, 66 deletions
diff --git a/sw-bot/build.gradle.kts b/sw-bot/build.gradle.kts index 64a03734..7c44ca16 100644 --- a/sw-bot/build.gradle.kts +++ b/sw-bot/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } dependencies { - implementation(project(":sw-client")) + api(project(":sw-client")) implementation(kotlin("stdlib-jdk8")) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1") diff --git a/sw-bot/src/main/kotlin/org/luxons/sevenwonders/bot/SevenWondersBot.kt b/sw-bot/src/main/kotlin/org/luxons/sevenwonders/bot/SevenWondersBot.kt index 17619f6a..d1a95d4d 100644 --- a/sw-bot/src/main/kotlin/org/luxons/sevenwonders/bot/SevenWondersBot.kt +++ b/sw-bot/src/main/kotlin/org/luxons/sevenwonders/bot/SevenWondersBot.kt @@ -1,74 +1,119 @@ package org.luxons.sevenwonders.bot -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.withTimeout -import org.luxons.sevenwonders.client.SevenWondersClient -import org.luxons.sevenwonders.client.SevenWondersSession -import org.luxons.sevenwonders.client.joinGameAndWaitLobby -import org.luxons.sevenwonders.model.Action -import org.luxons.sevenwonders.model.MoveType -import org.luxons.sevenwonders.model.PlayerMove -import org.luxons.sevenwonders.model.PlayerTurnInfo +import org.luxons.sevenwonders.client.* +import org.luxons.sevenwonders.model.* +import org.luxons.sevenwonders.model.api.ConnectedPlayer +import org.luxons.sevenwonders.model.api.actions.BotConfig import org.luxons.sevenwonders.model.api.actions.Icon import org.luxons.sevenwonders.model.resources.noTransactions +import org.luxons.sevenwonders.model.wonders.AssignedWonder import org.slf4j.LoggerFactory import kotlin.random.Random -import kotlin.time.Duration import kotlin.time.ExperimentalTime -import kotlin.time.hours - -@OptIn(ExperimentalTime::class) -data class BotConfig( - val minActionDelayMillis: Long = 500, - val maxActionDelayMillis: Long = 1000, - val globalTimeout: Duration = 10.hours, -) private val logger = LoggerFactory.getLogger(SevenWondersBot::class.simpleName) -@OptIn(ExperimentalTime::class, ExperimentalCoroutinesApi::class) +suspend fun SevenWondersClient.connectBot( + serverUrl: String, + name: String, + config: BotConfig = BotConfig(), +): SevenWondersBot { + logger.info("Connecting new bot '$name' to $serverUrl") + val session = connect(serverUrl) + val player = session.chooseName(name, Icon("desktop"), isHuman = false) + return SevenWondersBot(player, config, session) +} + +suspend fun SevenWondersClient.connectBots( + serverUrl: String, + names: List<String>, + config: BotConfig = BotConfig(), +): List<SevenWondersBot> = names.map { connectBot(serverUrl, it, config) } + +@OptIn(ExperimentalTime::class) class SevenWondersBot( - private val displayName: String, - private val botConfig: BotConfig = BotConfig(), + private val player: ConnectedPlayer, + private val config: BotConfig = BotConfig(), + private val session: SevenWondersSession, ) { - private val client = SevenWondersClient() + suspend fun createGameWithBotFriendsAndAutoPlay( + gameName: String, + otherBots: List<SevenWondersBot>, + customWonders: List<AssignedWonder>? = null, + customSettings: Settings? = null, + ): PlayerTurnInfo { + val nJoinerBots = otherBots.size + require(nJoinerBots >= 2) { "At least 2 more bots must join the game" } + require(customWonders == null || customWonders.size == nJoinerBots + 1) { + "Custom wonders don't match the number of players in the game" + } + + val lobby = session.createGameAndWaitLobby(gameName) + otherBots.forEach { + it.session.joinGameAndWaitLobby(lobby.id) + } + + customWonders?.let { session.reassignWonders(it) } + customSettings?.let { session.updateSettings(it) } - suspend fun play(serverUrl: String, gameId: Long) = withTimeout(botConfig.globalTimeout) { - val session = client.connect(serverUrl) - val player = session.chooseName(displayName, Icon("desktop"), isHuman = false) - val gameStartedEvents = session.watchGameStarted() - session.joinGameAndWaitLobby(gameId) - val firstTurn = gameStartedEvents.first() + return withContext(Dispatchers.Default) { + otherBots.forEach { + launch { + val turn = it.session.watchGameStarted().first() + it.autoPlayUntilEnd(turn) + } + } + val firstTurn = session.startGameAndAwaitFirstTurn() + autoPlayUntilEnd(firstTurn) + } + } + + suspend fun joinAndAutoPlay(gameId: Long): PlayerTurnInfo { + val firstTurn = session.joinGameAndWaitFirstTurn(gameId) + return autoPlayUntilEnd(firstTurn) + } + private suspend fun autoPlayUntilEnd(currentTurn: PlayerTurnInfo) = coroutineScope { + val endGameTurnInfo = async { + session.watchTurns().filter { it.action == Action.WATCH_SCORE }.first() + } session.watchTurns() - .onStart { emit(firstTurn) } + .onStart { emit(currentTurn) } .takeWhile { it.action != Action.WATCH_SCORE } - .onCompletion { - session.leaveGame() - session.disconnect() - } + .catch { e -> logger.error("BOT $player: error in turnInfo flow", e) } .collect { turn -> botDelay() val shortTurnDescription = "action ${turn.action}, ${turn.hand?.size ?: 0} cards in hand" logger.info("BOT $player: playing turn ($shortTurnDescription)") - session.playTurn(turn) + session.autoPlayTurn(turn) } + val lastTurn = endGameTurnInfo.await() + logger.info("BOT $player: leaving the game") + session.leaveGame() + session.disconnect() + logger.info("BOT $player: disconnected") + lastTurn } private suspend fun botDelay() { - delay(Random.nextLong(botConfig.minActionDelayMillis, botConfig.maxActionDelayMillis)) + val timeMillis = if (config.minActionDelayMillis == config.maxActionDelayMillis) { + config.minActionDelayMillis + } else { + Random.nextLong(config.minActionDelayMillis, config.maxActionDelayMillis) + } + delay(timeMillis) } +} - private suspend fun SevenWondersSession.playTurn(turn: PlayerTurnInfo) { - when (turn.action) { - Action.PLAY, Action.PLAY_2, Action.PLAY_LAST -> prepareMove(createPlayCardMove(turn)) - Action.PLAY_FREE_DISCARDED -> prepareMove(createPlayFreeDiscardedCardMove(turn)) - Action.PICK_NEIGHBOR_GUILD -> prepareMove(createPickGuildMove(turn)) - Action.SAY_READY -> sayReady() - Action.WAIT, Action.WATCH_SCORE -> Unit - } +private suspend fun SevenWondersSession.autoPlayTurn(turn: PlayerTurnInfo) { + when (turn.action) { + Action.PLAY, Action.PLAY_2, Action.PLAY_LAST -> prepareMove(createPlayCardMove(turn)) + Action.PLAY_FREE_DISCARDED -> prepareMove(createPlayFreeDiscardedCardMove(turn)) + Action.PICK_NEIGHBOR_GUILD -> prepareMove(createPickGuildMove(turn)) + Action.SAY_READY -> sayReady() + Action.WAIT, Action.WATCH_SCORE -> Unit } } @@ -80,9 +125,7 @@ private fun createPlayCardMove(turnInfo: PlayerTurnInfo): PlayerMove { val transactions = wonderBuildability.transactionsOptions.random() return PlayerMove(MoveType.UPGRADE_WONDER, hand.random().name, transactions) } - val playableCard = hand - .filter { it.playability.isPlayable } - .randomOrNull() + val playableCard = hand.filter { it.playability.isPlayable }.randomOrNull() return if (playableCard != null) { PlayerMove(MoveType.PLAY, playableCard.name, playableCard.playability.transactionOptions.random()) } else { diff --git a/sw-client/build.gradle.kts b/sw-client/build.gradle.kts index 421b58dd..68953bc4 100644 --- a/sw-client/build.gradle.kts +++ b/sw-client/build.gradle.kts @@ -13,6 +13,7 @@ kotlin { api(project(":sw-common-model")) api("org.hildan.krossbow:krossbow-stomp-kxserialization:1.1.5") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2") } } val commonTest by getting { diff --git a/sw-client/src/commonMain/kotlin/org/luxons/sevenwonders/client/SevenWondersClient.kt b/sw-client/src/commonMain/kotlin/org/luxons/sevenwonders/client/SevenWondersClient.kt index 68026888..fc097d86 100644 --- a/sw-client/src/commonMain/kotlin/org/luxons/sevenwonders/client/SevenWondersClient.kt +++ b/sw-client/src/commonMain/kotlin/org/luxons/sevenwonders/client/SevenWondersClient.kt @@ -1,5 +1,7 @@ package org.luxons.sevenwonders.client +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -148,8 +150,30 @@ class SevenWondersSession(private val stompSession: StompSessionWithKxSerializat } } -suspend fun SevenWondersSession.joinGameAndWaitLobby(gameId: Long): LobbyDTO { - val joinedLobbies = watchLobbyJoined() +suspend fun SevenWondersSession.createGameAndWaitLobby(gameName: String): LobbyDTO = coroutineScope { + val lobbies = watchLobbyJoined() + val joinedLobby = async { lobbies.first() } + createGame(gameName) + joinedLobby.await() +} + +suspend fun SevenWondersSession.joinGameAndWaitLobby(gameId: Long): LobbyDTO = coroutineScope { + val lobbies = watchLobbyJoined() + val joinedLobby = async { lobbies.first() } + joinGame(gameId) + joinedLobby.await() +} + +suspend fun SevenWondersSession.startGameAndAwaitFirstTurn(): PlayerTurnInfo = coroutineScope { + val gameStartedEvents = watchGameStarted() + val deferredFirstTurn = async { gameStartedEvents.first() } + startGame() + deferredFirstTurn.await() +} + +suspend fun SevenWondersSession.joinGameAndWaitFirstTurn(gameId: Long): PlayerTurnInfo = coroutineScope { + val gameStartedEvents = watchGameStarted() + val deferredFirstTurn = async { gameStartedEvents.first() } joinGame(gameId) - return joinedLobbies.first() + deferredFirstTurn.await() } diff --git a/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/AutoGame.kt b/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/AutoGame.kt new file mode 100644 index 00000000..9822b303 --- /dev/null +++ b/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/AutoGame.kt @@ -0,0 +1,27 @@ +package org.luxons.sevenwonders.model.api + +import kotlinx.serialization.Serializable +import org.luxons.sevenwonders.model.Settings +import org.luxons.sevenwonders.model.TableState +import org.luxons.sevenwonders.model.api.actions.BotConfig +import org.luxons.sevenwonders.model.score.ScoreBoard +import org.luxons.sevenwonders.model.wonders.AssignedWonder +import kotlin.random.Random + +@Serializable +data class AutoGameAction( + val nbPlayers: Int = 3, + val gameName: String = "AutoGame-${Random.nextInt().toString(16)}", + val customSettings: Settings? = null, + val customWonders: List<AssignedWonder>? = null, + /** + * The configuration of the bots that will play the game. + */ + val config: BotConfig = BotConfig(0, 0), +) + +@Serializable +data class AutoGameResult( + val scoreBoard: ScoreBoard, + val table: TableState, +) diff --git a/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/Player.kt b/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/Player.kt index 3963112a..8ed15f24 100644 --- a/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/Player.kt +++ b/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/Player.kt @@ -17,7 +17,9 @@ data class ConnectedPlayer( override val displayName: String, override val isHuman: Boolean, override val icon: Icon?, -) : BasicPlayerInfo +) : BasicPlayerInfo { + override fun toString(): String = "'$displayName' ($username)" +} @Serializable data class PlayerDTO( diff --git a/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/actions/Actions.kt b/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/actions/Actions.kt index b0be3ae0..8d352402 100644 --- a/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/actions/Actions.kt +++ b/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/actions/Actions.kt @@ -106,4 +106,14 @@ class AddBotAction( * The display name for the bot to add. */ val botDisplayName: String, + /** + * The configuration of the bot to add. + */ + val config: BotConfig = BotConfig(), +) + +@Serializable +data class BotConfig( + val minActionDelayMillis: Long = 50, + val maxActionDelayMillis: Long = 500, ) diff --git a/sw-server/build.gradle.kts b/sw-server/build.gradle.kts index 38d48d9c..a129f952 100644 --- a/sw-server/build.gradle.kts +++ b/sw-server/build.gradle.kts @@ -12,7 +12,10 @@ dependencies { implementation(project(":sw-engine")) implementation(project(":sw-bot")) implementation(kotlin("reflect")) // required by Spring 5 - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1") + + val coroutinesVersion = "1.4.2" + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:$coroutinesVersion") // for Spring implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1") implementation("org.springframework.boot:spring-boot-starter-websocket") diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/config/WebSecurityConfig.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/config/WebSecurityConfig.kt index 9557ba1a..aa080189 100644 --- a/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/config/WebSecurityConfig.kt +++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/config/WebSecurityConfig.kt @@ -8,5 +8,7 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur class WebSecurityConfig : WebSecurityConfigurerAdapter() { // this disables default authentication settings - override fun configure(httpSecurity: HttpSecurity) = Unit + override fun configure(httpSecurity: HttpSecurity) { + http.cors().and().csrf().disable() + } } diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/AutoGameController.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/AutoGameController.kt new file mode 100644 index 00000000..4acdc3be --- /dev/null +++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/AutoGameController.kt @@ -0,0 +1,52 @@ +package org.luxons.sevenwonders.server.controllers + +import kotlinx.coroutines.withTimeout +import org.luxons.sevenwonders.bot.connectBot +import org.luxons.sevenwonders.bot.connectBots +import org.luxons.sevenwonders.client.SevenWondersClient +import org.luxons.sevenwonders.model.api.AutoGameAction +import org.luxons.sevenwonders.model.api.AutoGameResult +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController +import java.security.Principal +import kotlin.time.ExperimentalTime +import kotlin.time.minutes + +/** + * Handles actions in the game's lobby. The lobby is the place where players gather before a game. + */ +@RestController +class AutoGameController( + @Value("\${server.port}") private val serverPort: String, +) { + @OptIn(ExperimentalTime::class) + @PostMapping("/autoGame") + suspend fun autoGame(@RequestBody action: AutoGameAction, principal: Principal): AutoGameResult { + logger.info("Starting auto-game {}", action.gameName) + val client = SevenWondersClient() + val serverUrl = "ws://localhost:$serverPort" + + val lastTurn = withTimeout(5.minutes) { + val otherBotNames = List(action.nbPlayers - 1) { "JoinerBot${it + 1}" } + val owner = client.connectBot(serverUrl, "OwnerBot", action.config) + val joiners = client.connectBots(serverUrl, otherBotNames, action.config) + + owner.createGameWithBotFriendsAndAutoPlay( + gameName = action.gameName, + otherBots = joiners, + customWonders = action.customWonders, + customSettings = action.customSettings, + ) + } + + val scoreBoard = lastTurn.scoreBoard ?: error("Last turn info doesn't have scoreboard") + return AutoGameResult(scoreBoard, lastTurn.table) + } + + companion object { + private val logger = LoggerFactory.getLogger(AutoGameController::class.java) + } +} diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/LobbyController.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/LobbyController.kt index aa01a23a..557a1714 100644 --- a/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/LobbyController.kt +++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/LobbyController.kt @@ -2,7 +2,10 @@ package org.luxons.sevenwonders.server.controllers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import org.luxons.sevenwonders.bot.SevenWondersBot +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.luxons.sevenwonders.bot.connectBot +import org.luxons.sevenwonders.client.SevenWondersClient import org.luxons.sevenwonders.model.api.GameListEvent import org.luxons.sevenwonders.model.api.actions.AddBotAction import org.luxons.sevenwonders.model.api.actions.ReassignWondersAction @@ -22,6 +25,8 @@ import org.springframework.messaging.simp.SimpMessagingTemplate import org.springframework.stereotype.Controller import org.springframework.validation.annotation.Validated import java.security.Principal +import kotlin.time.ExperimentalTime +import kotlin.time.hours /** * Handles actions in the game's lobby. The lobby is the place where players gather before a game. @@ -144,14 +149,19 @@ class LobbyController( template.convertAndSend("/topic/games", GameListEvent.CreateOrUpdate(lobbyDto).wrap()) } + @OptIn(ExperimentalTime::class) @MessageMapping("/lobby/addBot") fun addBot(@Validated action: AddBotAction, principal: Principal) { val lobby = principal.player.ownedLobby - val bot = SevenWondersBot(action.botDisplayName) + val bot = runBlocking { + SevenWondersClient().connectBot("ws://localhost:$serverPort", action.botDisplayName, action.config) + } + logger.info("Starting bot {} in game '{}'", action.botDisplayName, lobby.name) GlobalScope.launch { - bot.play("ws://localhost:$serverPort", lobby.id) + withTimeout(6.hours) { + bot.joinAndAutoPlay(lobby.id) + } } - logger.info("Added bot {} to game '{}'", action.botDisplayName, lobby.name) } /** diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/SevenWondersTest.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/SevenWondersTest.kt index 02f43fcf..7c830140 100644 --- a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/SevenWondersTest.kt +++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/SevenWondersTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.junit.runner.RunWith import org.luxons.sevenwonders.client.SevenWondersClient import org.luxons.sevenwonders.client.SevenWondersSession +import org.luxons.sevenwonders.client.createGameAndWaitLobby import org.luxons.sevenwonders.client.joinGameAndWaitLobby import org.luxons.sevenwonders.model.Action import org.luxons.sevenwonders.model.api.GameListEvent @@ -60,7 +61,7 @@ class SevenWondersTest { val session2 = newPlayer("Player2") val gameName = "Test Game" - val lobby = ownerSession.createGameAndWaitLobby(gameName) + val lobby = ownerSession.createGameWithLegacySettingsAndWaitLobby(gameName) session1.joinGameAndWaitLobby(lobby.id) session2.joinGameAndWaitLobby(lobby.id) @@ -80,7 +81,7 @@ class SevenWondersTest { val ownerSession = newPlayer("GameOwner") val gameName = "Test Game" - val lobby = ownerSession.createGameAndWaitLobby(gameName) + val lobby = ownerSession.createGameWithLegacySettingsAndWaitLobby(gameName) assertEquals(gameName, lobby.name) disconnect(ownerSession) @@ -97,7 +98,7 @@ class SevenWondersTest { val ownerSession = newPlayer("GameOwner") val gameName = "Test Game" - val createdLobby = ownerSession.createGameAndWaitLobby(gameName) + val createdLobby = ownerSession.createGameWithLegacySettingsAndWaitLobby(gameName) val afterGameListEvent = withTimeout(500) { games.receive() } assertTrue(afterGameListEvent is GameListEvent.CreateOrUpdate) @@ -114,7 +115,7 @@ class SevenWondersTest { val session2 = newPlayer("Player2") val startEvents1 = session1.watchGameStarted() - val lobby = session1.createGameAndWaitLobby("Test Game") + val lobby = session1.createGameWithLegacySettingsAndWaitLobby("Test Game") val startEvents2 = session2.watchGameStarted() session2.joinGameAndWaitLobby(lobby.id) @@ -146,10 +147,8 @@ class SevenWondersTest { } } -private suspend fun SevenWondersSession.createGameAndWaitLobby(gameName: String): LobbyDTO { - val joinedLobbies = watchLobbyJoined() - createGame(gameName) - val lobby = joinedLobbies.first() +private suspend fun SevenWondersSession.createGameWithLegacySettingsAndWaitLobby(gameName: String): LobbyDTO { + val lobby = createGameAndWaitLobby(gameName) updateSettings(lobby.settings.copy(askForReadiness = true)) return lobby } |