Exit Full View

Games Cupboard / gamescupboard-server / src / main / kotlin / uk / co / nickthecoder / gamescupboard / server / Lobby.kt

package uk.co.nickthecoder.gamescupboard.server

import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.html.*
import io.ktor.server.http.content.*
import io.ktor.server.plugins.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.html.*
import uk.co.nickthecoder.gamescupboard.server.Lobby.createGame
import uk.co.nickthecoder.gamescupboard.server.Lobby.gameType
import uk.co.nickthecoder.gamescupboard.server.Lobby.lobby
import java.lang.Integer.min

/**
 * Routing for the lobby. There is no KorGE related code here.
 *
 * The first page of the lobby ([lobby]) show all of the [GameType]s, such as "Cards", "Crossed Words" etc.
 * The next page ([gameType]) shows [GameVariation]s. For example "Crossed Words" (aka scrabble) has regular
 * scrabble, number scrabble and bananagrams (a non-turned based variation).
 *
 * The first player creates a new Game (using the [GameVariation] to initialise the game. See [createGame]).
 * Subsequent players can then "Join" this game.
 *
 * "Create" and "Join" both direct you to the KorGE web page (index.html from [Play]'s static files).
 */
object Lobby {

    fun route(route: Route) {
        with(route) {
            // Contains style css, javascript and any images etc. used by the lobby
            static("/resources") { resources("${Lobby::class.java.packageName}.resources") }
            // Thumbnail images for each [GameType]. Each thumbnail must be called "${GameType.name}.png"
            static("/thumbnails") { resources("${Lobby::class.java.packageName}.thumbnails") }

            get("/") { lobby(call) }
            get("/attributions") { attributions(call) }
            get("/gameType/{gameType}") { gameType(call) }
            get("/setupGame/{gameType}/{variation}") { setupGame(call) }
            post("/createGame") { createGame(call) }
            get("/joinGame/{gameId}") { joinGame(call) }

            authenticate("auth-basic") {
                get("/admin") { admin(call) }
                post("/deleteGame") { deleteGame(call) }
                post("/deleteGames") { deleteGames(call) }
            }
        }
    }

    /**
     * Lists all of the [GameType]s.
     * Each is a simple link to [gameType].
     * No state changes on the server.
     */
    private suspend fun lobby(call: ApplicationCall) {
        call.respondHtmlTemplate(LobbyTemplate()) {
            pageTitle { +"Games Cupboard" }
            pageHeading { +"Games Cupboard" }

            content {
                div(classes = "gameTypes") {
                    for (gameType in gameTypes) {
                        div(classes = "gameType") {
                            div(classes = "thumbnail") {
                                val activeCount = GamesCupboardServer.activeCount(gameType)
                                a(href = "/gameType/${gameType.name}") {
                                    img(src = "/thumbnails/${gameType.thumbnail}", alt = gameType.label)
                                    br
                                    +gameType.label
                                    if (activeCount > 0) {
                                        +" ($activeCount)"
                                    }
                                }
                            }
                        }
                    }
                }
            }

            pageFooting {
                +"Powered by "
                a(href = "https://korge.org/") { +"KorGE" }
                br
                a(href = "/attributions") { +"Other Contributors" }
            }
        }
    }

