Exit Full View

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

package uk.co.nickthecoder.gamescupboard.server

import AddObjects
import ChangePrivacy
import ChatMessage
import CommandError
import CycleText
import DragObject
import FaceUpOrDown
import GameInfo
import HighlightMouse
import MoveObject
import NotInvited
import Packet
import RemoveObjects
import RenamePlayer
import RestoreFromBin
import RunCommand
import ScaleObject
import ScoreSheetData
import io.ktor.server.http.content.*
import io.ktor.server.plugins.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.serialization.json.Json
import uk.co.nickthecoder.gamescupboard.common.*
import uk.co.nickthecoder.gamescupboard.server.commands.Commands
import java.util.*

val json = Json

/**
 * This is where the browser gets all the resources for the game(s).
 * Including the (huge) gamescupboard-client.js and gamescupboard-client.js.map files which contain all the "compiled" Kotlin code.
 * Game resources (bitmaps and sounds) are also routed here.
 *
 * There are only 2 routing rules. One for the websocket, and the other for all the static files.
 */
object Play {

    fun route(route: Route) {
        with(route) {

            println("www path = '${GamesCupboardServer.wwwPath}'")
            route("/play") {
                files(GamesCupboardServer.wwwPath)
            }

            webSocket("/websocket/{gameId}") {
                val gameId = call.parameters["gameId"]?.toIntOrNull() ?: throw NotFoundException("Missing game id")
                val preferredSeatNumber = call.parameters["seat"]?.toIntOrNull() ?: 0
                val game = GamesCupboardServer.findGameById(gameId) ?: throw NotFoundException("Game $gameId not found")
                val code = call.parameters["code"]
                val isSpectator = call.parameters["spectate"] == "true"

                if (!game.invitationCode.isNullOrBlank() && game.invitationCode != code) {
                    println("Invitation code given $code, expected : ${game.invitationCode}")
                    // Wrong or missing invitation code. Do NOT let them join the game.
                    send(NotInvited)
                } else {
                    playerJoined(game, preferredSeatNumber, isSpectator)
                }
            }
        }
    }

    private suspend fun WebSocketServerSession.playerJoined(
        game: Game,
        preferredSeatNumber: Int,
        wantsToBeASpectator: Boolean
    ) {
        val connectedPlayer = ConnectedPlayer(this, game, preferredSeatNumber, wantsToBeASpectator)

        // A new player has joined.
        game.add(connectedPlayer)

        val avatar = if (!connectedPlayer.isSpectator) {
            // Create an Avatar for the new player
            Avatar(
                game.generateObjectId(),
                0,
                0,
                playerId = connectedPlayer.player.id,
                draggable = game.gameVariation.draggableAvatars
            )
        } else {
            null
        }
        if (avatar != null) {
            game.gameVariation.avatarPositions.position(avatar, game.seatCount)
            game.addAvatar(connectedPlayer, avatar)
        }

        try {
            send(
                GameInfo(
                    connectedPlayer.player,
                    game.seatCount,
                    game.connectedPlayers.values.map { it.player }.filter { it !== connectedPlayer.player },
                    gameTypeLabel = game.gameType.label,
                    variationLabel = game.gameVariation.label,
                    gameLabel = game.label,
                    bgColor = game.gameVariation.bgColor,
                    grids = game.gameVariation.grids,
                    specialAreas = game.gameVariation.specialAreas,
                    specialPoints = game.gameVariation.specialPoints,
                    commands = game.gameVariation.commands.map { it.info } + Commands.standardCommandInfos,
                    commandPrototypes = game.gameVariation.commandPrototypes,
                    backgroundObjects = game.backgroundObjects(),
                    playingObjects = game.playingObjects(),
                    foregroundObjects = game.foregroundObjects(),
                    rules = game.gameVariation.rules,
                    buttons = game.gameVariation.buttons,
                    mirrorX = if (connectedPlayer.id % 2 == 1) game.gameVariation.mirrorX else 0,
                    mirrorY = if (connectedPlayer.id % 2 == 1) game.gameVariation.mirrorY else 0,
                    playerColors = game.gameVariation.playerColors,
                    scoreSheetHasBidColumn = game.gameVariation.scoreSheetHasBidColumn
                )
            )

            // Notify all other clients of the newly joined player.
            // NOTE, this must be sent BEFORE the avatar (below).
            connectedPlayer.sendToOthers(connectedPlayer.joinedMessage())
            if (avatar != null) {
                connectedPlayer.sendToOthers(avatar.newMessage())
            }

            /**
             * If this is the first player in the game. Call "initialiser" commands.
             * e.g. Card games will shuffle the deck.
             */
            if (game.connectedPlayers.size == 1) {
                for (c in game.gameVariation.commands) {
                    if (c.isInitialiser) {
                        c.run(connectedPlayer, emptyList())
                    }
                }
            }

        } catch (e: Exception) {
            println("Error while sending initialisation messages to a client")
            e.printStackTrace()
        }
        try {
            listen(game, connectedPlayer)
            println("Finished listening to ${connectedPlayer.player.name} (#${connectedPlayer.id})")
            game.connectedPlayers.remove(connectedPlayer.id)
        } catch (e: Throwable) {
            println("Error during listen")
            e.printStackTrace()
        }
    }

