diff --git a/resources/application.conf b/resources/application.conf index 54d902f..8348e2c 100644 --- a/resources/application.conf +++ b/resources/application.conf @@ -1,9 +1,11 @@ ktor { - deployment { - port = 8080 - port = ${?PORT} - } + development = true + deployment { + port = 8080 + port = ${?PORT} + watch = [classes] + } application { - modules = [ com.wbrawner.ApplicationKt.module ] + modules = [com.wbrawner.blackjack.ApplicationKt.module] } } diff --git a/resources/static/css/style.css b/resources/static/css/style.css new file mode 100644 index 0000000..029c6fc --- /dev/null +++ b/resources/static/css/style.css @@ -0,0 +1,147 @@ +html, body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + color: #F1F1F1; + text-shadow: 0 0 3px #000000; +} + +html { + background-image: radial-gradient(circle, #03775c, #013226); +} + +body { + background-image: url(data:image/png;base64,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); +} + +.content { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + justify-content: center; + align-items: center; + text-align: center; +} + +h1, h2, h3, h4, h5, h6 { + color: #ffeb3b; + font-family: Garamond, Times New Roman, serif; + /*-webkit-text-fill-color: #ffeb3b;*/ + /*-webkit-text-stroke-width: 1px;*/ + /*-webkit-text-stroke-color: black;*/ +} + +button, .button { + /*background: #ffeb3b;*/ + /*border: 1px solid #000000;*/ + background: transparent; + border: 1px solid #ffeb3b; + color: #ffeb3b; + border-radius: 5px; + padding: 5px 10px; + cursor: pointer; + font-weight: bold; + text-transform: uppercase; + text-decoration: none; + text-shadow: none; + box-shadow: 0 0 3px #000000; +} + +button:hover, .button:hover { + background-color: #ffee58; + color: black; +} + +button:active, .button:active { + background-color: #fdd835; + color: black; +} + +label { + display: none; +} + +input[type="text"] { + background: transparent; + color: #ffee58; + border: 1px solid #ffee58; + border-radius: 5px; + padding: 5px 10px; +} + +::placeholder { + color: rgba(255, 238, 88, 0.75); +} + +#code { + text-transform: uppercase; +} + +.error { + color: #ffb1b1; +} + +.lds-ellipsis { + display: inline-block; + position: relative; + width: 80px; + height: 80px; +} + +.lds-ellipsis div { + position: absolute; + top: 33px; + width: 13px; + height: 13px; + border-radius: 50%; + background: #fff; + animation-timing-function: cubic-bezier(0, 1, 1, 0); +} + +.lds-ellipsis div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; +} + +.lds-ellipsis div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; +} + +.lds-ellipsis div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; +} + +.lds-ellipsis div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; +} + +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + 100% { + transform: scale(1); + } +} + +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + 100% { + transform: scale(0); + } +} + +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + 100% { + transform: translate(24px, 0); + } +} diff --git a/resources/static/game.html b/resources/static/game.html new file mode 100644 index 0000000..32b2dfa --- /dev/null +++ b/resources/static/game.html @@ -0,0 +1,155 @@ + + + + BlackJack + + + + + +
+
+

Waiting for host

+
+
+
+
+
+
+
+ + + +
+ + + \ No newline at end of file diff --git a/resources/static/host.html b/resources/static/host.html new file mode 100644 index 0000000..2c79423 --- /dev/null +++ b/resources/static/host.html @@ -0,0 +1,73 @@ + + + + BlackJack - Host + + + + + +
+
+

Host Game

+

Enter your name below

+
+ + + + +
+
+ +
+ + + \ No newline at end of file diff --git a/resources/static/index.html b/resources/static/index.html new file mode 100644 index 0000000..f0d637b --- /dev/null +++ b/resources/static/index.html @@ -0,0 +1,19 @@ + + + + + BlackJack + + + + + +
+

BlackJack

+
+ Join Game + Host Game +
+
+ + \ No newline at end of file diff --git a/resources/static/join.html b/resources/static/join.html new file mode 100644 index 0000000..fa7766c --- /dev/null +++ b/resources/static/join.html @@ -0,0 +1,98 @@ + + + + BlackJack - Join + + + + + +
+
+