    private suspend fun attributions(call: ApplicationCall) {
        call.respondHtmlTemplate(LobbyTemplate()) {
            pageTitle { +"Attributions" }
            pageHeading { +"Many thanks to the following contributors..." }

            content {

                h2 { +"Code" }

                h3 {
                    a(href = "https://korge.org/") { +"The KorGE Folks" }
                    +" for their multi-platform game engine"
                }

                h3 {
                    a(href = "https://www.jetbrains.com/") { +"The folks at JetBrains" }
                    +" for the Kotlin language and Ktor"
                }

                h3 {
                    a(href = "https://github.com/Kietyo/KorgeMultiplayerDemo") { +"Kietyo" }
                    +" For his multi-player KorGE demo"
                }

                h2 { +"Sound Effects" }

                h3 {
                    a(href = "https://freesound.org/people/theliongirl10/") { +"theliongirl10" }
                    +" for sound effects : "
                    a(href = "https://freesound.org/people/theliongirl10/sounds/417197/") { +"playerLeft" }
                }

                h3 {
                    a(href = "https://freesound.org/people/KorGround/") { +"KorGround" }
                    +" for sound effects : "
                    a(href = "https://freesound.org/people/KorGround/sounds/344687/") { +"cancelDrag" }
                }

                h3 {
                    a(href = "https://freesound.org/people/Duisterwho/") { +"Duisterwho" }
                    +" for sound effects : "
                    a(href = "https://freesound.org/people/Duisterwho/sounds/644584/") { +"hello" }
                }

                h3 {
                    a(href = "https://freesound.org/people/theuncertainman/") { +"theuncertainman" }
                    +" for sound effects : "
                    a(href = "https://freesound.org/people/theuncertainman/sounds/402643/") { +"go" }
                }
                h3 {
                    a(href = "https://freesound.org/people/MatthewWong/") { +"MatthewWong" }
                    +" for sound effects : "
                    a(href = "https://freesound.org/people/MatthewWong/sounds/361564/") { +"playerJoined" }
                }
                h3 {
                    a(href = "https://freesound.org/people/arbernaut/") { +"arbernaut" }
                    +" for sound effects : "
                    a(href = "https://freesound.org/people/arbernaut/sounds/450528/") { +"lol" }
                }
                h3 {
                    a(href = "https://freesound.org/people/pan14/") { +"pan14" }
                    +" for sound effects : "
                    a(href = "https://freesound.org/people/pan14/sounds/263133/") { +"faceDown, faceUp" }
                }
                h3 {
                    a(href = "https://freesound.org/people/Dragunnitum/") { +"Dragunnitum" }
                    +" for sound effects : "
                    a(href = "https://freesound.org/people/Dragunnitum/sounds/403033/") { +"yes, no" }
                }


                h2 { +"Graphics" }

                h3 {
                    a(href = "https://tekeye.uk/svg/mini-svg-playing-card-set\n") { +"Playing Cards" }
                }

            }
        }
    }

