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
+
+
+
+
+
+
+
+
+
Click the code below to copy a link you can share with
+ your friends
+
+
+
+
+
+
Game here
+
+
+
+
+
+
+
+
+
\ 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
+
+
+
+
+
+
+
+
\ 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)
+// }
+// }
}
}