From 2c28e6e21137ec6bb1ae6ca34860ad920c289426 Mon Sep 17 00:00:00 2001 From: Joffrey Bion Date: Thu, 5 Jul 2018 10:38:14 +0200 Subject: Kotlin migration: Spring server --- .../kotlin/org/luxons/sevenwonders/SevenWonders.kt | 13 +++ .../sevenwonders/actions/ChooseNameAction.kt | 19 ++++ .../sevenwonders/actions/CreateGameAction.kt | 21 ++++ .../luxons/sevenwonders/actions/JoinGameAction.kt | 17 ++++ .../sevenwonders/actions/PrepareMoveAction.kt | 18 ++++ .../sevenwonders/actions/ReorderPlayersAction.kt | 18 ++++ .../sevenwonders/actions/UpdateSettingsAction.kt | 18 ++++ .../config/AnonymousUsersHandshakeHandler.kt | 27 +++++ .../config/TopicSubscriptionInterceptor.kt | 38 +++++++ .../sevenwonders/config/WebSecurityConfig.kt | 12 +++ .../luxons/sevenwonders/config/WebSocketConfig.kt | 42 ++++++++ .../controllers/GameBrowserController.kt | 109 +++++++++++++++++++++ .../sevenwonders/controllers/GameController.kt | 99 +++++++++++++++++++ .../sevenwonders/controllers/HomeController.kt | 47 +++++++++ .../sevenwonders/controllers/LobbyController.kt | 106 ++++++++++++++++++++ .../org/luxons/sevenwonders/doc/Documentation.kt | 6 ++ .../org/luxons/sevenwonders/errors/ErrorDTO.kt | 29 ++++++ .../luxons/sevenwonders/errors/ExceptionHandler.kt | 44 +++++++++ .../kotlin/org/luxons/sevenwonders/lobby/Lobby.kt | 107 ++++++++++++++++++++ .../kotlin/org/luxons/sevenwonders/lobby/Player.kt | 70 +++++++++++++ .../org/luxons/sevenwonders/output/PreparedCard.kt | 6 ++ .../sevenwonders/repositories/LobbyRepository.kt | 33 +++++++ .../sevenwonders/repositories/PlayerRepository.kt | 41 ++++++++ .../validation/DestinationAccessValidator.kt | 55 +++++++++++ 24 files changed, 995 insertions(+) create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/SevenWonders.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/actions/ChooseNameAction.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/actions/CreateGameAction.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/actions/JoinGameAction.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/actions/PrepareMoveAction.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/actions/ReorderPlayersAction.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/actions/UpdateSettingsAction.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/config/AnonymousUsersHandshakeHandler.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/config/TopicSubscriptionInterceptor.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/config/WebSecurityConfig.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/config/WebSocketConfig.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/controllers/GameBrowserController.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/controllers/GameController.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/controllers/HomeController.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/controllers/LobbyController.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/doc/Documentation.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/errors/ErrorDTO.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/errors/ExceptionHandler.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/lobby/Lobby.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/lobby/Player.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/output/PreparedCard.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/repositories/LobbyRepository.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/repositories/PlayerRepository.kt create mode 100644 backend/src/main/kotlin/org/luxons/sevenwonders/validation/DestinationAccessValidator.kt (limited to 'backend/src/main/kotlin/org') diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/SevenWonders.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/SevenWonders.kt new file mode 100644 index 00000000..04f03956 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/SevenWonders.kt @@ -0,0 +1,13 @@ +package org.luxons.sevenwonders + +import org.hildan.livedoc.spring.boot.starter.EnableJSONDoc +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +@EnableJSONDoc +class SevenWonders + +fun main(args: Array) { + runApplication(*args) +} diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/actions/ChooseNameAction.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/actions/ChooseNameAction.kt new file mode 100644 index 00000000..ab444780 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/actions/ChooseNameAction.kt @@ -0,0 +1,19 @@ +package org.luxons.sevenwonders.actions + +import org.hildan.livedoc.core.annotations.types.ApiType +import org.hildan.livedoc.core.annotations.types.ApiTypeProperty +import org.luxons.sevenwonders.doc.Documentation +import javax.validation.constraints.Size + +/** + * The action to choose the player's name. This is the first action that should be called. + */ +@ApiType(group = Documentation.GROUP_ACTIONS) +class ChooseNameAction( + /** + * The display name of the player. May contain spaces and special characters. + */ + @Size(min = 2, max = 20) + @ApiTypeProperty(required = true) + val playerName: String +) diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/actions/CreateGameAction.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/actions/CreateGameAction.kt new file mode 100644 index 00000000..fbe598f2 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/actions/CreateGameAction.kt @@ -0,0 +1,21 @@ +package org.luxons.sevenwonders.actions + +import org.hildan.livedoc.core.annotations.types.ApiType +import org.hildan.livedoc.core.annotations.types.ApiTypeProperty +import org.luxons.sevenwonders.doc.Documentation +import javax.validation.constraints.Size + +/** + * The action to create a game. + */ +@ApiType(group = Documentation.GROUP_ACTIONS) +class CreateGameAction( + /** + * The name of the game to create. + */ + @Size(min = 2, max = 30) + @ApiTypeProperty(required = true) + val gameName: String +) + + diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/actions/JoinGameAction.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/actions/JoinGameAction.kt new file mode 100644 index 00000000..002309b3 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/actions/JoinGameAction.kt @@ -0,0 +1,17 @@ +package org.luxons.sevenwonders.actions + +import org.hildan.livedoc.core.annotations.types.ApiType +import org.hildan.livedoc.core.annotations.types.ApiTypeProperty +import org.luxons.sevenwonders.doc.Documentation + +/** + * The action to join a game. + */ +@ApiType(group = Documentation.GROUP_ACTIONS) +class JoinGameAction( + /** + * The ID of the game to join. + */ + @ApiTypeProperty(required = true) + val gameId: Long +) diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/actions/PrepareMoveAction.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/actions/PrepareMoveAction.kt new file mode 100644 index 00000000..6b39c486 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/actions/PrepareMoveAction.kt @@ -0,0 +1,18 @@ +package org.luxons.sevenwonders.actions + +import org.hildan.livedoc.core.annotations.types.ApiType +import org.hildan.livedoc.core.annotations.types.ApiTypeProperty +import org.luxons.sevenwonders.doc.Documentation +import org.luxons.sevenwonders.game.api.PlayerMove + +/** + * The action to prepare the next move during a game. + */ +@ApiType(group = Documentation.GROUP_ACTIONS) +class PrepareMoveAction( + /** + * The move to prepare. + */ + @ApiTypeProperty(required = true) + val move: PlayerMove +) diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/actions/ReorderPlayersAction.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/actions/ReorderPlayersAction.kt new file mode 100644 index 00000000..79a32137 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/actions/ReorderPlayersAction.kt @@ -0,0 +1,18 @@ +package org.luxons.sevenwonders.actions + +import org.hildan.livedoc.core.annotations.types.ApiType +import org.hildan.livedoc.core.annotations.types.ApiTypeProperty +import org.luxons.sevenwonders.doc.Documentation + +/** + * The action to update the order of the players around the table. Can only be called in the lobby by the owner of the + * game. + */ +@ApiType(group = Documentation.GROUP_ACTIONS) +class ReorderPlayersAction( + /** + * The list of usernames of the players, in the new order. + */ + @ApiTypeProperty(required = true) + val orderedPlayers: List +) diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/actions/UpdateSettingsAction.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/actions/UpdateSettingsAction.kt new file mode 100644 index 00000000..d13e5b45 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/actions/UpdateSettingsAction.kt @@ -0,0 +1,18 @@ +package org.luxons.sevenwonders.actions + +import org.hildan.livedoc.core.annotations.types.ApiType +import org.hildan.livedoc.core.annotations.types.ApiTypeProperty +import org.luxons.sevenwonders.doc.Documentation +import org.luxons.sevenwonders.game.api.CustomizableSettings + +/** + * The action to update the settings of the game. Can only be called in the lobby by the owner of the game. + */ +@ApiType(group = Documentation.GROUP_ACTIONS) +class UpdateSettingsAction( + /** + * The new values for the settings. + */ + @ApiTypeProperty(required = true) + val settings: CustomizableSettings +) diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/config/AnonymousUsersHandshakeHandler.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/config/AnonymousUsersHandshakeHandler.kt new file mode 100644 index 00000000..db707d1b --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/config/AnonymousUsersHandshakeHandler.kt @@ -0,0 +1,27 @@ +package org.luxons.sevenwonders.config + +import org.springframework.http.server.ServerHttpRequest +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.web.socket.WebSocketHandler +import org.springframework.web.socket.server.support.DefaultHandshakeHandler +import java.security.Principal + +/** + * Generates [Principal] objects for anonymous users in the form "playerX", where X is an auto-incremented number. + */ +internal class AnonymousUsersHandshakeHandler : DefaultHandshakeHandler() { + + private var playerId = 0 + + override fun determineUser( + request: ServerHttpRequest, + wsHandler: WebSocketHandler, + attributes: Map + ): Principal? { + var p = super.determineUser(request, wsHandler, attributes) + if (p == null) { + p = UsernamePasswordAuthenticationToken("player" + playerId++, null) + } + return p + } +} diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/config/TopicSubscriptionInterceptor.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/config/TopicSubscriptionInterceptor.kt new file mode 100644 index 00000000..f4c55c2c --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/config/TopicSubscriptionInterceptor.kt @@ -0,0 +1,38 @@ +package org.luxons.sevenwonders.config + +import org.luxons.sevenwonders.validation.DestinationAccessValidator +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.messaging.Message +import org.springframework.messaging.MessageChannel +import org.springframework.messaging.simp.stomp.StompCommand +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.messaging.support.ChannelInterceptor +import org.springframework.stereotype.Component + +@Component +class TopicSubscriptionInterceptor @Autowired constructor( + private val destinationAccessValidator: DestinationAccessValidator +) : ChannelInterceptor { + + override fun preSend(message: Message<*>, channel: MessageChannel): Message<*>? { + val headerAccessor = StompHeaderAccessor.wrap(message) + if (StompCommand.SUBSCRIBE == headerAccessor.command) { + val username = headerAccessor.user!!.name + val destination = headerAccessor.destination!! + if (!destinationAccessValidator.hasAccess(username, destination)) { + sendForbiddenSubscriptionError(username, destination) + return null + } + } + return message + } + + private fun sendForbiddenSubscriptionError(username: String, destination: String?) { + logger.error(String.format("Player '%s' is not allowed to access %s", username, destination)) + } + + companion object { + private val logger = LoggerFactory.getLogger(TopicSubscriptionInterceptor::class.java) + } +} diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/config/WebSecurityConfig.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/config/WebSecurityConfig.kt new file mode 100644 index 00000000..06b2bc90 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/config/WebSecurityConfig.kt @@ -0,0 +1,12 @@ +package org.luxons.sevenwonders.config + +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter + +@Configuration +class WebSecurityConfig : WebSecurityConfigurerAdapter() { + + // this disables default authentication settings + override fun configure(httpSecurity: HttpSecurity) = Unit +} diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/config/WebSocketConfig.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/config/WebSocketConfig.kt new file mode 100644 index 00000000..bebb3233 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/config/WebSocketConfig.kt @@ -0,0 +1,42 @@ +package org.luxons.sevenwonders.config + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.messaging.simp.config.ChannelRegistration +import org.springframework.messaging.simp.config.MessageBrokerRegistry +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.StompEndpointRegistry +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer +import org.springframework.web.socket.server.support.DefaultHandshakeHandler + +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfig @Autowired constructor(private val topicSubscriptionInterceptor: TopicSubscriptionInterceptor) : + WebSocketMessageBrokerConfigurer { + + override fun configureMessageBroker(config: MessageBrokerRegistry) { + // prefixes for all subscriptions + config.enableSimpleBroker("/queue", "/topic") + config.setUserDestinationPrefix("/user") + + // /app for normal calls, /topic for subscription events + config.setApplicationDestinationPrefixes("/app", "/topic") + } + + override fun registerStompEndpoints(registry: StompEndpointRegistry) { + registry.addEndpoint("/seven-wonders-websocket") + .setHandshakeHandler(handshakeHandler()) + .setAllowedOrigins("http://localhost:3000") // to allow frontend server proxy requests in dev mode + .withSockJS() + } + + @Bean + fun handshakeHandler(): DefaultHandshakeHandler { + return AnonymousUsersHandshakeHandler() + } + + override fun configureClientInboundChannel(registration: ChannelRegistration) { + registration.interceptors(topicSubscriptionInterceptor) + } +} diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/controllers/GameBrowserController.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/controllers/GameBrowserController.kt new file mode 100644 index 00000000..b8e4e732 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/controllers/GameBrowserController.kt @@ -0,0 +1,109 @@ +package org.luxons.sevenwonders.controllers + +import org.hildan.livedoc.core.annotations.Api +import org.luxons.sevenwonders.actions.CreateGameAction +import org.luxons.sevenwonders.actions.JoinGameAction +import org.luxons.sevenwonders.errors.ApiMisuseException +import org.luxons.sevenwonders.lobby.Lobby +import org.luxons.sevenwonders.repositories.LobbyRepository +import org.luxons.sevenwonders.repositories.PlayerRepository +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +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.annotation.SubscribeMapping +import org.springframework.stereotype.Controller +import org.springframework.validation.annotation.Validated +import java.security.Principal + +/** + * This is the place where the player looks for a game. + */ +@Api(name = "GameBrowser") +@Controller +class GameBrowserController @Autowired constructor( + 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 + * subscription, and then each time the list changes. + * + * @param principal the connected user's information + * + * @return the current list of [Lobby]s + */ + @SubscribeMapping("/games") // prefix /topic not shown + fun listGames(principal: Principal): Collection { + logger.info("Player '{}' subscribed to /topic/games", principal.name) + return lobbyRepository.list() + } + + /** + * Creates a new [Lobby]. + * + * @param action the action to create the game + * @param principal the connected user's information + * + * @return the newly created [Lobby] + */ + @MessageMapping("/lobby/create") + @SendToUser("/queue/lobby/joined") + fun createGame(@Validated action: CreateGameAction, principal: Principal): Lobby { + checkThatUserIsNotInAGame(principal, "cannot create another game") + + val gameOwner = playerRepository.find(principal.name) + val lobby = lobbyRepository.create(action.gameName, gameOwner) + + logger.info( + "Game '{}' ({}) created by {} ({})", lobby.name, lobby.id, gameOwner.displayName, gameOwner.username + ) + + // notify everyone that a new game exists + template.convertAndSend("/topic/games", listOf(lobby)) + return lobby + } + + /** + * Joins an existing [Lobby]. + * + * @param action the action to join the game + * @param principal the connected user's information + * + * @return the [Lobby] that has just been joined + */ + @MessageMapping("/lobby/join") + @SendToUser("/queue/lobby/joined") + fun joinGame(@Validated action: JoinGameAction, principal: Principal): Lobby { + checkThatUserIsNotInAGame(principal, "cannot join another game") + + val lobby = lobbyRepository.find(action.gameId!!) + val newPlayer = playerRepository.find(principal.name) + lobby.addPlayer(newPlayer) + + logger.info( + "Player '{}' ({}) joined game {}", newPlayer.displayName, newPlayer.username, lobby.name + ) + lobbyController.sendLobbyUpdateToPlayers(lobby) + return lobby + } + + private fun checkThatUserIsNotInAGame(principal: Principal, impossibleActionDescription: String) { + val player = playerRepository.find(principal.name) + if (player.isInLobby || player.isInGame) { + throw UserAlreadyInGameException(player.lobby.name, impossibleActionDescription) + } + } + + internal class UserAlreadyInGameException(gameName: String, impossibleActionDescription: String) : + ApiMisuseException("Client already in game '$gameName', $impossibleActionDescription") + + companion object { + + private val logger = LoggerFactory.getLogger(GameBrowserController::class.java) + } +} diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/controllers/GameController.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/controllers/GameController.kt new file mode 100644 index 00000000..e05bf319 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/controllers/GameController.kt @@ -0,0 +1,99 @@ +package org.luxons.sevenwonders.controllers + +import org.hildan.livedoc.core.annotations.Api +import org.luxons.sevenwonders.actions.PrepareMoveAction +import org.luxons.sevenwonders.game.Game +import org.luxons.sevenwonders.game.api.Table +import org.luxons.sevenwonders.lobby.Player +import org.luxons.sevenwonders.output.PreparedCard +import org.luxons.sevenwonders.repositories.PlayerRepository +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.stereotype.Controller +import java.security.Principal + +/** + * This API is for in-game events management. + */ +@Api(name = "Game") +@Controller +class GameController @Autowired constructor( + private val template: SimpMessagingTemplate, + private val playerRepository: PlayerRepository +) { + private val Principal.player + get() = playerRepository.find(name) + + /** + * Notifies the game that the player is ready to receive his hand. + * + * @param principal + * the connected user's information + */ + @MessageMapping("/game/sayReady") + fun ready(principal: Principal) { + val player = principal.player + player.isReady = true + val game = player.game + logger.info("Game {}: player {} is ready for the next turn", game.id, player) + + val lobby = player.lobby + val players = lobby.getPlayers() + + val allReady = players.stream().allMatch { it.isReady } + if (allReady) { + logger.info("Game {}: all players ready, sending turn info", game.id) + players.forEach { it.isReady = false } + sendTurnInfo(players, game) + } else { + sendPlayerReady(game.id, player) + } + } + + private fun sendTurnInfo(players: List, game: Game) { + for (turnInfo in game.getCurrentTurnInfo()) { + val player = players[turnInfo.playerIndex] + template.convertAndSendToUser(player.username, "/queue/game/turn", turnInfo) + } + } + + private fun sendPlayerReady(gameId: Long, player: Player) = + template.convertAndSend("/topic/game/$gameId/playerReady", player.username) + + /** + * Prepares the player's next move. When all players have prepared their moves, all moves are executed. + * + * @param action + * the action to prepare the move + * @param principal + * the connected user's information + */ + @MessageMapping("/game/prepareMove") + fun prepareMove(action: PrepareMoveAction, principal: Principal) { + val player = principal.player + val game = player.game + val preparedCardBack = game.prepareMove(player.index, action.move) + val preparedCard = PreparedCard(player, preparedCardBack) + logger.info("Game {}: player {} prepared move {}", game.id, principal.name, action.move) + + if (game.allPlayersPreparedTheirMove()) { + logger.info("Game {}: all players have prepared their move, executing turn...", game.id) + val table = game.playTurn() + sendPlayedMoves(game.id, table) + } else { + sendPreparedCard(game.id, preparedCard) + } + } + + private fun sendPlayedMoves(gameId: Long, table: Table) = + template.convertAndSend("/topic/game/$gameId/tableUpdates", table) + + private fun sendPreparedCard(gameId: Long, preparedCard: PreparedCard) = + template.convertAndSend("/topic/game/$gameId/prepared", preparedCard) + + companion object { + private val logger = LoggerFactory.getLogger(GameController::class.java) + } +} diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/controllers/HomeController.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/controllers/HomeController.kt new file mode 100644 index 00000000..e658a26b --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/controllers/HomeController.kt @@ -0,0 +1,47 @@ +package org.luxons.sevenwonders.controllers + +import org.hildan.livedoc.core.annotations.Api +import org.luxons.sevenwonders.actions.ChooseNameAction +import org.luxons.sevenwonders.lobby.Player +import org.luxons.sevenwonders.repositories.PlayerRepository +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.messaging.simp.annotation.SendToUser +import org.springframework.stereotype.Controller +import org.springframework.validation.annotation.Validated +import java.security.Principal + +/** + * Handles actions in the homepage of the game. + */ +@Api(name = "Home") +@Controller +class HomeController @Autowired constructor( + private val playerRepository: PlayerRepository +) { + + /** + * Creates/updates the player's name (for the user's session). + * + * @param action + * the action to choose the name of the player + * @param principal + * the connected user's information + * + * @return the created [Player] object + */ + @MessageMapping("/chooseName") + @SendToUser("/queue/nameChoice") + fun chooseName(@Validated action: ChooseNameAction, principal: Principal): Player { + val username = principal.name + val player = playerRepository.createOrUpdate(username, action.playerName) + + logger.info("Player '{}' chose the name '{}'", username, player.displayName) + return player + } + + companion object { + private val logger = LoggerFactory.getLogger(HomeController::class.java) + } +} diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/controllers/LobbyController.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/controllers/LobbyController.kt new file mode 100644 index 00000000..3b15d68a --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/controllers/LobbyController.kt @@ -0,0 +1,106 @@ +package org.luxons.sevenwonders.controllers + +import org.hildan.livedoc.core.annotations.Api +import org.luxons.sevenwonders.actions.ReorderPlayersAction +import org.luxons.sevenwonders.actions.UpdateSettingsAction +import org.luxons.sevenwonders.lobby.Lobby +import org.luxons.sevenwonders.lobby.Player +import org.luxons.sevenwonders.repositories.LobbyRepository +import org.luxons.sevenwonders.repositories.PlayerRepository +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.stereotype.Controller +import org.springframework.validation.annotation.Validated +import java.security.Principal + +/** + * Handles actions in the game's lobby. The lobby is the place where players gather before a game. + */ +@Api(name = "Lobby") +@Controller +class LobbyController @Autowired constructor( + private val lobbyRepository: LobbyRepository, + private val playerRepository: PlayerRepository, + private val template: SimpMessagingTemplate +) { + private val Principal.player: Player + get() = playerRepository.find(name) + + /** + * Leaves the current lobby. + * + * @param principal + * the connected user's information + */ + @MessageMapping("/lobby/leave") + fun leave(principal: Principal) { + val lobby = principal.player.lobby + val player = lobby.removePlayer(principal.name) + if (lobby.getPlayers().isEmpty()) { + lobbyRepository.remove(lobby.id) + } + + logger.info("Player {} left game '{}'", player, lobby.name) + sendLobbyUpdateToPlayers(lobby) + } + + /** + * Reorders the players in the current lobby. This can only be done by the lobby's owner. + * + * @param action + * the action to reorder the players + * @param principal + * the connected user's information + */ + @MessageMapping("/lobby/reorderPlayers") + fun reorderPlayers(@Validated action: ReorderPlayersAction, principal: Principal) { + val lobby = principal.player.ownedLobby + lobby.reorderPlayers(action.orderedPlayers) + + logger.info("Players in game '{}' reordered to {}", lobby.name, action.orderedPlayers) + sendLobbyUpdateToPlayers(lobby) + } + + /** + * Updates the game settings. This can only be done by the lobby's owner. + * + * @param action + * the action to update the settings + * @param principal + * the connected user's information + */ + @MessageMapping("/lobby/updateSettings") + fun updateSettings(@Validated action: UpdateSettingsAction, principal: Principal) { + val lobby = principal.player.ownedLobby + lobby.settings = action.settings + + logger.info("Updated settings of game '{}'", lobby.name) + sendLobbyUpdateToPlayers(lobby) + } + + internal fun sendLobbyUpdateToPlayers(lobby: Lobby) { + template.convertAndSend("/topic/lobby/" + lobby.id + "/updated", lobby) + template.convertAndSend("/topic/games", listOf(lobby)) + } + + /** + * Starts the game. + * + * @param principal + * the connected user's information + */ + @MessageMapping("/lobby/startGame") + fun startGame(principal: Principal) { + val lobby = principal.player.ownedLobby + val game = lobby.startGame() + + logger.info("Game {} successfully started", game.id) + template.convertAndSend("/topic/lobby/" + lobby.id + "/started", "") + } + + companion object { + private val logger = LoggerFactory.getLogger(LobbyController::class.java) + } +} diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/doc/Documentation.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/doc/Documentation.kt new file mode 100644 index 00000000..3b04356a --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/doc/Documentation.kt @@ -0,0 +1,6 @@ +package org.luxons.sevenwonders.doc + +object Documentation { + + const val GROUP_ACTIONS = "Actions" +} diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/errors/ErrorDTO.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/errors/ErrorDTO.kt new file mode 100644 index 00000000..c3eae0b5 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/errors/ErrorDTO.kt @@ -0,0 +1,29 @@ +package org.luxons.sevenwonders.errors + +import org.springframework.validation.FieldError +import org.springframework.validation.ObjectError + +enum class ErrorType { + VALIDATION, CLIENT, SERVER +} + +data class ErrorDTO( + val code: String, + val message: String, + val type: ErrorType, + val details: List = emptyList() +) + +data class ValidationErrorDTO( + val path: String, + val message: String, + val rejectedValue: Any? = null +) + +fun ObjectError.toDTO() = (this as? FieldError)?.fieldError() ?: objectError() + +fun FieldError.fieldError(): ValidationErrorDTO = + ValidationErrorDTO("$objectName.$field", "Invalid value for field '$field': $defaultMessage", rejectedValue) + +fun ObjectError.objectError(): ValidationErrorDTO = + ValidationErrorDTO(objectName, "Invalid value for object '$objectName': $defaultMessage") diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/errors/ExceptionHandler.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/errors/ExceptionHandler.kt new file mode 100644 index 00000000..76d01f5f --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/errors/ExceptionHandler.kt @@ -0,0 +1,44 @@ +package org.luxons.sevenwonders.errors + +import org.slf4j.LoggerFactory +import org.springframework.messaging.converter.MessageConversionException +import org.springframework.messaging.handler.annotation.MessageExceptionHandler +import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException +import org.springframework.messaging.simp.annotation.SendToUser +import org.springframework.web.bind.annotation.ControllerAdvice + +open class ApiMisuseException(message: String) : RuntimeException(message) + +@ControllerAdvice +@SendToUser("/queue/errors") +class ExceptionHandler { + + @MessageExceptionHandler + fun handleValidationError(exception: MethodArgumentNotValidException): ErrorDTO { + logger.error("Invalid input", exception) + val validationErrors = exception.bindingResult?.allErrors?.map { it.toDTO() } ?: emptyList() + return ErrorDTO("INVALID_DATA", "Invalid input data", ErrorType.VALIDATION, validationErrors) + } + + @MessageExceptionHandler + fun handleConversionError(exception: MessageConversionException): ErrorDTO { + logger.error("Error interpreting the message", exception) + return ErrorDTO("INVALID_MESSAGE_FORMAT", "Invalid input format", ErrorType.VALIDATION) + } + + @MessageExceptionHandler + fun handleApiError(exception: ApiMisuseException): ErrorDTO { + logger.error("Invalid API input", exception) + return ErrorDTO(exception.javaClass.simpleName, exception.message!!, ErrorType.CLIENT) + } + + @MessageExceptionHandler + fun handleUnexpectedInternalError(exception: Throwable): ErrorDTO { + logger.error("Uncaught exception thrown during message handling", exception) + return ErrorDTO(exception.javaClass.simpleName, exception.localizedMessage ?: "", ErrorType.SERVER) + } + + companion object { + private val logger = LoggerFactory.getLogger(ExceptionHandler::class.java) + } +} diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/lobby/Lobby.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/lobby/Lobby.kt new file mode 100644 index 00000000..aaafd517 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/lobby/Lobby.kt @@ -0,0 +1,107 @@ +package org.luxons.sevenwonders.lobby + +import org.luxons.sevenwonders.game.Game +import org.luxons.sevenwonders.game.api.CustomizableSettings +import org.luxons.sevenwonders.game.data.GameDefinition +import java.util.ArrayList + +enum class State { + LOBBY, PLAYING +} + +class Lobby( + val id: Long, + val name: String, + private var _owner: Player, + @field:Transient private val gameDefinition: GameDefinition +) { + private var players: MutableList = ArrayList(gameDefinition.maxPlayers) + + var settings: CustomizableSettings = CustomizableSettings() + + var owner = _owner.username + + var state = State.LOBBY + private set + + init { + addPlayer(_owner) + } + + fun getPlayers(): List = players + + @Synchronized + fun addPlayer(player: Player) { + if (hasStarted()) { + throw GameAlreadyStartedException(name) + } + if (maxPlayersReached()) { + throw PlayerOverflowException(gameDefinition.maxPlayers) + } + if (playerNameAlreadyUsed(player.displayName)) { + throw PlayerNameAlreadyUsedException(player.displayName, name) + } + player.join(this) + players.add(player) + } + + private fun hasStarted(): Boolean = state != State.LOBBY + + private fun maxPlayersReached(): Boolean = players.size >= gameDefinition.maxPlayers + + private fun playerNameAlreadyUsed(name: String?): Boolean = players.any { it.displayName == name } + + @Synchronized + fun startGame(): Game { + if (!hasEnoughPlayers()) { + throw PlayerUnderflowException(gameDefinition.minPlayers) + } + state = State.PLAYING + val game = gameDefinition.initGame(id, settings, players.size) + players.forEachIndexed { index, player -> player.join(game, index) } + return game + } + + private fun hasEnoughPlayers(): Boolean = players.size >= gameDefinition.minPlayers + + @Synchronized + fun reorderPlayers(orderedUsernames: List) { + players = orderedUsernames.map { find(it) }.toMutableList() + } + + private fun find(username: String): Player = + players.firstOrNull { it.username == username } ?: throw UnknownPlayerException(username) + + @Synchronized + fun isOwner(username: String?): Boolean = _owner.username == username + + @Synchronized + fun containsUser(username: String): Boolean = players.any { it.username == username } + + @Synchronized + fun removePlayer(username: String): Player { + val player = find(username) + players.remove(player) + player.leave() + + if (player == _owner && !players.isEmpty()) { + _owner = players[0] + } + return player + } + + internal class GameAlreadyStartedException(name: String) : + IllegalStateException("Game '$name' has already started") + + internal class PlayerOverflowException(max: Int) : + IllegalStateException("Maximum $max players allowed") + + internal class PlayerUnderflowException(min: Int) : + IllegalStateException("Minimum $min players required to start a game") + + internal class PlayerNameAlreadyUsedException(displayName: String, gameName: String) : + IllegalStateException("Name '$displayName' is already used by a player in game '$gameName'") + + internal class UnknownPlayerException(username: String) : + IllegalStateException("Unknown player '$username'") +} diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/lobby/Player.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/lobby/Player.kt new file mode 100644 index 00000000..48a31047 --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/lobby/Player.kt @@ -0,0 +1,70 @@ +package org.luxons.sevenwonders.lobby + +import com.fasterxml.jackson.annotation.JsonIgnore +import org.luxons.sevenwonders.errors.ApiMisuseException +import org.luxons.sevenwonders.game.Game + +class Player( + val username: String, + var displayName: String +) { + var index: Int = -1 + + var isReady: Boolean = false + + val isGameOwner: Boolean + get() = _lobby?.isOwner(username) ?: false + + val isInLobby: Boolean + get() = _lobby != null + + val isInGame: Boolean + get() = _game != null + + @Transient + private var _lobby: Lobby? = null + + @get:JsonIgnore + val lobby: Lobby + get() = _lobby ?: throw PlayerNotInLobbyException(username) + + @get:JsonIgnore + val ownedLobby: Lobby + get() = if (isGameOwner) lobby else throw PlayerIsNotOwnerException(username) + + @Transient + private var _game: Game? = null + + @get:JsonIgnore + val game: Game + get() = _game ?: throw PlayerNotInGameException(username) + + fun join(lobby: Lobby) { + _lobby = lobby + } + + fun join(game: Game, index: Int) { + _game = game + this.index = index + } + + fun leave() { + _lobby = null + _game = null + index = -1 + } + + override fun toString(): String { + return "'$displayName' ($username)" + } +} + +internal class PlayerNotInLobbyException(username: String) : + ApiMisuseException("User $username is not in a lobby, create or join a game first") + +internal class PlayerIsNotOwnerException(username: String) : + ApiMisuseException("User $username does not own the lobby he's in") + +internal class PlayerNotInGameException(username: String) : + ApiMisuseException("User $username is not in a game, start a game first") + diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/output/PreparedCard.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/output/PreparedCard.kt new file mode 100644 index 00000000..956b1a2c --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/output/PreparedCard.kt @@ -0,0 +1,6 @@ +package org.luxons.sevenwonders.output + +import org.luxons.sevenwonders.game.cards.CardBack +import org.luxons.sevenwonders.lobby.Player + +class PreparedCard(val player: Player, val cardBack: CardBack) diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/repositories/LobbyRepository.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/repositories/LobbyRepository.kt new file mode 100644 index 00000000..261f723c --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/repositories/LobbyRepository.kt @@ -0,0 +1,33 @@ +package org.luxons.sevenwonders.repositories + +import org.luxons.sevenwonders.game.data.GameDefinitionLoader +import org.luxons.sevenwonders.lobby.Lobby +import org.luxons.sevenwonders.lobby.Player +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Repository +import java.util.HashMap + +@Repository +class LobbyRepository @Autowired constructor() { + + private val gameDefinitionLoader: GameDefinitionLoader = GameDefinitionLoader() + + private val lobbies = HashMap() + + private var lastGameId: Long = 0 + + fun list(): Collection = lobbies.values + + fun create(gameName: String, owner: Player): Lobby { + val id = lastGameId++ + val lobby = Lobby(id, gameName, owner, gameDefinitionLoader.gameDefinition) + lobbies[id] = lobby + return lobby + } + + fun find(lobbyId: Long): Lobby = lobbies[lobbyId] ?: throw LobbyNotFoundException(lobbyId) + + fun remove(lobbyId: Long): Lobby = lobbies.remove(lobbyId) ?: throw LobbyNotFoundException(lobbyId) +} + +internal class LobbyNotFoundException(id: Long) : RuntimeException("Lobby not found for id '$id'") diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/repositories/PlayerRepository.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/repositories/PlayerRepository.kt new file mode 100644 index 00000000..4d552eaa --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/repositories/PlayerRepository.kt @@ -0,0 +1,41 @@ +package org.luxons.sevenwonders.repositories + +import org.luxons.sevenwonders.errors.ApiMisuseException +import org.luxons.sevenwonders.lobby.Player +import org.springframework.stereotype.Repository +import java.util.HashMap + +@Repository +class PlayerRepository { + + private val players = HashMap() + + operator fun contains(username: String): Boolean = players.containsKey(username) + + fun createOrUpdate(username: String, displayName: String): Player { + return if (players.containsKey(username)) { + update(username, displayName) + } else { + create(username, displayName) + } + } + + private fun create(username: String, displayName: String): Player { + val player = Player(username, displayName) + players[username] = player + return player + } + + private fun update(username: String, displayName: String): Player { + val player = find(username) + player.displayName = displayName + return player + } + + fun find(username: String): Player = players[username] ?: throw PlayerNotFoundException(username) + + fun remove(username: String): Player = players.remove(username) ?: throw PlayerNotFoundException(username) +} + +internal class PlayerNotFoundException(username: String) : + ApiMisuseException("Player '$username' doesn't exist") diff --git a/backend/src/main/kotlin/org/luxons/sevenwonders/validation/DestinationAccessValidator.kt b/backend/src/main/kotlin/org/luxons/sevenwonders/validation/DestinationAccessValidator.kt new file mode 100644 index 00000000..5800edbb --- /dev/null +++ b/backend/src/main/kotlin/org/luxons/sevenwonders/validation/DestinationAccessValidator.kt @@ -0,0 +1,55 @@ +package org.luxons.sevenwonders.validation + +import org.luxons.sevenwonders.repositories.LobbyRepository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component +import java.util.regex.Matcher +import java.util.regex.Pattern + +@Component +class DestinationAccessValidator @Autowired constructor(private val lobbyRepository: LobbyRepository) { + + fun hasAccess(username: String?, destination: String): Boolean { + return when { + username == null -> false // unnamed user cannot belong to anything + hasForbiddenGameReference(username, destination) -> false + hasForbiddenLobbyReference(username, destination) -> false + else -> true + } + } + + private fun hasForbiddenGameReference(username: String, destination: String): Boolean { + val gameMatcher = gameDestination.matcher(destination) + if (!gameMatcher.matches()) { + return false // no game reference is always OK + } + val gameId = extractId(gameMatcher) + return !isUserInLobby(username, gameId) + } + + private fun hasForbiddenLobbyReference(username: String, destination: String): Boolean { + val lobbyMatcher = lobbyDestination.matcher(destination) + if (!lobbyMatcher.matches()) { + return false // no lobby reference is always OK + } + val lobbyId = extractId(lobbyMatcher) + return !isUserInLobby(username, lobbyId) + } + + private fun isUserInLobby(username: String, lobbyId: Int): Boolean { + val lobby = lobbyRepository.find(lobbyId.toLong()) + return lobby.containsUser(username) + } + + companion object { + + private val lobbyDestination = Pattern.compile(".*?/lobby/(?\\d+?)(/.*)?") + + private val gameDestination = Pattern.compile(".*?/game/(?\\d+?)(/.*)?") + + private fun extractId(matcher: Matcher): Int { + val id = matcher.group("id") + return Integer.parseInt(id) + } + } +} -- cgit