    /**
     * Lists all the [GameVariation]s for a given [GameType].
     * You can choose to start a new game (posts to [createGame]),
     * or join an existing game (links to index.html in [Play]'s static files).
     */
    private suspend fun gameType(call: ApplicationCall) {

        val gameTypeName = call.parameters["gameType"] ?: throw NotFoundException("Game Type name not specified")
        val gameType = gameTypes.firstOrNull { it.name == gameTypeName }
            ?: throw NotFoundException("Game Type '$gameTypeName' not found")
        val hideFullGames = call.parameters["hide"] != null

        call.respondHtmlTemplate(LobbyTemplate()) {
            pageTitle { +gameType.label }
            pageHeading { +gameType.label }

            content {

                if (hideFullGames) {
                    a(href = "/gameType/${gameType.name}") {
                        +"Show Full Games"
                    }
                } else {
                    a(href = "/gameType/${gameType.name}?hide=full") {
                        +"Hide Full Games"
                    }
                }

                div(classes = "variations") {
                    for ((counter, variation) in gameType.variations.withIndex()) {
                        div(classes = "variation") {
                            div(classes = "variationColumn") {
                                h2 { +variation.label }
                                div(classes = "newGame roundedBox") {
                                    h3 {
                                        +"New Game"
                                        a(href = "/setupGame/${gameType.name}/${variation.name}") {
                                            classes = setOf("floatRight")
                                            +"Customise..."
                                        }
                                    }
                                    form("/createGame", method = FormMethod.post) {
                                        hiddenInput(name = "gameType") { value = gameType.name }
                                        hiddenInput(name = "variation") { value = variation.name }
                                        table(classes = "wide") {
                                            row {
                                                div(classes = "formError") {
                                                    id = "error${counter}"
                                                }
                                            }
                                            row("Label") {
                                                textInput(classes = "wide", name = "label") {
                                                    id = "gameLabel${counter}"
                                                }
                                            }
                                            row("Players") {
                                                numberInput(classes = "input4", name = "players") {
                                                    id = "players${counter}"
                                                    min = "${variation.minPlayers}"
                                                    max = "${variation.maxPlayers}"
                                                    // The default is 4, unless that is too large for this variation.
                                                    value = "${min(4, variation.maxPlayers)}"
                                                }
                                                submitInput(classes = "floatRight", name = "submit") {
                                                    onClick =
                                                        "return checkGameValues( $counter, ${variation.minPlayers}, ${variation.maxPlayers} );"
                                                    value = "Create"
                                                }
                                            }
                                        }
                                    }
                                }
                                div(classes = "existingGames") {
                                    h3 { +"Existing Games" }
                                    //println( "${!hideFullGames} || ${!it.isFull()} = ${(!hideFullGames || !it.isFull())}" )
                                    GamesCupboardServer.findGamesByVariation(variation)
                                        .filter { (!hideFullGames || !it.isFull()) && it.invitationCode == null }
                                        .forEach { game ->
                                            div(classes = "roundedBox") {
                                                a(href = "/joinGame/${game.id}") { +game.label }
                                                // + "Last Active ${game...}
                                                br()
                                                +"${game.connectedPlayers.size} of ${game.seatCount} players"
                                                span(classes = "floatRight") {
                                                    +game.lastActive.ago()
                                                }
                                            }
                                        }
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    /**
     * A link from "Customise..." in [gameType].
     *
     *
     * NOTE. Test the validation!!!
     */
    private suspend fun setupGame(call: ApplicationCall) {

        val gameTypeName = call.parameters["gameType"] ?: throw NotFoundException("Game Type not specified")
        val gameVariationName = call.parameters["variation"] ?: throw NotFoundException("Game Variation not specified")

        val gameType = gameTypes.firstOrNull { it.name == gameTypeName }
            ?: throw NotFoundException("Game Type '$gameTypeName' not found")
        val variation = gameType.variations.firstOrNull { it.name == gameVariationName }
            ?: throw NotFoundException("Game Variation '$gameVariationName' not found")

        call.respondHtmlTemplate(LobbyTemplate()) {
            pageTitle { +"Setup Game" }
            pageHeading { +"Setup Game : ${gameType.label} (${variation.label})" }

            content {

                form("/createGame", method = FormMethod.post) {

                    hiddenInput(name = "gameType") { value = gameType.name }
                    hiddenInput(name = "variation") { value = variation.name }
                    table(classes = "roundedTable") {

                        row("Label") {
                            textInput(name = "label") {
                                width = "20"
                                id = "gameLabel"
                            }
                        }
                        row("Players") {
                            numberInput(classes = "input4", name = "players") {
                                if (variation.minPlayers == variation.maxPlayers) {
                                    disabled = true
                                }
                                id = "players"
                                min = "${variation.minPlayers}"
                                max = "${variation.maxPlayers}"
                                value = "${min(4, variation.maxPlayers)}"
                            }
                        }
                        row("Invitation Code", info = {
                            +"Blank:"; i { +"Open to the Public." }
                            br
                            +"Non-blank: "; i { +"By Invitation Only." }
                        }) {
                            textInput(name = "code") {
                                style = "width: 8em"
                                id = "invitationCode"
                            }
                            buttonInput {
                                value = "Generate"
                                onClick = "return genInvitationCode();"
                            }
                        }
                        if (variation.options.isNotEmpty()) {
                            row("Options") {
                                for (option in variation.options) {
                                    label {
                                        checkBoxInput(name = option.name)
                                        +option.label
                                    }
                                    br
                                }
                            }
                        }
                        row {
                            div(classes = "formError") {
                                id = "error"
                            }
                        }
                        row {
                            classes = setOf("right")
                            submitInput(name = "submit") {
                                onClick =
                                    "return checkGameValues( '', ${variation.minPlayers}, ${variation.maxPlayers} );"
                                value = "Create"
                            }

                        }
                    }
                }
            }
        }
    }


    /**
     * A POST from [setupGame]. Creates a new [Game] instance, which is initialised from the
     * [GameVariation] passed using post parameters "gameType" and "variation".
     * They refer to [GameType.name] and [GameVariation.name].
     *
     * Currently, game states are stored in-memory (see [GamesCupboardServer.addGame]).
     * So when the server restarts, all active games will be lost.
     */
    private suspend fun createGame(call: ApplicationCall) {
        val parameters = call.receiveParameters()

        // Post parameters
        val gameTypeName = parameters["gameType"] ?: throw NotFoundException("Game Type Name not specified")
        val variationName = parameters["variation"] ?: throw NotFoundException("Variation Name not specified")
        val gameLabel = parameters["label"] ?: throw NotFoundException("Game label not specified")
        val players =
            parameters["players"]?.toIntOrNull() ?: throw NotFoundException("Number of players not specified")
        val invitationCode = parameters["code"]

        // Get the GameType and Variation
        val gameType = gameTypes.firstOrNull { it.name == gameTypeName }
            ?: throw NotFoundException("Game Type '$gameTypeName' not found")
        val baseVariation = gameType.variations.firstOrNull { it.name == variationName }
            ?: throw NotFoundException("Variation '$variationName' not found")

        var finalVariation = baseVariation
        for (option in baseVariation.options) {
            if (parameters[option.name] != null) {
                finalVariation = option.build(finalVariation)
            }
        }

        val newGame = Game(
            gameType,
            gameVariation = finalVariation,
            baseVariation = baseVariation,
            label = gameLabel,
            seatCount = players,
            invitationCode = invitationCode
        )
        GamesCupboardServer.addGame(newGame)

        println("Created game : $newGame")

        if (invitationCode.isNullOrBlank()) {
            call.respondRedirect("/joinGame/${newGame.id}")
        } else {
            call.respondRedirect("/joinGame/${newGame.id}?code=$invitationCode")
        }
    }

    /**
     * A GET. Lists the vacant seats - click to join.
     * Links to the KorGE index.html page, with additional "search" parameters for the gameId
     * and preferred seat number. ("Preferred, because if that seat if taken in the mean-time, you
     * will be assigned a *different* seat number.
     */
    private suspend fun joinGame(call: ApplicationCall) {
        val gameId =
            call.parameters["gameId"]?.toIntOrNull() ?: throw NotFoundException("gameId Name not specified")
        val code = call.parameters["code"]
        val codeSuffix = if (code == null) "" else "&code=$code"

        val game = GamesCupboardServer.findGameById(gameId) ?: throw NotFoundException("Game #$gameId not found")

        if (code != null && game.invitationCode != code) {
            // Give the SAME error message as if the game doesn't exist at all.
            throw NotFoundException("Game #$gameId not found")
        }

        call.respondHtmlTemplate(LobbyTemplate()) {
            pageTitle { + "Join Game : ${game.label}" }
            pageHeading { + "${game.gameVariation.label} : ${game.label}" }

            content {

                if (code != null) {
                    h2 { +"By Invitation Only" }
                    +"This game is not visible in the Lobby."
                    br
                    br
                    +"To let friends/family join, send them this page's address."
                    br
                    +"On most browsers "; i { +"Ctrl+L" }; +" then "; i { +"Ctrl+C" }; +" will copy the address, "
                    +"which you can then paste into an email."
                    br
                }

                h2 { +"Seats Available" }

                div(classes = "seats") {
                    for (seat in 1..game.seatCount) {
                        val playerAtSeat = game.connectedPlayers.values.firstOrNull { it.player.id == seat }
                        val isTaken = playerAtSeat != null
                        val href = if (isTaken) "#" else "/play/index.html?gameId=$gameId&seat=$seat$codeSuffix"
                        val color = if (isTaken) "#ccc" else game.gameVariation.playerColor(seat, false)
                        val classes = if (isTaken) "seat seatTaken" else "seat"
                        a(classes = classes, href = href) {
                            style = "background: $color"
                            if (isTaken) {
                                +"Taken by"
                            } else {
                                +"Seat #${seat}"
                            }
                            br
                            br
                            if (isTaken) {
                                +(playerAtSeat?.player?.name ?: "Taken")
                            } else {
                                +"Join"
                            }
                        }
                    }
                }

                if (game.allowSpectators) {
                    a(classes = "seat spectator", href = "/play/index.html?gameId=$gameId&spectate=true") {
                        style = "background: ${GameVariation.spectatorColor}"
                        +"Join as a "
                        br
                        br
                        +"Spectator"
                    }
                }

            }
        }
    }

    private suspend fun admin(call: ApplicationCall) {
        val filter = call.parameters["filter"] ?: "all"
        val filterMinutes = call.parameters["filterMinutes"]?.toLongOrNull() ?: 60

        call.respondHtmlTemplate(LobbyTemplate()) {
            pageTitle { +"Admin" }
            pageHeading { +"Admin" }

            content {

                h2 { +"Filter" }
                form("/admin") {
                    id = "filterForm"
                    label {
                        radioInput(name = "filter") {
                            value = "all"
                            checked = filter == "all"
                            onClick = "document.getElementById('filterForm').submit(); return true;"
                        }
                        +" All"
                    }
                    label {
                        radioInput(name = "filter") {
                            value = "active"
                            checked = filter == "active"
                            onClick = "document.getElementById('filterForm').submit(); return true;"
                        }
                        +" Active"
                    }
                    label {
                        radioInput(name = "filter") {
                            value = "inactive"
                            checked = filter == "inactive"
                            onClick = "document.getElementById('filterForm').submit(); return true;"
                        }
                        +" Inactive"
                    }
                    if (filter == "all") {
                        hiddenInput(name = "filterMinutes") { value = filterMinutes.toString() }
                    } else {
                        +" "
                        numberInput(classes = "input6", name = "filterMinutes") {
                            value = filterMinutes.toString()
                            min = "0"
                            max = (60 * 24).toString()
                        }
                        +" minutes ago "

                        submitInput(name = "update") { value = "Update" }
                    }

                }

                fun Game.matches(): Boolean {
                    return when (filter) {
                        "active" -> lastActive.minutesAgo() < filterMinutes
                        "inactive" -> lastActive.minutesAgo() >= filterMinutes
                        else -> true
                    }
                }

                h2 { +"Games" }

                for (gameType in gameTypes) {
                    h3 { +gameType.label }
                    table {
                        style = "width: 50em;"

                        for (variation in gameType.variations) {
                            val games = GamesCupboardServer.findGamesByVariation(variation).filter { it.matches() }

                            for (game in games) {
                                tr {
                                    td {
                                        style = "width: 10em;"
                                        +variation.label
                                    }
                                    td {
                                        a(href = "/joinGame/${game.id}") { +game.label }
                                    }
                                    td {
                                        style = "width: 10em;"
                                        +game.lastActive.ago()
                                    }
                                    td {
                                        style = "width: 6em; text-align: right;"
                                        form("/deleteGame", method = FormMethod.post) {
                                            hiddenInput(name = "gameId") { value = "${game.id}" }
                                            hiddenInput(name = "filter") { value = filter }
                                            hiddenInput(name = "filterMinutes") { value = "$filterMinutes" }
                                            submitInput { value = "Delete" }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }


                h2 { +"Beware!" }

                form("/deleteGames", method = FormMethod.post) {
                    hiddenInput(name = "filter") { value = filter }
                    hiddenInput(name = "filterMinutes") { value = "$filterMinutes" }
                    submitInput(name = "deleteAll") { value = "Delete All" }
                }

            }
        }
    }

    /**
     * Deletes all games, or those matching the filter parameters.
     *
     * NOTE. I POST here from a cronjob, so that inactive games are deleted periodically.
     */
    private suspend fun deleteGames(call: ApplicationCall) {

        val parameters = call.receiveParameters()
        val filter = parameters["filter"] ?: "all"
        val filterMinutes = parameters["filterMinutes"]?.toLongOrNull() ?: 60

        fun Game.matches(): Boolean {
            return when (filter) {
                "active" -> lastActive.minutesAgo() < filterMinutes
                "inactive" -> lastActive.minutesAgo() >= filterMinutes
                else -> true
            }
        }

        val gamesToDelete = GamesCupboardServer.allGames().filter { it.matches() }.toList()
        for (game in gamesToDelete) {
            GamesCupboardServer.removeGame(game)
        }

        call.respondRedirect("/admin?filter=$filter&filterMinutes=$filterMinutes")
    }

    private suspend fun deleteGame(call: ApplicationCall) {

        val parameters = call.receiveParameters()
        val gameId = parameters["gameId"]?.toIntOrNull() ?: throw NotFoundException("Game ID not specified")
        val game = GamesCupboardServer.findGameById(gameId) ?: throw NotFoundException("Game #$gameId not found")
        val filter = parameters["filter"] ?: "all"
        val filterMinutes = parameters["filterMinutes"]?.toLongOrNull() ?: 60
        try {

            GamesCupboardServer.removeGame(game)

            call.respondRedirect("/admin?filter=$filter&filterMinutes=$filterMinutes")
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

}