    /**
     * Listen to messages sent from the new player's client.
     * I've had a tricky to handle bug, which causes the server to get into a failed state,
     * and then all messages between client and server fail. So I've added lots of try...catch
     * in an attempt to find and/or fix the problem.
     */
    private suspend fun WebSocketSession.listen(game: Game, connectedPlayer: ConnectedPlayer) {

        try {
            for (frame in incoming) {
                if (frame is Frame.Text) {
                    try {
                        game.lastActive = Date()
                        val frameText = frame.readText()
                        Pair(frameText, json.decodeOrNull<Packet>(frameText) ?: continue)

                    } catch (e: Throwable) {
                        println("Error reading packet.")
                        e.printStackTrace()
                        null
                    }?.let { (frameText, packet) ->

                        try {
                            val forward: Boolean = when (val message = packet.m) {
                                is MoveObject -> moveObject(connectedPlayer, message)
                                is DragObject -> dragObject(connectedPlayer, message)
                                is RemoveObjects -> removeObjects(connectedPlayer, message)
                                is RestoreFromBin -> restoreFromBin(connectedPlayer)
                                is ChangePrivacy -> changePrivacy(connectedPlayer, message)
                                is FaceUpOrDown -> faceUpOrDown(game, message)
                                is ScaleObject -> scaleObject(game, message)
                                is CycleText -> cycleText(game, message)

                                is RunCommand -> runCommand(connectedPlayer, message)
                                is ChatMessage -> chatMessage(game, message)
                                is RenamePlayer -> renamePlayer(connectedPlayer, message)

                                is ScoreSheetData -> true
                                is HighlightMouse -> true

                                else -> false
                            }

                            try {
                                if (forward) {
                                    connectedPlayer.sendToOthers(frameText)
                                }
                            } catch (e: Throwable) {
                                println("Error forwarding message")
                                e.printStackTrace()
                            }
                        } catch (e: Throwable) {
                            println("Error processing message")
                            e.printStackTrace()
                        }
                    }
                }
            }

        } catch (e: Throwable) {
            e.printStackTrace()

        } finally {
            println("Ending connection with ${connectedPlayer.player}")

            game.remove(connectedPlayer)
            game.send(connectedPlayer.leftMessage())

            game.removeAvatar(connectedPlayer)?.let { avatar ->
                game.send(avatar.removeMessage())
            }

            // Tidy up, if the player disconnected while dragging.
            connectedPlayer.draggingObject?.let { oldDrag ->
                oldDrag.draggingByPlayerId = null
                game.send(oldDrag.moveMessage())
            }
        }
    }

    private suspend fun runCommand(from: ConnectedPlayer, message: RunCommand): Boolean {

        if (from.isSpectator && message.name != "name") {
            // Spectators can only run the :name command.
            from.send(CommandError("Spectators cannot run commands"))
            return false
        }

        val command =
            Commands.find(message.name.lowercase(Locale.getDefault()))
                ?: from.game.gameVariation.commands.firstOrNull { it.name == message.name }

        if (command == null) {
            from.send(CommandError("Unknown command : '${message.name}'"))
        } else {
            command.checkAndRun(from, message.parameters)
        }
        // CommandMessages are not forwards to other players.
        // The command may well send *other* messages to all clients.
        return false
    }

