Implement game setup

You can create a new game and then invite people. On the receiving end, you can receive a link to join the game and then jump in. Once the host starts the game all players are dealt 2 cards.
This commit is contained in:
William Brawner 2021-02-28 09:22:06 -07:00
parent 1744e33886
commit 5e82a6f51d
12 changed files with 917 additions and 116 deletions

View file

@ -1,9 +1,11 @@
ktor {
development = true
deployment {
port = 8080
port = ${?PORT}
watch = [classes]
}
application {
modules = [ com.wbrawner.ApplicationKt.module ]
modules = [com.wbrawner.blackjack.ApplicationKt.module]
}
}

View file

@ -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);
}
}

155
resources/static/game.html Normal file
View file

@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="en_US">
<head>
<title>BlackJack</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" href="css/style.css"/>
<script type="text/javascript" src="js/app.js"></script>
</head>
<body onload="processData()">
<div class="content">
<div id="connecting">
<h1 class="title">Waiting for host</h1>
<div class="lds-ellipsis">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
<div id="host" style="display: none;">
<p id="copy-info" style="transition: all 0.25s ease;">Click the code below to copy a link you can share with
your friends</p>
<h1 id="code" onclick="copyCode()" style="cursor: pointer;"></h1>
<button id="start" class="button" disabled="disabled" onclick="startGame()">Start Game</button>
</div>
<ul id="players"></ul>
<div id="game" style="display: none;">
<p>Game here</p>
<div class="actions">
<button id="hit" class="button" onclick="hit()">Hit</button>
<button id="stand" class="button" onclick="stand()">Stand</button>
</div>
</div>
</div>
<script type="text/javascript">
let ws;
let player;
let code;
function processData() {
let params = new URLSearchParams(window.location.search);
if (!params.has('code')) {
console.log('Redirecting');
window.location.href = './';
return;
}
code = params.get('code');
playerJSON = window.localStorage.getItem(code);
if (!playerJSON || !(player = JSON.parse(playerJSON))) {
console.log("Invalid player");
window.location.href = `./join?code=${code}`;
return;
}
connectToGame();
}
function connectToGame() {
const scheme = window.location.protocol === 'http:' ? 'ws' : 'wss';
ws = new WebSocket(`${scheme}://${window.location.host}/${window.location.pathname}s/${code}/${player.secret}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data);
switch (data.type) {
case 'game':
handleGame(data);
break;
case 'player':
handlePlayer(data);
break;
case 'error':
handleError(data);
break;
default:
console.error('Unknown data type', data)
}
}
}
function handleGame(game) {
if (!game.started) {
if (game.host === player.name) {
// We're the host!
document.getElementById('connecting').style.display = 'none';
document.getElementById('code').innerText = code;
document.getElementById('host').style.display = 'block';
if (game.players.length > 1) document.getElementById('start').removeAttribute('disabled');
}
const players = document.getElementById('players');
players.innerHTML = '';
game.players.forEach(p => {
const li = document.createElement('li');
let text = p.name;
if (p.name === player.name && p.name === game.host) {
text += ' (Host, You)';
} else if (p.name === game.host) {
text += ' (Host)';
} else if (p.name === player.name) {
text += ' (You)';
}
li.innerText = text;
players.appendChild(li);
});
} else {
document.getElementById('connecting').style.display = 'none';
document.getElementById('host').style.display = 'none';
document.getElementById('players').style.display = 'none';
document.getElementById('game').style.display = 'block';
}
}
function handleError(error) {
console.error(error);
window.location.href = './';
}
function handlePlayer(player) {
// TODO: Show cards in hand
console.log(player);
}
function startGame() {
if (document.getElementById('players').childElementCount < 2) return;
ws.send(JSON.stringify({
type: 'startGame',
player: player.secret
}));
}
function copyCode() {
navigator.permissions.query({name: "clipboard-write"}).then(result => {
if (result.state === "granted" || result.state === "prompt") {
let code = document.getElementById('code').innerText;
let url = new URL(window.location.href);
url.searchParams.delete('host');
url.searchParams.set('code', code);
url.pathname = url.pathname.replace('host', 'join');
navigator.clipboard.writeText(url.toString());
setCopyInfoText('Copied!');
setTimeout(() => setCopyInfoText('Click the code below to copy a link you can share with your friends'), 2250);
}
});
}
function setCopyInfoText(text) {
let info = document.getElementById('copy-info');
if (info.innerText === text) return;
info.style.opacity = '0';
setTimeout(() => {
info.innerText = text;
info.style.opacity = '1';
}, 250);
}
</script>
</body>
</html>

View file

@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en_US">
<head>
<title>BlackJack - Host</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" href="css/style.css"/>
<script type="text/javascript" src="js/app.js"></script>
</head>
<body onload="processData()">
<div class="content">
<div id="prompt">
<h1 class="title">Host Game</h1>
<p>Enter your name below</p>
<form action="/host" id="join-form">
<label for="host">Name</label>
<p id="error" class="error" style="display: none;">Invalid name. Please try again.</p>
<input id="host" name="host" type="text" minlength="1" maxlength="256" pattern="\w+" spellcheck="false"
placeholder="Name"/>
<input class="button" type="submit" value="Submit"/>
</form>
</div>
<div id="creating" style="display: none;">
<h1 class="title">Creating Game</h1>
<div class="lds-ellipsis">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
</div>
<script type="text/javascript">
function processData() {
let params = new URLSearchParams(window.location.search);
if (params.has('host')) {
createGame(params.get('host'))
}
}
function createGame(name) {
document.getElementById('prompt').style.display = 'none';
const creating = document.getElementById('creating');
console.log(`Creating game. Host: ${name}`);
creating.style.display = 'block';
fetch(
`./api/new-game`,
{
method: 'POST',
body: JSON.stringify({name: name}),
headers: {
'content-type': 'application/json'
}
})
.then(response => response.json())
.then(response => {
console.log(`Created game`, response);
window.localStorage.setItem(
response.gameId,
JSON.stringify({
name: name,
secret: response.playerSecret
})
);
let gameUrl = new URL(window.location.href);
gameUrl.pathname = gameUrl.pathname.replace('host', 'game');
gameUrl.search = `code=${response.gameId}`;
window.location.replace(gameUrl.toString());
})
.catch(err => console.error(`Failed to create game`, err))
}
</script>
</body>
</html>

View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<!--suppress HtmlUnknownTarget -->
<html lang="en_US">
<head>
<title>BlackJack</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" href="css/style.css"/>
<script type="text/javascript" src="js/app.js"></script>
</head>
<body>
<div class="content">
<h1 class="title">BlackJack</h1>
<div class="actions">
<a href="/join" class="button">Join Game</a>
<a href="/host" class="button">Host Game</a>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en_US">
<head>
<title>BlackJack - Join</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" href="css/style.css"/>
<script type="text/javascript" src="js/app.js"></script>
</head>
<body onload="processCode()">
<div class="content">
<div id="prompt">
<h1 class="title">Join Game</h1>
<p>Enter your invite code and name below</p>
<form action="/join" id="code-form" onsubmit="joinGame()">
<label for="code">Invite Code</label>
<p id="code-error" class="error" style="display: none;">Invalid code. Please try again.</p>
<input id="code" name="code" type="text" minlength="4" maxlength="4" pattern="[0-9a-zA-Z]{4}"
spellcheck="false"
placeholder="Code"/>
<label for="name">Name</label>
<p id="name-error" class="error" style="display: none;"></p>
<input id="name" name="name" type="text" minlength="1" maxlength="256" pattern="\w+" spellcheck="false"
placeholder="Name"/>
<input class="button" type="submit" value="Submit"/>
</form>
</div>
<div id="connecting" style="display: none;">
<h1 class="title">Connecting</h1>
<div class="lds-ellipsis">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
</div>
<script type="text/javascript">
function processCode() {
let params = new URLSearchParams(window.location.search);
if (!params.has('code')) return;
let code = params.get('code');
let codeInput = document.getElementById('code');
codeInput.value = code;
}
function joinGame(e) {
if (e) e.preventDefault();
showLoading(true);
let code = document.getElementById('code').value;
let name = document.getElementById('name').value;
fetch(
`./api/join-game`,
{
method: 'POST',
body: JSON.stringify({
code: code,
name: name
}),
headers: {
'content-type': 'application/json'
}
})
.then(response => response.text())
.then(response => {
window.localStorage.setItem(
code,
JSON.stringify({
name: name,
secret: response
})
);
let gameUrl = new URL(window.location.href);
gameUrl.pathname = gameUrl.pathname.replace('join', 'game');
gameUrl.search = `code=${code}`;
window.location.replace(gameUrl.toString());
})
.catch(err => {
console.error(`Invalid name`, err);
showLoading(false);
document.getElementById('name-error').style.display = 'block';
document.getElementById('name-error').innerText = err.message;
})
}
function showLoading(loading) {
const code = document.getElementById('prompt');
const connecting = document.getElementById('connecting');
if (loading) {
code.style.display = 'none';
connecting.style.display = 'block';
} else {
code.style.display = 'none';
connecting.style.display = 'block';
}
}
</script>
</body>
</html>

View file

@ -0,0 +1 @@
console.log('BlackJack loaded');

View file

@ -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<String>): 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"))
}
}
}

View file

@ -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<Action> {
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")
}
}
}

View file

@ -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<String>): Unit = io.ktor.server.netty.EngineMain.main(args)
private val games = ConcurrentHashMap.newKeySet<Game>()
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
}
}
}
}
}

View file

@ -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<GameState> =
MutableSharedFlow<GameState>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST).apply {
tryEmit(GameState(id, owner, listOf(PlayerState(owner)), owner))
}
val state = _state.asSharedFlow()
private val players: MutableList<Player> = 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<PlayerState>,
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<WebSocketSession> = AtomicReference(),
val hand: MutableList<Card> = 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<Card> {
val deck = ArrayList<Card>(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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
//source: https://github.com/Kotlin/kotlinx.coroutines/issues/1686#issuecomment-777357672
suspend fun <T> 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<ReentrantMutexContextElement>

View file

@ -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)
// }
// }
}
}