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