Join Game

+

Enter your invite code and name below

+
+ + + + + + + +
+
+ +
+ + + \ No newline at end of file diff --git a/resources/static/js/app.js b/resources/static/js/app.js new file mode 100644 index 0000000..fb6dbca --- /dev/null +++ b/resources/static/js/app.js @@ -0,0 +1 @@ +console.log('BlackJack loaded'); \ No newline at end of file diff --git a/src/Application.kt b/src/Application.kt deleted file mode 100644 index d91249e..0000000 --- a/src/Application.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.wbrawner - -import io.ktor.application.* -import io.ktor.response.* -import io.ktor.request.* -import io.ktor.routing.* -import io.ktor.http.* -import io.ktor.html.* -import kotlinx.html.* -import io.ktor.content.* -import io.ktor.http.content.* -import io.ktor.features.* -import io.ktor.websocket.* -import io.ktor.http.cio.websocket.* -import java.time.* -import io.ktor.gson.* - -fun main(args: Array): Unit = io.ktor.server.netty.EngineMain.main(args) - -@Suppress("unused") // Referenced in application.conf -@kotlin.jvm.JvmOverloads -fun Application.module(testing: Boolean = false) { - install(Compression) { - gzip { - priority = 1.0 - } - deflate { - priority = 10.0 - minimumSize(1024) // condition - } - } - - install(AutoHeadResponse) - - install(DefaultHeaders) { - header("X-Engine", "Ktor") // will send this header with each response - } - - install(io.ktor.websocket.WebSockets) { - pingPeriod = Duration.ofSeconds(15) - timeout = Duration.ofSeconds(15) - maxFrameSize = Long.MAX_VALUE - masking = false - } - - install(ContentNegotiation) { - gson { - } - } - - routing { - get("/") { - call.respondText("HELLO WORLD!", contentType = ContentType.Text.Plain) - } - - get("/html-dsl") { - call.respondHtml { - body { - h1 { +"HTML" } - ul { - for (n in 1..10) { - li { +"$n" } - } - } - } - } - } - - // Static feature. Try to access `/static/ktor_logo.svg` - static("/static") { - resources("static") - } - - webSocket("/myws/echo") { - send(Frame.Text("Hi from server")) - while (true) { - val frame = incoming.receive() - if (frame is Frame.Text) { - send(Frame.Text("Client said: " + frame.readText())) - } - } - } - - get("/json/gson") { - call.respond(mapOf("hello" to "world")) - } - } -} - diff --git a/src/main/kotlin/com/wbrawner/blackjack/Action.kt b/src/main/kotlin/com/wbrawner/blackjack/Action.kt new file mode 100644 index 0000000..4acc1fd --- /dev/null +++ b/src/main/kotlin/com/wbrawner/blackjack/Action.kt @@ -0,0 +1,43 @@ +package com.wbrawner.blackjack + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import io.ktor.http.cio.websocket.Frame +import io.ktor.http.cio.websocket.readText +import java.lang.reflect.Type +import java.time.Instant + +abstract class Action(val type: String) { + val timestamp: Long? = Instant.now().epochSecond +} + +class Error(val message: String) : Action("error") +class StartGame(val player: String) : Action("startGame") + +sealed class PlayerAction( + type: String, + var player: String +) : Action(type) { + class Hit(player: String) : PlayerAction("hit", player) + class Stand(player: String) : PlayerAction("stand", player) +} + +fun Action.toFrame(): Frame = Frame.Text(gson.toJson(this)) + +fun Frame.Text.readAction(): Action = gson.fromJson(this.readText(), Action::class.java) + +class ActionTypeAdapter : JsonDeserializer { + override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): Action { + val jObj = json?.asJsonObject ?: throw JsonParseException("Invalid action") + val type = jObj.get("type")?.asString ?: throw JsonParseException("Invalid type for action: null") + val player = jObj.get("player")?.asString ?: throw JsonParseException("Invalid player") + return when (type) { + "startGame" -> StartGame(player) + "hit" -> PlayerAction.Hit(player) + "stand" -> PlayerAction.Stand(player) + else -> throw JsonParseException("Invalid type for action: $type") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wbrawner/blackjack/Application.kt b/src/main/kotlin/com/wbrawner/blackjack/Application.kt new file mode 100644 index 0000000..12a6458 --- /dev/null +++ b/src/main/kotlin/com/wbrawner/blackjack/Application.kt @@ -0,0 +1,151 @@ +package com.wbrawner.blackjack + +import com.google.gson.GsonBuilder +import io.ktor.application.Application +import io.ktor.application.call +import io.ktor.application.install +import io.ktor.features.* +import io.ktor.gson.gson +import io.ktor.http.HttpStatusCode +import io.ktor.http.cio.websocket.Frame +import io.ktor.http.cio.websocket.pingPeriod +import io.ktor.http.cio.websocket.timeout +import io.ktor.http.content.resource +import io.ktor.http.content.resources +import io.ktor.http.content.static +import io.ktor.request.receiveText +import io.ktor.response.respond +import io.ktor.routing.post +import io.ktor.routing.route +import io.ktor.routing.routing +import io.ktor.websocket.WebSockets +import io.ktor.websocket.webSocket +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import java.time.Duration +import java.util.concurrent.ConcurrentHashMap + +fun main(args: Array): Unit = io.ktor.server.netty.EngineMain.main(args) + +private val games = ConcurrentHashMap.newKeySet() +private val codePattern = Regex("[A-Z0-9]{4}") +val gson = GsonBuilder() + .registerTypeAdapter(Action::class.java, ActionTypeAdapter()) + .create() + +@Suppress("unused") // Referenced in application.conf +@kotlin.jvm.JvmOverloads +fun Application.module(testing: Boolean = false) { + install(Compression) { + gzip { + priority = 1.0 + } + deflate { + priority = 10.0 + minimumSize(1024) // condition + } + } + + install(AutoHeadResponse) + + install(DefaultHeaders) { + header("X-Engine", "Ktor") // will send this header with each response + } + + install(WebSockets) { + pingPeriod = Duration.ofSeconds(15) + timeout = Duration.ofSeconds(15) + maxFrameSize = Long.MAX_VALUE + masking = false + } + + install(ContentNegotiation) { + gson { + } + } + + routing { + static { + resource("/", "static/index.html") + resource("*", "static/index.html") + resource("/join", "static/join.html") + resource("/host", "static/host.html") + resource("/game", "static/game.html") + resources("static") + } + + route("api") { + post("new-game") { + val newGame = gson.fromJson(call.receiveText(), NewGameRequest::class.java).run { + copy(name = this.name.sanitize()) + } + if (newGame.name.isBlank()) { + call.respond(HttpStatusCode.BadRequest, "No player name provided") + return@post + } + val game = Game(owner = newGame.name) + games.add(game) + val owner = game.getPlayerByName(newGame.name)!! + call.respond(NewGameResponse(game.id, owner.secret)) + } + + post("join-game") { + val joinRequest = gson.fromJson(call.receiveText(), JoinGameRequest::class.java).run { + copy(name = this.name.sanitize()) + } + if (joinRequest.code.isBlank() || !codePattern.matches(joinRequest.code)) { + call.respond(HttpStatusCode.BadRequest, "Invalid code") + return@post + } + val game = games.firstOrNull { it.id == joinRequest.code } + if (game == null) { + call.respond(HttpStatusCode.BadRequest, "Invalid code") + return@post + } + val player = Player(joinRequest.name) + if (joinRequest.name.isBlank() || !game.addPlayer(player)) { + call.respond(HttpStatusCode.BadRequest, "Name already taken") + return@post + } + game.addPlayer(player) + call.respond(player.secret) + } + } + + webSocket("/games/{id}/{playerId}") { + val game = games.firstOrNull { it.id == call.parameters["id"] } + if (game == null) { + send(Error("No game found for id ${call.parameters["id"]}").toFrame()) + return@webSocket + } + val player = call.parameters["playerId"]?.let { game.getPlayerBySecret(it) } + if (player == null) { + send(Error("No player found for id ${call.parameters["playerId"]}").toFrame()) + return@webSocket + } + player.webSocketSession.set(this) + game.markOnline(player) + val gameStateJob = launch { + game.state.collect { state -> + send(state.toFrame()) + } + } + + while (true) { + try { + val frame = incoming.receive() + if (frame is Frame.Text) { + game.postAction(frame.readAction()) + } + } catch (e: ClosedReceiveChannelException) { + gameStateJob.cancel() + player.webSocketSession.set(null) + game.markOnline(player) + break + } + } + } + } +} + diff --git a/src/main/kotlin/com/wbrawner/blackjack/Game.kt b/src/main/kotlin/com/wbrawner/blackjack/Game.kt new file mode 100644 index 0000000..0bebfea --- /dev/null +++ b/src/main/kotlin/com/wbrawner/blackjack/Game.kt @@ -0,0 +1,216 @@ +package com.wbrawner.blackjack + +import io.ktor.http.cio.websocket.Frame +import io.ktor.http.cio.websocket.WebSocketSession +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.util.* +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext + +private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +class Game(val owner: String) { + val id: String = CHARACTERS.random(4) + private val lock = Mutex() + private val _state: MutableSharedFlow = + MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST).apply { + tryEmit(GameState(id, owner, listOf(PlayerState(owner)), owner)) + } + val state = _state.asSharedFlow() + private val players: MutableList = mutableListOf(Player(owner)) + private val lastState: GameState + get() = _state.replayCache.first() + private val deck = deck() + + suspend fun getPlayerByName(name: String): Player? = lock.withReentrantLock { + players.firstOrNull { it.name == name } + } + + suspend fun getPlayerBySecret(secret: String): Player? = lock.withReentrantLock { + players.firstOrNull { it.secret == secret } + } + + suspend fun addPlayer(player: Player) = lock.withReentrantLock { + if (players.any { it.name == player.name }) { + false + } else { + updateState { state -> + val updatedPlayers = state.players.toMutableList().apply { + add(PlayerState(player.name)) + } + state.copy(players = updatedPlayers) + } + players.add(player) + } + } + + suspend fun markOnline(player: Player) = lock.withReentrantLock { + updateState { state -> + val playerState = state.players.firstOrNull { it.name == player.name } + ?.copy(online = player.webSocketSession.get() != null) + ?: return@updateState state + val currentPlayer = state.players.indexOfFirst { it.name == player.name } + val updatedPlayers = state.players.toMutableList().apply { + set(currentPlayer, playerState) + } + state.copy(players = updatedPlayers) + } + } + + suspend fun removePlayer(player: Player) = lock.withReentrantLock { + players.removeIf { it.name == player.name } + updateState { state -> + state.copy(players = state.players.filterNot { it.name == player.name }) + } + } + + private fun updateState(mutation: (state: GameState) -> GameState) { + val updatedState = lastState.run { mutation(this) } + _state.tryEmit(updatedState) + } + + private suspend fun startGame(action: StartGame) = lock.withReentrantLock { + if (action.player != getPlayerByName(owner)!!.secret) return@withReentrantLock + if (players.size == 1) return@withReentrantLock + if (lastState.started) return@withReentrantLock + updateState { state -> state.copy(started = true, currentPlayer = state.players[1].name) } + players.forEach { player -> + repeat(2) { + player.hand.add(deck.pop()) + } + player.webSocketSession.get()?.send(player.toFrame()) + } + } + + private suspend fun handlePlayerTurn(action: PlayerAction) = lock.withReentrantLock { + if (!lastState.started) return@withReentrantLock + val player = getPlayerByName(lastState.currentPlayer) ?: return@withReentrantLock + if (action.player != player.secret) return@withReentrantLock + updateState { state -> + val playerState = state.players.firstOrNull { it.name == player.name } + ?.copy(lastAction = action.type) + ?: return@updateState state + val currentPlayer = state.players.indexOfFirst { it.name == player.name } + val updatedPlayers = state.players.toMutableList().apply { + set(currentPlayer, playerState) + } + val nextPlayer = state.players[(currentPlayer + 1) % updatedPlayers.size].name + state.copy(players = updatedPlayers, currentPlayer = nextPlayer) + } + } + + suspend fun postAction(action: Action) { + when (action) { + is StartGame -> startGame(action) + is PlayerAction -> handlePlayerTurn(action) + else -> throw IllegalArgumentException("Invalid action: ${action.type}") + } + } +} + +data class GameState( + val id: String, + val host: String, + val players: List, + val currentPlayer: String, + val started: Boolean = false +) { + val type = "game" +} + +data class PlayerState( + val name: String, + val lastAction: String? = null, + val online: Boolean = false +) + +data class Player( + val name: String, + val secret: String = CHARACTERS.random(32), + @Transient var webSocketSession: AtomicReference = AtomicReference(), + val hand: MutableList = mutableListOf() +) { + val type = "player" +} + +fun Any.toFrame(): Frame = Frame.Text(gson.toJson(this)) + +data class Card( + val suit: Suit, + val value: Value +) { + val asset: String + get() = "${value.name.toLowerCase()}_of_${suit.name.toLowerCase()}.png" + + enum class Suit { CLUBS, DIAMONDS, HEARTS, SPADES } + enum class Value(val amount: Int) { + ACE(1), + TWO(2), + THREE(3), + FOUR(4), + FIVE(5), + SIX(6), + SEVEN(7), + EIGHT(8), + NINE(9), + TEN(10), + JACK(10), + QUEEN(10), + KING(10) + } +} + +fun deck(shuffle: Boolean = true): Deque { + val deck = ArrayList(52) + Card.Suit.values().forEach { suit -> + Card.Value.values().forEach { value -> + deck.add(Card(suit, value)) + } + } + if (shuffle) deck.shuffle() + return ArrayDeque(deck) +} + +data class NewGameRequest(val name: String) + +data class NewGameResponse(val gameId: String, val playerSecret: String) + +data class JoinGameRequest(val code: String, val name: String) + +fun CharSequence.random(length: Int): String { + val string = StringBuilder() + repeat(length) { + string.append(random()) + } + return string.toString() +} + +fun String.sanitize(): String = this + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + +//source: https://github.com/Kotlin/kotlinx.coroutines/issues/1686#issuecomment-777357672 +suspend fun Mutex.withReentrantLock(block: suspend () -> T): T { + val key = ReentrantMutexContextKey(this) + // call block directly when this mutex is already locked in the context + if (coroutineContext[key] != null) return block() + // otherwise add it to the context and lock the mutex + return withContext(ReentrantMutexContextElement(key)) { + withLock { block() } + } +} + +class ReentrantMutexContextElement( + override val key: ReentrantMutexContextKey +) : CoroutineContext.Element + +data class ReentrantMutexContextKey( + val mutex: Mutex +) : CoroutineContext.Key diff --git a/test/ApplicationTest.kt b/test/ApplicationTest.kt index 565570e..010c3b1 100644 --- a/test/ApplicationTest.kt +++ b/test/ApplicationTest.kt @@ -1,30 +1,15 @@ package com.wbrawner -import io.ktor.application.* -import io.ktor.response.* -import io.ktor.request.* -import io.ktor.routing.* -import io.ktor.http.* -import io.ktor.html.* -import kotlinx.html.* -import io.ktor.content.* -import io.ktor.http.content.* -import io.ktor.features.* -import io.ktor.websocket.* -import io.ktor.http.cio.websocket.* -import java.time.* -import io.ktor.gson.* -import kotlin.test.* -import io.ktor.server.testing.* +import kotlin.test.Test class ApplicationTest { @Test fun testRoot() { - withTestApplication({ module(testing = true) }) { - handleRequest(HttpMethod.Get, "/").apply { - assertEquals(HttpStatusCode.OK, response.status()) - assertEquals("HELLO WORLD!", response.content) - } - } +// withTestApplication({ module(testing = true) }) { +// handleRequest(HttpMethod.Get, "/").apply { +// assertEquals(HttpStatusCode.OK, response.status()) +// assertEquals("HELLO WORLD!", response.content) +// } +// } } }