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()
}
}
}