From f59a4603011c35bd69552f519c24ba6077a7882b Mon Sep 17 00:00:00 2001 From: Cornelius Wichering Date: Sat, 8 Jun 2024 23:55:48 +0200 Subject: [PATCH 1/6] feat: add immutable `GameResult` --- api/src/main/kotlin/pp/api/Rooms.kt | 44 +++++----- api/src/main/kotlin/pp/api/RoomsResource.kt | 10 ++- api/src/main/kotlin/pp/api/Util.kt | 17 ++++ api/src/main/kotlin/pp/api/data/GamePhase.kt | 81 ++++++++++++++++++- api/src/main/kotlin/pp/api/data/Room.kt | 18 ++++- api/src/main/kotlin/pp/api/data/User.kt | 6 ++ .../main/kotlin/pp/api/dto/ClientGamePhase.kt | 26 ++++++ api/src/main/kotlin/pp/api/dto/RoomDto.kt | 44 +++++----- api/src/main/kotlin/pp/api/dto/UserDto.kt | 6 +- api/src/test/kotlin/pp/api/RoomTest.kt | 10 +-- .../test/kotlin/pp/api/RoomsResourceTest.kt | 61 +++++++++++--- api/src/test/kotlin/pp/api/RoomsTest.kt | 30 +++---- api/src/test/kotlin/pp/api/dto/RoomDtoTest.kt | 27 ++++--- api/src/test/kotlin/pp/api/dto/UserDtoTest.kt | 8 +- 14 files changed, 280 insertions(+), 108 deletions(-) create mode 100644 api/src/main/kotlin/pp/api/dto/ClientGamePhase.kt diff --git a/api/src/main/kotlin/pp/api/Rooms.kt b/api/src/main/kotlin/pp/api/Rooms.kt index 4b3b6ca..4847b16 100644 --- a/api/src/main/kotlin/pp/api/Rooms.kt +++ b/api/src/main/kotlin/pp/api/Rooms.kt @@ -8,9 +8,8 @@ import jakarta.websocket.CloseReason.CloseCodes.VIOLATED_POLICY import jakarta.websocket.Session import pp.api.data.ChangeName import pp.api.data.ChatMessage -import pp.api.data.GamePhase -import pp.api.data.GamePhase.CARDS_REVEALED -import pp.api.data.GamePhase.PLAYING +import pp.api.data.GamePhase.CardsRevealed +import pp.api.data.GamePhase.Playing import pp.api.data.PlayCard import pp.api.data.RevealCards import pp.api.data.Room @@ -100,8 +99,8 @@ class Rooms { is ChangeName -> changeName(session, request.name) is PlayCard -> playCard(session, request.cardValue) is ChatMessage -> chatMessage(session, request.message) - is RevealCards -> changeGamePhase(session, CARDS_REVEALED) - is StartNewRound -> changeGamePhase(session, PLAYING) + is RevealCards -> changeGamePhaseToCardsRevealed(session) + is StartNewRound -> changeGamePhaseToPlaying(session) else -> { // spotlessApply keeps generating this else if it doesnt exist } @@ -179,7 +178,7 @@ class Rooms { private fun playCard(session: Session, cardValue: String?) { get(session)?.let { (room, user) -> - if (room.gamePhase == PLAYING) { + if (room.gamePhase is Playing) { if (cardValue != null && cardValue !in room.deck) { update(room withInfo "${user.username} tried to play card with illegal value: $cardValue") } else { @@ -200,35 +199,30 @@ class Rooms { } } - private fun changeGamePhase(session: Session, newGamePhase: GamePhase) { + private fun changeGamePhaseToPlaying(session: Session) { get(session)?.let { (room, user) -> - val canRevealCards = room.gamePhase == PLAYING && newGamePhase == CARDS_REVEALED - val canStartNextRound = room.gamePhase == CARDS_REVEALED && newGamePhase == PLAYING - - if (canRevealCards || canStartNextRound) { + if (room.gamePhase is CardsRevealed) { val updatedRoom = room.run { copy( - gamePhase = newGamePhase, - users = if (newGamePhase == CARDS_REVEALED) { - users - } else { - users.map { user -> - user.copy( - cardValue = null - ) - } + gamePhase = Playing, + users = users.map { user -> + user.copy( + cardValue = null + ) } ) } - val message = if (newGamePhase == CARDS_REVEALED) "revealed the cards" else "started a new round" - update(updatedRoom withInfo "${user.username} $message") - } else { - val error = "${user.username} tried to change game phase to $newGamePhase, but that's illegal" - update(room withInfo error) + update(updatedRoom withInfo "${user.username} started a new round") } } } + private fun changeGamePhaseToCardsRevealed(session: Session) { + get(session)?.let { (room, user) -> + update(room withCardsRevealedBy user) + } + } + private operator fun get(roomId: String): Room? = allRooms.firstOrNull { it.roomId == roomId } private operator fun get(session: Session): Pair? = allRooms .firstOrNull { diff --git a/api/src/main/kotlin/pp/api/RoomsResource.kt b/api/src/main/kotlin/pp/api/RoomsResource.kt index 238d101..982f24e 100644 --- a/api/src/main/kotlin/pp/api/RoomsResource.kt +++ b/api/src/main/kotlin/pp/api/RoomsResource.kt @@ -188,5 +188,13 @@ class RoomsResource( */ @GET @Produces(APPLICATION_JSON) - fun getRooms(): List = rooms.getRooms().sortedBy { it.roomId }.map { RoomDto(it) } + fun getRooms(): List = rooms + .getRooms() + .sortedBy { it.roomId } + .map { + RoomDto( + room = it, + yourUser = null + ) + } } diff --git a/api/src/main/kotlin/pp/api/Util.kt b/api/src/main/kotlin/pp/api/Util.kt index 660ac8e..8379734 100644 --- a/api/src/main/kotlin/pp/api/Util.kt +++ b/api/src/main/kotlin/pp/api/Util.kt @@ -9,8 +9,11 @@ import java.nio.charset.StandardCharsets import java.time.LocalTime import java.time.LocalTime.now import java.time.temporal.ChronoUnit.MILLIS +import kotlin.random.Random import kotlin.time.Duration.Companion.minutes +private val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray() + /** * Parses a query string. * @@ -40,3 +43,17 @@ fun parseQuery(query: String?): Map { * @return a time 3 minutes from [LocalTime.now] */ fun threeMinutesFromNow(): LocalTime = now().plus(3.minutes.inWholeMilliseconds, MILLIS) + +/** + * Generate a random string of given length. + * + * @param length length of the string to generate + * @return a randome string of the given length, consisting of A-Za-z0-9 + */ +fun generateRandomId(length: Int = 6): String { + val sb = StringBuilder(length) + repeat(length) { + sb.append(chars[Random.nextInt(chars.size)]) + } + return sb.toString() +} diff --git a/api/src/main/kotlin/pp/api/data/GamePhase.kt b/api/src/main/kotlin/pp/api/data/GamePhase.kt index 9ad7d6d..9929511 100644 --- a/api/src/main/kotlin/pp/api/data/GamePhase.kt +++ b/api/src/main/kotlin/pp/api/data/GamePhase.kt @@ -1,21 +1,94 @@ +/** + * This file contains all classes that represent a games phase. + */ + package pp.api.data +import java.util.Locale.US + +/** + * A thing thad plays a card + * + * @property username name of the user at the time the cards were revealed. + * Note that the user might have changed its name afterwards. + * @property userId id of the user that played the card. + * Note that the user might have left the [Room] already. + */ +data class CardPlayer( + val username: String, + val userId: String, +) { + constructor(user: User) : this(user.username, user.id) +} + +/** + * A single card laying on the table at the time the cards in a [Room] were revealed. + * + * @property playedBy user that played the card + * @property value value of the card + */ +data class Card( + val playedBy: CardPlayer, + val value: String?, +) + +/** + * A games result + * + * @property cards the cards that were played + * @property average average value of the cards + */ +data class GameResult( + val cards: List, + val average: String, +) + /** * Phase the pp game is in. * * The game phase restrict what users can do. While some actions (eg. playing a card) are only allowed during a specific * game phase, other actions (eg. sending a chat message) are independent of the phase. */ -enum class GamePhase { +sealed class GamePhase { /** * In this phase, users can play cards or change the phase to [CardsRevealed]. Users cannot see any other players * played cards */ - PLAYING, + data object Playing : GamePhase() /** * In this phase, players cannot play cards but only observe the results or change the phase to [Playing] + * + * @property gameResult */ - CARDS_REVEALED, - ; + data class CardsRevealed( + val gameResult: GameResult, + ) : GamePhase() { + constructor(room: Room) : this( + GameResult( + cards = room.users.filter { it.userType == UserType.PARTICIPANT } + .map { + Card( + playedBy = CardPlayer(it), + value = it.cardValue + ) + }, + average = if (1 == room.participants + .groupBy { it.cardValue }.size && room.users.first().cardValue != null + ) { + room.participants.first().cardValue!! + } else { + val hasSomeNoInt = room.participants.any { it.cardValue?.toIntOrNull() == null } + room.users + .mapNotNull { + it.cardValue?.toIntOrNull() + } + .average() + .run { + "%.1f".format(US, this) + (if (hasSomeNoInt) " (?)" else "") + } + } + ) + ) + } } diff --git a/api/src/main/kotlin/pp/api/data/Room.kt b/api/src/main/kotlin/pp/api/data/Room.kt index 6e2e9f4..64a269b 100644 --- a/api/src/main/kotlin/pp/api/data/Room.kt +++ b/api/src/main/kotlin/pp/api/data/Room.kt @@ -2,7 +2,7 @@ package pp.api.data import io.quarkus.logging.Log import jakarta.websocket.Session -import pp.api.data.GamePhase.PLAYING +import pp.api.data.GamePhase.Playing import pp.api.data.UserType.PARTICIPANT /** @@ -25,7 +25,7 @@ class Room( val roomId: String, val users: List = listOf(), val deck: List = listOf("1", "2", "3", "5", "8", "13", "☕"), - val gamePhase: GamePhase = PLAYING, + val gamePhase: GamePhase = Playing, val log: List = emptyList(), ) { /** @@ -100,6 +100,20 @@ class Room( ) } + /** + * Create a copy of this room, with the cards revealed by the given [user] + * + * @param user the [User] that change the phase + * @return a copy of this room, with the cards revealed + */ + infix fun withCardsRevealedBy(user: User): Room = if (gamePhase is Playing) { + copy( + gamePhase = GamePhase.CardsRevealed(this) + ) withInfo "${user.username} revealed the cards" + } else { + this + } + /** * Crate a copy of this room, with the given message added as [LogEntry] with level [LogLevel.INFO] * diff --git a/api/src/main/kotlin/pp/api/data/User.kt b/api/src/main/kotlin/pp/api/data/User.kt index 5549a68..121211d 100644 --- a/api/src/main/kotlin/pp/api/data/User.kt +++ b/api/src/main/kotlin/pp/api/data/User.kt @@ -2,6 +2,7 @@ package pp.api.data import jakarta.websocket.Session import pp.api.data.UserType.SPECTATOR +import pp.api.generateRandomId import pp.api.parseQuery import pp.api.threeMinutesFromNow import java.time.LocalTime @@ -783,6 +784,11 @@ data class User( val session: Session, var connectionDeadline: LocalTime = threeMinutesFromNow(), ) { + /** + * This user's unique id + */ + val id: String = generateRandomId() + /** * Create a new [User] for the given [Session] * diff --git a/api/src/main/kotlin/pp/api/dto/ClientGamePhase.kt b/api/src/main/kotlin/pp/api/dto/ClientGamePhase.kt new file mode 100644 index 0000000..83035c2 --- /dev/null +++ b/api/src/main/kotlin/pp/api/dto/ClientGamePhase.kt @@ -0,0 +1,26 @@ +package pp.api.dto + +import pp.api.data.GamePhase + +/** + * Game phase as presented to clients + */ +enum class ClientGamePhase { + PLAYING, + CARDS_REVEALED, + ; + + companion object { + /** + * Determine the [ClientGamePhase] for a given [GamePhase] + * + * @param gamePhase a [GamePhase] + * @return [PLAYING], if [gamePhase] is [GamePhase.Playing], else [CARDS_REVEALED] + */ + operator fun invoke(gamePhase: GamePhase): ClientGamePhase = + when (gamePhase) { + is GamePhase.Playing -> PLAYING + is GamePhase.CardsRevealed -> CARDS_REVEALED + } + } +} diff --git a/api/src/main/kotlin/pp/api/dto/RoomDto.kt b/api/src/main/kotlin/pp/api/dto/RoomDto.kt index c1da670..a06ee22 100644 --- a/api/src/main/kotlin/pp/api/dto/RoomDto.kt +++ b/api/src/main/kotlin/pp/api/dto/RoomDto.kt @@ -2,11 +2,10 @@ package pp.api.dto import io.quarkus.runtime.annotations.RegisterForReflection import pp.api.data.GamePhase -import pp.api.data.GamePhase.CARDS_REVEALED +import pp.api.data.GameResult import pp.api.data.LogEntry import pp.api.data.Room import pp.api.data.User -import java.util.Locale.US /** * State of a room as presented to clients @@ -18,45 +17,38 @@ import java.util.Locale.US * @property deck card values that are playable in this room * @property gamePhase [GamePhase] the room is currently in * @property average represents the average of the card values played. Will only show real data if [gamePhase] is - * [CARDS_REVEALED] + * [GamePhase.CardsRevealed] * @property log list of [LogEntry]s for this rooms + * @property gameResult result of the current round will be null if [gamePhase] is [ClientGamePhase.PLAYING] */ // see https://quarkus.io/guides/writing-native-applications-tips#registerForReflection @RegisterForReflection(registerFullHierarchy = true) data class RoomDto( val roomId: String, val deck: List, - val gamePhase: GamePhase, + val gamePhase: ClientGamePhase, val users: List, val average: String, val log: List, + val gameResult: GameResult?, ) { constructor(room: Room, yourUser: User? = null) : this( roomId = room.roomId, deck = room.deck, - gamePhase = room.gamePhase, - users = room.users.map { user -> - UserDto(user, isYourUser = user == yourUser, room.gamePhase) - }.sortedBy { it.username }, - average = (if (room.gamePhase == CARDS_REVEALED) { - if (1 == room.participants - .groupBy { it.cardValue }.size && room.users.first().cardValue != null - ) { - room.participants.first().cardValue!! - } else { - val hasSomeNoInt = room.participants.any { it.cardValue?.toIntOrNull() == null } - room.users - .mapNotNull { - it.cardValue?.toIntOrNull() - } - .average() - .run { - "%.1f".format(US, this) + (if (hasSomeNoInt) " (?)" else "") - } + gamePhase = ClientGamePhase(room.gamePhase), + users = room.users + .map { user -> + UserDto(user, isYourUser = user == yourUser, room.gamePhase) } - } else { - "?" - }), + .sortedBy { it.username }, + average = when (room.gamePhase) { + is GamePhase.CardsRevealed -> room.gamePhase.gameResult.average + else -> "?" + }, log = room.log, + gameResult = when (room.gamePhase) { + is GamePhase.CardsRevealed -> room.gamePhase.gameResult + else -> null + } ) } diff --git a/api/src/main/kotlin/pp/api/dto/UserDto.kt b/api/src/main/kotlin/pp/api/dto/UserDto.kt index 3d09465..b70ed61 100644 --- a/api/src/main/kotlin/pp/api/dto/UserDto.kt +++ b/api/src/main/kotlin/pp/api/dto/UserDto.kt @@ -1,7 +1,6 @@ package pp.api.dto import pp.api.data.GamePhase -import pp.api.data.GamePhase.CARDS_REVEALED import pp.api.data.User import pp.api.data.UserType import pp.api.data.UserType.SPECTATOR @@ -14,12 +13,14 @@ import pp.api.data.UserType.SPECTATOR * @property isYourUser `true` if this user is "you" - the user associated with the session that receives this message * @property cardValue value of the card played by this user. Will only be shown if `isYourUser == true` or gamePhase of * the room is `CARDS_REVEALED`. Else, `✅` will be shown to indicate the user has played a card or `❌` if it hasn't. + * @property id the user's unique id */ data class UserDto( val username: String, val userType: UserType, val isYourUser: Boolean, val cardValue: String, + val id: String, ) { constructor( user: User, @@ -31,12 +32,13 @@ data class UserDto( isYourUser = isYourUser, cardValue = if (user.userType == SPECTATOR) { "" - } else if (gamePhase == CARDS_REVEALED || isYourUser) { + } else if (gamePhase is GamePhase.CardsRevealed || isYourUser) { user.cardValue ?: "" } else { user.cardValue?.let { "✅" } ?: "❌" }, + id = user.id, ) } diff --git a/api/src/test/kotlin/pp/api/RoomTest.kt b/api/src/test/kotlin/pp/api/RoomTest.kt index 426d6c1..460e4a5 100644 --- a/api/src/test/kotlin/pp/api/RoomTest.kt +++ b/api/src/test/kotlin/pp/api/RoomTest.kt @@ -10,7 +10,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.mockito.Mockito.mock import org.mockito.kotlin.whenever -import pp.api.data.GamePhase.CARDS_REVEALED +import pp.api.data.GamePhase.Playing import pp.api.data.LogEntry import pp.api.data.LogLevel import pp.api.data.Room @@ -49,14 +49,14 @@ class RoomTest { roomId = "new-id", users = listOf(user), deck = deck, - gamePhase = CARDS_REVEALED, + gamePhase = Playing, log = log ) assertNotEquals(room, copy) assertEquals(copy.roomId, "new-id") assertEquals(copy.users, listOf(user)) assertEquals(copy.deck, deck) - assertEquals(copy.gamePhase, CARDS_REVEALED) + assertEquals(copy.gamePhase, Playing) assertEquals(copy.log, log) } @@ -107,13 +107,13 @@ class RoomTest { roomId = "nice-id", users = listOf(user), deck = listOf("nice", "card"), - gamePhase = CARDS_REVEALED, + gamePhase = Playing, log = listOf(info("nice message")) ) val roomWithoutUser = room.minus(session) assertTrue(roomWithoutUser.isEmpty()) assertEquals(listOf("nice", "card"), roomWithoutUser.deck) - assertEquals(CARDS_REVEALED, roomWithoutUser.gamePhase) + assertEquals(Playing, roomWithoutUser.gamePhase) assertEquals(listOf(info("nice message")), roomWithoutUser.log) val unknownSession = mock(Session::class.java) diff --git a/api/src/test/kotlin/pp/api/RoomsResourceTest.kt b/api/src/test/kotlin/pp/api/RoomsResourceTest.kt index baf2b44..b560817 100644 --- a/api/src/test/kotlin/pp/api/RoomsResourceTest.kt +++ b/api/src/test/kotlin/pp/api/RoomsResourceTest.kt @@ -53,19 +53,19 @@ class RoomsResourceTest { } @Test + @Suppress("TOO_LONG_FUNCTION", "I think its ok :D") fun getRoomsWithUser() { + val user = User( + username = "username", + userType = PARTICIPANT, + cardValue = "19", + session = mock(), + ) whenever(rooms.getRooms()).thenReturn( setOf( Room( roomId = "roomId", - users = listOf( - User( - username = "username", - userType = PARTICIPANT, - cardValue = "19", - session = mock(), - ) - ) + users = listOf(user) ) ) ) @@ -79,7 +79,50 @@ class RoomsResourceTest { equalTo( """[{"roomId":"roomId","deck":["1","2","3","5","8","13","☕"], |"gamePhase":"PLAYING","users":[{"username":"username","userType":"PARTICIPANT", - |"isYourUser":false,"cardValue":"✅"}],"average":"?","log":[]}]""".trimMargin().replace("\n", "") + |"isYourUser":false,"cardValue":"✅","id":"${user.id}"}],"average":"?","log":[],"gameResult":null}]""" + .trimMargin() + .replace("\n", "") + ) + ) + } + + @Test + @Suppress("TOO_LONG_FUNCTION", "I think its ok :D") + fun getRoomsWithRevealedCards() { + val user = User( + username = "username", + userType = PARTICIPANT, + cardValue = "19", + session = mock(), + ) + whenever(rooms.getRooms()).thenReturn( + setOf( + Room( + roomId = "roomId", + ) withUser user withCardsRevealedBy user + ) + ) + + given() + .get() + .then() + .statusCode(200) + .contentType(JSON) + .body( + equalTo( + """[{"roomId":"roomId", + |"deck":["1","2","3","5","8","13","☕"], + |"gamePhase":"CARDS_REVEALED", + |"users":[ + |{"username":"username","userType":"PARTICIPANT", + |"isYourUser":false,"cardValue":"19","id":"${user.id}"}], + |"average":"19", + |"log":[{"level":"INFO","message":"username revealed the cards"}], + |"gameResult":{"cards":[{"playedBy":{"username":"username","userId":"${user.id}"}, + |"value":"19"}], + |"average":"19"}}]""" + .trimMargin() + .replace("\n", "") ) ) } diff --git a/api/src/test/kotlin/pp/api/RoomsTest.kt b/api/src/test/kotlin/pp/api/RoomsTest.kt index 146ff55..4c8928f 100644 --- a/api/src/test/kotlin/pp/api/RoomsTest.kt +++ b/api/src/test/kotlin/pp/api/RoomsTest.kt @@ -14,8 +14,8 @@ import org.mockito.Mockito.mock import org.mockito.kotlin.whenever import pp.api.data.ChangeName import pp.api.data.ChatMessage -import pp.api.data.GamePhase.CARDS_REVEALED -import pp.api.data.GamePhase.PLAYING +import pp.api.data.GamePhase.CardsRevealed +import pp.api.data.GamePhase.Playing import pp.api.data.LogEntry import pp.api.data.LogLevel.CHAT import pp.api.data.LogLevel.INFO @@ -337,9 +337,9 @@ class RoomsTest { whenever(session.id).thenReturn("new-session-id") rooms.ensureRoomContainsUser("nice-id", user) - assertEquals(PLAYING, rooms.getRooms().first().gamePhase) + assertEquals(Playing, rooms.getRooms().first().gamePhase) rooms.submitUserRequest(RevealCards(), session) - assertEquals(CARDS_REVEALED, rooms.getRooms().first().gamePhase) + assertTrue(rooms.getRooms().first().gamePhase is CardsRevealed) // Playing cards should not be possible now rooms.submitUserRequest(PlayCard("nice card"), session) @@ -358,7 +358,7 @@ class RoomsTest { } @Test - fun submitUserRevealCardsWhenAlreadyRevealedAddsInfoMessage() { + fun submitUserRevealCardsWhenAlreadyRevealedDoesNothing() { val rooms = Rooms() val remote = mock(Async::class.java) val session = mock(Session::class.java) @@ -368,16 +368,14 @@ class RoomsTest { whenever(session.id).thenReturn("new-session-id") rooms.ensureRoomContainsUser("nice-id", user) rooms.submitUserRequest(RevealCards(), session) - assertEquals(CARDS_REVEALED, rooms.getRooms().first().gamePhase) + assertTrue(rooms.getRooms().first().gamePhase is CardsRevealed) // revealing when already revealed should do nothing rooms.submitUserRequest(RevealCards(), session) - assertEquals(CARDS_REVEALED, rooms.getRooms().first().gamePhase) + assertTrue(rooms.getRooms().first().gamePhase is CardsRevealed) assertEquals( - 3, rooms.getRooms().first().log + 2, rooms.getRooms().first().log .size ) - assertTrue(rooms.getRooms().first().log - .any { it.level == INFO && "tried to change game phase" in it.message }) } @Test @@ -394,7 +392,7 @@ class RoomsTest { val unknownSession = mock(Session::class.java) whenever(unknownSession.id).thenReturn("unknown-session-id") rooms.submitUserRequest(RevealCards(), unknownSession) - assertEquals(PLAYING, rooms.getRooms().first().gamePhase) + assertEquals(Playing, rooms.getRooms().first().gamePhase) } @Test @@ -408,9 +406,9 @@ class RoomsTest { whenever(session.id).thenReturn("new-session-id") rooms.ensureRoomContainsUser("nice-id", user) - assertEquals(PLAYING, rooms.getRooms().first().gamePhase) + assertEquals(Playing, rooms.getRooms().first().gamePhase) rooms.submitUserRequest(RevealCards(), session) - assertEquals(CARDS_REVEALED, rooms.getRooms().first().gamePhase) + assertTrue(rooms.getRooms().first().gamePhase is CardsRevealed) rooms.submitUserRequest(StartNewRound(), session) assertNull( rooms.getRooms().first().users @@ -435,17 +433,15 @@ class RoomsTest { rooms.ensureRoomContainsUser("nice-id", user) // starting a new round when already playing should do nothing rooms.submitUserRequest(StartNewRound(), session) - assertEquals(PLAYING, rooms.getRooms().first().gamePhase) + assertEquals(Playing, rooms.getRooms().first().gamePhase) assertEquals( "7", rooms.getRooms().first().users .first().cardValue ) assertEquals( - 2, rooms.getRooms().first().log + 1, rooms.getRooms().first().log .size ) - assertTrue(rooms.getRooms().first().log - .any { it.level == INFO && "tried to change game phase" in it.message }) } @Test diff --git a/api/src/test/kotlin/pp/api/dto/RoomDtoTest.kt b/api/src/test/kotlin/pp/api/dto/RoomDtoTest.kt index 7c7dd6b..b34467b 100644 --- a/api/src/test/kotlin/pp/api/dto/RoomDtoTest.kt +++ b/api/src/test/kotlin/pp/api/dto/RoomDtoTest.kt @@ -2,15 +2,16 @@ package pp.api.dto import jakarta.websocket.Session import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test import org.mockito.Mockito.mock import org.mockito.kotlin.whenever -import pp.api.data.GamePhase.CARDS_REVEALED -import pp.api.data.GamePhase.PLAYING +import pp.api.data.GamePhase.Playing import pp.api.data.LogEntry import pp.api.data.Room import pp.api.data.User import pp.api.data.UserType.PARTICIPANT +import pp.api.dto.ClientGamePhase.PLAYING class RoomDtoTest { @Test @@ -23,19 +24,20 @@ class RoomDtoTest { val otherUser = User("other", PARTICIPANT, "7", otherSession) val room = Room( roomId = "nice id", - gamePhase = PLAYING + gamePhase = Playing ) withUser itsYou withUser otherUser val dto = RoomDto(room, itsYou) assertEquals(room.roomId, dto.roomId) - assertEquals(room.gamePhase, dto.gamePhase) + assertEquals(PLAYING, dto.gamePhase) assertEquals( listOf( - UserDto("name", PARTICIPANT, true, "13"), - UserDto("other", PARTICIPANT, false, "✅"), + UserDto("name", PARTICIPANT, true, "13", itsYou.id), + UserDto("other", PARTICIPANT, false, "✅", otherUser.id), ), dto.users ) assertEquals("?", dto.average) + assertNull(dto.gameResult) assertEquals(emptyList(), dto.log) } @@ -49,10 +51,9 @@ class RoomDtoTest { val otherUser = User("other", PARTICIPANT, "2", otherSession) val room = Room( roomId = "nice id", - gamePhase = CARDS_REVEALED - ) withUser itsYou withUser otherUser + ) withUser itsYou withUser otherUser withCardsRevealedBy itsYou val dto = RoomDto(room, itsYou) - assertEquals("3.0", dto.average) + assertEquals("3.0", dto.gameResult?.average) } @Test @@ -65,8 +66,8 @@ class RoomDtoTest { val otherUser = User("other", PARTICIPANT, "\uD83C\uDF54", otherSession) val room = Room( roomId = "nice id", - gamePhase = CARDS_REVEALED - ) withUser itsYou withUser otherUser + gamePhase = Playing, + ) withUser itsYou withUser otherUser withCardsRevealedBy itsYou val dto = RoomDto(room, itsYou) assertEquals("\uD83C\uDF54", dto.average) } @@ -81,8 +82,8 @@ class RoomDtoTest { val otherUser = User("other", PARTICIPANT, null, otherSession) val room = Room( roomId = "nice id", - gamePhase = CARDS_REVEALED - ) withUser itsYou withUser otherUser + gamePhase = Playing + ) withUser itsYou withUser otherUser withCardsRevealedBy itsYou val dto = RoomDto(room, itsYou) assertEquals("NaN (?)", dto.average) } diff --git a/api/src/test/kotlin/pp/api/dto/UserDtoTest.kt b/api/src/test/kotlin/pp/api/dto/UserDtoTest.kt index 4b3aa9e..f09eb45 100644 --- a/api/src/test/kotlin/pp/api/dto/UserDtoTest.kt +++ b/api/src/test/kotlin/pp/api/dto/UserDtoTest.kt @@ -4,7 +4,7 @@ import jakarta.websocket.Session import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.mockito.Mockito -import pp.api.data.GamePhase.PLAYING +import pp.api.data.GamePhase.Playing import pp.api.data.User import pp.api.data.UserType.PARTICIPANT @@ -13,12 +13,12 @@ class UserDtoTest { fun secondaryConstructor() { val session = Mockito.mock(Session::class.java) val user = User("name", PARTICIPANT, "13", session) - val userDto = UserDto(user, false, PLAYING) + val userDto = UserDto(user, false, Playing) assertEquals("✅", userDto.cardValue) - val yourUserDto = UserDto(user, true, PLAYING) + val yourUserDto = UserDto(user, true, Playing) assertEquals("13", yourUserDto.cardValue) val noCardUser = User("name", PARTICIPANT, null, session) - val noCardUserDto = UserDto(noCardUser, false, PLAYING) + val noCardUserDto = UserDto(noCardUser, false, Playing) assertEquals("❌", noCardUserDto.cardValue) } } From 544ce1b03cece11f2dcec04da8ada2a126cda505 Mon Sep 17 00:00:00 2001 From: Cornelius Wichering Date: Wed, 12 Jun 2024 22:27:50 +0200 Subject: [PATCH 2/6] feat: display Room as title above room name --- client/tui.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/tui.go b/client/tui.go index f87bb01..57413f2 100644 --- a/client/tui.go +++ b/client/tui.go @@ -68,6 +68,8 @@ func (tui *TUI) createHeader() (*tview.Flex, []inputCapturer) { title := tview.NewTextView(). SetDynamicColors(true) title.SetBorder(true) + title.SetTitle("Room") + title.SetTitleAlign(tview.AlignLeft) title.SetText(tui.Room.RoomID) copyButton := tview.NewButton("Copy room name") From dcb7f88c2f500904fd29d13a0c02517611544ec9 Mon Sep 17 00:00:00 2001 From: Cornelius Wichering Date: Wed, 12 Jun 2024 22:30:48 +0200 Subject: [PATCH 3/6] fix: exit gracefully on websocket error Previously, the client just panicked - and by not shutting down the tui normally, left the console in shambles. --- client/ppwsclient.go | 10 ++++++---- client/root.go | 6 +++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/client/ppwsclient.go b/client/ppwsclient.go index de980ad..9f40e39 100644 --- a/client/ppwsclient.go +++ b/client/ppwsclient.go @@ -13,13 +13,14 @@ type PpWsClient struct { wsURL string room *Room onUpdate func() + onError func(err error) connection *websocket.Conn channel chan interface{} } // New creates a new PpWsClient with a given ws URL. -func New(wsURL string, room *Room, onUpdate func()) *PpWsClient { - return &PpWsClient{wsURL, room, onUpdate, nil, nil} +func New(wsURL string, room *Room, onUpdate func(), onError func(err error)) *PpWsClient { + return &PpWsClient{wsURL, room, onUpdate, onError, nil, nil} } // Start runs the pp client. @@ -48,10 +49,11 @@ func (client *PpWsClient) Start() error { }() for { err = c.ReadJSON(client.room) - client.onUpdate() if err != nil { // ignore.coverage - log.Panic(err) // ignore.coverage + client.onError(err) // ignore.coverage + return err } + client.onUpdate() } } diff --git a/client/root.go b/client/root.go index 41848c9..10c4147 100644 --- a/client/root.go +++ b/client/root.go @@ -53,7 +53,11 @@ with the given id.` + printHeader() roomWebsocketURL := getWsURL() ui := NewTUI() - client := New(roomWebsocketURL, ui.Room, ui.OnUpdate) + onError := func(err error) { + ui.App.Stop() + fmt.Printf("Error: %s\n", err) + } + client := New(roomWebsocketURL, ui.Room, ui.OnUpdate, onError) ui.WsClient = client // Having an actual ui and websocket client run doesn't work in tests since there is no one to stop the app _, isTest := os.LookupEnv("SUB_CMD_FLAGS") From 5469d0c345bec2e2f8a1dda675b5d6adcc04732d Mon Sep 17 00:00:00 2001 From: Cornelius Wichering Date: Wed, 12 Jun 2024 22:43:35 +0200 Subject: [PATCH 4/6] feat: disable buttons that do nothing in the current phase of the game For example, there's nothing to reveal, if the cards are already revealed. --- client/data.go | 9 +++++---- client/tui.go | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/data.go b/client/data.go index aff8265..57f8c42 100644 --- a/client/data.go +++ b/client/data.go @@ -99,8 +99,9 @@ type User struct { // Room is a planning poker room, including all participants and a state type Room struct { - RoomID string `json:"roomId"` - Deck []string `json:"deck"` - Users []*User `json:"users"` - Average string `json:"average"` + RoomID string `json:"roomId"` + Deck []string `json:"deck"` + GamePhase string `json:"gamePhase"` + Users []*User `json:"users"` + Average string `json:"average"` } diff --git a/client/tui.go b/client/tui.go index 57413f2..bbd5294 100644 --- a/client/tui.go +++ b/client/tui.go @@ -205,11 +205,13 @@ func (tui *TUI) createActionsArea() (*tview.Flex, []inputCapturer) { revealButton := tview.NewButton("Reveal").SetSelectedFunc(func() { tui.WsClient.SendMessage(RevealCards()) }) + revealButton.SetDisabled(tui.Room.GamePhase == "CARDS_REVEALED") inputs = append(inputs, revealButton) rows.AddItem(revealButton, 3, 1, false) newRoundButton := tview.NewButton("New Round").SetSelectedFunc(func() { tui.WsClient.SendMessage(StartNewRound()) }) + newRoundButton.SetDisabled(tui.Room.GamePhase == "PLAYING") inputs = append(inputs, newRoundButton) rows.AddItem(newRoundButton, 3, 1, false) rows.AddItem(nil, 0, 10, false) From 5acad37ebec2aa98c9ab8aa83e106cafdd807c42 Mon Sep 17 00:00:00 2001 From: Cornelius Wichering Date: Wed, 12 Jun 2024 23:10:33 +0200 Subject: [PATCH 5/6] build(deps): bump quarkus 3.10.1 -> 3.11.1 --- api/gradle.properties | 4 ++-- api/gradle/libs.versions.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/gradle.properties b/api/gradle.properties index 2620506..1e2dd3e 100644 --- a/api/gradle.properties +++ b/api/gradle.properties @@ -3,6 +3,6 @@ quarkus.analytics.disabled=true quarkusPlatformArtifactId=quarkus-bom quarkusPlatformGroupId=io.quarkus.platform -quarkusPlatformVersion=3.10.1 +quarkusPlatformVersion=3.11.1 quarkusPluginId=io.quarkus -quarkusPluginVersion=3.10.1 +quarkusPluginVersion=3.11.1 diff --git a/api/gradle/libs.versions.toml b/api/gradle/libs.versions.toml index 732ea05..01bc301 100644 --- a/api/gradle/libs.versions.toml +++ b/api/gradle/libs.versions.toml @@ -2,7 +2,7 @@ kotlin = "2.0.0" spotless = "6.25.0" detekt = "1.23.6" -quarkus = "3.10.1" +quarkus = "3.11.1" hamcrest-json = "0.3" mockito = "5.3.1" mockito-inline = "5.2.0" From 85c48fb10acf0a5a75401eeff6a812915e29b7b0 Mon Sep 17 00:00:00 2001 From: Cornelius Wichering Date: Wed, 12 Jun 2024 23:34:32 +0200 Subject: [PATCH 6/6] feat: make game result stable In the past, if a user left (or joined) while the cards were revealed, the average would change since it was always computed from the "current" users. Now, the client will show data from "gameResult" room property which is computed the exact moment the cards are revealed and will not change even if users join or leave --- api/src/main/kotlin/pp/api/data/GamePhase.kt | 6 ++-- .../test/kotlin/pp/api/RoomsResourceTest.kt | 2 +- client/data.go | 31 +++++++++++++--- client/tui.go | 35 ++++++++++++++----- 4 files changed, 57 insertions(+), 17 deletions(-) diff --git a/api/src/main/kotlin/pp/api/data/GamePhase.kt b/api/src/main/kotlin/pp/api/data/GamePhase.kt index 9929511..54762b9 100644 --- a/api/src/main/kotlin/pp/api/data/GamePhase.kt +++ b/api/src/main/kotlin/pp/api/data/GamePhase.kt @@ -11,12 +11,12 @@ import java.util.Locale.US * * @property username name of the user at the time the cards were revealed. * Note that the user might have changed its name afterwards. - * @property userId id of the user that played the card. + * @property id id of the user that played the card. * Note that the user might have left the [Room] already. */ data class CardPlayer( val username: String, - val userId: String, + val id: String, ) { constructor(user: User) : this(user.username, user.id) } @@ -66,7 +66,7 @@ sealed class GamePhase { ) : GamePhase() { constructor(room: Room) : this( GameResult( - cards = room.users.filter { it.userType == UserType.PARTICIPANT } + cards = room.users.sortedBy { it.username }.filter { it.userType == UserType.PARTICIPANT } .map { Card( playedBy = CardPlayer(it), diff --git a/api/src/test/kotlin/pp/api/RoomsResourceTest.kt b/api/src/test/kotlin/pp/api/RoomsResourceTest.kt index b560817..7fa18ef 100644 --- a/api/src/test/kotlin/pp/api/RoomsResourceTest.kt +++ b/api/src/test/kotlin/pp/api/RoomsResourceTest.kt @@ -118,7 +118,7 @@ class RoomsResourceTest { |"isYourUser":false,"cardValue":"19","id":"${user.id}"}], |"average":"19", |"log":[{"level":"INFO","message":"username revealed the cards"}], - |"gameResult":{"cards":[{"playedBy":{"username":"username","userId":"${user.id}"}, + |"gameResult":{"cards":[{"playedBy":{"username":"username","id":"${user.id}"}, |"value":"19"}], |"average":"19"}}]""" .trimMargin() diff --git a/client/data.go b/client/data.go index 57f8c42..2abb1ac 100644 --- a/client/data.go +++ b/client/data.go @@ -91,17 +91,38 @@ func StartNewRound() NoDetailsAction { // User is participant in a planning poker type User struct { + ID string `json:"id"` Username string `json:"username"` UserType UserType `json:"userType"` YourUser bool `json:"yourUser"` CardValue string `json:"cardValue"` } +// CardPlayer is card player as part of a game result. This is not the same as a User since the user might have left the +// game +type CardPlayer struct { + Username string `json:"username"` + ID string `json:"id"` +} + +// PlayedCard describes a card that was revealed +type PlayedCard struct { + PlayedBy *CardPlayer `json:"playedBy"` + Value string `json:"value"` +} + +// GameResult represents the result of a single round. +type GameResult struct { + Cards []*PlayedCard `json:"cards"` + Average string `json:"average"` +} + // Room is a planning poker room, including all participants and a state type Room struct { - RoomID string `json:"roomId"` - Deck []string `json:"deck"` - GamePhase string `json:"gamePhase"` - Users []*User `json:"users"` - Average string `json:"average"` + RoomID string `json:"roomId"` + Deck []string `json:"deck"` + GamePhase string `json:"gamePhase"` + GameResult GameResult `json:"gameResult"` + Users []*User `json:"users"` + Average string `json:"average"` } diff --git a/client/tui.go b/client/tui.go index bbd5294..0b4afc0 100644 --- a/client/tui.go +++ b/client/tui.go @@ -112,18 +112,37 @@ func (tui *TUI) createQuitButton() *tview.Button { func (tui *TUI) createUsersTable() *tview.Flex { usernames := "" cardValues := "" - for _, user := range tui.Room.Users { - if user.UserType == Participant { - usernames += user.Username + if tui.Room.GamePhase == "PLAYING" { + for _, user := range tui.Room.Users { + if user.UserType == Participant { + usernames += user.Username + if user.YourUser { + usernames += " (*)" + } + usernames += "\n" + if user.CardValue == "" { + cardValues += "?\n" + } else { + cardValues += user.CardValue + "\n" + } + } + } + } + if tui.Room.GamePhase == "CARDS_REVEALED" { + var myUserID string + for _, user := range tui.Room.Users { if user.YourUser { + myUserID = user.ID + break + } + } + for _, card := range tui.Room.GameResult.Cards { + usernames += card.PlayedBy.Username + if card.PlayedBy.ID == myUserID { usernames += " (*)" } usernames += "\n" - if user.CardValue == "" { - cardValues += "?\n" - } else { - cardValues += user.CardValue + "\n" - } + cardValues += card.Value + "\n" } } usersText := tview.NewTextView().