diff --git a/assets/DataBaseArchitecture.md b/assets/DataBaseArchitecture.md new file mode 100644 index 0000000..db86c1a --- /dev/null +++ b/assets/DataBaseArchitecture.md @@ -0,0 +1,44 @@ +classDiagram + direction TB + + class GameRepository { + -SwintusDao dao + +saveGameResult(winnerId, winnerName, totalTurns, profiles) List~PlayerProfile~ + +loadLeaderboard() List~PlayerProfile~ + } + + class SwintusDao { + -Connection connection + +getAllPlayersStats() List~PlayerStatsEntity~ + +insertPlayerStats(entity PlayerStatsEntity) + +updatePlayerStats(entity PlayerStatsEntity) + +insertGameHistory(entity GameHistoryEntity) + } + + class PlayerStatsEntity { + +UUID playerId + +String username + +int rating + +int gamesPlayed + +int wins + } + + class GameHistoryEntity { + +UUID gameId + +UUID winnerId + +int totalTurns + +String dateTime + } + + class SQLiteDatabase { + <> + +swintus.db + -- Tables -- + +player_stats + +game_history + } + + GameRepository o-- SwintusDao + SwintusDao --> SQLiteDatabase + SwintusDao ..> PlayerStatsEntity + SwintusDao ..> GameHistoryEntity \ No newline at end of file diff --git a/assets/Screenshot at May 23 09-56-45.png b/assets/Screenshot at May 23 09-56-45.png new file mode 100644 index 0000000..90b82a9 Binary files /dev/null and b/assets/Screenshot at May 23 09-56-45.png differ diff --git a/build.gradle.kts b/build.gradle.kts index 4b5c728..b982370 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,11 @@ plugins { kotlin("jvm") version "1.9.22" + id("org.jetbrains.compose") version "1.6.10" application } repositories { + google() mavenCentral() } @@ -11,6 +13,8 @@ application { mainClass.set("MainKt") } -kotlin { - jvmToolchain(21) -} +dependencies { + implementation(compose.desktop.currentOs) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") + implementation("org.xerial:sqlite-jdbc:3.45.2.0") +} \ No newline at end of file diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index e95707a..eb5deec 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -1,3 +1,19 @@ -fun main() { - println("Hello, world!") +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import data.local.dao.SwintusDao +import data.repository.GameRepository +import view.Application +import viewmodel.SwintusViewModel + +fun main() = application { + val dao = SwintusDao() + val repository = GameRepository(dao) + val viewModel = SwintusViewModel(repository) + + Window( + onCloseRequest = ::exitApplication, + title = "Свинтус" + ) { + Application(viewModel = viewModel) + } } \ No newline at end of file diff --git a/src/main/kotlin/data/local/dao/SwintusDao.kt b/src/main/kotlin/data/local/dao/SwintusDao.kt new file mode 100644 index 0000000..1eb3de6 --- /dev/null +++ b/src/main/kotlin/data/local/dao/SwintusDao.kt @@ -0,0 +1,141 @@ +package data.local.dao + +import data.local.entities.PlayerStatsEntity +import data.local.entities.GameHistoryEntity +import java.io.File +import java.sql.Connection +import java.sql.DriverManager + +class SwintusDao { + private val dbFile = File(System.getProperty("user.home"), ".swintus_game.db") + + private fun getConnection(): Connection { + return DriverManager.getConnection("jdbc:sqlite:${dbFile.absolutePath}") + } + + init { + getConnection().use { conn -> + conn.createStatement().use { stmt -> + stmt.execute(""" + CREATE TABLE IF NOT EXISTS player_stats ( + playerId TEXT PRIMARY KEY, + username TEXT NOT NULL, + gamesPlayed INTEGER NOT NULL, + wins INTEGER NOT NULL, + rating INTEGER NOT NULL + ) + """.trimIndent()) + + stmt.execute(""" + CREATE TABLE IF NOT EXISTS game_history ( + gameId TEXT PRIMARY KEY, + endTime INTEGER NOT NULL, + winnerId TEXT NOT NULL, + winnerName TEXT NOT NULL, + totalTurns INTEGER NOT NULL, + playersList TEXT NOT NULL + ) + """.trimIndent()) + } + } + } + + fun getPlayerStats(playerId: String): PlayerStatsEntity? { + return getConnection().use { conn -> + val sql = "SELECT * FROM player_stats WHERE playerId = ?" + conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, playerId) + val rs = stmt.executeQuery() + if (rs.next()) { + PlayerStatsEntity( + playerId = rs.getString("playerId"), + username = rs.getString("username"), + gamesPlayed = rs.getInt("gamesPlayed"), + wins = rs.getInt("wins"), + rating = rs.getInt("rating") + ) + } else null + } + } + } + + fun insertOrUpdatePlayer(stats: PlayerStatsEntity) { + getConnection().use { conn -> + val sql = """ + INSERT OR REPLACE INTO player_stats (playerId, username, gamesPlayed, wins, rating) + VALUES (?, ?, ?, ?, ?) + """.trimIndent() + conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, stats.playerId) + stmt.setString(2, stats.username) + stmt.setInt(3, stats.gamesPlayed) + stmt.setInt(4, stats.wins) + stmt.setInt(5, stats.rating) + stmt.executeUpdate() + } + } + } + + fun getAllPlayersStats(): List { + val list = mutableListOf() + getConnection().use { conn -> + val sql = "SELECT * FROM player_stats" + conn.createStatement().use { stmt -> + val rs = stmt.executeQuery(sql) + while (rs.next()) { + list.add( + PlayerStatsEntity( + playerId = rs.getString("playerId"), + username = rs.getString("username"), + gamesPlayed = rs.getInt("gamesPlayed"), + wins = rs.getInt("wins"), + rating = rs.getInt("rating") + ) + ) + } + } + } + return list + } + + fun insertGameHistory(history: GameHistoryEntity) { + getConnection().use { conn -> + val sql = """ + INSERT INTO game_history (gameId, endTime, winnerId, winnerName, totalTurns, playersList) + VALUES (?, ?, ?, ?, ?, ?) + """.trimIndent() + conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, history.gameId) + stmt.setLong(2, history.endTime) + stmt.setString(3, history.winnerId) + stmt.setString(4, history.winnerName) + stmt.setInt(5, history.totalTurns) + stmt.setString(6, history.playersList) + stmt.executeUpdate() + } + } + } + + fun getGameHistory(): List { + val list = mutableListOf() + getConnection().use { conn -> + val sql = "SELECT * FROM game_history" + conn.createStatement().use { stmt -> + val rs = stmt.executeQuery(sql) + while (rs.next()) { + list.add( + GameHistoryEntity( + gameId = rs.getString("gameId"), + endTime = rs.getLong("endTime"), + winnerId = rs.getString("winnerId"), + winnerName = rs.getString("winnerName"), + totalTurns = rs.getInt("totalTurns"), + playersList = rs.getString("playersList") + ) + ) + } + } + } + return list + } +} \ No newline at end of file diff --git a/src/main/kotlin/data/local/db/SwintusDatabase.kt b/src/main/kotlin/data/local/db/SwintusDatabase.kt new file mode 100644 index 0000000..4b129f3 --- /dev/null +++ b/src/main/kotlin/data/local/db/SwintusDatabase.kt @@ -0,0 +1,7 @@ +package data.local.db + +import data.local.dao.SwintusDao + +class SwintusDatabase { + fun swintusDao(): SwintusDao = SwintusDao() +} \ No newline at end of file diff --git a/src/main/kotlin/data/local/entities/GameHistoryEntity.kt b/src/main/kotlin/data/local/entities/GameHistoryEntity.kt new file mode 100644 index 0000000..a84f929 --- /dev/null +++ b/src/main/kotlin/data/local/entities/GameHistoryEntity.kt @@ -0,0 +1,10 @@ +package data.local.entities + +data class GameHistoryEntity( + val gameId: String, + val endTime: Long, + val winnerId: String, + val winnerName: String, + val totalTurns: Int, + val playersList: String +) \ No newline at end of file diff --git a/src/main/kotlin/data/local/entities/PlayerStatsEntity.kt b/src/main/kotlin/data/local/entities/PlayerStatsEntity.kt new file mode 100644 index 0000000..ebc0415 --- /dev/null +++ b/src/main/kotlin/data/local/entities/PlayerStatsEntity.kt @@ -0,0 +1,9 @@ +package data.local.entities + +data class PlayerStatsEntity( + val playerId: String, + val username: String, + val gamesPlayed: Int, + val wins: Int, + val rating: Int +) \ No newline at end of file diff --git a/src/main/kotlin/data/repository/GameRepository.kt b/src/main/kotlin/data/repository/GameRepository.kt new file mode 100644 index 0000000..12a1561 --- /dev/null +++ b/src/main/kotlin/data/repository/GameRepository.kt @@ -0,0 +1,53 @@ +package data.repository + +import Application.PlayerProfile +import data.local.dao.SwintusDao +import data.local.entities.GameHistoryEntity +import data.local.entities.PlayerStatsEntity +import java.util.UUID + +class GameRepository(private val dao: SwintusDao) { + + suspend fun saveGameResult(winnerId: UUID, winnerName: String, totalTurns: Int, players: List) { + val gameHistory = GameHistoryEntity( + gameId = UUID.randomUUID().toString(), + endTime = System.currentTimeMillis(), + winnerId = winnerId.toString(), + winnerName = winnerName, + totalTurns = totalTurns, + playersList = players.joinToString(",") { it.id.toString() } + ) + dao.insertGameHistory(gameHistory) + + for (player in players) { + val currentStats = dao.getPlayerStats(player.id.toString()) + ?: PlayerStatsEntity(player.id.toString(), player.username, 0, 0, 0) + + val isWinner = player.id == winnerId + val newWins = if (isWinner) currentStats.wins + 1 else currentStats.wins + val newRating = if (isWinner) currentStats.rating + 5 else maxOf(0, currentStats.rating - 2) + + dao.insertOrUpdatePlayer( + PlayerStatsEntity( + playerId = player.id.toString(), + username = player.username, + gamesPlayed = currentStats.gamesPlayed + 1, + wins = newWins, + rating = newRating + ) + ) + } + } + + suspend fun loadLeaderboard(): List { + return dao.getAllPlayersStats().map { + PlayerProfile( + id = java.util.UUID.fromString(it.playerId), + username = it.username, + rating = it.rating, + gamesPlayed = it.gamesPlayed, + wins = it.wins + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/view/Application.kt b/src/main/kotlin/view/Application.kt new file mode 100644 index 0000000..7616f68 --- /dev/null +++ b/src/main/kotlin/view/Application.kt @@ -0,0 +1,27 @@ +package view + +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import view.screens.GameScreen +import view.screens.LobbyScreen +import viewmodel.SwintusViewModel + +@Composable +fun Application() { + val viewModel = remember { SwintusViewModel() } + val gameState by viewModel.gameState.collectAsState() + + LaunchedEffect(viewModel) { + viewModel.onResetToLobby = { + viewModel.gameState.value = null + } + } + + MaterialTheme { + if (gameState == null) { + LobbyScreen(viewModel = viewModel) + } else { + GameScreen(viewModel = viewModel) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/view/DatabaseProvider.kt b/src/main/kotlin/view/DatabaseProvider.kt new file mode 100644 index 0000000..a1682b1 --- /dev/null +++ b/src/main/kotlin/view/DatabaseProvider.kt @@ -0,0 +1,9 @@ +package view + +import data.local.db.SwintusDatabase +import data.repository.GameRepository + +object DatabaseProvider { + lateinit var database: SwintusDatabase + lateinit var repository: GameRepository +} \ No newline at end of file diff --git a/src/main/kotlin/view/screens/LobbyScreen.kt b/src/main/kotlin/view/screens/LobbyScreen.kt new file mode 100644 index 0000000..e38aeb7 --- /dev/null +++ b/src/main/kotlin/view/screens/LobbyScreen.kt @@ -0,0 +1,365 @@ +package view.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Person +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import viewmodel.SwintusViewModel +import Application.PlayerProfile +import Application.GameAdministrator +import java.util.UUID + +@Composable +fun LobbyScreen(viewModel: SwintusViewModel) { + val lobbyPlayers = remember { mutableStateListOf() } + var newPlayerName by remember { mutableStateOf("") } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF121824)) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Card( + shape = RoundedCornerShape(24.dp), + backgroundColor = Color(0xFF1E2638), + elevation = 16.dp, + modifier = Modifier + .width(480.dp) + .height(580.dp) + .border(1.dp, Color(0xFF334155), RoundedCornerShape(24.dp)) + ) { + LobbyCardContent( + lobbyPlayers = lobbyPlayers, + newPlayerName = newPlayerName, + onNameChange = { newPlayerName = it }, + onAddPlayer = { + lobbyPlayers.add( + PlayerProfile( + id = UUID.randomUUID(), + username = newPlayerName.trim(), + rating = 0, + gamesPlayed = 0, + wins = 0 + ) + ) + newPlayerName = "" + }, + onRemovePlayer = { lobbyPlayers.remove(it) }, + onStartGame = { + val administrator = GameAdministrator() + administrator.startGame(lobbyPlayers.toList()) + viewModel.startGameFromLobby(administrator.gameState) + } + ) + } + } +} + +@Composable +fun LobbyCardContent( + lobbyPlayers: List, + newPlayerName: String, + onNameChange: (String) -> Unit, + onAddPlayer: () -> Unit, + onRemovePlayer: (PlayerProfile) -> Unit, + onStartGame: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + LobbyHeader() + + PlayerInputField( + value = newPlayerName, + onValueChange = onNameChange, + onAddClick = onAddPlayer, + currentCount = lobbyPlayers.size + ) + + Spacer(modifier = Modifier.height(24.dp)) + + LobbyPlayersCounter(currentCount = lobbyPlayers.size) + + Spacer(modifier = Modifier.height(12.dp)) + + LobbyPlayersListSection( + lobbyPlayers = lobbyPlayers, + onRemovePlayer = onRemovePlayer + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + StartGameButton( + playerCount = lobbyPlayers.size, + onStartGame = onStartGame + ) + } +} + +@Composable +fun LobbyHeader() { + Text( + text = "СВИНТУС", + fontSize = 32.sp, + fontWeight = FontWeight.Black, + color = Color(0xFFFF6B00), + letterSpacing = 2.sp, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Text( + text = "ПОДГОТОВКА К БИТВЕ", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF94A3B8), + letterSpacing = 4.sp, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + textAlign = TextAlign.Center + ) +} + +@Composable +fun PlayerInputField( + value: String, + onValueChange: (String) -> Unit, + onAddClick: () -> Unit, + currentCount: Int +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + NameInputField(value = value, onValueChange = onValueChange) + + Spacer(modifier = Modifier.width(12.dp)) + + AddPlayerButton( + isEnabled = value.isNotBlank() && currentCount < 8, + onClick = onAddClick + ) + } +} + +@Composable +fun RowScope.NameInputField(value: String, onValueChange: (String) -> Unit) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text("Имя нового игрока", color = Color(0xFF94A3B8)) }, + colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = Color(0xFFE2E8F0), + focusedBorderColor = Color(0xFFFF6B00), + unfocusedBorderColor = Color(0xFF334155), + cursorColor = Color(0xFFFF6B00), + focusedLabelColor = Color(0xFFFF6B00) + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.weight(1f), + singleLine = true + ) +} + +@Composable +fun AddPlayerButton(isEnabled: Boolean, onClick: () -> Unit) { + Button( + onClick = onClick, + enabled = isEnabled, + colors = ButtonDefaults.buttonColors( + backgroundColor = Color(0xFFFF6B00), + contentColor = Color.White, + disabledBackgroundColor = Color(0xFF334155) + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.size(56.dp), + contentPadding = PaddingValues(0.dp) + ) { + Icon(Icons.Default.Add, contentDescription = "Добавить") + } +} + +@Composable +fun LobbyPlayersCounter(currentCount: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Участники лобби", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFFE2E8F0) + ) + Text( + text = "$currentCount / 8", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = if (currentCount >= 2) Color(0xFFFF6B00) else Color(0xFF94A3B8) + ) + } +} + +@Composable +fun ColumnScope.LobbyPlayersListSection( + lobbyPlayers: List, + onRemovePlayer: (PlayerProfile) -> Unit +) { + if (lobbyPlayers.isEmpty()) { + EmptyLobbyStub() + } else { + PlayersLazyColumn(lobbyPlayers = lobbyPlayers, onRemovePlayer = onRemovePlayer) + } +} + +@Composable +fun ColumnScope.PlayersLazyColumn( + lobbyPlayers: List, + onRemovePlayer: (PlayerProfile) -> Unit +) { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(lobbyPlayers) { player -> + PlayerLobbyRow(player = player, onRemovePlayer = onRemovePlayer) + } + } +} + +@Composable +fun ColumnScope.EmptyLobbyStub() { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = "Добавьте минимум 2 игроков, чтобы начать игру", + color = Color(0xFF94A3B8), + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } +} + +@Composable +fun PlayerLobbyRow(player: PlayerProfile, onRemovePlayer: (PlayerProfile) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF263147), RoundedCornerShape(12.dp)) + .border(1.dp, Color(0xFF2D3A54), RoundedCornerShape(12.dp)) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + PlayerInfoLabel(username = player.username) + DeletePlayerButton(onClick = { onRemovePlayer(player) }) + } +} + +@Composable +fun PlayerInfoLabel(username: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Person, + contentDescription = null, + tint = Color(0xFFFF6B00), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = username, + color = Color(0xFFE2E8F0), + fontWeight = FontWeight.Medium, + fontSize = 15.sp + ) + } +} + +@Composable +fun DeletePlayerButton(onClick: () -> Unit) { + IconButton( + onClick = onClick, + modifier = Modifier.size(24.dp) + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Удалить", + tint = Color(0xFFEF4444) + ) + } +} + +@Composable +fun StartGameButton(playerCount: Int, onStartGame: () -> Unit) { + val isButtonEnabled = playerCount >= 2 + + Button( + onClick = onStartGame, + modifier = Modifier + .fillMaxWidth() + .height(54.dp), + enabled = isButtonEnabled, + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Transparent, + disabledBackgroundColor = Color(0xFF334155) + ), + contentPadding = PaddingValues(0.dp) + ) { + StartGameButtonContent(isButtonEnabled = isButtonEnabled) + } +} + +@Composable +fun StartGameButtonContent(isButtonEnabled: Boolean) { + val buttonModifier = if (isButtonEnabled) { + Modifier + .fillMaxSize() + .background(Brush.horizontalGradient(listOf(Color(0xFFFF6B00), Color(0xFFFF8833)))) + } else { + Modifier + .fillMaxSize() + .background(Color(0xFF334155)) + } + + Box( + modifier = buttonModifier, + contentAlignment = Alignment.Center + ) { + Text( + text = "ПОЕХАЛИ!", + fontSize = 16.sp, + fontWeight = FontWeight.Black, + color = if (isButtonEnabled) Color.White else Color(0xFF94A3B8), + letterSpacing = 2.sp + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/viewmodel/SwintusViewModel.kt b/src/main/kotlin/viewmodel/SwintusViewModel.kt new file mode 100644 index 0000000..eb26db5 --- /dev/null +++ b/src/main/kotlin/viewmodel/SwintusViewModel.kt @@ -0,0 +1,186 @@ +package viewmodel + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import kotlinx.coroutines.flow.MutableStateFlow +import Game.GameState +import Game.GameStateLogic +import Cards.Card +import Cards.Types.ActionCard +import Cards.Types.NumberCard +import Cards.Types.WildCard +import Validator.TurnActions.PlayCardTurn +import Validator.TurnActions.DrawCardTurn +import java.util.UUID + +class SwintusViewModel { + + val gameState = MutableStateFlow(null) + val stateVersion = MutableStateFlow(0) + val actionLogs = mutableStateListOf("Игра началась.") + private val forgottenSwintusTurns = mutableStateMapOf() + + var onResetToLobby: (() -> Unit)? = null + + fun startGameFromLobby(initialState: GameState) { + actionLogs.clear() + actionLogs.add("Игра успешно запущена!") + gameState.value = initialState + updateVersion() + } + + fun handlePlayCardFromId(playerId: UUID, card: Card, shoutedSwintus: Boolean): String? { + val state = gameState.value ?: return "Игра не запущена" + val clickedPlayer = state.players.find { it.playerId == playerId } ?: return "Игрок не найден" + + val validationError = checkTurnAndHand(state, clickedPlayer, card) + if (validationError != null) return validationError + + val handSizeBefore = clickedPlayer.hand.size + val finalDeclaredColor = if (card is WildCard) card.chosenColor else card.color + + val turn = PlayCardTurn( + gameId = state.gameId, turnNumber = state.turnNumber, playerId = clickedPlayer, + card = card, declaredSwintus = shoutedSwintus, declaredColor = finalDeclaredColor + ) + + gameState.value = GameStateLogic(state).applyTurn(turn) + checkForgottenSwintus(clickedPlayer.playerId, handSizeBefore, shoutedSwintus, state.turnNumber) + + actionLogs.add("${clickedPlayer.name} сыграл карту: ${getCardName(card)}") + updateVersion() + return null + } + + fun handleDrawCard(): String? { + val state = gameState.value ?: return "Игра не запущена" + val currentPlayer = getCurrentPlayerSafely(state) + + if (currentPlayer.hand.any { it.canPlayOn(state.topCard, currentPlayer, state) }) { + return "У вас уже есть подходящая карта для хода! Вы обязаны скинуть её." + } + + val turn = DrawCardTurn( + gameId = state.gameId, turnNumber = state.turnNumber, + playerId = currentPlayer, cardsDrawn = 1, playedAfterDraw = false + ) + + gameState.value = GameStateLogic(state).applyTurn(turn) + actionLogs.add("${currentPlayer.name} взял карту из колоды.") + updateVersion() + return null + } + + fun handleAccusePlayer(targetPlayerId: UUID, UIcurrentPlayerName: String): String? { + val state = gameState.value ?: return "Игра не запущена" + val targetPlayer = state.players.find { it.playerId == targetPlayerId } ?: return "Игрок не найден" + val violationTurn = forgottenSwintusTurns[targetPlayerId] + + val validationError = checkAccuseValidity(targetPlayer, violationTurn, state.turnNumber, state.players.size) + if (validationError != null) return validationError + + executePenaltyDrawn(state, targetPlayer) + + actionLogs.add("Поймали! $UIcurrentPlayerName обвинил ${targetPlayer.name}. +2 карты нарушителю!") + forgottenSwintusTurns.remove(targetPlayerId) + updateVersion() + return null + } + + fun resetToLobby() { + onResetToLobby?.invoke() + } + + private fun checkTurnAndHand(state: GameState, player: Game.InGamePlayer, card: Card): String? { + if (state.players.indexOf(player) != state.currentPlayerIndex) { + val currentName = state.players.getOrNull(state.currentPlayerIndex)?.name ?: "другого игрока" + return "Сейчас ход игрока $currentName!" + } + val hasCard = player.hand.any { it::class == card::class && it.color == card.color && + (it !is NumberCard || (card is NumberCard && it.value == card.value)) } + if (!hasCard) return "У вас нет этой карты!" + if (!card.canPlayOn(state.topCard, player, state)) { + return "Эту карту нельзя выложить на ${getCardName(state.topCard)}!" + } + return null + } + + private fun checkForgottenSwintus(playerId: UUID, handSizeBefore: Int, shoutedSwintus: Boolean, turnNumber: Int) { + if (handSizeBefore == 2 && !shoutedSwintus) { + forgottenSwintusTurns[playerId] = turnNumber + } + } + + private fun getCurrentPlayerSafely(state: GameState): Game.InGamePlayer { + val size = state.players.size + val safeIndex = ((state.currentPlayerIndex % size) + size) % size + return state.players[safeIndex] + } + + private fun checkAccuseValidity(targetPlayer: Game.InGamePlayer, violationTurn: Int?, currentTurn: Int, playersCount: Int): String? { + if (targetPlayer.hand.size != 1) return "У этого игрока не одна карта!" + if (violationTurn == null) return "Этот игрок не нарушал правила!" + if (currentTurn - violationTurn > playersCount) { + forgottenSwintusTurns.remove(targetPlayer.playerId) + return "Время обвинения истекло! Прошел целый круг." + } + return null + } + + private fun executePenaltyDrawn(state: GameState, targetPlayer: Game.InGamePlayer) { + val updatedHand = targetPlayer.hand.toMutableList() + repeat(2) { + if (state.giveCard.cards.isEmpty() && state.discardCard.cards.isNotEmpty()) { + recycleDiscardPile(state) + } + if (state.giveCard.cards.isNotEmpty()) { + updatedHand.add(state.giveCard.draw(state.discardCard)) + } + } + targetPlayer.hand = updatedHand + } + + private fun recycleDiscardPile(state: GameState) { + val recycled = mutableListOf() + while (state.discardCard.cards.isNotEmpty()) { + recycled.add(state.discardCard.cards.pop()) + } + recycled.shuffle() + state.giveCard.cards.addAll(recycled) + } + + private fun updateVersion() { + stateVersion.value += 1 + } + + fun getCardName(card: Card): String { + return when (card) { + is NumberCard -> "${formatColor(card.color)} Цифра ${card.value}" + is ActionCard -> "${formatColor(card.color)} [${card.effect::class.java.simpleName.replace("Effect", "").uppercase()}]" + is WildCard -> "ПОЛИСВИН ${formatWildChosenColor(card.chosenColor)}".trim() + else -> card.javaClass.simpleName.replace("Card", "") + } + } + + private fun formatColor(color: Game.Color): String = when (color) { + Game.Color.RED -> "Красный" + Game.Color.GREEN -> "Зеленый" + Game.Color.BLUE -> "Синий" + Game.Color.YELLOW -> "Желтый" + Game.Color.GRAY -> "Серый" + } + + private fun formatWildChosenColor(chosenColor: Game.Color): String { + if (chosenColor == Game.Color.GRAY) return "" + return "(Цвет: ${formatColor(chosenColor)})" + } + + fun Card.canPlayOn(topCard: Card, player: Game.InGamePlayer, state: GameState): Boolean { + if (this is WildCard || this.color == Game.Color.GRAY) return true + if (topCard is WildCard) return this.color == topCard.chosenColor + if (this.color == topCard.color) return true + if (this is NumberCard && topCard is NumberCard) return this.value == topCard.value + if (this is ActionCard && topCard is ActionCard) return this.effect::class == topCard.effect::class + return false + } +} \ No newline at end of file diff --git a/src/test/kotlin/Integration/DatabaseIntegrationTest.kt b/src/test/kotlin/Integration/DatabaseIntegrationTest.kt new file mode 100644 index 0000000..6f5db83 --- /dev/null +++ b/src/test/kotlin/Integration/DatabaseIntegrationTest.kt @@ -0,0 +1,79 @@ +package integration + +import Application.PlayerProfile +import androidx.room.Room +import data.local.db.SwintusDatabase +import data.local.dao.SwintusDao +import data.repository.GameRepository +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.IOException +import java.util.UUID + +class DatabaseIntegrationTest { + + private lateinit var db: SwintusDatabase + private lateinit var dao: SwintusDao + private lateinit var repository: GameRepository + + @BeforeEach + fun createDb() { + db = Room.inMemoryDatabaseBuilder( + androidx.room.Room::class.java, // Используется в KMP/JVM контексте + SwintusDatabase::class.java + ).build() + dao = db.swintusDao() + repository = GameRepository(dao) + } + + @AfterEach + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + fun `saveGameResult correctly writes to database tables and links data`() = runTest { + val p1Id = UUID.randomUUID() + val p2Id = UUID.randomUUID() + + val players = listOf( + PlayerProfile(id = p1Id, username = "Игрок 1", rating = 10, gamesPlayed = 5, wins = 2), + PlayerProfile(id = p2Id, username = "Игрок 2", rating = 4, gamesPlayed = 3, wins = 1) + ) + + repository.saveGameResult( + winnerId = p1Id, + winnerName = "Игрок 1", + totalTurns = 24, + players = players + ) + + val p1Stats = dao.getPlayerStats(p1Id.toString()) + assertNotNull(p1Stats) + assertEquals("Игрок 1", p1Stats!!.username) + assertEquals(6, p1Stats.gamesPlayed) + assertEquals(3, p1Stats.wins) + assertEquals(15, p1Stats.rating) + + val p2Stats = dao.getPlayerStats(p2Id.toString()) + assertNotNull(p2Stats) + assertEquals("Игрок 2", p2Stats!!.username) + assertEquals(4, p2Stats.gamesPlayed) + assertEquals(1, p2Stats.wins) + assertEquals(2, p2Stats.rating) + + val historyList = dao.getGameHistory() + assertEquals(1, historyList.size) + + val gameRecord = historyList[0] + assertEquals(p1Id.toString(), gameRecord.winnerId) + assertEquals("Игрок 1", gameRecord.winnerName) + assertEquals(24, gameRecord.totalTurns) + assertEquals("$p1Id,$p2Id", gameRecord.playersList) + } +} \ No newline at end of file diff --git a/src/test/kotlin/data/repository/GameRepositoryTest.kt b/src/test/kotlin/data/repository/GameRepositoryTest.kt new file mode 100644 index 0000000..08ee7ca --- /dev/null +++ b/src/test/kotlin/data/repository/GameRepositoryTest.kt @@ -0,0 +1,83 @@ +package data.repository + +import Application.PlayerProfile +import data.local.dao.SwintusDao +import data.local.entities.PlayerStatsEntity +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.any +import java.util.UUID + +class GameRepositoryTest { + + private val dao = mock(SwintusDao::class.java) + private val repository = GameRepository(dao) + + @Test + fun `saveGameResult updates win count and adds 5 points for winner`() = runTest { + val winnerId = UUID.randomUUID() + val winnerProfile = PlayerProfile(winnerId, "Кирилл", rating = 10, gamesPlayed = 2, wins = 1) + + val existingEntity = PlayerStatsEntity(winnerId.toString(), "Кирилл", gamesPlayed = 2, wins = 1, rating = 10) + `when`(dao.getPlayerStats(winnerId.toString())).thenReturn(existingEntity) + + repository.saveGameResult(winnerId, "Кирилл", 15, listOf(winnerProfile)) + + verify(dao).insertOrUpdatePlayer( + PlayerStatsEntity( + playerId = winnerId.toString(), + username = "Кирилл", + gamesPlayed = 3, + wins = 2, + rating = 15 + ) + ) + } + + @Test + fun `saveGameResult deducts 2 points for loser`() = runTest { + val loserId = UUID.randomUUID() + val loserProfile = PlayerProfile(loserId, "Оппонент", rating = 10, gamesPlayed = 5, wins = 2) + + val existingEntity = PlayerStatsEntity(loserId.toString(), "Оппонент", gamesPlayed = 5, wins = 2, rating = 10) + `when`(dao.getPlayerStats(loserId.toString())).thenReturn(existingEntity) + + // Передаем другой UUID в качестве победителя, чтобы этот игрок считался проигравшим + repository.saveGameResult(UUID.randomUUID(), "Победитель", 15, listOf(loserProfile)) + + verify(dao).insertOrUpdatePlayer( + PlayerStatsEntity( + playerId = loserId.toString(), + username = "Оппонент", + gamesPlayed = 6, + wins = 2, + rating = 8 + ) + ) + } + + @Test + fun `saveGameResult keeps rating at zero if loser has low points`() = runTest { + val loserId = UUID.randomUUID() + val loserProfile = PlayerProfile(loserId, "Новичок", rating = 1, gamesPlayed = 0, wins = 0) + + val existingEntity = PlayerStatsEntity(loserId.toString(), "Новичок", gamesPlayed = 0, wins = 0, rating = 1) + `when`(dao.getPlayerStats(loserId.toString())).thenReturn(existingEntity) + + repository.saveGameResult(UUID.randomUUID(), "Победитель", 15, listOf(loserProfile)) + + verify(dao).insertOrUpdatePlayer( + PlayerStatsEntity( + playerId = loserId.toString(), + username = "Новичок", + gamesPlayed = 1, + wins = 0, + rating = 0 // Защита сработала: 1 - 2 = -1 -> округляется до 0 + ) + ) + } +} \ No newline at end of file