summaryrefslogtreecommitdiff
path: root/sw-server
diff options
context:
space:
mode:
Diffstat (limited to 'sw-server')
-rw-r--r--sw-server/build.gradle.kts48
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/SevenWonders.kt13
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/ChooseNameAction.kt19
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/CreateGameAction.kt19
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/JoinGameAction.kt17
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/PrepareMoveAction.kt18
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/ReorderPlayersAction.kt18
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/UpdateSettingsAction.kt18
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/api/LobbyDTO.kt17
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/api/PlayerDTO.kt14
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/config/AnonymousUsersHandshakeHandler.kt27
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/config/TopicSubscriptionInterceptor.kt38
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/config/WebSecurityConfig.kt12
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/config/WebSocketConfig.kt44
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/GameBrowserController.kt112
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/GameController.kt102
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/HomeController.kt46
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/LobbyController.kt107
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/doc/Documentation.kt6
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/errors/ErrorDTO.kt29
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/errors/ExceptionHandler.kt44
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/lobby/Lobby.kt111
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/lobby/Player.kt61
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/repositories/LobbyRepository.kt31
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/repositories/PlayerRepository.kt41
-rw-r--r--sw-server/src/main/kotlin/org/luxons/sevenwonders/validation/DestinationAccessValidator.kt47
-rw-r--r--sw-server/src/main/resources/application.properties2
-rw-r--r--sw-server/src/main/resources/static/images/tokens/coin1.pngbin0 -> 6284 bytes
-rw-r--r--sw-server/src/main/resources/static/images/tokens/coin3.pngbin0 -> 8770 bytes
-rw-r--r--sw-server/src/main/resources/static/images/tokens/free.pngbin0 -> 5062 bytes
-rw-r--r--sw-server/src/main/resources/static/images/tokens/pyramid-stage0.pngbin0 -> 3286 bytes
-rw-r--r--sw-server/src/main/resources/static/images/tokens/pyramid-stage1.pngbin0 -> 4114 bytes
-rw-r--r--sw-server/src/main/resources/static/images/tokens/pyramid-stage2.pngbin0 -> 4285 bytes
-rw-r--r--sw-server/src/main/resources/static/images/tokens/pyramid-stage3.pngbin0 -> 20663 bytes
-rw-r--r--sw-server/src/main/resources/static/images/tokens/pyramid.pngbin0 -> 3886 bytes
-rw-r--r--sw-server/src/main/resources/static/images/tokens/victory1.pngbin0 -> 3676 bytes
-rw-r--r--sw-server/src/main/resources/static/images/tokens/victory3.pngbin0 -> 4786 bytes
-rw-r--r--sw-server/src/main/resources/static/images/tokens/victory5.pngbin0 -> 7657 bytes
-rw-r--r--sw-server/src/main/resources/static/images/tokens/victoryminus1.pngbin0 -> 5925 bytes
-rw-r--r--sw-server/src/test/kotlin/org/luxons/sevenwonders/SevenWondersTest.kt145
-rw-r--r--sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/GameBrowserControllerTest.kt124
-rw-r--r--sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/HomeControllerTest.kt25
-rw-r--r--sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/LobbyControllerTest.kt217
-rw-r--r--sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/TestPrincipal.kt8
-rw-r--r--sw-server/src/test/kotlin/org/luxons/sevenwonders/lobby/LobbyTest.kt266
-rw-r--r--sw-server/src/test/kotlin/org/luxons/sevenwonders/repositories/LobbyRepositoryTest.kt78
-rw-r--r--sw-server/src/test/kotlin/org/luxons/sevenwonders/repositories/PlayerRepositoryTest.kt75
-rw-r--r--sw-server/src/test/kotlin/org/luxons/sevenwonders/test/TestUtils.kt10
-rw-r--r--sw-server/src/test/kotlin/org/luxons/sevenwonders/test/api/SevenWondersClient.kt38
-rw-r--r--sw-server/src/test/kotlin/org/luxons/sevenwonders/test/api/SevenWondersSession.kt70
-rw-r--r--sw-server/src/test/kotlin/org/luxons/sevenwonders/validation/DestinationAccessValidatorTest.kt147
51 files changed, 2264 insertions, 0 deletions
diff --git a/sw-server/build.gradle.kts b/sw-server/build.gradle.kts
new file mode 100644
index 00000000..99374cdf
--- /dev/null
+++ b/sw-server/build.gradle.kts
@@ -0,0 +1,48 @@
+plugins {
+ kotlin("jvm")
+ kotlin("plugin.spring")
+ id("org.springframework.boot") version "2.1.3.RELEASE"
+ id("org.jlleitschuh.gradle.ktlint") version "7.1.0"
+}
+
+apply(plugin = "io.spring.dependency-management")
+
+tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
+ kotlinOptions.jvmTarget = "1.8"
+}
+
+dependencies {
+ compile(project(":sw-common-model"))
+ compile(project(":sw-engine"))
+ compile(kotlin("stdlib-jdk8"))
+ compile(kotlin("reflect")) // required by Spring 5
+
+ compile("org.springframework.boot:spring-boot-starter-websocket")
+ compile("org.springframework.boot:spring-boot-starter-security")
+ // required by spring security when using websockets
+ compile("org.springframework.security:spring-security-messaging")
+
+ compile("com.fasterxml.jackson.module:jackson-module-kotlin")
+
+ compile("ch.qos.logback:logback-classic:1.1.8")
+ compile("org.hildan.livedoc:livedoc-springboot:4.3.2")
+ compile("org.hildan.livedoc:livedoc-ui-webjar:4.3.2")
+
+ annotationProcessor("org.hildan.livedoc:livedoc-javadoc-processor:4.3.2")
+
+ testImplementation(kotlin("test"))
+ testImplementation(kotlin("test-junit"))
+ testImplementation("org.springframework.boot:spring-boot-starter-test")
+ testImplementation("org.hildan.jackstomp:jackstomp:2.0.0")
+ testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin")
+}
+
+// packages the frontend app within the jar
+tasks.bootJar {
+ from("../sw-ui/build") {
+ into("static")
+ }
+}
+
+// make sure we build the frontend before creating the jar
+tasks.bootJar.get().dependsOn(":sw-ui:assemble")
diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/SevenWonders.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/SevenWonders.kt
new file mode 100644
index 00000000..04f03956
--- /dev/null
+++ b/sw-server/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<String>) {
+ runApplication<SevenWonders>(*args)
+}
diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/ChooseNameAction.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/ChooseNameAction.kt
new file mode 100644
index 00000000..ab444780
--- /dev/null
+++ b/sw-server/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/sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/CreateGameAction.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/CreateGameAction.kt
new file mode 100644
index 00000000..c10f9c34
--- /dev/null
+++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/CreateGameAction.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 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/sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/JoinGameAction.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/JoinGameAction.kt
new file mode 100644
index 00000000..002309b3
--- /dev/null
+++ b/sw-server/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/sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/PrepareMoveAction.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/PrepareMoveAction.kt
new file mode 100644
index 00000000..6b39c486
--- /dev/null
+++ b/sw-server/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/sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/ReorderPlayersAction.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/ReorderPlayersAction.kt
new file mode 100644
index 00000000..79a32137
--- /dev/null
+++ b/sw-server/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<String>
+)
diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/UpdateSettingsAction.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/actions/UpdateSettingsAction.kt
new file mode 100644
index 00000000..d13e5b45
--- /dev/null
+++ b/sw-server/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/sw-server/src/main/kotlin/org/luxons/sevenwonders/api/LobbyDTO.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/api/LobbyDTO.kt
new file mode 100644
index 00000000..b4445f32
--- /dev/null
+++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/api/LobbyDTO.kt
@@ -0,0 +1,17 @@
+package org.luxons.sevenwonders.api
+
+import org.luxons.sevenwonders.lobby.Lobby
+import org.luxons.sevenwonders.lobby.State
+
+data class LobbyDTO(
+ val id: Long,
+ val name: String,
+ val owner: String,
+ val players: List<PlayerDTO>,
+ val state: State
+)
+
+fun Lobby.toDTO(currentUser: String): LobbyDTO {
+ val players = getPlayers().map { it.toDTO(currentUser) }
+ return LobbyDTO(id, name, owner.username, players, state)
+}
diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/api/PlayerDTO.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/api/PlayerDTO.kt
new file mode 100644
index 00000000..54c69122
--- /dev/null
+++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/api/PlayerDTO.kt
@@ -0,0 +1,14 @@
+package org.luxons.sevenwonders.api
+
+import org.luxons.sevenwonders.lobby.Player
+
+data class PlayerDTO(
+ val username: String,
+ val displayName: String,
+ val index: Int,
+ val isGameOwner: Boolean,
+ val isUser: Boolean
+)
+
+fun Player.toDTO(currentUser: String) =
+ PlayerDTO(username, displayName, index, isGameOwner, username === currentUser)
diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/config/AnonymousUsersHandshakeHandler.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/config/AnonymousUsersHandshakeHandler.kt
new file mode 100644
index 00000000..db707d1b
--- /dev/null
+++ b/sw-server/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<String, Any>
+ ): Principal? {
+ var p = super.determineUser(request, wsHandler, attributes)
+ if (p == null) {
+ p = UsernamePasswordAuthenticationToken("player" + playerId++, null)
+ }
+ return p
+ }
+}
diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/config/TopicSubscriptionInterceptor.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/config/TopicSubscriptionInterceptor.kt
new file mode 100644
index 00000000..f4c55c2c
--- /dev/null
+++ b/sw-server/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/sw-server/src/main/kotlin/org/luxons/sevenwonders/config/WebSecurityConfig.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/config/WebSecurityConfig.kt
new file mode 100644
index 00000000..06b2bc90
--- /dev/null
+++ b/sw-server/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/sw-server/src/main/kotlin/org/luxons/sevenwonders/config/WebSocketConfig.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/config/WebSocketConfig.kt
new file mode 100644
index 00000000..743e3d1a
--- /dev/null
+++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/config/WebSocketConfig.kt
@@ -0,0 +1,44 @@
+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
+
+const val SEVEN_WONDERS_WS_ENDPOINT = "/seven-wonders-websocket"
+
+@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_WS_ENDPOINT)
+ .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/sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/GameBrowserController.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/GameBrowserController.kt
new file mode 100644
index 00000000..f856365f
--- /dev/null
+++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/GameBrowserController.kt
@@ -0,0 +1,112 @@
+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.api.LobbyDTO
+import org.luxons.sevenwonders.api.toDTO
+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<LobbyDTO> {
+ logger.info("Player '{}' subscribed to /topic/games", principal.name)
+ return lobbyRepository.list().map { it.toDTO(principal.name) }
+ }
+
+ /**
+ * 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): LobbyDTO {
+ 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
+ val lobbyDto = lobby.toDTO(principal.name)
+ template.convertAndSend("/topic/games", listOf(lobbyDto))
+ return lobbyDto
+ }
+
+ /**
+ * 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): LobbyDTO {
+ 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
+ )
+ val lobbyDTO = lobby.toDTO(principal.name)
+ lobbyController.sendLobbyUpdateToPlayers(lobbyDTO)
+ return lobbyDTO
+ }
+
+ 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/sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/GameController.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/GameController.kt
new file mode 100644
index 00000000..b37c9c7c
--- /dev/null
+++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/GameController.kt
@@ -0,0 +1,102 @@
+package org.luxons.sevenwonders.controllers
+
+import org.hildan.livedoc.core.annotations.Api
+import org.luxons.sevenwonders.actions.PrepareMoveAction
+import org.luxons.sevenwonders.api.PlayerDTO
+import org.luxons.sevenwonders.api.toDTO
+import org.luxons.sevenwonders.game.Game
+import org.luxons.sevenwonders.game.api.Table
+import org.luxons.sevenwonders.game.cards.CardBack
+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.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()
+
+ sendPlayerReady(game.id, player)
+
+ val allReady = players.all { it.isReady }
+ if (allReady) {
+ logger.info("Game {}: all players ready, sending turn info", game.id)
+ players.forEach { it.isReady = false }
+ sendTurnInfo(players, game)
+ }
+ }
+
+ private fun sendTurnInfo(players: List<Player>, 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.toDTO(principal.name), preparedCardBack)
+ logger.info("Game {}: player {} prepared move {}", game.id, principal.name, action.move)
+ sendPreparedCard(game.id, preparedCard)
+
+ if (game.allPlayersPreparedTheirMove()) {
+ logger.info("Game {}: all players have prepared their move, executing turn...", game.id)
+ val table = game.playTurn()
+ sendPlayedMoves(game.id, table)
+ }
+ }
+
+ 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)
+ }
+}
+
+class PreparedCard(val player: PlayerDTO, val cardBack: CardBack)
diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/HomeController.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/HomeController.kt
new file mode 100644
index 00000000..bd672000
--- /dev/null
+++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/HomeController.kt
@@ -0,0 +1,46 @@
+package org.luxons.sevenwonders.controllers
+
+import org.hildan.livedoc.core.annotations.Api
+import org.luxons.sevenwonders.actions.ChooseNameAction
+import org.luxons.sevenwonders.api.PlayerDTO
+import org.luxons.sevenwonders.api.toDTO
+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 [PlayerDTO] object
+ */
+ @MessageMapping("/chooseName")
+ @SendToUser("/queue/nameChoice")
+ fun chooseName(@Validated action: ChooseNameAction, principal: Principal): PlayerDTO {
+ val username = principal.name
+ val player = playerRepository.createOrUpdate(username, action.playerName)
+
+ logger.info("Player '{}' chose the name '{}'", username, player.displayName)
+ return player.toDTO(username)
+ }
+
+ companion object {
+ private val logger = LoggerFactory.getLogger(HomeController::class.java)
+ }
+}
diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/LobbyController.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/LobbyController.kt
new file mode 100644
index 00000000..4e4120a9
--- /dev/null
+++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/controllers/LobbyController.kt
@@ -0,0 +1,107 @@
+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.api.LobbyDTO
+import org.luxons.sevenwonders.api.toDTO
+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.toDTO(principal.name))
+ }
+
+ /**
+ * 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.toDTO(principal.name))
+ }
+
+ /**
+ * 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.toDTO(principal.name))
+ }
+
+ internal fun sendLobbyUpdateToPlayers(lobby: LobbyDTO) {
+ 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/sw-server/src/main/kotlin/org/luxons/sevenwonders/doc/Documentation.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/doc/Documentation.kt
new file mode 100644
index 00000000..3b04356a
--- /dev/null
+++ b/sw-server/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/sw-server/src/main/kotlin/org/luxons/sevenwonders/errors/ErrorDTO.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/errors/ErrorDTO.kt
new file mode 100644
index 00000000..c3eae0b5
--- /dev/null
+++ b/sw-server/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<ValidationErrorDTO> = 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/sw-server/src/main/kotlin/org/luxons/sevenwonders/errors/ExceptionHandler.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/errors/ExceptionHandler.kt
new file mode 100644
index 00000000..76d01f5f
--- /dev/null
+++ b/sw-server/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/sw-server/src/main/kotlin/org/luxons/sevenwonders/lobby/Lobby.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/lobby/Lobby.kt
new file mode 100644
index 00000000..08249193
--- /dev/null
+++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/lobby/Lobby.kt
@@ -0,0 +1,111 @@
+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
+
+enum class State {
+ LOBBY, PLAYING
+}
+
+class Lobby(
+ val id: Long,
+ val name: String,
+ var owner: Player,
+ private val gameDefinition: GameDefinition
+) {
+ private val players: MutableList<Player> = ArrayList(gameDefinition.maxPlayers)
+
+ var settings: CustomizableSettings = CustomizableSettings()
+
+ var state = State.LOBBY
+ private set
+
+ init {
+ addPlayer(owner)
+ }
+
+ fun getPlayers(): List<Player> = 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<String>) {
+ val usernames = players.map { it.username }
+ if (orderedUsernames.toSet() != usernames.toSet()) {
+ throw PlayerListMismatchException(orderedUsernames)
+ }
+ players.sortBy { orderedUsernames.indexOf(it.username) }
+ }
+
+ 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) :
+ IllegalArgumentException("Name '$displayName' is already used by a player in game '$gameName'")
+
+ internal class UnknownPlayerException(username: String) :
+ IllegalArgumentException("Unknown player '$username'")
+
+ internal class PlayerListMismatchException(usernames: List<String>) :
+ IllegalArgumentException("Newly ordered usernames $usernames don't match the current player list")
+}
diff --git a/sw-server/src/main/kotlin/org/luxons/sevenwonders/lobby/Player.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/lobby/Player.kt
new file mode 100644
index 00000000..d6e9b344
--- /dev/null
+++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/lobby/Player.kt
@@ -0,0 +1,61 @@
+package org.luxons.sevenwonders.lobby
+
+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
+
+ private var _lobby: Lobby? = null
+
+ val lobby: Lobby
+ get() = _lobby ?: throw PlayerNotInLobbyException(username)
+
+ val ownedLobby: Lobby
+ get() = if (isGameOwner) lobby else throw PlayerIsNotOwnerException(username)
+
+ private var _game: Game? = null
+
+ 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 = "'$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/sw-server/src/main/kotlin/org/luxons/sevenwonders/repositories/LobbyRepository.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/repositories/LobbyRepository.kt
new file mode 100644
index 00000000..768aa659
--- /dev/null
+++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/repositories/LobbyRepository.kt
@@ -0,0 +1,31 @@
+package org.luxons.sevenwonders.repositories
+
+import org.luxons.sevenwonders.game.data.GameDefinition
+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 lobbies = HashMap<Long, Lobby>()
+
+ private var lastGameId: Long = 0
+
+ fun list(): Collection<Lobby> = lobbies.values
+
+ fun create(gameName: String, owner: Player): Lobby {
+ val id = lastGameId++
+ val lobby = Lobby(id, gameName, owner, GameDefinition.load())
+ 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/sw-server/src/main/kotlin/org/luxons/sevenwonders/repositories/PlayerRepository.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/repositories/PlayerRepository.kt
new file mode 100644
index 00000000..4d552eaa
--- /dev/null
+++ b/sw-server/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<String, Player>()
+
+ 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/sw-server/src/main/kotlin/org/luxons/sevenwonders/validation/DestinationAccessValidator.kt b/sw-server/src/main/kotlin/org/luxons/sevenwonders/validation/DestinationAccessValidator.kt
new file mode 100644
index 00000000..5f704357
--- /dev/null
+++ b/sw-server/src/main/kotlin/org/luxons/sevenwonders/validation/DestinationAccessValidator.kt
@@ -0,0 +1,47 @@
+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.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 = gameMatcher.group("id").toLong()
+ 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 = lobbyMatcher.group("id").toLong()
+ return !isUserInLobby(username, lobbyId)
+ }
+
+ private fun isUserInLobby(username: String, lobbyId: Long): Boolean =
+ lobbyRepository.find(lobbyId).containsUser(username)
+
+ companion object {
+
+ private val lobbyDestination = Pattern.compile(".*?/lobby/(?<id>\\d+?)(/.*)?")
+
+ private val gameDestination = Pattern.compile(".*?/game/(?<id>\\d+?)(/.*)?")
+ }
+}
diff --git a/sw-server/src/main/resources/application.properties b/sw-server/src/main/resources/application.properties
new file mode 100644
index 00000000..aa2e5d12
--- /dev/null
+++ b/sw-server/src/main/resources/application.properties
@@ -0,0 +1,2 @@
+livedoc.version=1.0
+livedoc.packages[0]=org.luxons.sevenwonders
diff --git a/sw-server/src/main/resources/static/images/tokens/coin1.png b/sw-server/src/main/resources/static/images/tokens/coin1.png
new file mode 100644
index 00000000..dd57e5f0
--- /dev/null
+++ b/sw-server/src/main/resources/static/images/tokens/coin1.png
Binary files differ
diff --git a/sw-server/src/main/resources/static/images/tokens/coin3.png b/sw-server/src/main/resources/static/images/tokens/coin3.png
new file mode 100644
index 00000000..546d41b6
--- /dev/null
+++ b/sw-server/src/main/resources/static/images/tokens/coin3.png
Binary files differ
diff --git a/sw-server/src/main/resources/static/images/tokens/free.png b/sw-server/src/main/resources/static/images/tokens/free.png
new file mode 100644
index 00000000..1c8d0782
--- /dev/null
+++ b/sw-server/src/main/resources/static/images/tokens/free.png
Binary files differ
diff --git a/sw-server/src/main/resources/static/images/tokens/pyramid-stage0.png b/sw-server/src/main/resources/static/images/tokens/pyramid-stage0.png
new file mode 100644
index 00000000..b6a3977f
--- /dev/null
+++ b/sw-server/src/main/resources/static/images/tokens/pyramid-stage0.png
Binary files differ
diff --git a/sw-server/src/main/resources/static/images/tokens/pyramid-stage1.png b/sw-server/src/main/resources/static/images/tokens/pyramid-stage1.png
new file mode 100644
index 00000000..ead4a34e
--- /dev/null
+++ b/sw-server/src/main/resources/static/images/tokens/pyramid-stage1.png
Binary files differ
diff --git a/sw-server/src/main/resources/static/images/tokens/pyramid-stage2.png b/sw-server/src/main/resources/static/images/tokens/pyramid-stage2.png
new file mode 100644
index 00000000..7239a3a4
--- /dev/null
+++ b/sw-server/src/main/resources/static/images/tokens/pyramid-stage2.png
Binary files differ
diff --git a/sw-server/src/main/resources/static/images/tokens/pyramid-stage3.png b/sw-server/src/main/resources/static/images/tokens/pyramid-stage3.png
new file mode 100644
index 00000000..cab9912b
--- /dev/null
+++ b/sw-server/src/main/resources/static/images/tokens/pyramid-stage3.png
Binary files differ
diff --git a/sw-server/src/main/resources/static/images/tokens/pyramid.png b/sw-server/src/main/resources/static/images/tokens/pyramid.png
new file mode 100644
index 00000000..074247da
--- /dev/null
+++ b/sw-server/src/main/resources/static/images/tokens/pyramid.png
Binary files differ
diff --git a/sw-server/src/main/resources/static/images/tokens/victory1.png b/sw-server/src/main/resources/static/images/tokens/victory1.png
new file mode 100644
index 00000000..6b9aff29
--- /dev/null
+++ b/sw-server/src/main/resources/static/images/tokens/victory1.png
Binary files differ
diff --git a/sw-server/src/main/resources/static/images/tokens/victory3.png b/sw-server/src/main/resources/static/images/tokens/victory3.png
new file mode 100644
index 00000000..474cb30c
--- /dev/null
+++ b/sw-server/src/main/resources/static/images/tokens/victory3.png
Binary files differ
diff --git a/sw-server/src/main/resources/static/images/tokens/victory5.png b/sw-server/src/main/resources/static/images/tokens/victory5.png
new file mode 100644
index 00000000..ad042119
--- /dev/null
+++ b/sw-server/src/main/resources/static/images/tokens/victory5.png
Binary files differ
diff --git a/sw-server/src/main/resources/static/images/tokens/victoryminus1.png b/sw-server/src/main/resources/static/images/tokens/victoryminus1.png
new file mode 100644
index 00000000..00a615c7
--- /dev/null
+++ b/sw-server/src/main/resources/static/images/tokens/victoryminus1.png
Binary files differ
diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/SevenWondersTest.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/SevenWondersTest.kt
new file mode 100644
index 00000000..01de366a
--- /dev/null
+++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/SevenWondersTest.kt
@@ -0,0 +1,145 @@
+package org.luxons.sevenwonders
+
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.luxons.sevenwonders.test.api.SevenWondersClient
+import org.luxons.sevenwonders.test.api.SevenWondersSession
+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 java.util.concurrent.TimeUnit
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+@RunWith(SpringRunner::class)
+@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
+class SevenWondersTest {
+
+ @LocalServerPort
+ private val randomServerPort: Int = 0
+
+ private lateinit var client: SevenWondersClient
+
+ private lateinit var serverUrl: String
+
+ @Before
+ fun setUpClientAndUrl() {
+ client = SevenWondersClient()
+ serverUrl = "ws://localhost:$randomServerPort"
+ }
+
+ private fun disconnect(vararg sessions: SevenWondersSession) {
+ for (session in sessions) {
+ session.disconnect()
+ }
+ }
+
+ @Test
+ fun chooseName() {
+ val session = client.connect(serverUrl)
+ val playerName = "Test User"
+ val player = session.chooseName(playerName)
+ assertNotNull(player)
+ assertEquals(playerName, player.displayName)
+ session.disconnect()
+ }
+
+ private fun newPlayer(name: String): SevenWondersSession {
+ val otherSession = client.connect(serverUrl)
+ otherSession.chooseName(name)
+ return otherSession
+ }
+
+ @Test
+ fun lobbySubscription_ignoredForOutsiders() {
+ val ownerSession = newPlayer("GameOwner")
+ val session1 = newPlayer("Player1")
+ val session2 = newPlayer("Player2")
+ val gameName = "Test Game"
+ val lobby = ownerSession.createGame(gameName)
+ session1.joinGame(lobby.id)
+ session2.joinGame(lobby.id)
+
+ val outsiderSession = newPlayer("Outsider")
+ val session = outsiderSession.jackstompSession
+ val started = session.subscribeEmptyMsgs("/topic/lobby/" + lobby.id + "/started")
+
+ ownerSession.startGame(lobby.id)
+ val nothing = started.next(1, TimeUnit.SECONDS)
+ assertNull(nothing)
+ disconnect(ownerSession, session1, session2, outsiderSession)
+ }
+
+ @Test
+ fun createGame_success() {
+ val ownerSession = newPlayer("GameOwner")
+
+ val gameName = "Test Game"
+ val lobby = ownerSession.createGame(gameName)
+ assertNotNull(lobby)
+ assertEquals(gameName, lobby.name)
+
+ disconnect(ownerSession)
+ }
+
+ @Test
+ fun createGame_seenByConnectedPlayers() {
+ val otherSession = newPlayer("OtherPlayer")
+ val games = otherSession.watchGames()
+
+ var receivedLobbies = games.next()
+ assertNotNull(receivedLobbies)
+ assertEquals(0, receivedLobbies.size)
+
+ val ownerSession = newPlayer("GameOwner")
+ val gameName = "Test Game"
+ val createdLobby = ownerSession.createGame(gameName)
+
+ receivedLobbies = games.next()
+ assertNotNull(receivedLobbies)
+ assertEquals(1, receivedLobbies.size)
+ val receivedLobby = receivedLobbies[0]
+ assertEquals(createdLobby.id, receivedLobby.id)
+ assertEquals(createdLobby.name, receivedLobby.name)
+
+ disconnect(ownerSession, otherSession)
+ }
+
+ @Test
+ fun startGame_3players() {
+ val session1 = newPlayer("Player1")
+ val session2 = newPlayer("Player2")
+
+ val lobby = session1.createGame("Test Game")
+ session2.joinGame(lobby.id)
+
+ val session3 = newPlayer("Player3")
+ session3.joinGame(lobby.id)
+
+ session1.startGame(lobby.id)
+
+ val turns1 = session1.watchTurns()
+ val turns2 = session2.watchTurns()
+ val turns3 = session3.watchTurns()
+ session1.sayReady()
+ session2.sayReady()
+ session3.sayReady()
+ val turn1 = turns1.next()
+ val turn2 = turns2.next()
+ val turn3 = turns3.next()
+ assertNotNull(turn1)
+ assertNotNull(turn2)
+ assertNotNull(turn3)
+
+ disconnect(session1, session2, session3)
+ }
+
+ @After
+ fun tearDown() {
+ client.stop()
+ }
+}
diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/GameBrowserControllerTest.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/GameBrowserControllerTest.kt
new file mode 100644
index 00000000..343b7f34
--- /dev/null
+++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/GameBrowserControllerTest.kt
@@ -0,0 +1,124 @@
+package org.luxons.sevenwonders.controllers
+
+import org.junit.Before
+import org.junit.Test
+import org.luxons.sevenwonders.actions.CreateGameAction
+import org.luxons.sevenwonders.actions.JoinGameAction
+import org.luxons.sevenwonders.api.toDTO
+import org.luxons.sevenwonders.controllers.GameBrowserController.UserAlreadyInGameException
+import org.luxons.sevenwonders.repositories.LobbyRepository
+import org.luxons.sevenwonders.repositories.PlayerNotFoundException
+import org.luxons.sevenwonders.repositories.PlayerRepository
+import org.luxons.sevenwonders.test.mockSimpMessagingTemplate
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class GameBrowserControllerTest {
+
+ private lateinit var playerRepository: PlayerRepository
+
+ private lateinit var gameBrowserController: GameBrowserController
+
+ @Before
+ fun setUp() {
+ playerRepository = PlayerRepository()
+ val lobbyRepository = LobbyRepository()
+ val template = mockSimpMessagingTemplate()
+ val lobbyController = LobbyController(lobbyRepository, playerRepository, template)
+ gameBrowserController = GameBrowserController(lobbyController, lobbyRepository, playerRepository, template)
+ }
+
+ @Test
+ fun listGames_initiallyEmpty() {
+ val principal = TestPrincipal("testuser")
+ val games = gameBrowserController.listGames(principal)
+ assertTrue(games.isEmpty())
+ }
+
+ @Test
+ fun createGame_success() {
+ val player = playerRepository.createOrUpdate("testuser", "Test User")
+ val principal = TestPrincipal("testuser")
+
+ val action = CreateGameAction("Test Game")
+
+ val createdLobby = gameBrowserController.createGame(action, principal)
+
+ assertEquals("Test Game", createdLobby.name)
+
+ val games = gameBrowserController.listGames(principal)
+ assertFalse(games.isEmpty())
+ val lobby = games.iterator().next()
+ assertEquals(lobby, createdLobby)
+ assertEquals(player.toDTO(principal.name), lobby.players[0])
+ }
+
+ @Test
+ fun createGame_failsForUnknownPlayer() {
+ val principal = TestPrincipal("unknown")
+ val action = CreateGameAction("Test Game")
+
+ assertFailsWith<PlayerNotFoundException> {
+ gameBrowserController.createGame(action, principal)
+ }
+ }
+
+ @Test
+ fun createGame_failsWhenAlreadyInGame() {
+ playerRepository.createOrUpdate("testuser", "Test User")
+ val principal = TestPrincipal("testuser")
+
+ val createGameAction1 = CreateGameAction("Test Game 1")
+
+ // auto-enters the game
+ gameBrowserController.createGame(createGameAction1, principal)
+
+ val createGameAction2 = CreateGameAction("Test Game 2")
+
+ // already in a game
+ assertFailsWith<UserAlreadyInGameException> {
+ gameBrowserController.createGame(createGameAction2, principal)
+ }
+ }
+
+ @Test
+ fun joinGame_success() {
+ val owner = playerRepository.createOrUpdate("testowner", "Test User Owner")
+ val ownerPrincipal = TestPrincipal("testowner")
+ val createGameAction = CreateGameAction("Test Game")
+
+ val createdLobby = gameBrowserController.createGame(createGameAction, ownerPrincipal)
+ assertEquals(owner.toDTO(ownerPrincipal.name), createdLobby.players[0])
+
+ val joiner = playerRepository.createOrUpdate("testjoiner", "Test User Joiner")
+ val joinerPrincipal = TestPrincipal("testjoiner")
+ val joinGameAction = JoinGameAction(createdLobby.id)
+
+ val joinedLobby = gameBrowserController.joinGame(joinGameAction, joinerPrincipal)
+
+ assertEquals(owner.toDTO(joinerPrincipal.name), joinedLobby.players[0])
+ assertEquals(joiner.toDTO(joinerPrincipal.name), joinedLobby.players[1])
+ }
+
+ @Test
+ fun joinGame_failsWhenAlreadyInGame() {
+ playerRepository.createOrUpdate("testowner", "Test User Owner")
+ val ownerPrincipal = TestPrincipal("testowner")
+ val createGameAction = CreateGameAction("Test Game")
+
+ val createdLobby = gameBrowserController.createGame(createGameAction, ownerPrincipal)
+
+ playerRepository.createOrUpdate("testjoiner", "Test User Joiner")
+ val joinerPrincipal = TestPrincipal("testjoiner")
+ val joinGameAction = JoinGameAction(createdLobby.id)
+
+ // joins the game
+ gameBrowserController.joinGame(joinGameAction, joinerPrincipal)
+ // should fail because already in a game
+ assertFailsWith<UserAlreadyInGameException> {
+ gameBrowserController.joinGame(joinGameAction, joinerPrincipal)
+ }
+ }
+}
diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/HomeControllerTest.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/HomeControllerTest.kt
new file mode 100644
index 00000000..3374a025
--- /dev/null
+++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/HomeControllerTest.kt
@@ -0,0 +1,25 @@
+package org.luxons.sevenwonders.controllers
+
+import org.junit.Test
+import org.luxons.sevenwonders.actions.ChooseNameAction
+import org.luxons.sevenwonders.repositories.PlayerRepository
+import kotlin.test.assertEquals
+
+class HomeControllerTest {
+
+ @Test
+ fun chooseName() {
+ val playerRepository = PlayerRepository()
+ val homeController = HomeController(playerRepository)
+
+ val action = ChooseNameAction("Test User")
+ val principal = TestPrincipal("testuser")
+
+ val player = homeController.chooseName(action, principal)
+
+ assertEquals("testuser", player.username)
+ assertEquals("Test User", player.displayName)
+ assertEquals(false, player.isGameOwner)
+ assertEquals(true, player.isUser)
+ }
+}
diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/LobbyControllerTest.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/LobbyControllerTest.kt
new file mode 100644
index 00000000..a140e000
--- /dev/null
+++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/LobbyControllerTest.kt
@@ -0,0 +1,217 @@
+package org.luxons.sevenwonders.controllers
+
+import org.junit.Before
+import org.junit.Test
+import org.luxons.sevenwonders.actions.ReorderPlayersAction
+import org.luxons.sevenwonders.actions.UpdateSettingsAction
+import org.luxons.sevenwonders.game.api.CustomizableSettings
+import org.luxons.sevenwonders.game.api.WonderSidePickMethod.ALL_A
+import org.luxons.sevenwonders.lobby.Lobby
+import org.luxons.sevenwonders.lobby.Player
+import org.luxons.sevenwonders.lobby.PlayerIsNotOwnerException
+import org.luxons.sevenwonders.lobby.PlayerNotInLobbyException
+import org.luxons.sevenwonders.lobby.State
+import org.luxons.sevenwonders.repositories.LobbyRepository
+import org.luxons.sevenwonders.repositories.PlayerNotFoundException
+import org.luxons.sevenwonders.repositories.PlayerRepository
+import org.luxons.sevenwonders.test.mockSimpMessagingTemplate
+import java.util.HashMap
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+class LobbyControllerTest {
+
+ private lateinit var playerRepository: PlayerRepository
+
+ private lateinit var lobbyRepository: LobbyRepository
+
+ private lateinit var lobbyController: LobbyController
+
+ @Before
+ fun setUp() {
+ val template = mockSimpMessagingTemplate()
+ playerRepository = PlayerRepository()
+ lobbyRepository = LobbyRepository()
+ lobbyController = LobbyController(lobbyRepository, playerRepository, template)
+ }
+
+ @Test
+ fun init_succeeds() {
+ val owner = playerRepository.createOrUpdate("testuser", "Test User")
+ val lobby = lobbyRepository.create("Test Game", owner)
+
+ assertTrue(lobby.getPlayers().contains(owner))
+ assertSame(lobby, owner.lobby)
+ assertEquals(owner, lobby.owner)
+ assertTrue(owner.isInLobby)
+ assertFalse(owner.isInGame)
+ }
+
+ @Test
+ fun leave_failsWhenPlayerDoesNotExist() {
+ val principal = TestPrincipal("I don't exist")
+
+ assertFailsWith<PlayerNotFoundException> {
+ lobbyController.leave(principal)
+ }
+ }
+
+ @Test
+ fun leave_failsWhenNotInLobby() {
+ playerRepository.createOrUpdate("testuser", "Test User")
+ val principal = TestPrincipal("testuser")
+
+ assertFailsWith<PlayerNotInLobbyException> {
+ lobbyController.leave(principal)
+ }
+ }
+
+ @Test
+ fun leave_succeedsWhenInALobby_asOwner() {
+ val player = playerRepository.createOrUpdate("testuser", "Test User")
+ val lobby = lobbyRepository.create("Test Game", player)
+
+ val principal = TestPrincipal("testuser")
+ lobbyController.leave(principal)
+
+ assertFalse(lobbyRepository.list().contains(lobby))
+ assertFalse(player.isInLobby)
+ assertFalse(player.isInGame)
+ }
+
+ @Test
+ fun leave_succeedsWhenInALobby_asPeasant() {
+ val player = playerRepository.createOrUpdate("testuser", "Test User")
+ val lobby = lobbyRepository.create("Test Game", player)
+ val player2 = addPlayer(lobby, "testuser2")
+
+ val principal = TestPrincipal("testuser2")
+ lobbyController.leave(principal)
+
+ assertFalse(lobby.getPlayers().contains(player2))
+ assertFalse(player2.isInLobby)
+ assertFalse(player2.isInGame)
+ }
+
+ @Test
+ fun reorderPlayers_succeedsForOwner() {
+ val player = playerRepository.createOrUpdate("testuser", "Test User")
+ val lobby = lobbyRepository.create("Test Game", player)
+
+ val player2 = addPlayer(lobby, "testuser2")
+ val player3 = addPlayer(lobby, "testuser3")
+ val player4 = addPlayer(lobby, "testuser4")
+
+ val players = listOf(player, player2, player3, player4)
+ assertEquals(players, lobby.getPlayers())
+
+ val reorderedPlayers = listOf(player3, player, player2, player4)
+ val playerNames = reorderedPlayers.map { it.username }
+ val reorderPlayersAction = ReorderPlayersAction(playerNames)
+
+ val principal = TestPrincipal("testuser")
+ lobbyController.reorderPlayers(reorderPlayersAction, principal)
+
+ assertEquals(reorderedPlayers, lobby.getPlayers())
+ }
+
+ @Test
+ fun reorderPlayers_failsForPeasant() {
+ val player = playerRepository.createOrUpdate("testuser", "Test User")
+ val lobby = lobbyRepository.create("Test Game", player)
+
+ val player2 = addPlayer(lobby, "testuser2")
+ val player3 = addPlayer(lobby, "testuser3")
+
+ val reorderedPlayers = listOf(player3, player, player2)
+ val playerNames = reorderedPlayers.map { it.username }
+ val reorderPlayersAction = ReorderPlayersAction(playerNames)
+
+ val principal = TestPrincipal("testuser2")
+
+ assertFailsWith<PlayerIsNotOwnerException> {
+ lobbyController.reorderPlayers(reorderPlayersAction, principal)
+ }
+ }
+
+ @Test
+ fun updateSettings_succeedsForOwner() {
+ val player = playerRepository.createOrUpdate("testuser", "Test User")
+ val lobby = lobbyRepository.create("Test Game", player)
+
+ addPlayer(lobby, "testuser2")
+ addPlayer(lobby, "testuser3")
+ addPlayer(lobby, "testuser4")
+
+ assertEquals(CustomizableSettings(), lobby.settings)
+
+ val newSettings = CustomizableSettings(12L, 5, ALL_A, 5, 5, 4, 10, 2, HashMap())
+ val updateSettingsAction = UpdateSettingsAction(newSettings)
+
+ val principal = TestPrincipal("testuser")
+ lobbyController.updateSettings(updateSettingsAction, principal)
+
+ assertEquals(newSettings, lobby.settings)
+ }
+
+ @Test
+ fun updateSettings_failsForPeasant() {
+ val player = playerRepository.createOrUpdate("testuser", "Test User")
+ val lobby = lobbyRepository.create("Test Game", player)
+
+ addPlayer(lobby, "testuser2")
+ addPlayer(lobby, "testuser3")
+
+ val updateSettingsAction = UpdateSettingsAction(CustomizableSettings())
+
+ val principal = TestPrincipal("testuser2")
+
+ assertFailsWith<PlayerIsNotOwnerException> {
+ lobbyController.updateSettings(updateSettingsAction, principal)
+ }
+ }
+
+ @Test
+ fun startGame_succeedsForOwner() {
+ val player = playerRepository.createOrUpdate("testuser", "Test User")
+ val lobby = lobbyRepository.create("Test Game", player)
+
+ addPlayer(lobby, "testuser2")
+ addPlayer(lobby, "testuser3")
+ addPlayer(lobby, "testuser4")
+
+ val principal = TestPrincipal("testuser")
+ lobbyController.startGame(principal)
+
+ assertSame(State.PLAYING, lobby.state)
+ }
+
+ @Test
+ fun startGame_failsForPeasant() {
+ val player = playerRepository.createOrUpdate("testuser", "Test User")
+ val lobby = lobbyRepository.create("Test Game", player)
+
+ addPlayer(lobby, "testuser2")
+ addPlayer(lobby, "testuser3")
+
+ val principal = TestPrincipal("testuser2")
+
+ assertFailsWith<PlayerIsNotOwnerException> {
+ lobbyController.startGame(principal)
+ }
+ }
+
+ private fun addPlayer(lobby: Lobby, username: String): Player {
+ val player = playerRepository.createOrUpdate(username, username)
+ lobby.addPlayer(player)
+
+ assertTrue(lobby.getPlayers().contains(player))
+ assertSame(lobby, player.lobby)
+ assertTrue(player.isInLobby)
+ assertFalse(player.isInGame)
+ return player
+ }
+}
diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/TestPrincipal.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/TestPrincipal.kt
new file mode 100644
index 00000000..76b0f8fa
--- /dev/null
+++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/controllers/TestPrincipal.kt
@@ -0,0 +1,8 @@
+package org.luxons.sevenwonders.controllers
+
+import java.security.Principal
+
+internal class TestPrincipal(private val name: String) : Principal {
+
+ override fun getName(): String = name
+}
diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/lobby/LobbyTest.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/lobby/LobbyTest.kt
new file mode 100644
index 00000000..967a97e2
--- /dev/null
+++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/lobby/LobbyTest.kt
@@ -0,0 +1,266 @@
+package org.luxons.sevenwonders.lobby
+
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Test
+import org.junit.experimental.theories.DataPoints
+import org.junit.experimental.theories.Theories
+import org.junit.experimental.theories.Theory
+import org.junit.runner.RunWith
+import org.luxons.sevenwonders.game.api.CustomizableSettings
+import org.luxons.sevenwonders.game.data.GameDefinition
+import org.luxons.sevenwonders.lobby.Lobby.GameAlreadyStartedException
+import org.luxons.sevenwonders.lobby.Lobby.PlayerListMismatchException
+import org.luxons.sevenwonders.lobby.Lobby.PlayerNameAlreadyUsedException
+import org.luxons.sevenwonders.lobby.Lobby.PlayerOverflowException
+import org.luxons.sevenwonders.lobby.Lobby.PlayerUnderflowException
+import org.luxons.sevenwonders.lobby.Lobby.UnknownPlayerException
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+@RunWith(Theories::class)
+class LobbyTest {
+
+ private lateinit var gameOwner: Player
+
+ private lateinit var lobby: Lobby
+
+ @Before
+ fun setUp() {
+ gameOwner = Player("gameowner", "Game owner")
+ lobby = Lobby(0, "Test Game", gameOwner, gameDefinition)
+ }
+
+ @Test
+ fun testId() {
+ val lobby = Lobby(5, "Test Game", gameOwner, gameDefinition)
+ assertEquals(5, lobby.id)
+ }
+
+ @Test
+ fun testName() {
+ val lobby = Lobby(5, "Test Game", gameOwner, gameDefinition)
+ assertEquals("Test Game", lobby.name)
+ }
+
+ @Test
+ fun testOwner() {
+ val lobby = Lobby(5, "Test Game", gameOwner, gameDefinition)
+ assertSame(gameOwner, lobby.getPlayers()[0])
+ assertSame(lobby, gameOwner.lobby)
+ }
+
+ @Test
+ fun isOwner_falseWhenNull() {
+ assertFalse(lobby.isOwner(null))
+ }
+
+ @Test
+ fun isOwner_falseWhenEmptyString() {
+ assertFalse(lobby.isOwner(""))
+ }
+
+ @Test
+ fun isOwner_falseWhenGarbageString() {
+ assertFalse(lobby.isOwner("this is garbage"))
+ }
+
+ @Test
+ fun isOwner_trueWhenOwnerUsername() {
+ assertTrue(lobby.isOwner(gameOwner.username))
+ }
+
+ @Test
+ fun isOwner_falseWhenOtherPlayerName() {
+ val player = Player("testuser", "Test User")
+ lobby.addPlayer(player)
+ assertFalse(lobby.isOwner(player.username))
+ }
+
+ @Test
+ fun addPlayer_success() {
+ val player = Player("testuser", "Test User")
+ lobby.addPlayer(player)
+ assertTrue(lobby.containsUser("testuser"))
+ assertSame(lobby, player.lobby)
+ }
+
+ @Test
+ fun addPlayer_failsOnSameName() {
+ val player = Player("testuser", "Test User")
+ val player2 = Player("testuser2", "Test User")
+ lobby.addPlayer(player)
+ assertFailsWith<PlayerNameAlreadyUsedException> {
+ lobby.addPlayer(player2)
+ }
+ }
+
+ @Test
+ fun addPlayer_playerOverflowWhenTooMany() {
+ assertFailsWith<PlayerOverflowException> {
+ // the owner + the max number gives an overflow
+ addPlayers(gameDefinition.maxPlayers)
+ }
+ }
+
+ @Test
+ fun addPlayer_failWhenGameStarted() {
+ // total with owner is the minimum
+ addPlayers(gameDefinition.minPlayers - 1)
+ lobby.startGame()
+ assertFailsWith<GameAlreadyStartedException> {
+ lobby.addPlayer(Player("soonerNextTime", "The Late Guy"))
+ }
+ }
+
+ private fun addPlayers(nbPlayers: Int) {
+ repeat(nbPlayers) {
+ val player = Player("testuser$it", "Test User $it")
+ lobby.addPlayer(player)
+ }
+ }
+
+ @Test
+ fun removePlayer_failsWhenNotPresent() {
+ assertFailsWith<UnknownPlayerException> {
+ lobby.removePlayer("anyname")
+ }
+ }
+
+ @Test
+ fun removePlayer_success() {
+ val player = Player("testuser", "Test User")
+ lobby.addPlayer(player)
+ assertTrue(player.isInLobby)
+ assertFalse(player.isInGame)
+
+ lobby.removePlayer("testuser")
+ assertFalse(lobby.containsUser("testuser"))
+ assertFalse(player.isInLobby)
+ assertFalse(player.isInGame)
+ }
+
+ @Test
+ fun reorderPlayers_success() {
+ val player1 = Player("testuser1", "Test User 1")
+ val player2 = Player("testuser2", "Test User 2")
+ val player3 = Player("testuser3", "Test User 3")
+ lobby.addPlayer(player1)
+ lobby.addPlayer(player2)
+ lobby.addPlayer(player3)
+
+ val reorderedUsernames = listOf("testuser3", "gameowner", "testuser1", "testuser2")
+ lobby.reorderPlayers(reorderedUsernames)
+
+ assertEquals(reorderedUsernames, lobby.getPlayers().map { it.username })
+ }
+
+ @Test
+ fun reorderPlayers_failsOnUnknownPlayer() {
+ val player1 = Player("testuser1", "Test User 1")
+ val player2 = Player("testuser2", "Test User 2")
+ lobby.addPlayer(player1)
+ lobby.addPlayer(player2)
+
+ assertFailsWith<PlayerListMismatchException> {
+ lobby.reorderPlayers(listOf("unknown", "testuser2", "gameowner"))
+ }
+ }
+
+ @Test
+ fun reorderPlayers_failsOnExtraPlayer() {
+ val player1 = Player("testuser1", "Test User 1")
+ val player2 = Player("testuser2", "Test User 2")
+ lobby.addPlayer(player1)
+ lobby.addPlayer(player2)
+
+ assertFailsWith<PlayerListMismatchException> {
+ lobby.reorderPlayers(listOf("testuser2", "onemore", "testuser1", "gameowner"))
+ }
+ }
+
+ @Test
+ fun reorderPlayers_failsOnMissingPlayer() {
+ val player1 = Player("testuser1", "Test User 1")
+ val player2 = Player("testuser2", "Test User 2")
+ lobby.addPlayer(player1)
+ lobby.addPlayer(player2)
+
+ assertFailsWith<PlayerListMismatchException> {
+ lobby.reorderPlayers(listOf("testuser2", "gameowner"))
+ }
+ }
+
+ @Theory
+ fun startGame_failsBelowMinPlayers(nbPlayers: Int) {
+ assumeTrue(nbPlayers < gameDefinition.minPlayers)
+
+ // there is already the owner
+ addPlayers(nbPlayers - 1)
+
+ assertFailsWith<PlayerUnderflowException> {
+ lobby.startGame()
+ }
+ }
+
+ @Theory
+ fun startGame_succeedsAboveMinPlayers(nbPlayers: Int) {
+ assumeTrue(nbPlayers >= gameDefinition.minPlayers)
+ assumeTrue(nbPlayers <= gameDefinition.maxPlayers)
+ // there is already the owner
+ addPlayers(nbPlayers - 1)
+
+ assertEquals(nbPlayers, lobby.getPlayers().size)
+ lobby.getPlayers().forEach {
+ assertSame(lobby, it.lobby)
+ assertTrue(it.isInLobby)
+ assertFalse(it.isInGame)
+ }
+
+ val game = lobby.startGame()
+ assertNotNull(game)
+ lobby.getPlayers().forEachIndexed { index, it ->
+ assertSame(index, it.index)
+ assertSame(lobby, it.lobby)
+ assertSame(game, it.game)
+ assertTrue(it.isInLobby)
+ assertTrue(it.isInGame)
+ }
+ }
+
+ @Test
+ fun startGame_switchesState() {
+ assertEquals(State.LOBBY, lobby.state)
+ // there is already the owner
+ addPlayers(gameDefinition.minPlayers - 1)
+ lobby.startGame()
+ assertEquals(State.PLAYING, lobby.state)
+ }
+
+ @Test
+ fun setSettings() {
+ val settings = CustomizableSettings()
+ lobby.settings = settings
+ assertSame(settings, lobby.settings)
+ }
+
+ companion object {
+
+ private lateinit var gameDefinition: GameDefinition
+
+ @JvmStatic
+ @DataPoints
+ fun nbPlayers(): IntArray = intArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
+
+ @JvmStatic
+ @BeforeClass
+ fun loadDefinition() {
+ gameDefinition = GameDefinition.load()
+ }
+ }
+}
diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/repositories/LobbyRepositoryTest.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/repositories/LobbyRepositoryTest.kt
new file mode 100644
index 00000000..446feee6
--- /dev/null
+++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/repositories/LobbyRepositoryTest.kt
@@ -0,0 +1,78 @@
+package org.luxons.sevenwonders.repositories
+
+import org.junit.Before
+import org.junit.Test
+import org.luxons.sevenwonders.lobby.Player
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotNull
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+import kotlin.test.fail
+
+class LobbyRepositoryTest {
+
+ private lateinit var repository: LobbyRepository
+
+ @Before
+ fun setUp() {
+ repository = LobbyRepository()
+ }
+
+ @Test
+ fun list_initiallyEmpty() {
+ assertTrue(repository.list().isEmpty())
+ }
+
+ @Test
+ fun list_returnsAllLobbies() {
+ val owner = Player("owner", "The Owner")
+ val lobby1 = repository.create("Test Name 1", owner)
+ val lobby2 = repository.create("Test Name 2", owner)
+ assertTrue(repository.list().contains(lobby1))
+ assertTrue(repository.list().contains(lobby2))
+ }
+
+ @Test
+ fun create_withCorrectOwner() {
+ val owner = Player("owner", "The Owner")
+ val lobby = repository.create("Test Name", owner)
+ assertTrue(lobby.isOwner(owner.username))
+ }
+
+ @Test
+ fun find_failsOnUnknownId() {
+ assertFailsWith<LobbyNotFoundException> {
+ repository.find(123)
+ }
+ }
+
+ @Test
+ fun find_returnsTheSameObject() {
+ val owner = Player("owner", "The Owner")
+ val lobby1 = repository.create("Test Name 1", owner)
+ val lobby2 = repository.create("Test Name 2", owner)
+ assertSame(lobby1, repository.find(lobby1.id))
+ assertSame(lobby2, repository.find(lobby2.id))
+ }
+
+ @Test
+ fun remove_failsOnUnknownId() {
+ assertFailsWith<LobbyNotFoundException> {
+ repository.remove(123)
+ }
+ }
+
+ @Test
+ fun remove_succeeds() {
+ val owner = Player("owner", "The Owner")
+ val lobby1 = repository.create("Test Name 1", owner)
+ assertNotNull(repository.find(lobby1.id))
+ repository.remove(lobby1.id)
+ try {
+ repository.find(lobby1.id)
+ fail() // the call to find() should have failed
+ } catch (e: LobbyNotFoundException) {
+ // the lobby has been properly removed
+ }
+ }
+}
diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/repositories/PlayerRepositoryTest.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/repositories/PlayerRepositoryTest.kt
new file mode 100644
index 00000000..aeedc54c
--- /dev/null
+++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/repositories/PlayerRepositoryTest.kt
@@ -0,0 +1,75 @@
+package org.luxons.sevenwonders.repositories
+
+import org.junit.Before
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+class PlayerRepositoryTest {
+
+ private lateinit var repository: PlayerRepository
+
+ @Before
+ fun setUp() {
+ repository = PlayerRepository()
+ }
+
+ @Test
+ fun contains_falseIfNoUserAdded() {
+ assertFalse(repository.contains("anyUsername"))
+ }
+
+ @Test
+ fun contains_trueForCreatedPlayer() {
+ repository.createOrUpdate("player1", "Player 1")
+ assertTrue(repository.contains("player1"))
+ }
+
+ @Test
+ fun createOrUpdate_createsProperly() {
+ val player1 = repository.createOrUpdate("player1", "Player 1")
+ assertEquals("player1", player1.username)
+ assertEquals("Player 1", player1.displayName)
+ }
+
+ @Test
+ fun createOrUpdate_updatesDisplayName() {
+ val player1 = repository.createOrUpdate("player1", "Player 1")
+ val player1Updated = repository.createOrUpdate("player1", "Much Better Name")
+ assertSame(player1, player1Updated)
+ assertEquals("Much Better Name", player1Updated.displayName)
+ }
+
+ @Test
+ fun find_failsOnUnknownUsername() {
+ assertFailsWith<PlayerNotFoundException> {
+ repository.find("anyUsername")
+ }
+ }
+
+ @Test
+ fun find_returnsTheSameObject() {
+ val player1 = repository.createOrUpdate("player1", "Player 1")
+ val player2 = repository.createOrUpdate("player2", "Player 2")
+ assertSame(player1, repository.find("player1"))
+ assertSame(player2, repository.find("player2"))
+ }
+
+ @Test
+ fun remove_failsOnUnknownUsername() {
+ assertFailsWith<PlayerNotFoundException> {
+ repository.remove("anyUsername")
+ }
+ }
+
+ @Test
+ fun remove_succeeds() {
+ repository.createOrUpdate("player1", "Player 1")
+ assertTrue(repository.contains("player1"))
+ repository.remove("player1")
+ assertFalse(repository.contains("player1"))
+ }
+}
diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/test/TestUtils.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/test/TestUtils.kt
new file mode 100644
index 00000000..9f328c5f
--- /dev/null
+++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/test/TestUtils.kt
@@ -0,0 +1,10 @@
+package org.luxons.sevenwonders.test
+
+import org.springframework.messaging.Message
+import org.springframework.messaging.MessageChannel
+import org.springframework.messaging.simp.SimpMessagingTemplate
+
+fun mockSimpMessagingTemplate(): SimpMessagingTemplate = SimpMessagingTemplate(object : MessageChannel {
+ override fun send(message: Message<*>): Boolean = true
+ override fun send(message: Message<*>, timeout: Long): Boolean = true
+})
diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/test/api/SevenWondersClient.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/test/api/SevenWondersClient.kt
new file mode 100644
index 00000000..ee5827cc
--- /dev/null
+++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/test/api/SevenWondersClient.kt
@@ -0,0 +1,38 @@
+package org.luxons.sevenwonders.test.api
+
+import com.fasterxml.jackson.databind.module.SimpleModule
+import com.fasterxml.jackson.module.kotlin.KotlinModule
+import org.hildan.jackstomp.JackstompClient
+import org.luxons.sevenwonders.config.SEVEN_WONDERS_WS_ENDPOINT
+import org.luxons.sevenwonders.game.resources.MutableResources
+import org.luxons.sevenwonders.game.resources.Resources
+import org.springframework.messaging.converter.MappingJackson2MessageConverter
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.TimeoutException
+
+class SevenWondersClient {
+
+ private val client: JackstompClient
+
+ init {
+ val customMappingsModule = SimpleModule("ConcreteResourcesDeserializationModule")
+ customMappingsModule.addAbstractTypeMapping(Resources::class.java, MutableResources::class.java)
+
+ val mappingJackson2MessageConverter = MappingJackson2MessageConverter()
+ mappingJackson2MessageConverter.objectMapper.registerModule(customMappingsModule)
+ mappingJackson2MessageConverter.objectMapper.registerModule(KotlinModule())
+
+ client = JackstompClient()
+ client.webSocketClient.messageConverter = mappingJackson2MessageConverter
+ }
+
+ @Throws(InterruptedException::class, ExecutionException::class, TimeoutException::class)
+ fun connect(serverUrl: String): SevenWondersSession {
+ val session = client.syncConnect(serverUrl + SEVEN_WONDERS_WS_ENDPOINT)
+ return SevenWondersSession(session)
+ }
+
+ fun stop() {
+ client.stop()
+ }
+}
diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/test/api/SevenWondersSession.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/test/api/SevenWondersSession.kt
new file mode 100644
index 00000000..70031a71
--- /dev/null
+++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/test/api/SevenWondersSession.kt
@@ -0,0 +1,70 @@
+package org.luxons.sevenwonders.test.api
+
+import org.hildan.jackstomp.Channel
+import org.hildan.jackstomp.JackstompSession
+import org.luxons.sevenwonders.actions.ChooseNameAction
+import org.luxons.sevenwonders.actions.CreateGameAction
+import org.luxons.sevenwonders.actions.JoinGameAction
+import org.luxons.sevenwonders.api.LobbyDTO
+import org.luxons.sevenwonders.api.PlayerDTO
+import org.luxons.sevenwonders.errors.ErrorDTO
+import org.luxons.sevenwonders.game.api.PlayerTurnInfo
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class SevenWondersSession(val jackstompSession: JackstompSession) {
+
+ fun disconnect() {
+ jackstompSession.disconnect()
+ }
+
+ fun watchErrors(): Channel<ErrorDTO> = jackstompSession.subscribe("/user/queue/errors", ErrorDTO::class.java)
+
+ @Throws(InterruptedException::class)
+ fun chooseName(displayName: String): PlayerDTO {
+ val action = ChooseNameAction(displayName)
+ return jackstompSession.request(action, PlayerDTO::class.java, "/app/chooseName", "/user/queue/nameChoice")
+ }
+
+ fun watchGames(): Channel<Array<LobbyDTO>> {
+ return jackstompSession.subscribe("/topic/games", Array<LobbyDTO>::class.java)
+ }
+
+ @Throws(InterruptedException::class)
+ fun createGame(gameName: String): LobbyDTO {
+ val action = CreateGameAction(gameName)
+ return jackstompSession.request(action, LobbyDTO::class.java, "/app/lobby/create", "/user/queue/lobby/joined")
+ }
+
+ @Throws(InterruptedException::class)
+ fun joinGame(gameId: Long): LobbyDTO {
+ val action = JoinGameAction(gameId)
+ val lobby =
+ jackstompSession.request(action, LobbyDTO::class.java, "/app/lobby/join", "/user/queue/lobby/joined")
+ assertNotNull(lobby)
+ assertEquals(gameId, lobby.id)
+ return lobby
+ }
+
+ fun watchLobbyUpdates(gameId: Long): Channel<LobbyDTO> =
+ jackstompSession.subscribe("/topic/lobby/$gameId/updated", LobbyDTO::class.java)
+
+ fun watchLobbyStart(gameId: Long): Channel<LobbyDTO> =
+ jackstompSession.subscribe("/topic/lobby/$gameId/started", LobbyDTO::class.java)
+
+ @Throws(InterruptedException::class)
+ fun startGame(gameId: Long) {
+ val sendDestination = "/app/lobby/startGame"
+ val receiveDestination = "/topic/lobby/$gameId/started"
+ val received = jackstompSession.request(null, sendDestination, receiveDestination)
+ assertTrue(received)
+ }
+
+ fun sayReady() {
+ jackstompSession.send("/app/game/sayReady", "")
+ }
+
+ fun watchTurns(): Channel<PlayerTurnInfo> =
+ jackstompSession.subscribe("/user/queue/game/turn", PlayerTurnInfo::class.java)
+}
diff --git a/sw-server/src/test/kotlin/org/luxons/sevenwonders/validation/DestinationAccessValidatorTest.kt b/sw-server/src/test/kotlin/org/luxons/sevenwonders/validation/DestinationAccessValidatorTest.kt
new file mode 100644
index 00000000..85d03e99
--- /dev/null
+++ b/sw-server/src/test/kotlin/org/luxons/sevenwonders/validation/DestinationAccessValidatorTest.kt
@@ -0,0 +1,147 @@
+package org.luxons.sevenwonders.validation
+
+import org.junit.Before
+import org.junit.Test
+import org.luxons.sevenwonders.lobby.Lobby
+import org.luxons.sevenwonders.lobby.Player
+import org.luxons.sevenwonders.repositories.LobbyNotFoundException
+import org.luxons.sevenwonders.repositories.LobbyRepository
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class DestinationAccessValidatorTest {
+
+ private lateinit var lobbyRepository: LobbyRepository
+
+ private lateinit var destinationAccessValidator: DestinationAccessValidator
+
+ @Before
+ fun setup() {
+ lobbyRepository = LobbyRepository()
+ destinationAccessValidator = DestinationAccessValidator(lobbyRepository)
+ }
+
+ private fun createLobby(gameName: String, ownerUsername: String, vararg otherPlayers: String): Lobby {
+ val owner = Player(ownerUsername, ownerUsername)
+ val lobby = lobbyRepository.create(gameName, owner)
+ for (playerName in otherPlayers) {
+ val player = Player(playerName, playerName)
+ lobby.addPlayer(player)
+ }
+ return lobby
+ }
+
+ private fun createGame(gameName: String, ownerUsername: String, vararg otherPlayers: String) {
+ val lobby = createLobby(gameName, ownerUsername, *otherPlayers)
+ lobby.startGame()
+ }
+
+ @Test
+ fun validate_failsOnNullUser() {
+ assertFalse(destinationAccessValidator.hasAccess(null, "doesNotMatter"))
+ }
+
+ @Test
+ fun validate_successWhenNoReference() {
+ assertTrue(destinationAccessValidator.hasAccess("", ""))
+ assertTrue(destinationAccessValidator.hasAccess("", "test"))
+ assertTrue(destinationAccessValidator.hasAccess("testUser", "test"))
+ }
+
+ @Test
+ fun validate_successWhenNoRefFollows() {
+ assertTrue(destinationAccessValidator.hasAccess("testUser", "/game/"))
+ assertTrue(destinationAccessValidator.hasAccess("testUser", "/lobby/"))
+ assertTrue(destinationAccessValidator.hasAccess("testUser", "prefix/game/"))
+ assertTrue(destinationAccessValidator.hasAccess("testUser", "prefix/lobby/"))
+ assertTrue(destinationAccessValidator.hasAccess("testUser", "/game//suffix"))
+ assertTrue(destinationAccessValidator.hasAccess("testUser", "/lobby//suffix"))
+ }
+
+ @Test
+ fun validate_successWhenRefIsNotANumber() {
+ assertTrue(destinationAccessValidator.hasAccess("testUser", "/game/notANumber"))
+ assertTrue(destinationAccessValidator.hasAccess("testUser", "/lobby/notANumber"))
+ assertTrue(destinationAccessValidator.hasAccess("testUser", "prefix/game/notANumber"))
+ assertTrue(destinationAccessValidator.hasAccess("testUser", "prefix/lobby/notANumber"))
+ assertTrue(destinationAccessValidator.hasAccess("testUser", "/game/notANumber/suffix"))
+ assertTrue(destinationAccessValidator.hasAccess("testUser", "/lobby/notANumber/suffix"))
+ }
+
+ @Test
+ fun validate_failWhenNoLobbyExist() {
+ assertFailsWith<LobbyNotFoundException> {
+ destinationAccessValidator.hasAccess("", "/lobby/0")
+ }
+ }
+
+ @Test
+ fun validate_failWhenNoGameExist() {
+ assertFailsWith<LobbyNotFoundException> {
+ destinationAccessValidator.hasAccess("", "/game/0")
+ }
+ }
+
+ @Test
+ fun validate_failWhenReferencedLobbyDoesNotExist() {
+ createLobby("Test Game", "ownerUser1")
+ createLobby("Test Game 2", "ownerUser2")
+ assertFailsWith<LobbyNotFoundException> {
+ destinationAccessValidator.hasAccess("doesNotMatter", "/lobby/3")
+ }
+ }
+
+ @Test
+ fun validate_failWhenReferencedGameDoesNotExist() {
+ createGame("Test Game 1", "user1", "user2", "user3")
+ createGame("Test Game 2", "user4", "user5", "user6")
+ assertFailsWith<LobbyNotFoundException> {
+ destinationAccessValidator.hasAccess("doesNotMatter", "/game/3")
+ }
+ }
+
+ @Test
+ fun validate_failWhenUserIsNotPartOfReferencedLobby() {
+ createLobby("Test Game", "ownerUser")
+ destinationAccessValidator.hasAccess("userNotInLobby", "/lobby/0")
+ }
+
+ @Test
+ fun validate_failWhenUserIsNotPartOfReferencedGame() {
+ createGame("Test Game", "ownerUser", "otherUser1", "otherUser2")
+ destinationAccessValidator.hasAccess("userNotInGame", "/game/0")
+ }
+
+ @Test
+ fun validate_successWhenUserIsOwnerOfReferencedLobby() {
+ createLobby("Test Game 1", "user1")
+ assertTrue(destinationAccessValidator.hasAccess("user1", "/lobby/0"))
+ createLobby("Test Game 2", "user2")
+ assertTrue(destinationAccessValidator.hasAccess("user2", "/lobby/1"))
+ }
+
+ @Test
+ fun validate_successWhenUserIsMemberOfReferencedLobby() {
+ createLobby("Test Game 1", "user1", "user2")
+ assertTrue(destinationAccessValidator.hasAccess("user2", "/lobby/0"))
+ createLobby("Test Game 2", "user3", "user4")
+ assertTrue(destinationAccessValidator.hasAccess("user4", "/lobby/1"))
+ }
+
+ @Test
+ fun validate_successWhenUserIsOwnerOfReferencedGame() {
+ createGame("Test Game 1", "owner1", "user2", "user3")
+ assertTrue(destinationAccessValidator.hasAccess("owner1", "/game/0"))
+ createGame("Test Game 2", "owner4", "user5", "user6")
+ assertTrue(destinationAccessValidator.hasAccess("owner4", "/game/1"))
+ }
+
+ @Test
+ fun validate_successWhenUserIsMemberOfReferencedGame() {
+ createGame("Test Game 1", "owner1", "user2", "user3")
+ assertTrue(destinationAccessValidator.hasAccess("user2", "/game/0"))
+ createGame("Test Game 2", "owner4", "user5", "user6")
+ assertTrue(destinationAccessValidator.hasAccess("user6", "/game/1"))
+ }
+}
bgstack15