diff options
21 files changed, 441 insertions, 296 deletions
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 7530b40e..4442ad32 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 @@ -7,6 +7,7 @@ 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.api.events.GameEvent import org.luxons.sevenwonders.model.resources.noTransactions import org.luxons.sevenwonders.model.wonders.AssignedWonder import org.slf4j.LoggerFactory @@ -21,7 +22,7 @@ suspend fun SevenWondersClient.connectBot( ): SevenWondersBot { logger.info("Connecting new bot '$name' to $serverUrl") val session = connect(serverUrl) - val player = session.chooseName(name, Icon("desktop"), isHuman = false) + val player = session.chooseNameAndAwait(name, Icon("desktop"), isHuman = false) return SevenWondersBot(player, config, session) } @@ -62,40 +63,51 @@ class SevenWondersBot( return withContext(Dispatchers.Default) { otherBots.forEach { - launch { - val turn = it.session.watchGameStarted().first() - it.autoPlayUntilEnd(turn) - } + launch { it.autoPlayUntilEnd() } } - val firstTurn = session.startGameAndAwaitFirstTurn() - autoPlayUntilEnd(firstTurn) + val endTurn = async { autoPlayUntilEnd() } + session.startGame() + endTurn.await() } } - suspend fun joinAndAutoPlay(gameId: Long): PlayerTurnInfo<*> { - val firstTurn = session.joinGameAndAwaitFirstTurn(gameId) - return autoPlayUntilEnd(firstTurn) + suspend fun joinAndAutoPlay(gameId: Long) = coroutineScope { + launch { autoPlayUntilEnd() } + session.joinGame(gameId) } - private suspend fun autoPlayUntilEnd(currentTurn: PlayerTurnInfo<*>) = coroutineScope { + private suspend fun autoPlayUntilEnd(): PlayerTurnInfo<TurnAction.WatchScore> = coroutineScope { val endGameTurnInfo = async { - session.watchTurns().filter { it.action is TurnAction.WatchScore }.first() + @Suppress("UNCHECKED_CAST") + session.watchTurns() + .filter { it.action is TurnAction.WatchScore } + .first() as PlayerTurnInfo<TurnAction.WatchScore> } - session.watchTurns() - .onStart { - session.sayReady() - emit(currentTurn) - } - .takeWhile { it.action !is TurnAction.WatchScore } - .catch { e -> logger.error("BOT $player: error in turnInfo flow", e) } - .collect { turn -> - botDelay() - logger.info("BOT $player: playing turn (action ${turn.action})") - session.autoPlayTurn(turn) + session.watchGameEvents() + .catch { e -> logger.error("BOT $player: error in game events flow", e) } + .takeWhile { it !is GameEvent.LobbyLeft } + .collect { event -> + when (event) { + is GameEvent.NameChosen -> error("Unexpected name chosen event in bot") + is GameEvent.GameStarted -> session.sayReady() + is GameEvent.NewTurnStarted -> if (event.turnInfo.action is TurnAction.WatchScore) { + logger.info("BOT $player: leaving the game") + session.leaveGame() + } else { + botDelay() + logger.info("BOT $player: playing turn (action ${event.turnInfo.action})") + session.autoPlayTurn(event.turnInfo) + } + is GameEvent.LobbyJoined, + is GameEvent.LobbyUpdated, + is GameEvent.PlayerIsReady, + is GameEvent.MovePrepared, + GameEvent.MoveUnprepared, + is GameEvent.CardPrepared -> Unit // ignore those + GameEvent.LobbyLeft -> error("Unexpected lobby left event in bot") // collect should have ended + } } val lastTurn = endGameTurnInfo.await() - logger.info("BOT $player: leaving the game") - session.leaveGameAndAwaitEnd() session.disconnect() logger.info("BOT $player: disconnected") lastTurn 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 d14b07b7..f11439ef 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 @@ -4,8 +4,10 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.* -import kotlinx.serialization.builtins.serializer +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import org.hildan.krossbow.stomp.StompClient import org.hildan.krossbow.stomp.config.HeartBeat import org.hildan.krossbow.stomp.config.HeartBeatTolerance @@ -15,13 +17,11 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe import org.hildan.krossbow.stomp.conversions.kxserialization.withJsonConversions import org.hildan.krossbow.stomp.sendEmptyMsg import org.luxons.sevenwonders.model.PlayerMove -import org.luxons.sevenwonders.model.PlayerTurnInfo import org.luxons.sevenwonders.model.Settings import org.luxons.sevenwonders.model.api.* import org.luxons.sevenwonders.model.api.actions.* import org.luxons.sevenwonders.model.api.errors.ErrorDTO -import org.luxons.sevenwonders.model.api.events.GameEvent -import org.luxons.sevenwonders.model.api.events.GameEventWrapper +import org.luxons.sevenwonders.model.api.events.* import org.luxons.sevenwonders.model.wonders.AssignedWonder class SevenWondersClient { @@ -43,27 +43,20 @@ class SevenWondersSession(private val stompSession: StompSessionWithKxSerializat suspend fun watchErrors(): Flow<ErrorDTO> = stompSession.subscribe("/user/queue/errors", ErrorDTO.serializer()) - suspend fun chooseName(displayName: String, icon: Icon? = null, isHuman: Boolean = true): ConnectedPlayer { - return doAndWaitForEvent( - send = { - stompSession.convertAndSend( - destination = "/app/chooseName", - body = ChooseNameAction(displayName, icon, isHuman), - serializer = ChooseNameAction.serializer(), - ) - }, - subscribe = { - stompSession.subscribe( - destination = "/user/queue/nameChoice", - deserializer = ConnectedPlayer.serializer(), - ) - } - ) - } - suspend fun watchGames(): Flow<GameListEvent> = stompSession.subscribe("/topic/games", GameListEventWrapper.serializer()).map { it.event } + suspend fun watchGameEvents(): Flow<GameEvent> = + stompSession.subscribe("/user/queue/game/events", GameEventWrapper.serializer()).map { it.event } + + suspend fun chooseName(displayName: String, icon: Icon? = null, isHuman: Boolean = true) { + stompSession.convertAndSend( + destination = "/app/chooseName", + body = ChooseNameAction(displayName, icon, isHuman), + serializer = ChooseNameAction.serializer(), + ) + } + suspend fun createGame(gameName: String) { stompSession.convertAndSend("/app/lobby/create", CreateGameAction(gameName), CreateGameAction.serializer()) } @@ -72,9 +65,6 @@ class SevenWondersSession(private val stompSession: StompSessionWithKxSerializat stompSession.convertAndSend("/app/lobby/join", JoinGameAction(gameId), JoinGameAction.serializer()) } - suspend fun watchLobbyJoined(): Flow<LobbyDTO> = - stompSession.subscribe("/user/queue/lobby/joined", LobbyDTO.serializer()) - suspend fun leaveLobby() { stompSession.sendEmptyMsg("/app/lobby/leave") } @@ -83,8 +73,6 @@ class SevenWondersSession(private val stompSession: StompSessionWithKxSerializat stompSession.sendEmptyMsg("/app/lobby/disband") } - suspend fun watchLobbyLeft(): Flow<Long> = stompSession.subscribe("/user/queue/lobby/left", Long.serializer()) - suspend fun addBot(displayName: String) { stompSession.convertAndSend("/app/lobby/addBot", AddBotAction(displayName), AddBotAction.serializer()) } @@ -113,29 +101,10 @@ class SevenWondersSession(private val stompSession: StompSessionWithKxSerializat ) } - suspend fun watchLobbyUpdates(): Flow<LobbyDTO> = - stompSession.subscribe("/user/queue/lobby/updated", LobbyDTO.serializer()) - - suspend fun watchGameStarted(): Flow<PlayerTurnInfo> = - stompSession.subscribe("/user/queue/lobby/started", PlayerTurnInfo.serializer()) - suspend fun startGame() { stompSession.sendEmptyMsg("/app/lobby/startGame") } - @OptIn(ExperimentalCoroutinesApi::class) - suspend fun watchGameEvents(gameId: Long): Flow<GameEvent> { - val private = watchPublicGameEvents() - val public = watchPrivateGameEvents(gameId) - return merge(private, public) - } - - private suspend fun watchPrivateGameEvents(gameId: Long) = - stompSession.subscribe("/topic/game/$gameId/events", GameEventWrapper.serializer()).map { it.event } - - suspend fun watchPublicGameEvents() = - stompSession.subscribe("/user/queue/game/events", GameEventWrapper.serializer()).map { it.event } - suspend fun sayReady() { stompSession.sendEmptyMsg("/app/game/sayReady") } @@ -157,6 +126,13 @@ class SevenWondersSession(private val stompSession: StompSessionWithKxSerializat } } +suspend fun SevenWondersSession.chooseNameAndAwait(displayName: String, icon: Icon? = null, isHuman: Boolean = true): ConnectedPlayer { + return doAndWaitForEvent( + send = { chooseName(displayName, icon, isHuman) }, + subscribe = { watchNameChoice() } + ) +} + suspend fun SevenWondersSession.createGameAndAwaitLobby(gameName: String): LobbyDTO = doAndWaitForEvent( send = { createGame(gameName) }, subscribe = { watchLobbyJoined() }, @@ -167,21 +143,6 @@ suspend fun SevenWondersSession.joinGameAndAwaitLobby(gameId: Long): LobbyDTO = subscribe = { watchLobbyJoined() }, ) -suspend fun SevenWondersSession.startGameAndAwaitFirstTurn(): PlayerTurnInfo = doAndWaitForEvent( - send = { startGame() }, - subscribe = { watchGameStarted() }, -) - -suspend fun SevenWondersSession.joinGameAndAwaitFirstTurn(gameId: Long): PlayerTurnInfo = doAndWaitForEvent( - send = { joinGame(gameId) }, - subscribe = { watchGameStarted() }, -) - -suspend fun SevenWondersSession.leaveGameAndAwaitEnd() = doAndWaitForEvent( - send = { leaveGame() }, - subscribe = { watchLobbyLeft() }, -) - @OptIn(ExperimentalCoroutinesApi::class) private suspend fun <T> doAndWaitForEvent(send: suspend () -> Unit, subscribe: suspend () -> Flow<T>): T = coroutineScope { @@ -192,4 +153,10 @@ private suspend fun <T> doAndWaitForEvent(send: suspend () -> Unit, subscribe: s } suspend fun SevenWondersSession.watchTurns() = - watchPublicGameEvents().filterIsInstance<GameEvent.NewTurnStarted>().map { it.turnInfo } + watchGameEvents().filterIsInstance<GameEvent.NewTurnStarted>().map { it.turnInfo } + +suspend fun SevenWondersSession.watchLobbyJoined(): Flow<LobbyDTO> = + watchGameEvents().filterIsInstance<GameEvent.LobbyJoined>().map { it.lobby } + +suspend fun SevenWondersSession.watchNameChoice(): Flow<ConnectedPlayer> = + watchGameEvents().filterIsInstance<GameEvent.NameChosen>().map { it.player } diff --git a/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/Lobby.kt b/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/Lobby.kt index 59515057..2c7e2c5c 100644 --- a/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/Lobby.kt +++ b/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/Lobby.kt @@ -1,36 +1,11 @@ package org.luxons.sevenwonders.model.api -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.luxons.sevenwonders.model.Settings import org.luxons.sevenwonders.model.wonders.PreGameWonder const val SEVEN_WONDERS_WS_ENDPOINT = "/seven-wonders-websocket" -// workaround for https://github.com/Kotlin/kotlinx.serialization/issues/1194 -@Serializable -data class GameListEventWrapper( - val event: GameListEvent -) - -fun GameListEvent.wrap(): GameListEventWrapper = GameListEventWrapper(this) - -@Serializable -sealed class GameListEvent { - - @SerialName("ReplaceList") - @Serializable - data class ReplaceList(val lobbies: List<LobbyDTO>) : GameListEvent() - - @SerialName("CreateOrUpdate") - @Serializable - data class CreateOrUpdate(val lobby: LobbyDTO) : GameListEvent() - - @SerialName("Delete") - @Serializable - data class Delete(val lobbyId: Long) : GameListEvent() -} - enum class State { LOBBY, PLAYING, diff --git a/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/events/Events.kt b/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/events/GameEvents.kt index d8c05e91..c978fb96 100644 --- a/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/events/Events.kt +++ b/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/events/GameEvents.kt @@ -3,6 +3,9 @@ package org.luxons.sevenwonders.model.api.events import kotlinx.serialization.Serializable import org.luxons.sevenwonders.model.PlayerMove import org.luxons.sevenwonders.model.PlayerTurnInfo +import org.luxons.sevenwonders.model.TurnAction +import org.luxons.sevenwonders.model.api.ConnectedPlayer +import org.luxons.sevenwonders.model.api.LobbyDTO import org.luxons.sevenwonders.model.cards.PreparedCard // workaround for https://github.com/Kotlin/kotlinx.serialization/issues/1194 @@ -17,12 +20,30 @@ fun GameEvent.wrap() = GameEventWrapper(this) sealed class GameEvent { @Serializable - data class NewTurnStarted(val turnInfo: PlayerTurnInfo) : GameEvent() + data class NameChosen(val player: ConnectedPlayer) : GameEvent() + + @Serializable + data class LobbyJoined(val lobby: LobbyDTO) : GameEvent() + + @Serializable + data class LobbyUpdated(val lobby: LobbyDTO) : GameEvent() + + @Serializable + object LobbyLeft : GameEvent() + + @Serializable + data class GameStarted(val turnInfo: PlayerTurnInfo<TurnAction.SayReady>) : GameEvent() + + @Serializable + data class NewTurnStarted(val turnInfo: PlayerTurnInfo<*>) : GameEvent() @Serializable data class MovePrepared(val move: PlayerMove) : GameEvent() @Serializable + object MoveUnprepared : GameEvent() + + @Serializable data class CardPrepared(val preparedCard: PreparedCard) : GameEvent() @Serializable diff --git a/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/events/GameListEvents.kt b/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/events/GameListEvents.kt new file mode 100644 index 00000000..2dd8f551 --- /dev/null +++ b/sw-common-model/src/commonMain/kotlin/org/luxons/sevenwonders/model/api/events/GameListEvents.kt @@ -0,0 +1,29 @@ +package org.luxons.sevenwonders.model.api.events + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.luxons.sevenwonders.model.api.LobbyDTO + +// workaround for https://github.com/Kotlin/kotlinx.serialization/issues/1194 +@Serializable +data class GameListEventWrapper( + val event: GameListEvent +) + +fun GameListEvent.wrap(): GameListEventWrapper = GameListEventWrapper(this) + +@Serializable +sealed class GameListEvent { + + @SerialName("ReplaceList") + @Serializable + data class ReplaceList(val lobbies: List<LobbyDTO>) : GameListEvent() + + @SerialName("CreateOrUpdate") + @Serializable + data class CreateOrUpdate(val lobby: LobbyDTO) : GameListEvent() + + @SerialName("Delete") + @Serializable + data class Delete(val lobbyId: Long) : GameListEvent() +} diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/ControllerUtils.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/ControllerUtils.kt new file mode 100644 index 00000000..41796bb6 --- /dev/null +++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/ControllerUtils.kt @@ -0,0 +1,19 @@ +package org.luxons.sevenwonders.server.controllers + +import org.luxons.sevenwonders.model.api.events.GameEvent +import org.luxons.sevenwonders.model.api.events.GameListEvent +import org.luxons.sevenwonders.model.api.events.wrap +import org.luxons.sevenwonders.server.lobby.Player +import org.springframework.messaging.simp.SimpMessageSendingOperations + +internal fun SimpMessageSendingOperations.sendGameEvent(player: Player, event: GameEvent) { + convertAndSendToUser(player.username, "/queue/game/events", event.wrap()) +} + +internal fun SimpMessageSendingOperations.sendGameEvent(players: List<Player>, event: GameEvent) { + players.forEach { sendGameEvent(it, event) } +} + +internal fun SimpMessageSendingOperations.sendGameListEvent(event: GameListEvent) { + convertAndSend("/topic/games", event.wrap()) +} diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/GameBrowserController.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/GameBrowserController.kt index 8e90eb27..d3382c62 100644 --- a/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/GameBrowserController.kt +++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/GameBrowserController.kt @@ -1,11 +1,11 @@ package org.luxons.sevenwonders.server.controllers -import org.luxons.sevenwonders.model.api.GameListEvent -import org.luxons.sevenwonders.model.api.GameListEventWrapper -import org.luxons.sevenwonders.model.api.LobbyDTO import org.luxons.sevenwonders.model.api.actions.CreateGameAction import org.luxons.sevenwonders.model.api.actions.JoinGameAction -import org.luxons.sevenwonders.model.api.wrap +import org.luxons.sevenwonders.model.api.events.GameEvent +import org.luxons.sevenwonders.model.api.events.GameListEvent +import org.luxons.sevenwonders.model.api.events.GameListEventWrapper +import org.luxons.sevenwonders.model.api.events.wrap import org.luxons.sevenwonders.server.ApiMisuseException import org.luxons.sevenwonders.server.api.toDTO import org.luxons.sevenwonders.server.lobby.Lobby @@ -14,8 +14,7 @@ import org.luxons.sevenwonders.server.repositories.LobbyRepository import org.luxons.sevenwonders.server.repositories.PlayerRepository import org.slf4j.LoggerFactory import org.springframework.messaging.handler.annotation.MessageMapping -import org.springframework.messaging.simp.SimpMessagingTemplate -import org.springframework.messaging.simp.annotation.SendToUser +import org.springframework.messaging.simp.SimpMessageSendingOperations import org.springframework.messaging.simp.annotation.SubscribeMapping import org.springframework.stereotype.Controller import org.springframework.validation.annotation.Validated @@ -26,10 +25,10 @@ import java.security.Principal */ @Controller class GameBrowserController( + private val messenger: SimpMessageSendingOperations, private val lobbyController: LobbyController, private val lobbyRepository: LobbyRepository, private val playerRepository: PlayerRepository, - private val template: SimpMessagingTemplate, ) { /** * Gets the created or updated games. The list of existing games is received on this topic at once upon @@ -54,8 +53,7 @@ class GameBrowserController( * @return the newly created [Lobby] */ @MessageMapping("/lobby/create") - @SendToUser("/queue/lobby/joined") - fun createGame(@Validated action: CreateGameAction, principal: Principal): LobbyDTO { + fun createGame(@Validated action: CreateGameAction, principal: Principal) { checkThatUserIsNotInAGame(principal, "cannot create another game") val player = playerRepository.get(principal.name) @@ -65,8 +63,8 @@ class GameBrowserController( // notify everyone that a new game exists val lobbyDto = lobby.toDTO() - template.convertAndSend("/topic/games", GameListEvent.CreateOrUpdate(lobbyDto).wrap()) - return lobbyDto + messenger.sendGameListEvent(GameListEvent.CreateOrUpdate(lobbyDto)) + messenger.sendGameEvent(player, GameEvent.LobbyJoined(lobbyDto)) } /** @@ -78,8 +76,7 @@ class GameBrowserController( * @return the [Lobby] that has just been joined */ @MessageMapping("/lobby/join") - @SendToUser("/queue/lobby/joined") - fun joinGame(@Validated action: JoinGameAction, principal: Principal): LobbyDTO { + fun joinGame(@Validated action: JoinGameAction, principal: Principal) { checkThatUserIsNotInAGame(principal, "cannot join another game") val lobby = lobbyRepository.get(action.gameId) @@ -90,7 +87,7 @@ class GameBrowserController( logger.info("Player '{}' ({}) joined game {}", player.displayName, player.username, lobby.name) lobbyController.sendLobbyUpdateToPlayers(lobby) } - return lobby.toDTO() + messenger.sendGameEvent(player, GameEvent.LobbyJoined(lobby.toDTO())) } private fun checkThatUserIsNotInAGame(principal: Principal, impossibleActionDescription: String) { diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/GameController.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/GameController.kt index 70fb3220..17687e8b 100644 --- a/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/GameController.kt +++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/GameController.kt @@ -2,11 +2,9 @@ package org.luxons.sevenwonders.server.controllers import io.micrometer.core.instrument.MeterRegistry import org.luxons.sevenwonders.engine.Game -import org.luxons.sevenwonders.model.api.GameListEvent import org.luxons.sevenwonders.model.api.actions.PrepareMoveAction import org.luxons.sevenwonders.model.api.events.GameEvent -import org.luxons.sevenwonders.model.api.events.wrap -import org.luxons.sevenwonders.model.api.wrap +import org.luxons.sevenwonders.model.api.events.GameListEvent import org.luxons.sevenwonders.model.cards.PreparedCard import org.luxons.sevenwonders.model.hideHandsAndWaitForReadiness import org.luxons.sevenwonders.server.api.toDTO @@ -16,7 +14,7 @@ import org.luxons.sevenwonders.server.repositories.LobbyRepository import org.luxons.sevenwonders.server.repositories.PlayerRepository import org.slf4j.LoggerFactory import org.springframework.messaging.handler.annotation.MessageMapping -import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.messaging.simp.SimpMessageSendingOperations import org.springframework.stereotype.Controller import java.security.Principal @@ -25,7 +23,7 @@ import java.security.Principal */ @Controller class GameController( - private val template: SimpMessagingTemplate, + private val messenger: SimpMessageSendingOperations, private val playerRepository: PlayerRepository, private val lobbyRepository: LobbyRepository, private val meterRegistry: MeterRegistry, @@ -49,7 +47,7 @@ class GameController( synchronized(game) { player.isReady = true if (lobby.settings.askForReadiness) { - sendPlayerReady(game.id, player) + messenger.sendGameEvent(lobby.getPlayers(), GameEvent.PlayerIsReady(player.username)) } logger.info("Game {}: player {} is ready for the next turn", game.id, player) @@ -94,7 +92,7 @@ class GameController( logger.info("Game {}: player {} preparing move {}", game.id, player, action.move) val preparedCardBack = game.prepareMove(player.index, action.move) val preparedCard = PreparedCard(player.username, preparedCardBack) - sendPreparedCard(game.id, preparedCard) + messenger.sendGameEvent(lobby.getPlayers(), GameEvent.CardPrepared(preparedCard)) if (game.allPlayersPreparedTheirMove()) { logger.info("Game {}: all players have prepared their move, executing turn...", game.id) @@ -104,7 +102,7 @@ class GameController( handleEndOfGame(game, player, lobby) } } else { - template.convertAndSendToUser(player.username, "/queue/game/events", GameEvent.MovePrepared(action.move).wrap()) + messenger.sendGameEvent(player, GameEvent.MovePrepared(action.move)) } } } @@ -113,7 +111,7 @@ class GameController( meterRegistry.counter("games.finished").increment() logger.info("Game {}: end of game, displaying score board", game.id) player.lobby.setEndOfGame() - template.convertAndSend("/topic/games", GameListEvent.CreateOrUpdate(lobby.toDTO()).wrap()) + messenger.sendGameListEvent(GameListEvent.CreateOrUpdate(lobby.toDTO())) } @MessageMapping("/game/unprepareMove") @@ -128,24 +126,17 @@ class GameController( game.unprepareMove(player.index) val preparedCard = PreparedCard(player.username, null) logger.info("Game {}: player {} unprepared his move", game.id, player) - sendPreparedCard(game.id, preparedCard) + messenger.sendGameEvent(player.lobby.getPlayers(), GameEvent.CardPrepared(preparedCard)) + messenger.sendGameEvent(player, GameEvent.MoveUnprepared) } } - private fun sendPlayerReady(gameId: Long, player: Player) { - template.convertAndSend("/topic/game/$gameId/events", GameEvent.PlayerIsReady(player.username).wrap()) - } - - private fun sendPreparedCard(gameId: Long, preparedCard: PreparedCard) { - template.convertAndSend("/topic/game/$gameId/events", GameEvent.CardPrepared(preparedCard).wrap()) - } - private fun sendTurnInfo(players: List<Player>, game: Game, hideHands: Boolean) { val turns = game.getCurrentTurnInfo() val turnsToSend = if (hideHands) turns.hideHandsAndWaitForReadiness() else turns for (turnInfo in turnsToSend) { val player = players[turnInfo.playerIndex] - template.convertAndSendToUser(player.username, "/queue/game/events", GameEvent.NewTurnStarted(turnInfo).wrap()) + messenger.sendGameEvent(player, GameEvent.NewTurnStarted(turnInfo)) } } @@ -162,13 +153,13 @@ class GameController( synchronized(game) { lobby.removePlayer(player.username) logger.info("Game {}: player {} left the game", game.id, player) - template.convertAndSendToUser(player.username, "/queue/lobby/left", lobby.id) + messenger.sendGameEvent(player, GameEvent.LobbyLeft) // This could cause problems if the humans are faster than bots to leave a finished game, // but this case should be quite rare, so it does not matter much if (lobby.getPlayers().none { it.isHuman }) { lobbyRepository.remove(lobby.id) - template.convertAndSend("/topic/games", GameListEvent.Delete(lobby.id).wrap()) + messenger.sendGameListEvent(GameListEvent.Delete(lobby.id)) logger.info("Game {}: game deleted", game.id) } } diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/HomeController.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/HomeController.kt index 230623d8..59258e44 100644 --- a/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/HomeController.kt +++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/server/controllers/HomeController.kt @@ -4,10 +4,11 @@ import io.micrometer.core.instrument.MeterRegistry import org.luxons.sevenwonders.model.api.ConnectedPlayer import org.luxons.sevenwonders.model.api.PlayerDTO import org.luxons.sevenwonders.model.api.actions.ChooseNameAction +import org.luxons.sevenwonders.model.api.events.GameEvent import org.luxons.sevenwonders.server.repositories.PlayerRepository import org.slf4j.LoggerFactory import org.springframework.messaging.handler.annotation.MessageMapping -import org.springframework.messaging.simp.annotation.SendToUser +import org.springframework.messaging.simp.SimpMessageSendingOperations import org.springframework.stereotype.Controller import org.springframework.validation.annotation.Validated import java.security.Principal @@ -17,6 +18,7 @@ import java.security.Principal */ @Controller class HomeController( + private val messenger: SimpMessageSendingOperations, private val playerRepository: PlayerRepository, private val meterRegistry: MeterRegistry, ) { @@ -29,14 +31,14 @@ class HomeController( * @return the created [PlayerDTO] object */ @MessageMapping("/chooseName") - @SendToUser("/queue/nameChoice") - fun chooseName(@Validated action: ChooseNameAction, principal: Principal): ConnectedPlayer { + fun chooseName(@Validated action: ChooseNameAction, principal: Principal) { val username = principal.name val player = playerRepository.createOrUpdate(username, action.playerName, action.isHuman, action.icon) meterRegistry.counter("players.connections").increment() logger.info("Player '{}' chose the name '{}'", username, player.displayName) - return ConnectedPlayer(username, player.displayName, player.isHuman, player.icon) + val connectedPlayer = ConnectedPlayer(username, player.displayName, player.isHuman, player.icon) + messenger.sendGameEvent(player, GameEvent.NameChosen(connectedPlayer)) } companion object { 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 6a696d25..56e94493 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 @@ -7,12 +7,12 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull 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 import org.luxons.sevenwonders.model.api.actions.ReorderPlayersAction import org.luxons.sevenwonders.model.api.actions.UpdateSettingsAction -import org.luxons.sevenwonders.model.api.wrap +import org.luxons.sevenwonders.model.api.events.GameEvent +import org.luxons.sevenwonders.model.api.events.GameListEvent import org.luxons.sevenwonders.model.hideHandsAndWaitForReadiness import org.luxons.sevenwonders.server.api.toDTO import org.luxons.sevenwonders.server.lobby.Lobby @@ -23,7 +23,7 @@ import org.luxons.sevenwonders.server.repositories.PlayerRepository import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.messaging.handler.annotation.MessageMapping -import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.messaging.simp.SimpMessageSendingOperations import org.springframework.stereotype.Controller import org.springframework.validation.annotation.Validated import java.security.Principal @@ -34,9 +34,9 @@ import kotlin.time.milliseconds */ @Controller class LobbyController( + private val messenger: SimpMessageSendingOperations, private val lobbyRepository: LobbyRepository, private val playerRepository: PlayerRepository, - private val template: SimpMessagingTemplate, @Value("\${server.port}") private val serverPort: String, private val meterRegistry: MeterRegistry, ) { @@ -56,7 +56,7 @@ class LobbyController( synchronized(lobby) { lobby.removePlayer(principal.name) logger.info("Player {} left the lobby of game '{}'", player, lobby.name) - template.convertAndSendToUser(player.username, "/queue/lobby/left", lobby.id) + messenger.sendGameEvent(player, GameEvent.LobbyLeft) if (lobby.getPlayers().none { it.isHuman }) { deleteLobby(lobby) @@ -79,7 +79,7 @@ class LobbyController( synchronized(lobby) { lobby.getPlayers().forEach { it.leave() - template.convertAndSendToUser(it.username, "/queue/lobby/left", lobby.id) + messenger.sendGameEvent(it, GameEvent.LobbyLeft) } logger.info("Player {} disbanded game '{}'", player, lobby.name) deleteLobby(lobby) @@ -89,7 +89,7 @@ class LobbyController( private fun deleteLobby(lobby: Lobby) { lobbyRepository.remove(lobby.id) - template.convertAndSend("/topic/games", GameListEvent.Delete(lobby.id).wrap()) + messenger.sendGameListEvent(GameListEvent.Delete(lobby.id)) logger.info("Game '{}' removed", lobby.name) } @@ -147,9 +147,9 @@ class LobbyController( internal fun sendLobbyUpdateToPlayers(lobby: Lobby) { val lobbyDto = lobby.toDTO() lobby.getPlayers().forEach { - template.convertAndSendToUser(it.username, "/queue/lobby/updated", lobbyDto) + messenger.sendGameEvent(it, GameEvent.LobbyUpdated(lobbyDto)) } - template.convertAndSend("/topic/games", GameListEvent.CreateOrUpdate(lobbyDto).wrap()) + messenger.sendGameListEvent(GameListEvent.CreateOrUpdate(lobbyDto)) } @MessageMapping("/lobby/addBot") @@ -191,9 +191,9 @@ class LobbyController( currentTurnInfo.forEach { val player = lobby.getPlayers()[it.playerIndex] - template.convertAndSendToUser(player.username, "/queue/lobby/started", it) + messenger.sendGameEvent(player, GameEvent.GameStarted(it)) } - template.convertAndSend("/topic/games", GameListEvent.CreateOrUpdate(lobby.toDTO()).wrap()) + messenger.sendGameListEvent(GameListEvent.CreateOrUpdate(lobby.toDTO())) } private fun Lobby.resetPlayersReadyState() { 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 7f05688b..549ef267 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 @@ -1,23 +1,18 @@ package org.luxons.sevenwonders.server -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.produceIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.* import org.junit.runner.RunWith import org.luxons.sevenwonders.client.* import org.luxons.sevenwonders.model.TurnAction -import org.luxons.sevenwonders.model.api.GameListEvent -import org.luxons.sevenwonders.model.api.LobbyDTO -import org.luxons.sevenwonders.server.test.runAsyncTest +import org.luxons.sevenwonders.model.api.events.GameEvent +import org.luxons.sevenwonders.model.api.events.GameListEvent +import org.luxons.sevenwonders.server.test.* import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest.WebEnvironment import org.springframework.boot.web.server.LocalServerPort import org.springframework.test.context.junit4.SpringRunner import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(SpringRunner::class) @@ -43,13 +38,13 @@ class SevenWondersTest { fun chooseName_succeedsWithCorrectDisplayName() = runAsyncTest { val session = connectNewClient() val playerName = "Test User" - val player = session.chooseName(playerName) + val player = session.chooseNameAndAwait(playerName) assertEquals(playerName, player.displayName) session.disconnect() } private suspend fun newPlayer(name: String): SevenWondersSession = connectNewClient().apply { - chooseName(name) + chooseNameAndAwait(name) } @Test @@ -59,17 +54,18 @@ class SevenWondersTest { val session2 = newPlayer("Player2") val gameName = "Test Game" - val lobby = ownerSession.createGameWithLegacySettingsAndWaitLobby(gameName) + val lobby = ownerSession.createGameAndAwaitLobby(gameName) session1.joinGameAndAwaitLobby(lobby.id) session2.joinGameAndAwaitLobby(lobby.id) val outsiderSession = newPlayer("Outsider") - val gameStartedEvents = outsiderSession.watchGameStarted() - ownerSession.startGameAndAwaitFirstTurn() + val outsiderAsserter = outsiderSession.eventAsserter(scope = this) - val nullForTimeout = withTimeoutOrNull(50) { gameStartedEvents.first() } - assertNull(nullForTimeout, "outsider should not receive the game start event of this game") + val ownerAsserter = ownerSession.eventAsserter(scope = this) + ownerSession.startGame() + ownerAsserter.expectGameEvent<GameEvent.GameStarted>() + outsiderAsserter.expectNoGameEvent("outsider should not receive the game start event of this game") disconnect(ownerSession, session1, session2, outsiderSession) } @@ -79,7 +75,7 @@ class SevenWondersTest { val ownerSession = newPlayer("GameOwner") val gameName = "Test Game" - val lobby = ownerSession.createGameWithLegacySettingsAndWaitLobby(gameName) + val lobby = ownerSession.createGameAndAwaitLobby(gameName) assertEquals(gameName, lobby.name) disconnect(ownerSession) @@ -88,18 +84,16 @@ class SevenWondersTest { @Test fun createGame_seenByConnectedPlayers() = runAsyncTest { val otherSession = newPlayer("OtherPlayer") - val games = otherSession.watchGames().produceIn(this) + val asserter = otherSession.eventAsserter(scope = this) - val initialListEvent = withTimeout(500) { games.receive() } - assertTrue(initialListEvent is GameListEvent.ReplaceList) + val initialListEvent = asserter.expectGameListEvent<GameListEvent.ReplaceList>() assertEquals(0, initialListEvent.lobbies.size) val ownerSession = newPlayer("GameOwner") val gameName = "Test Game" - val createdLobby = ownerSession.createGameWithLegacySettingsAndWaitLobby(gameName) + val createdLobby = ownerSession.createGameAndAwaitLobby(gameName) - val afterGameListEvent = withTimeout(500) { games.receive() } - assertTrue(afterGameListEvent is GameListEvent.CreateOrUpdate) + val afterGameListEvent = asserter.expectGameListEvent<GameListEvent.CreateOrUpdate>() val receivedLobby = afterGameListEvent.lobby assertEquals(createdLobby.id, receivedLobby.id) assertEquals(createdLobby.name, receivedLobby.name) @@ -107,46 +101,57 @@ class SevenWondersTest { disconnect(ownerSession, otherSession) } + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Test fun startGame_3players() = runAsyncTest { val session1 = newPlayer("Player1") val session2 = newPlayer("Player2") - val startEvents1 = session1.watchGameStarted() - val lobby = session1.createGameWithLegacySettingsAndWaitLobby("Test Game") + val asserter1 = session1.eventAsserter(scope = this) + val lobby = session1.createGameAndAwaitLobby("Test Game") + asserter1.expectGameEvent<GameEvent.LobbyJoined>() - val startEvents2 = session2.watchGameStarted() - session2.joinGameAndAwaitLobby(lobby.id) + val asserter2 = session2.eventAsserter(scope = this) + session2.joinGame(lobby.id) + asserter1.expectGameEvent<GameEvent.LobbyUpdated>() + asserter2.expectGameEvent<GameEvent.LobbyUpdated>() + asserter2.expectGameEvent<GameEvent.LobbyJoined>() // player 3 connects after game creation (on purpose) val session3 = newPlayer("Player3") - val startEvents3 = session3.watchGameStarted() - session3.joinGameAndAwaitLobby(lobby.id) + val asserter3 = session3.eventAsserter(scope = this) + session3.joinGame(lobby.id) + asserter1.expectGameEvent<GameEvent.LobbyUpdated>() + asserter2.expectGameEvent<GameEvent.LobbyUpdated>() + asserter3.expectGameEvent<GameEvent.LobbyUpdated>() + asserter3.expectGameEvent<GameEvent.LobbyJoined>() session1.startGame() - - listOf( - session1 to startEvents1, - session2 to startEvents2, - session3 to startEvents3, - ).forEach { (session, startEvents) -> - launch { - val initialReadyTurn = startEvents.first() - assertTrue(initialReadyTurn.action is TurnAction.SayReady) - val turns = session.watchTurns() - session.sayReady() - - val firstActualTurn = turns.first() - val action = firstActualTurn.action - assertTrue(action is TurnAction.PlayFromHand) - session.disconnect() - } - } + asserter1.expectGameEvent<GameEvent.GameStarted>() + asserter2.expectGameEvent<GameEvent.GameStarted>() + asserter3.expectGameEvent<GameEvent.GameStarted>() + + session2.sayReady() + asserter2.expectNoGameEvent("nothing should happen while other players are not ready for game start") + asserter1.expectNoGameEvent("nothing should happen while other players are not ready for game start") + asserter3.expectNoGameEvent("nothing should happen while other players are not ready for game start") + + session1.sayReady() + asserter1.expectNoGameEvent("nothing should happen while other players are not ready for game start") + session3.sayReady() + + asserter1.expectPlayFromHandTurn() + asserter2.expectPlayFromHandTurn() + asserter3.expectPlayFromHandTurn() + + session1.disconnect() + session2.disconnect() + session3.disconnect() } } -private suspend fun SevenWondersSession.createGameWithLegacySettingsAndWaitLobby(gameName: String): LobbyDTO { - val lobby = createGameAndAwaitLobby(gameName) - updateSettings(lobby.settings.copy(askForReadiness = true)) - return lobby +private suspend fun EventAsserter.expectPlayFromHandTurn() { + val firstTurn = expectGameEvent<GameEvent.NewTurnStarted>() + val action = firstTurn.turnInfo.action + assertTrue(action is TurnAction.PlayFromHand) } diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/controllers/GameBrowserControllerTest.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/controllers/GameBrowserControllerTest.kt index 0f6e031b..70369a59 100644 --- a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/controllers/GameBrowserControllerTest.kt +++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/controllers/GameBrowserControllerTest.kt @@ -3,14 +3,18 @@ package org.luxons.sevenwonders.server.controllers import io.micrometer.core.instrument.simple.SimpleMeterRegistry import org.junit.Before import org.junit.Test -import org.luxons.sevenwonders.model.api.GameListEvent import org.luxons.sevenwonders.model.api.actions.CreateGameAction import org.luxons.sevenwonders.model.api.actions.JoinGameAction +import org.luxons.sevenwonders.model.api.events.GameEvent +import org.luxons.sevenwonders.model.api.events.GameListEvent import org.luxons.sevenwonders.server.controllers.GameBrowserController.UserAlreadyInGameException import org.luxons.sevenwonders.server.repositories.LobbyRepository import org.luxons.sevenwonders.server.repositories.PlayerNotFoundException import org.luxons.sevenwonders.server.repositories.PlayerRepository -import org.luxons.sevenwonders.server.test.mockSimpMessagingTemplate +import org.luxons.sevenwonders.server.test.MockMessageChannel +import org.luxons.sevenwonders.server.test.expectSentGameEventTo +import org.luxons.sevenwonders.server.test.expectSentGameListEvent +import org.springframework.messaging.simp.SimpMessagingTemplate import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -18,18 +22,21 @@ import kotlin.test.assertTrue class GameBrowserControllerTest { + private lateinit var messageChannel: MockMessageChannel + private lateinit var playerRepository: PlayerRepository private lateinit var gameBrowserController: GameBrowserController @Before fun setUp() { + messageChannel = MockMessageChannel() + val messenger = SimpMessagingTemplate(messageChannel) val meterRegistry = SimpleMeterRegistry() - playerRepository = PlayerRepository(meterRegistry) val lobbyRepository = LobbyRepository(meterRegistry) - val template = mockSimpMessagingTemplate() - val lobbyController = LobbyController(lobbyRepository, playerRepository, template, "UNUSED", meterRegistry) - gameBrowserController = GameBrowserController(lobbyController, lobbyRepository, playerRepository, template) + playerRepository = PlayerRepository(meterRegistry) + val lobbyController = LobbyController(messenger, lobbyRepository, playerRepository, "UNUSED", meterRegistry) + gameBrowserController = GameBrowserController(messenger, lobbyController, lobbyRepository, playerRepository) } @Test @@ -46,9 +53,16 @@ class GameBrowserControllerTest { val action = CreateGameAction("Test Game") - val createdLobby = gameBrowserController.createGame(action, principal) + gameBrowserController.createGame(action, principal) + + val createdEvent = messageChannel.expectSentGameListEvent<GameListEvent.CreateOrUpdate>() + val lobbyJoinedEvent = messageChannel.expectSentGameEventTo<GameEvent.LobbyJoined>("testuser") + val createdLobby = createdEvent.lobby assertEquals("Test Game", createdLobby.name) + assertEquals(createdLobby, lobbyJoinedEvent.lobby) + + messageChannel.expectNoMoreMessages() val gameListEvent = gameBrowserController.listGames(principal).event as GameListEvent.ReplaceList assertFalse(gameListEvent.lobbies.isEmpty()) @@ -91,17 +105,34 @@ class GameBrowserControllerTest { val ownerPrincipal = TestPrincipal("testowner") val createGameAction = CreateGameAction("Test Game") - val createdLobby = gameBrowserController.createGame(createGameAction, ownerPrincipal) + gameBrowserController.createGame(createGameAction, ownerPrincipal) + + val createdEvent = messageChannel.expectSentGameListEvent<GameListEvent.CreateOrUpdate>() + messageChannel.expectSentGameEventTo<GameEvent.LobbyJoined>("testowner") + val createdLobby = createdEvent.lobby assertEquals(owner.username, createdLobby.players[0].username) + messageChannel.expectNoMoreMessages() + val joiner = playerRepository.createOrUpdate("testjoiner", "Test User Joiner") val joinerPrincipal = TestPrincipal("testjoiner") val joinGameAction = JoinGameAction(createdLobby.id) - val joinedLobby = gameBrowserController.joinGame(joinGameAction, joinerPrincipal) + gameBrowserController.joinGame(joinGameAction, joinerPrincipal) + + // lobby update for existing players + messageChannel.expectSentGameEventTo<GameEvent.LobbyUpdated>("testowner") + messageChannel.expectSentGameEventTo<GameEvent.LobbyUpdated>("testjoiner") + // lobby update for people on game browser page + messageChannel.expectSentGameListEvent<GameListEvent.CreateOrUpdate>() + // lobby joined for the player who joined + val joinedLobbyEvent = messageChannel.expectSentGameEventTo<GameEvent.LobbyJoined>("testjoiner") + val joinedLobby = joinedLobbyEvent.lobby assertEquals(owner.username, joinedLobby.players[0].username) assertEquals(joiner.username, joinedLobby.players[1].username) + + messageChannel.expectNoMoreMessages() } @Test @@ -110,7 +141,11 @@ class GameBrowserControllerTest { val ownerPrincipal = TestPrincipal("testowner") val createGameAction = CreateGameAction("Test Game") - val createdLobby = gameBrowserController.createGame(createGameAction, ownerPrincipal) + gameBrowserController.createGame(createGameAction, ownerPrincipal) + + val createdEvent = messageChannel.expectSentGameListEvent<GameListEvent.CreateOrUpdate>() + messageChannel.expectSentGameEventTo<GameEvent.LobbyJoined>("testowner") + val createdLobby = createdEvent.lobby playerRepository.createOrUpdate("testjoiner", "Test User Joiner") val joinerPrincipal = TestPrincipal("testjoiner") diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/controllers/HomeControllerTest.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/controllers/HomeControllerTest.kt index c73fba10..4976e2de 100644 --- a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/controllers/HomeControllerTest.kt +++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/controllers/HomeControllerTest.kt @@ -4,7 +4,11 @@ import io.micrometer.core.instrument.simple.SimpleMeterRegistry import org.junit.Test import org.luxons.sevenwonders.model.api.actions.ChooseNameAction import org.luxons.sevenwonders.model.api.actions.Icon +import org.luxons.sevenwonders.model.api.events.GameEvent import org.luxons.sevenwonders.server.repositories.PlayerRepository +import org.luxons.sevenwonders.server.test.MockMessageChannel +import org.luxons.sevenwonders.server.test.expectSentGameEventTo +import org.springframework.messaging.simp.SimpMessagingTemplate import kotlin.test.assertEquals class HomeControllerTest { @@ -13,15 +17,21 @@ class HomeControllerTest { fun chooseName() { val meterRegistry = SimpleMeterRegistry() val playerRepository = PlayerRepository(meterRegistry) - val homeController = HomeController(playerRepository, meterRegistry) + val messageChannel = MockMessageChannel() + val messenger = SimpMessagingTemplate(messageChannel) + val homeController = HomeController(messenger, playerRepository, meterRegistry) val action = ChooseNameAction("Test User", Icon("person"), isHuman = true) val principal = TestPrincipal("testuser") - val player = homeController.chooseName(action, principal) + homeController.chooseName(action, principal) + val payload = messageChannel.expectSentGameEventTo<GameEvent.NameChosen>("testuser") + val player = payload.player assertEquals("testuser", player.username) assertEquals("Test User", player.displayName) assertEquals("person", player.icon?.name) + + messageChannel.expectNoMoreMessages() } } diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/controllers/LobbyControllerTest.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/controllers/LobbyControllerTest.kt index 7d9db01d..38f6ffb3 100644 --- a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/controllers/LobbyControllerTest.kt +++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/controllers/LobbyControllerTest.kt @@ -14,7 +14,8 @@ import org.luxons.sevenwonders.server.lobby.PlayerNotInLobbyException import org.luxons.sevenwonders.server.repositories.LobbyRepository import org.luxons.sevenwonders.server.repositories.PlayerNotFoundException import org.luxons.sevenwonders.server.repositories.PlayerRepository -import org.luxons.sevenwonders.server.test.mockSimpMessagingTemplate +import org.luxons.sevenwonders.server.test.MockMessageChannel +import org.springframework.messaging.simp.SimpMessagingTemplate import java.util.* import kotlin.test.* @@ -29,10 +30,10 @@ class LobbyControllerTest { @Before fun setUp() { val meterRegistry = SimpleMeterRegistry() - val template = mockSimpMessagingTemplate() + val template = SimpMessagingTemplate(MockMessageChannel()) playerRepository = PlayerRepository(meterRegistry) lobbyRepository = LobbyRepository(meterRegistry) - lobbyController = LobbyController(lobbyRepository, playerRepository, template, "UNUSED", meterRegistry) + lobbyController = LobbyController(template, lobbyRepository, playerRepository, "UNUSED", meterRegistry) } @Test diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/test/ClientEventsAsserts.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/test/ClientEventsAsserts.kt new file mode 100644 index 00000000..d87c2122 --- /dev/null +++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/test/ClientEventsAsserts.kt @@ -0,0 +1,48 @@ +package org.luxons.sevenwonders.server.test + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.produceIn +import kotlinx.coroutines.withTimeoutOrNull +import org.luxons.sevenwonders.client.SevenWondersSession +import org.luxons.sevenwonders.model.api.events.GameEvent +import org.luxons.sevenwonders.model.api.events.GameListEvent +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.milliseconds +import kotlin.time.seconds + +class EventAsserter( + val gameListEvents: ReceiveChannel<GameListEvent>, + val gameEvents: ReceiveChannel<GameEvent>, +) + +@OptIn(FlowPreview::class) +suspend fun SevenWondersSession.eventAsserter(scope: CoroutineScope): EventAsserter { + val gameListEvents = watchGames().produceIn(scope) + val gameEvents = watchGameEvents().produceIn(scope) + return EventAsserter(gameListEvents, gameEvents) +} + +suspend inline fun EventAsserter.expectNoGameEvent(message: String? = null, timeout: Duration = 50.milliseconds) { + val event = withTimeoutOrNull(timeout) { gameEvents.receive() } + val extraMessage = message?.let { " ($it)" } ?: "" + assertNull(event, "Expected no game event$extraMessage, but received $event") +} + +suspend inline fun <reified T : GameEvent> EventAsserter.expectGameEvent(timeout: Duration = 1.seconds): T { + val event = withTimeoutOrNull(timeout) { gameEvents.receive() } + assertNotNull(event, "Expected event of type ${T::class.simpleName}, received nothing in $timeout") + assertTrue(event is T, "Expected event of type ${T::class.simpleName}, received $event") + return event +} + +suspend inline fun <reified T : GameListEvent> EventAsserter.expectGameListEvent(timeout: Duration = 1.seconds): T { + val event = withTimeoutOrNull(timeout) { gameListEvents.receive() } + assertNotNull(event, "Expected event of type ${T::class.simpleName}, received nothing in $timeout") + assertTrue(event is T, "Expected event of type ${T::class.simpleName}, received $event") + return event +} diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/test/MockMessageChannel.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/test/MockMessageChannel.kt new file mode 100644 index 00000000..e27a2550 --- /dev/null +++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/test/MockMessageChannel.kt @@ -0,0 +1,53 @@ +package org.luxons.sevenwonders.server.test + +import org.luxons.sevenwonders.model.api.events.GameEvent +import org.luxons.sevenwonders.model.api.events.GameEventWrapper +import org.luxons.sevenwonders.model.api.events.GameListEvent +import org.luxons.sevenwonders.model.api.events.GameListEventWrapper +import org.springframework.messaging.Message +import org.springframework.messaging.MessageChannel +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class MockMessageChannel : MessageChannel { + private val _messages: MutableList<Message<*>> = mutableListOf() + val messages: List<Message<*>> get() = _messages + + override fun send(message: Message<*>, timeout: Long): Boolean = _messages.add(message) + + fun expectSentMessageTo(expectedDestination: String): Message<*> { + val m = _messages.removeFirstOrNull() + assertNotNull(m, "Expected sent message, but no messages found") + assertEquals(expectedDestination, m.headers["simpDestination"], "Incorrect message destination") + return m + } + + fun expectNoMoreMessages() { + assertTrue( + actual = messages.isEmpty(), + message = "No more messages should have been sent, but ${messages.size} were found: $messages", + ) + } +} + +inline fun <reified T> MockMessageChannel.expectSentMessageWithPayload(expectedDestination: String): T { + val m = expectSentMessageTo(expectedDestination) + val payload = m.payload + assertTrue(payload is T, "Message payload should be of type ${T::class.simpleName}") + return payload +} + +inline fun <reified T : GameListEvent> MockMessageChannel.expectSentGameListEvent(): T { + val wrappedEvent = expectSentMessageWithPayload<GameListEventWrapper>("/topic/games") + val event = wrappedEvent.event + assertTrue(event is T, "Expected game list event of type ${T::class.simpleName}, got $event") + return event +} + +inline fun <reified T : GameEvent> MockMessageChannel.expectSentGameEventTo(username: String): T { + val wrappedEvent = expectSentMessageWithPayload<GameEventWrapper>("/user/$username/queue/game/events") + val event = wrappedEvent.event + assertTrue(event is T, "Expected game event of type ${T::class.simpleName}, got $event") + return event +} diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/test/TestUtils.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/test/TestUtils.kt index efd40a6d..6e64e9da 100644 --- a/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/test/TestUtils.kt +++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/server/test/TestUtils.kt @@ -3,18 +3,8 @@ package org.luxons.sevenwonders.server.test import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull -import org.springframework.messaging.Message -import org.springframework.messaging.MessageChannel -import org.springframework.messaging.simp.SimpMessagingTemplate import kotlin.test.assertNotNull -fun mockSimpMessagingTemplate(): SimpMessagingTemplate = SimpMessagingTemplate( - object : MessageChannel { - override fun send(message: Message<*>): Boolean = true - override fun send(message: Message<*>, timeout: Long): Boolean = true - }, -) - fun runAsyncTest(timeoutMillis: Long = 10000, block: suspend CoroutineScope.() -> Unit) = runBlocking { val result = withTimeoutOrNull(timeoutMillis, block) assertNotNull(result, "Test timed out, exceeded ${timeoutMillis}ms") diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt index 6ef52ec6..b0c56a79 100644 --- a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt @@ -4,8 +4,8 @@ import org.luxons.sevenwonders.model.PlayerMove import org.luxons.sevenwonders.model.PlayerTurnInfo import org.luxons.sevenwonders.model.TurnAction import org.luxons.sevenwonders.model.api.ConnectedPlayer -import org.luxons.sevenwonders.model.api.GameListEvent import org.luxons.sevenwonders.model.api.LobbyDTO +import org.luxons.sevenwonders.model.api.events.GameListEvent import org.luxons.sevenwonders.model.cards.PreparedCard import redux.RAction diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt index 3fb72904..e79b063e 100644 --- a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt @@ -2,9 +2,9 @@ package org.luxons.sevenwonders.ui.redux import org.luxons.sevenwonders.client.GameState import org.luxons.sevenwonders.model.api.ConnectedPlayer -import org.luxons.sevenwonders.model.api.GameListEvent import org.luxons.sevenwonders.model.api.LobbyDTO import org.luxons.sevenwonders.model.api.PlayerDTO +import org.luxons.sevenwonders.model.api.events.GameListEvent import redux.RAction data class SwState( diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/RouteBasedSagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/RouteBasedSagas.kt index b2fad7e1..a014a318 100644 --- a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/RouteBasedSagas.kt +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/RouteBasedSagas.kt @@ -1,20 +1,11 @@ package org.luxons.sevenwonders.ui.redux.sagas -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.map import org.luxons.sevenwonders.client.SevenWondersSession -import org.luxons.sevenwonders.model.api.events.GameEvent import org.luxons.sevenwonders.ui.redux.* import org.luxons.sevenwonders.ui.router.Navigate import org.luxons.sevenwonders.ui.router.Route -suspend fun SwSagaContext.homeSaga(session: SevenWondersSession) { - val action = next<RequestChooseName>() - val player = session.chooseName(action.playerName) - dispatch(SetCurrentPlayerAction(player)) - dispatch(Navigate(Route.GAME_BROWSER)) -} - suspend fun SwSagaContext.gameBrowserSaga(session: SevenWondersSession) { // browser navigation could have brought us here: we should leave the game/lobby ensureNoCurrentGameNorLobby(session) @@ -23,42 +14,29 @@ suspend fun SwSagaContext.gameBrowserSaga(session: SevenWondersSession) { private suspend fun SwSagaContext.ensureNoCurrentGameNorLobby(session: SevenWondersSession) { if (reduxState.gameState != null) { - console.warn("User left a game via browser navigation, cleaning up...") + console.warn("User left a game via browser navigation, telling the server...") session.leaveGame() } else if (reduxState.currentLobby != null) { - console.warn("User left the lobby via browser navigation, cleaning up...") + console.warn("User left the lobby via browser navigation, telling the server...") session.leaveLobby() } } suspend fun SwSagaContext.lobbySaga(session: SevenWondersSession) { - // browser navigation could have brought us here: we should leave the current game in that case if (reduxState.gameState != null) { console.warn("User left a game via browser navigation, telling the server...") session.leaveGame() - return - } - // browser navigation could have brought us here: we should go back to game browser if no lobby - if (reduxState.currentLobby == null) { - console.warn("User went to lobby via browser navigation, cleaning up...") + } else if (reduxState.currentLobby == null) { + console.warn("User went to lobby page via browser navigation, redirecting to game browser...") dispatch(Navigate(Route.GAME_BROWSER)) - return } - session.watchLobbyUpdates().map { UpdateLobbyAction(it) }.dispatchAll() } suspend fun SwSagaContext.gameSaga(session: SevenWondersSession) { - val game = reduxState.gameState ?: error("Game saga run without a current game") - coroutineScope { - session.watchGameEvents(game.gameId).map { - when (it) { - is GameEvent.NewTurnStarted -> TurnInfoEvent(it.turnInfo) - is GameEvent.MovePrepared -> PreparedMoveEvent(it.move) - is GameEvent.CardPrepared -> PreparedCardEvent(it.preparedCard) - is GameEvent.PlayerIsReady -> PlayerReadyEvent(it.username) - } - }.dispatchAllIn(this) - session.sayReady() + if (reduxState.gameState == null) { + // TODO properly redirect somewhere + error("Game saga run without a current game") } - console.log("End of game saga") + // notifies the server that the client is ready to receive the first hand + session.sayReady() } diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt index 13407247..ba3949cc 100644 --- a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt @@ -6,8 +6,8 @@ import kotlinx.coroutines.flow.collect import org.hildan.krossbow.stomp.ConnectionException import org.hildan.krossbow.stomp.MissingHeartBeatException import org.hildan.krossbow.stomp.WebSocketClosedUnexpectedly -import org.luxons.sevenwonders.client.SevenWondersClient -import org.luxons.sevenwonders.client.SevenWondersSession +import org.luxons.sevenwonders.client.* +import org.luxons.sevenwonders.model.api.events.GameEvent import org.luxons.sevenwonders.ui.redux.* import org.luxons.sevenwonders.ui.router.Navigate import org.luxons.sevenwonders.ui.router.Route @@ -34,12 +34,12 @@ suspend fun SwSagaContext.rootSaga() = try { launchApiActionHandlersIn(this, session) launchApiEventHandlersIn(this, session) - val player = session.chooseName(action.playerName) + val player = session.chooseNameAndAwait(action.playerName) dispatch(SetCurrentPlayerAction(player)) routerSaga(Route.GAME_BROWSER) { when (it) { - Route.HOME -> homeSaga(session) + Route.HOME -> Unit Route.LOBBY -> lobbySaga(session) Route.GAME_BROWSER -> gameBrowserSaga(session) Route.GAME -> gameSaga(session) @@ -78,6 +78,7 @@ private suspend fun serverErrorSaga(session: SevenWondersSession) { } private fun SwSagaContext.launchApiActionHandlersIn(scope: CoroutineScope, session: SevenWondersSession) { + scope.launchOnEach<RequestChooseName> { session.chooseName(it.playerName) } scope.launchOnEach<RequestCreateGame> { session.createGame(it.gameName) } scope.launchOnEach<RequestJoinGame> { session.joinGame(it.gameId) } @@ -96,26 +97,37 @@ private fun SwSagaContext.launchApiActionHandlersIn(scope: CoroutineScope, sessi } private fun SwSagaContext.launchApiEventHandlersIn(scope: CoroutineScope, session: SevenWondersSession) { - - scope.launch { - session.watchLobbyJoined().collect { lobby -> - dispatch(EnterLobbyAction(lobby)) - dispatch(Navigate(Route.LOBBY)) - } - } - scope.launch { - session.watchLobbyLeft().collect { - dispatch(LeaveLobbyAction) - dispatch(Navigate(Route.GAME_BROWSER)) - } - } - - scope.launch { - session.watchGameStarted().collect { turnInfo -> - val currentLobby = reduxState.currentLobby ?: error("Received game started event without being in a lobby") - dispatch(EnterGameAction(currentLobby, turnInfo)) - dispatch(Navigate(Route.GAME)) + session.watchGameEvents().collect { event -> + when (event) { + is GameEvent.NameChosen -> { + dispatch(SetCurrentPlayerAction(event.player)) + dispatch(Navigate(Route.GAME_BROWSER)) + } + is GameEvent.LobbyJoined -> { + dispatch(EnterLobbyAction(event.lobby)) + dispatch(Navigate(Route.LOBBY)) + } + is GameEvent.LobbyUpdated -> { + dispatch(UpdateLobbyAction(event.lobby)) + } + GameEvent.LobbyLeft -> { + dispatch(LeaveLobbyAction) + dispatch(Navigate(Route.GAME_BROWSER)) + } + is GameEvent.GameStarted -> { + val currentLobby = reduxState.currentLobby ?: error("Received game started event without being in a lobby") + dispatch(EnterGameAction(currentLobby, event.turnInfo)) + dispatch(Navigate(Route.GAME)) + } + is GameEvent.NewTurnStarted -> dispatch(TurnInfoEvent(event.turnInfo)) + is GameEvent.MovePrepared -> dispatch(PreparedMoveEvent(event.move)) + is GameEvent.CardPrepared -> dispatch(PreparedCardEvent(event.preparedCard)) + is GameEvent.PlayerIsReady -> dispatch(PlayerReadyEvent(event.username)) + // Currently the move is already unprepared when launching the unprepare request + // TODO add a "unpreparing" state and only update redux when the move is successfully unprepared + GameEvent.MoveUnprepared -> {} + } } } } |