    private suspend fun chatMessage(game: Game, chatMessage: ChatMessage): Boolean {
        return if (chatMessage.toId == null) {
            // Forward the message to all other players.
            true
        } else {

            game.connectedPlayers[chatMessage.toId]?.session?.send(chatMessage)
            // We've sent this chat message to ONE player. Do not forward it to everybody else.
            false
        }
    }

    private fun renamePlayer(from: ConnectedPlayer, message: RenamePlayer): Boolean {
        if (from.id == message.id) {
            from.player.name = message.name
            return true
        } else {
            println("Ignoring renamedPlayer. From ${from.id}, but message id = ${message.id}")
        }
        return false
    }

    private suspend fun moveObject(from: ConnectedPlayer, message: MoveObject): Boolean {
        val game = from.game

        game.findObject(message.id)?.let { go ->
            if (go is RegenGameObject && go.regen) {
                go.regen = false
                val copy = go.copy(game.generateObjectId()).apply {
                    regen = true
                    isCopy = true
                }
                from.game.add(copy)
                from.game.send(copy.newMessage())
            }
            go.x = message.x
            go.y = message.y

            go.draggingByPlayerId = null
            from.draggingObject = null

            from.game.moveZ(go, message.z)
        }
        return true
    }

    /**
     * NOTE, the x,y position of the GameObject on the server is NOT changed during a drag.
     * Only on completion (via the [MoveObject] message).
     */
    private fun dragObject(from: ConnectedPlayer, message: DragObject): Boolean {
        from.game.findObject(message.id)?.let { go ->
            if (go.draggingByPlayerId != null && go.draggingByPlayerId != from.id) {
                // The object is already being dragged by another client.
                // Do NOT update, nor forward the request to other clients.
                // NOTE, the client sending this message should receive DragObject messages
                // and a MoveObject message caused by the other client. These must cause the
                // "losing" client to cancel their drag.
                return false
            } else {
                go.draggingByPlayerId = from.id
                from.draggingObject = go
                return true
            }
        }
        return false
    }

    private fun removeObjects(from: ConnectedPlayer, message: RemoveObjects): Boolean {
        val game = from.game
        if (message.saveToBin) {
            game.binnedObjects.addAll(message.ids.mapNotNull { id -> game.findObject(id) })
        }
        for (id in message.ids) {
            game.removeById(message.ids)
        }
        return true
    }

    private suspend fun restoreFromBin(from: ConnectedPlayer): Boolean {
        val game = from.game
        val newObjects = game.binnedObjects.map { binned ->
            val newObject = binned.copy(game.generateObjectId()).apply {
                x = playingAreaWidth / 2
                y = playingAreaHeight / 2
            }
            game.add(newObject)
            newObject
        }
        game.binnedObjects.clear()
        game.send(AddObjects(newObjects))
        return false
    }

    private fun changePrivacy(from: ConnectedPlayer, message: ChangePrivacy): Boolean {
        if (message.privateToPlayerId == null || from.player.id == message.privateToPlayerId) {
            from.game.findObject(message.id)?.let { go ->
                go.privateToPlayerId = message.privateToPlayerId
                return true
            }
        }
        return false
    }

    private fun faceUpOrDown(game: Game, message: FaceUpOrDown): Boolean {
        game.findObject(message.id)?.let { go ->
            if (go is FlippableImageObject) {
                go.isFaceUp = message.isFaceUp
            }
        }
        return true
    }

    private fun scaleObject(game: Game, message: ScaleObject): Boolean {
        game.findObject(message.id)?.let { go ->
            if (go is ImageObject) {
                go.scale = message.scale
                return true
            }
        }
        return false
    }

    private fun cycleText(game: Game, message: CycleText): Boolean {
        game.findObject(message.id)?.let { go ->
            if (go is TextObject) {
                go.cyclicIndex = message.cyclicIndex
                return true
            }
        }
        return false
    }

}