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" 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..54762b9 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 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 id: 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.sortedBy { it.username }.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..7fa18ef 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","id":"${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) } } diff --git a/client/data.go b/client/data.go index aff8265..2abb1ac 100644 --- a/client/data.go +++ b/client/data.go @@ -91,16 +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"` - 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/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") diff --git a/client/tui.go b/client/tui.go index f87bb01..0b4afc0 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") @@ -110,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(). @@ -203,11 +224,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)