diff --git a/CHANGELOG.md b/CHANGELOG.md index 595a579..2e90c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Playable Mines (cu-08): the second game wired end-to-end, with + interactive multi-tile play. `MinesViewModel` -> `GameRepository` + validates and debits the stake, draws the mine positions on the C++/JNI + engine (committed via the hash) and lets the player reveal tiles: each + safe tile raises the multiplier (`MinesMath`, RTP 99%) and hitting a + mine loses the stake. Cash out credits `stake x multiplier` + (`WalletRepository.applyRoundResult`) and records the round in + `game_round`; balance, session profit and the recent strip read from + Room. The round detail maps Mines rounds (setup, multiplier, real + seeds). Covered by `MinesMathTest`, `MinesViewModelTest` and the new + `startMines`/`settleMines` cases in `GameRepositoryImplTest`. + - Profile P&L on real rounds (cu-15): `ProfileViewModel` now combines the signed-in identity with `GameRepository.rounds`, replacing the placeholder P&L and game-activity blocks. The all-time summary (net, diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/mines/MinesScreenTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/mines/MinesScreenTest.kt index 6e9c234..b25062c 100644 --- a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/mines/MinesScreenTest.kt +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/mines/MinesScreenTest.kt @@ -1,5 +1,9 @@ package com.plainstudio.stackcasino.feature.mines +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription @@ -17,10 +21,25 @@ class MinesScreenTest { @get:Rule val composeRule = createComposeRule() + /** + * Hosts the stateless screen with a local state holder so a bet + * preset tap flows back through `onPreset` and recomposes, mirroring + * how the ViewModel drives it in production. + */ private fun setScreen(onBack: () -> Unit = {}) { composeRule.setContent { StackcasinoTheme { - MinesScreen(onBack = onBack) + var state by remember { mutableStateOf(MinesUiState()) } + MinesScreen( + state = state, + onBack = onBack, + onPreset = { state = state.copy(betAmount = it) }, + onMines = { state = state.copy(mineCount = it) }, + onPlaceBet = {}, + onRevealTile = {}, + onCashout = {}, + onNewBet = {}, + ) } } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/data/game/GameRepositoryImpl.kt b/app/src/main/java/com/plainstudio/stackcasino/data/game/GameRepositoryImpl.kt index 0b96fc2..d4465ec 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/data/game/GameRepositoryImpl.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/data/game/GameRepositoryImpl.kt @@ -6,6 +6,9 @@ import com.plainstudio.stackcasino.domain.game.CoinSide import com.plainstudio.stackcasino.domain.game.GameEngine import com.plainstudio.stackcasino.domain.game.GameRepository import com.plainstudio.stackcasino.domain.game.GameRound +import com.plainstudio.stackcasino.domain.game.MinesMath +import com.plainstudio.stackcasino.domain.game.MinesRound +import com.plainstudio.stackcasino.domain.game.MinesStart import com.plainstudio.stackcasino.domain.game.PlayResult import com.plainstudio.stackcasino.domain.game.SeedGenerator import com.plainstudio.stackcasino.domain.wallet.BetSettlement @@ -95,6 +98,84 @@ class GameRepositoryImpl return PlayResult.Success(round) } + override suspend fun startMines( + numMines: Int, + stake: Double, + ): MinesStart { + val wallet = walletRepository.wallet.first() + minesError(numMines, stake, wallet.availableBalance)?.let { return MinesStart.Failure(it) } + + val serverSeed = seedGenerator.newSeed() + val clientSeed = seedGenerator.newSeed() + val roundNonce = nonce.incrementAndGet() + val outcome = + runCatching { engine.evaluateMines(serverSeed, clientSeed, roundNonce, numMines) } + .getOrElse { return MinesStart.Failure("The game engine is unavailable.") } + + when (walletRepository.applyRoundResult(stake, 0.0)) { + BetSettlement.InsufficientFunds -> return MinesStart.Failure(INSUFFICIENT_FUNDS) + BetSettlement.Success -> Unit + } + return MinesStart.Success( + MinesRound( + id = idGenerator.newId(), + numMines = numMines, + minePositions = parsePositions(outcome.result), + stake = stake, + currency = wallet.currencyCode, + serverSeed = serverSeed, + clientSeed = clientSeed, + nonce = roundNonce, + hash = outcome.hash, + resultRaw = outcome.result.trim(), + ), + ) + } + + override suspend fun settleMines( + round: MinesRound, + safeRevealed: Int, + cashedOut: Boolean, + ) { + val multiplier = if (cashedOut) MinesMath.multiplier(round.numMines, safeRevealed) else 0.0 + val payout = round.stake * multiplier + if (cashedOut && payout > 0.0) { + walletRepository.applyRoundResult(0.0, payout) + } + dao.insertRound( + GameRoundEntity( + id = round.id, + game = GameKey.Mines.name, + pick = "${round.numMines} mines", + stake = round.stake, + payout = payout, + won = cashedOut, + multiplier = multiplier, + currency = round.currency, + serverSeed = round.serverSeed, + clientSeed = round.clientSeed, + nonce = round.nonce, + hash = round.hash, + resultRaw = round.resultRaw, + timestamp = timeProvider.now().time, + ), + ) + } + + private fun parsePositions(csv: String): Set = + csv.split(",").mapNotNull { it.trim().toIntOrNull() }.toSet() + + private fun minesError( + numMines: Int, + stake: Double, + available: Double, + ): String? = + if (numMines !in MinesMath.MIN_MINES..MinesMath.MAX_MINES) { + "Pick between 1 and 24 mines." + } else { + stakeError(stake, available) + } + private fun stakeError( stake: Double, available: Double, diff --git a/app/src/main/java/com/plainstudio/stackcasino/data/game/NativeGameEngineAdapter.kt b/app/src/main/java/com/plainstudio/stackcasino/data/game/NativeGameEngineAdapter.kt index 30729e6..938ee5d 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/data/game/NativeGameEngineAdapter.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/data/game/NativeGameEngineAdapter.kt @@ -23,6 +23,13 @@ class NativeGameEngineAdapter nonce: Long, ): EngineResult = parse(native.evaluateCoinflip(serverSeed, clientSeed, nonce)) + override fun evaluateMines( + serverSeed: String, + clientSeed: String, + nonce: Long, + numMines: Int, + ): EngineResult = parse(native.evaluateMines(serverSeed, clientSeed, nonce, numMines)) + private fun parse(raw: String): EngineResult { val separator = raw.indexOf(SEPARATOR) require(separator >= 0) { "Malformed engine output: $raw" } diff --git a/app/src/main/java/com/plainstudio/stackcasino/domain/game/GameEngine.kt b/app/src/main/java/com/plainstudio/stackcasino/domain/game/GameEngine.kt index fdb1e73..3f509de 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/domain/game/GameEngine.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/domain/game/GameEngine.kt @@ -31,4 +31,18 @@ interface GameEngine { clientSeed: String, nonce: Long, ): EngineResult + + /** + * Evaluates a mines round. Returns the comma-separated mine positions + * (0-24 on the 5x5 grid) and the derivation hash. + * + * @param numMines the number of mines to place (1..24). + * @throws IllegalArgumentException for empty seeds, a non-positive nonce or an out-of-range mine count. + */ + fun evaluateMines( + serverSeed: String, + clientSeed: String, + nonce: Long, + numMines: Int, + ): EngineResult } diff --git a/app/src/main/java/com/plainstudio/stackcasino/domain/game/GameRepository.kt b/app/src/main/java/com/plainstudio/stackcasino/domain/game/GameRepository.kt index 4bf5d2b..a761748 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/domain/game/GameRepository.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/domain/game/GameRepository.kt @@ -16,6 +16,35 @@ sealed interface PlayResult { ) : PlayResult } +/** + * A mines round in progress. Drawn at bet placement (the mine positions + * are committed via [hash]); the ViewModel drives the tile reveals and + * settles the round through [GameRepository.settleMines]. + */ +data class MinesRound( + val id: String, + val numMines: Int, + val minePositions: Set, + val stake: Double, + val currency: String, + val serverSeed: String, + val clientSeed: String, + val nonce: Long, + val hash: String, + val resultRaw: String, +) + +/** Outcome of placing a mines bet (the stake is debited on success). */ +sealed interface MinesStart { + data class Success( + val round: MinesRound, + ) : MinesStart + + data class Failure( + val reason: String, + ) : MinesStart +} + /** * Plays provably-fair game rounds. Orchestrates the native [GameEngine], * the wallet settlement and the Room round ledger; Room stays the single @@ -38,4 +67,27 @@ interface GameRepository { pick: CoinSide, stake: Double, ): PlayResult + + /** + * Places a mines bet: validates the stake, draws the mine positions + * on the native engine and debits the stake from the wallet. The + * returned [MinesRound] carries the committed board for the ViewModel + * to drive reveals against. + */ + suspend fun startMines( + numMines: Int, + stake: Double, + ): MinesStart + + /** + * Settles a mines round and records it: on [cashedOut] the payout + * (stake x the multiplier for [safeRevealed] safe tiles) is credited; + * on a mine hit nothing is credited (the stake was debited at start). + * Either way the round is written to the `game_round` ledger. + */ + suspend fun settleMines( + round: MinesRound, + safeRevealed: Int, + cashedOut: Boolean, + ) } diff --git a/app/src/main/java/com/plainstudio/stackcasino/domain/game/MinesMath.kt b/app/src/main/java/com/plainstudio/stackcasino/domain/game/MinesMath.kt new file mode 100644 index 0000000..e80b5ff --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/domain/game/MinesMath.kt @@ -0,0 +1,38 @@ +package com.plainstudio.stackcasino.domain.game + +/** + * Pure payout math for Mines on the 5x5 grid. + * + * After revealing [safeRevealed] safe tiles with [numMines] mines on the + * board, the fair multiplier is the inverse of the probability of having + * survived that many reveals: + * + * `product over i in [0, safeRevealed) of (GRID - i) / (GRID - numMines - i)` + * + * scaled by [HOUSE_EDGE] (RTP 99%). Kept separate from the engine and the + * repository so the curve is unit-testable in isolation. + */ +object MinesMath { + const val GRID = 25 + const val MIN_MINES = 1 + const val MAX_MINES = 24 + private const val HOUSE_EDGE = 0.99 + + /** + * The cash-out multiplier after [safeRevealed] safe reveals. Returns + * 1.0 before any reveal. + */ + fun multiplier( + numMines: Int, + safeRevealed: Int, + ): Double { + require(numMines in MIN_MINES..MAX_MINES) { "numMines must be between $MIN_MINES and $MAX_MINES" } + val safeTiles = GRID - numMines + require(safeRevealed in 0..safeTiles) { "safeRevealed out of range" } + var multiplier = 1.0 + for (i in 0 until safeRevealed) { + multiplier *= (GRID - i).toDouble() / (GRID - numMines - i).toDouble() + } + return if (safeRevealed == 0) 1.0 else multiplier * HOUSE_EDGE + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesScreen.kt index 3764b5d..4d64f72 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft import androidx.compose.material.icons.outlined.IosShare @@ -27,12 +28,12 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector @@ -56,35 +57,41 @@ import com.plainstudio.stackcasino.ui.theme.TextLow import com.plainstudio.stackcasino.ui.theme.TextMedium /** - * Mines game screen reproducing the mockup (mockup/js/screens/mines.js) - * as a static shell: the header, recent results strip, stats strip, the - * 5x5 grid with its progress bar and the bet controls (bet, presets, - * mines-count picker with the risk meter, auto cashout, Place Bet). + * Mines game screen (mockup/js/screens/mines.js). * - * Choosing a bet preset and the mine count are local UI state; the round - * itself (mine placement via NativeGameEngine, tile reveals, the - * multiplier and cashout) lands with the games card in the final entrega. + * Stateless: [MinesViewModel] owns the live round (committed board, + * revealed tiles, running multiplier) and the round logic; the screen + * renders the hoisted [state] and reports the bet, tile reveals and cash + * out through callbacks. Tiles are tappable while a round is live; on a + * terminal event the board reveals every mine. */ @Composable fun MinesScreen( + state: MinesUiState, onBack: () -> Unit, + onPreset: (Int) -> Unit, + onMines: (Int) -> Unit, + onPlaceBet: () -> Unit, + onRevealTile: (Int) -> Unit, + onCashout: () -> Unit, + onNewBet: () -> Unit, modifier: Modifier = Modifier, ) { - var betPreset by rememberSaveable { mutableIntStateOf(DEFAULT_PRESET) } - var mines by rememberSaveable { mutableIntStateOf(DEFAULT_MINES) } var showRules by rememberSaveable { mutableStateOf(false) } Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { Column(modifier = Modifier.fillMaxSize()) { MinesHeader(onBack = onBack, onOpenRules = { showRules = true }) - RecentStrip() - StatsStrip() - MinesGrid(modifier = Modifier.weight(1f)) + RecentStrip(results = state.recentResults) + StatsStrip(state = state) + MinesGrid(state = state, onRevealTile = onRevealTile, modifier = Modifier.weight(1f)) MinesControls( - betPreset = betPreset, - onPreset = { betPreset = it }, - mines = mines, - onMines = { mines = it }, + state = state, + onPreset = onPreset, + onMines = onMines, + onPlaceBet = onPlaceBet, + onCashout = onCashout, + onNewBet = onNewBet, ) } } @@ -165,7 +172,7 @@ private fun HeaderButton( } @Composable -private fun RecentStrip() { +private fun RecentStrip(results: List) { Column( modifier = Modifier.fillMaxWidth().padding(horizontal = ScreenHorizontalPadding, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), @@ -173,7 +180,7 @@ private fun RecentStrip() { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text(text = "Recent", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) Text( - text = "4 rounds", + text = if (results.isEmpty()) "no rounds yet" else "${results.size} rounds", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing, @@ -181,7 +188,7 @@ private fun RecentStrip() { ) } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - RECENT_RESULTS.forEach { won -> RecentChip(won = won) } + results.forEach { won -> RecentChip(won = won) } } } } @@ -208,14 +215,25 @@ private fun RecentChip(won: Boolean) { } @Composable -private fun StatsStrip() { +private fun StatsStrip(state: MinesUiState) { + val profitColor = if (state.sessionProfitLabel.startsWith("-")) SemanticDanger else SemanticOk Row( modifier = Modifier.fillMaxWidth().padding(horizontal = ScreenHorizontalPadding, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - StatCell(label = "Balance", value = "$1,234.56", valueColor = TextHigh, modifier = Modifier.weight(1f)) - StatCell(label = "Profit", value = "+$15.10", valueColor = TextHigh, modifier = Modifier.weight(1f)) - StatCell(label = "Next Tile", value = "+$1.25", valueColor = SemanticOk, modifier = Modifier.weight(1f)) + StatCell(label = "Balance", value = state.balanceLabel, valueColor = TextHigh, modifier = Modifier.weight(1f)) + StatCell( + label = "Profit", + value = state.sessionProfitLabel, + valueColor = profitColor, + modifier = Modifier.weight(1f), + ) + StatCell( + label = "Next Tile", + value = state.nextGainLabel, + valueColor = SemanticOk, + modifier = Modifier.weight(1f), + ) } } @@ -229,9 +247,8 @@ private fun StatCell( Column( modifier = modifier - .background( - SurfaceRaised, - ).border(width = 1.dp, color = SurfaceOutline) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) .padding(horizontal = 12.dp, vertical = 8.dp), ) { Text(text = label, color = TextMedium, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) @@ -241,47 +258,97 @@ private fun StatCell( } @Composable -private fun MinesGrid(modifier: Modifier = Modifier) { +private fun MinesGrid( + state: MinesUiState, + onRevealTile: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val safeTiles = (MinesUiState.GRID - state.mineCount).coerceAtLeast(1) + val progress = state.safeRevealed.toFloat() / safeTiles Column( modifier = modifier.fillMaxWidth().padding(horizontal = GridHorizontalPadding, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(GridGap)) { - repeat(GRID_SIZE) { + repeat(GRID_SIZE) { row -> Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(GridGap)) { - repeat(GRID_SIZE) { - Box( - modifier = - Modifier - .weight( - 1f, - ).aspectRatio( - 1f, - ).background(SurfaceRaised) - .border(width = 1.dp, color = SurfaceOutline), + repeat(GRID_SIZE) { col -> + val index = row * GRID_SIZE + col + GridTile( + tile = state.tiles[index], + enabled = state.isPlaying, + onClick = { onRevealTile(index) }, + modifier = Modifier.weight(1f), ) } } } } - Text( - text = "Set your bet below", - color = TextLow, - fontSize = TinyFontSize, - letterSpacing = TrackedLetterSpacing, - ) + if (state.phase == MinesPhase.Idle) { + Text( + text = state.statusLabel, + color = TextLow, + fontSize = TinyFontSize, + letterSpacing = TrackedLetterSpacing, + ) + } + } + Box(modifier = Modifier.fillMaxWidth().height(ProgressBarHeight).background(SurfaceOutline)) { + Box(modifier = Modifier.fillMaxHeight().fillMaxWidth(progress.coerceIn(0f, 1f)).background(AccentViolet)) + } + } +} + +@Composable +private fun GridTile( + tile: TileState, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val background = + when (tile) { + TileState.Hidden -> SurfaceRaised + TileState.Safe -> SemanticOk.copy(alpha = TILE_TINT_ALPHA) + TileState.Mine -> SemanticDanger.copy(alpha = TILE_TINT_ALPHA) + } + val border = + when (tile) { + TileState.Hidden -> SurfaceOutline + TileState.Safe -> SemanticOk.copy(alpha = CHIP_BORDER_ALPHA) + TileState.Mine -> SemanticDanger.copy(alpha = CHIP_BORDER_ALPHA) + } + Box( + modifier = + modifier + .aspectRatio(1f) + .background(background) + .border(width = 1.dp, color = border) + .clickable(enabled = enabled && tile == TileState.Hidden, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + when (tile) { + TileState.Safe -> Dot(color = SemanticOk) + TileState.Mine -> Dot(color = SemanticDanger) + TileState.Hidden -> Unit } - Box(modifier = Modifier.fillMaxWidth().height(ProgressBarHeight).background(SurfaceOutline)) } } +@Composable +private fun Dot(color: Color) { + Box(modifier = Modifier.size(TileDotSize).clip(CircleShape).background(color)) +} + @Composable private fun MinesControls( - betPreset: Int, + state: MinesUiState, onPreset: (Int) -> Unit, - mines: Int, onMines: (Int) -> Unit, + onPlaceBet: () -> Unit, + onCashout: () -> Unit, + onNewBet: () -> Unit, ) { Column( modifier = @@ -291,40 +358,126 @@ private fun MinesControls( ), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(text = "Bet Amount", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) - BetField(amount = betPreset) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { - BET_PRESETS.forEach { amount -> - PillButton(label = "$$amount", selected = amount == betPreset, onClick = { - onPreset(amount) - }, modifier = Modifier.weight(1f)) - } - } - Text(text = "Mines", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { - MINE_COUNTS.forEach { count -> - PillButton( - label = "$count", - selected = count == mines, - onClick = { onMines(count) }, - modifier = Modifier.weight(1f), + when (state.phase) { + MinesPhase.Playing -> LiveRoundControls(state = state, onCashout = onCashout) + MinesPhase.Lost, MinesPhase.CashedOut -> ResultControls(state = state, onNewBet = onNewBet) + MinesPhase.Idle -> + SetupControls( + state = state, + onPreset = onPreset, + onMines = onMines, + onPlaceBet = onPlaceBet, ) - } } - RiskMeter(mines = mines) - PlaceBetButton() } } +@Composable +private fun ResultControls( + state: MinesUiState, + onNewBet: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text(text = "Result", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) + Text( + text = state.statusLabel, + color = if (state.phase == MinesPhase.CashedOut) SemanticOk else SemanticDanger, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + style = TabularNums, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text(text = "Multiplier", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) + Text( + text = state.multiplierLabel, + color = AccentViolet, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + style = TabularNums, + ) + } + } + ActionButton(label = "New Bet", enabled = true, background = AccentViolet, onClick = onNewBet) +} + +@Composable +private fun LiveRoundControls( + state: MinesUiState, + onCashout: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text(text = "Multiplier", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) + Text( + text = state.multiplierLabel, + color = AccentViolet, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + style = TabularNums, + ) + } + Text(text = state.statusLabel, color = TextLow, fontSize = MetaFontSize, letterSpacing = TrackedLetterSpacing) + } + ActionButton( + label = if (state.cashoutLabel.isNotEmpty()) state.cashoutLabel else "Reveal a tile", + enabled = state.canCashout, + background = SemanticOk, + onClick = onCashout, + ) +} + +@Composable +private fun SetupControls( + state: MinesUiState, + onPreset: (Int) -> Unit, + onMines: (Int) -> Unit, + onPlaceBet: () -> Unit, +) { + Text(text = "Bet Amount", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) + BetField(amount = state.betAmount) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + BET_PRESETS.forEach { amount -> + PillButton( + label = "$$amount", + selected = amount == state.betAmount, + onClick = { onPreset(amount) }, + modifier = Modifier.weight(1f), + ) + } + } + Text(text = "Mines", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + MINE_COUNTS.forEach { count -> + PillButton( + label = "$count", + selected = count == state.mineCount, + onClick = { onMines(count) }, + modifier = Modifier.weight(1f), + ) + } + } + RiskMeter(mines = state.mineCount) + ActionButton(label = "Place Bet", enabled = true, background = AccentViolet, onClick = onPlaceBet) +} + @Composable private fun BetField(amount: Int) { Row( modifier = Modifier .fillMaxWidth() - .background( - SurfaceRaised, - ).border(width = 1.dp, color = SurfaceOutline) + .background(SurfaceRaised) + .border(width = 1.dp, color = SurfaceOutline) .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -347,9 +500,8 @@ private fun PillButton( Box( modifier = modifier - .background( - background, - ).border(width = 1.dp, color = border) + .background(background) + .border(width = 1.dp, color = border) .clickable(onClick = onClick) .padding(vertical = 8.dp), contentAlignment = Alignment.Center, @@ -378,9 +530,8 @@ private fun RiskMeter(mines: Int) { Box( modifier = Modifier - .align( - Alignment.CenterEnd, - ).fillMaxWidth(maskFraction) + .align(Alignment.CenterEnd) + .fillMaxWidth(maskFraction) .fillMaxHeight() .background(SurfaceBase), ) @@ -389,19 +540,23 @@ private fun RiskMeter(mines: Int) { } @Composable -private fun PlaceBetButton() { +private fun ActionButton( + label: String, + enabled: Boolean, + background: Color, + onClick: () -> Unit, +) { Box( modifier = Modifier .fillMaxWidth() - .background(AccentViolet) - .clickable { - // round logic ships with the games card - }.padding(vertical = 12.dp), + .background(background.copy(alpha = if (enabled) 1f else DISABLED_ALPHA)) + .clickable(enabled = enabled, onClick = onClick) + .padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { Text( - text = "Place Bet", + text = label, color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, @@ -430,15 +585,12 @@ private fun riskFraction(mines: Int): Float = } private const val SHARE_MESSAGE = "Mines on Stack Casino - provably fair, 5x5 grid, RTP 99%." -private val RECENT_RESULTS = listOf(true, false, true, true) private val BET_PRESETS = listOf(5, 10, 25, 100) private const val MINES_LOW = 3 private const val MINES_MID = 5 private const val MINES_HIGH = 10 private const val MINES_MAX = 24 private val MINE_COUNTS = listOf(MINES_LOW, MINES_MID, MINES_HIGH, MINES_MAX) -private const val DEFAULT_PRESET = 10 -private const val DEFAULT_MINES = MINES_LOW private const val GRID_SIZE = 5 private const val RISK_LOW = 0.25f @@ -461,7 +613,8 @@ private val HeaderIconSize = 16.dp private val SubtitleIconSize = 10.dp private val RecentChipSize = 18.dp private val GridGap = 6.dp -private val ProgressBarHeight = 2.dp +private val TileDotSize = 12.dp +private val ProgressBarHeight = 4.dp private val RiskBarHeight = 2.dp private val TinyFontSize = 9.sp @@ -471,11 +624,22 @@ private val TrackedLetterSpacing = 1.2.sp private const val CHIP_TINT_ALPHA = 0.15f private const val CHIP_BORDER_ALPHA = 0.40f private const val PILL_SELECTED_ALPHA = 0.10f +private const val TILE_TINT_ALPHA = 0.18f +private const val DISABLED_ALPHA = 0.40f @Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 800) @Composable private fun MinesScreenPreview() { StackcasinoTheme { - MinesScreen(onBack = {}) + MinesScreen( + state = MinesUiState(balanceLabel = "$1,234.56", sessionProfitLabel = "+$15.10"), + onBack = {}, + onPreset = {}, + onMines = {}, + onPlaceBet = {}, + onRevealTile = {}, + onCashout = {}, + onNewBet = {}, + ) } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesUiState.kt new file mode 100644 index 0000000..d2e1907 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesUiState.kt @@ -0,0 +1,50 @@ +package com.plainstudio.stackcasino.feature.mines + +/** Lifecycle of a mines round. */ +enum class MinesPhase { + /** No bet placed; the grid is locked and the controls pick bet + mines. */ + Idle, + + /** Bet placed; tiles can be revealed and the player can cash out. */ + Playing, + + /** A mine was hit; the stake is lost and the board is revealed. */ + Lost, + + /** The player cashed out (or cleared the board) for a payout. */ + CashedOut, +} + +/** Render state of a single grid tile. */ +enum class TileState { Hidden, Safe, Mine } + +/** + * Everything the mines screen renders. Balance, session profit and the + * recent strip are derived from the Room-backed wallet + round streams; + * the rest is the live round state owned by [MinesViewModel]. + */ +data class MinesUiState( + val phase: MinesPhase = MinesPhase.Idle, + val betAmount: Int = DEFAULT_BET, + val mineCount: Int = DEFAULT_MINES, + val tiles: List = List(GRID) { TileState.Hidden }, + val balanceLabel: String = "$0.00", + val sessionProfitLabel: String = "+$0.00", + val multiplierLabel: String = "1.00x", + val nextGainLabel: String = "-", + val cashoutLabel: String = "", + val safeRevealed: Int = 0, + val recentResults: List = emptyList(), + val statusLabel: String = "Set your bet below", +) { + /** Cash out is offered once the round is live and at least one safe tile is open. */ + val canCashout: Boolean get() = phase == MinesPhase.Playing && safeRevealed > 0 + + val isPlaying: Boolean get() = phase == MinesPhase.Playing + + companion object { + const val GRID = 25 + const val DEFAULT_BET = 10 + const val DEFAULT_MINES = 3 + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesViewModel.kt new file mode 100644 index 0000000..4c5fd21 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/mines/MinesViewModel.kt @@ -0,0 +1,246 @@ +package com.plainstudio.stackcasino.feature.mines + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.plainstudio.stackcasino.domain.game.GameRepository +import com.plainstudio.stackcasino.domain.game.MinesMath +import com.plainstudio.stackcasino.domain.game.MinesRound +import com.plainstudio.stackcasino.domain.game.MinesStart +import com.plainstudio.stackcasino.domain.wallet.WalletRepository +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.Locale +import javax.inject.Inject + +/** + * Drives the mines screen. Owns the live round (the committed board, the + * revealed tiles and the running multiplier) and maps the Room-backed + * wallet + round streams into the balance, session profit and recent + * strip. The bet, reveals and cash out delegate to [GameRepository]; + * failures surface as one-shot [events]. + * + * The committed mine positions live here so reveals resolve locally, the + * same client-side trust model as the rest of the games (the integrity + * guarantee is the provably-fair hash recorded with the round). + */ +@HiltViewModel +class MinesViewModel + @Inject + constructor( + private val gameRepository: GameRepository, + private val walletRepository: WalletRepository, + ) : ViewModel() { + private val _state = MutableStateFlow(MinesUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + private var round: MinesRound? = null + private val revealedSafe = mutableSetOf() + + init { + viewModelScope.launch { walletRepository.ensureSeeded() } + viewModelScope.launch { + combine(walletRepository.wallet, gameRepository.rounds) { wallet, rounds -> + val mines = rounds.filter { it.game == GameKey.Mines } + Stats( + balance = wallet.availableBalance, + recent = mines.take(RECENT_COUNT).map { it.outcome == RoundOutcome.Win }, + profit = mines.sumOf { it.netProfit }, + ) + }.collect { stats -> + _state.update { + it.copy( + balanceLabel = "$" + money(stats.balance), + sessionProfitLabel = signedMoney(stats.profit), + recentResults = stats.recent, + ) + } + } + } + } + + fun onPreset(amount: Int) { + if (_state.value.phase != MinesPhase.Playing) _state.update { it.resetToIdle().copy(betAmount = amount) } + } + + fun onMines(count: Int) { + if (_state.value.phase != MinesPhase.Playing) _state.update { it.resetToIdle().copy(mineCount = count) } + } + + fun onPlaceBet() { + val current = _state.value + if (current.phase == MinesPhase.Playing) return + viewModelScope.launch { + when (val result = gameRepository.startMines(current.mineCount, current.betAmount.toDouble())) { + is MinesStart.Success -> beginRound(result.round) + is MinesStart.Failure -> _events.emit(result.reason) + } + } + } + + fun onRevealTile(index: Int) { + val current = _state.value + val active = round ?: return + if (current.phase != MinesPhase.Playing || current.tiles[index] != TileState.Hidden) return + if (index in active.minePositions) { + _state.update { + it.copy( + phase = MinesPhase.Lost, + tiles = buildTiles(active, revealMines = true), + cashoutLabel = "", + nextGainLabel = "-", + statusLabel = "Boom! You hit a mine.", + ) + } + settle(active, safeRevealed = revealedSafe.size, cashedOut = false) + } else { + revealSafe(active, index) + } + } + + fun onCashout() { + val active = round ?: return + if (!_state.value.canCashout) return + cashOut(active, revealedSafe.size) + } + + /** Clears a finished board back to the setup controls, keeping the bet + mine count. */ + fun onNewBet() { + if (_state.value.phase != MinesPhase.Playing) _state.update { it.resetToIdle() } + } + + private fun beginRound(newRound: MinesRound) { + round = newRound + revealedSafe.clear() + _state.update { + it.copy( + phase = MinesPhase.Playing, + tiles = List(MinesUiState.GRID) { TileState.Hidden }, + safeRevealed = 0, + multiplierLabel = formatMultiplier(1.0), + nextGainLabel = nextGainLabel(newRound, safeRevealed = 0), + cashoutLabel = "", + statusLabel = "Tap a tile to reveal", + ) + } + } + + private fun revealSafe( + active: MinesRound, + index: Int, + ) { + revealedSafe.add(index) + val safe = revealedSafe.size + val safeTiles = MinesMath.GRID - active.numMines + if (safe == safeTiles) { + cashOut(active, safe) + return + } + val multiplier = MinesMath.multiplier(active.numMines, safe) + _state.update { + it.copy( + tiles = buildTiles(active, revealMines = false), + safeRevealed = safe, + multiplierLabel = formatMultiplier(multiplier), + nextGainLabel = nextGainLabel(active, safe), + cashoutLabel = "Cash Out $" + money(active.stake * multiplier), + statusLabel = "Safe - keep going or cash out", + ) + } + } + + private fun cashOut( + active: MinesRound, + safe: Int, + ) { + val multiplier = MinesMath.multiplier(active.numMines, safe) + val payout = active.stake * multiplier + _state.update { + it.copy( + phase = MinesPhase.CashedOut, + tiles = buildTiles(active, revealMines = true), + safeRevealed = safe, + multiplierLabel = formatMultiplier(multiplier), + nextGainLabel = "-", + cashoutLabel = "", + statusLabel = "Cashed out $" + money(payout), + ) + } + settle(active, safeRevealed = safe, cashedOut = true) + } + + private fun settle( + active: MinesRound, + safeRevealed: Int, + cashedOut: Boolean, + ) { + round = null + viewModelScope.launch { gameRepository.settleMines(active, safeRevealed, cashedOut) } + } + + private fun buildTiles( + active: MinesRound, + revealMines: Boolean, + ): List = + List(MinesUiState.GRID) { index -> + when { + revealMines && index in active.minePositions -> TileState.Mine + index in revealedSafe -> TileState.Safe + else -> TileState.Hidden + } + } + + private fun nextGainLabel( + active: MinesRound, + safeRevealed: Int, + ): String { + val safeTiles = MinesMath.GRID - active.numMines + if (safeRevealed >= safeTiles) return "-" + val current = MinesMath.multiplier(active.numMines, safeRevealed) + val next = MinesMath.multiplier(active.numMines, safeRevealed + 1) + return "+$" + money(active.stake * (next - current)) + } + + private fun MinesUiState.resetToIdle(): MinesUiState = + if (phase == MinesPhase.Idle) { + this + } else { + copy( + phase = MinesPhase.Idle, + tiles = List(MinesUiState.GRID) { TileState.Hidden }, + safeRevealed = 0, + multiplierLabel = formatMultiplier(1.0), + nextGainLabel = "-", + cashoutLabel = "", + statusLabel = "Set your bet below", + ) + } + + private fun formatMultiplier(value: Double): String = String.format(Locale.US, "%.2f", value) + "x" + + private fun money(value: Double): String = String.format(Locale.US, "%,.2f", value) + + private fun signedMoney(value: Double): String = (if (value < 0) "-$" else "+$") + money(kotlin.math.abs(value)) + + private data class Stats( + val balance: Double, + val recent: List, + val profit: Double, + ) + + private companion object { + const val RECENT_COUNT = 4 + } + } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModel.kt index 809a28b..d10909c 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModel.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModel.kt @@ -53,9 +53,7 @@ class RoundDetailViewModel } private fun GameRound.toDetailData(): RoundDetailData { - val landed = CoinSide.fromEngineResult(resultRaw) val won = outcome == RoundOutcome.Win - val resultTone = if (won) StatTone.Accent else StatTone.Danger val short = shortId() return RoundDetailData( game = game, @@ -64,16 +62,11 @@ class RoundDetailViewModel outcome = outcome, netProfitLabel = signedMoney(netProfit), summaryLabel = "Bet $${money(stake)} · Payout $${money(payout)}", - stats = - listOf( - RoundStat(label = "Bet", value = "$${money(stake)}", tone = StatTone.Neutral), - RoundStat(label = "Prediction", value = pick, tone = StatTone.Accent), - RoundStat(label = "Result", value = landed.name, tone = resultTone), - ), - // The crash bar is crash-specific; coinflip has no cashout-vs-crash viz. + stats = stats(won), + // The crash bar is crash-specific; the other games have no cashout-vs-crash viz. crashBar = null, safeByLabel = null, - timeline = buildTimeline(won, landed), + timeline = buildTimeline(won), provablyFair = ProvablyFair( serverSeed = SeedValue("Server Seed", serverSeed), @@ -86,15 +79,43 @@ class RoundDetailViewModel ) } - private fun GameRound.buildTimeline( - won: Boolean, - landed: CoinSide, - ): List { + private fun GameRound.stats(won: Boolean): List { + val resultTone = if (won) StatTone.Accent else StatTone.Danger + return when (game) { + GameKey.Coinflip -> + listOf( + RoundStat("Bet", "$${money(stake)}", StatTone.Neutral), + RoundStat("Prediction", pick, StatTone.Accent), + RoundStat("Result", CoinSide.fromEngineResult(resultRaw).name, resultTone), + ) + else -> + listOf( + RoundStat("Bet", "$${money(stake)}", StatTone.Neutral), + RoundStat("Setup", pick, StatTone.Accent), + RoundStat("Multiplier", "${multiplierText()}x", resultTone), + ) + } + } + + private fun GameRound.buildTimeline(won: Boolean): List { val at = TIME_FORMAT.format(Date(timestamp)) + val outcomeEvent = + when (game) { + GameKey.Coinflip -> { + val landed = CoinSide.fromEngineResult(resultRaw).name + TimelineEvent("Landed $landed", at, if (won) TimelineTone.Ok else TimelineTone.Danger) + } + else -> + if (won) { + TimelineEvent("Cashed out at ${multiplierText()}x", at, TimelineTone.Ok) + } else { + TimelineEvent("Hit a mine", at, TimelineTone.Danger) + } + } return listOf( TimelineEvent("Round started", at, TimelineTone.Neutral), TimelineEvent("Bet placed · $${money(stake)}", at, TimelineTone.Neutral), - TimelineEvent("Landed ${landed.name}", at, if (won) TimelineTone.Ok else TimelineTone.Danger), + outcomeEvent, if (won) { TimelineEvent("Payout credited · $${money(payout)}", at, TimelineTone.Ok) } else { @@ -103,6 +124,8 @@ class RoundDetailViewModel ) } + private fun GameRound.multiplierText(): String = String.format(Locale.US, "%.2f", multiplier) + private fun GameRound.shortId(): String = id.filter { it.isLetterOrDigit() }.take(SHORT_ID_LEN) private fun GameKey.displayName(): String = diff --git a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt index 2204879..add19f1 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt @@ -29,6 +29,7 @@ import com.plainstudio.stackcasino.feature.lobby.LobbyScreen import com.plainstudio.stackcasino.feature.lobby.LobbyUiState import com.plainstudio.stackcasino.feature.lobby.previewLobbyData import com.plainstudio.stackcasino.feature.mines.MinesScreen +import com.plainstudio.stackcasino.feature.mines.MinesViewModel import com.plainstudio.stackcasino.feature.news.NewsDetailScreen import com.plainstudio.stackcasino.feature.news.NewsScreen import com.plainstudio.stackcasino.feature.notifications.NotificationsSheet @@ -151,7 +152,22 @@ private fun NavGraphBuilder.addSecondaryRoutes(navController: NavHostController) ) } composable(Route.Mines.path) { - MinesScreen(onBack = { navController.popBackStack() }) + val viewModel: MinesViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val toast = LocalToastController.current + LaunchedEffect(viewModel) { + viewModel.events.collect { message -> toast.show(ToastData(ToastType.Error, message)) } + } + MinesScreen( + state = state, + onBack = { navController.popBackStack() }, + onPreset = viewModel::onPreset, + onMines = viewModel::onMines, + onPlaceBet = viewModel::onPlaceBet, + onRevealTile = viewModel::onRevealTile, + onCashout = viewModel::onCashout, + onNewBet = viewModel::onNewBet, + ) } composable(Route.Crash.path) { CrashScreen(onBack = { navController.popBackStack() }) diff --git a/app/src/test/java/com/plainstudio/stackcasino/data/game/GameRepositoryImplTest.kt b/app/src/test/java/com/plainstudio/stackcasino/data/game/GameRepositoryImplTest.kt index 01155e1..702286b 100644 --- a/app/src/test/java/com/plainstudio/stackcasino/data/game/GameRepositoryImplTest.kt +++ b/app/src/test/java/com/plainstudio/stackcasino/data/game/GameRepositoryImplTest.kt @@ -5,6 +5,9 @@ import com.plainstudio.stackcasino.data.local.GameRoundEntity import com.plainstudio.stackcasino.domain.game.CoinSide import com.plainstudio.stackcasino.domain.game.EngineResult import com.plainstudio.stackcasino.domain.game.GameEngine +import com.plainstudio.stackcasino.domain.game.MinesMath +import com.plainstudio.stackcasino.domain.game.MinesRound +import com.plainstudio.stackcasino.domain.game.MinesStart import com.plainstudio.stackcasino.domain.game.PlayResult import com.plainstudio.stackcasino.domain.game.SeedGenerator import com.plainstudio.stackcasino.domain.wallet.BetSettlement @@ -166,6 +169,85 @@ class GameRepositoryImplTest { assertEquals(null, repository().getRound("missing")) } + @Test + fun `startMines draws the board and debits the stake`() = + runTest { + every { engine.evaluateMines(any(), any(), any(), 3) } returns EngineResult("0,5,12", HASH) + + val result = repository().startMines(numMines = 3, stake = 50.0) + + assertTrue(result is MinesStart.Success) + val round = (result as MinesStart.Success).round + assertEquals(setOf(0, 5, 12), round.minePositions) + assertEquals(3, round.numMines) + coVerify { walletRepository.applyRoundResult(50.0, 0.0) } + } + + @Test + fun `startMines rejects a stake above the balance without drawing`() = + runTest { + val result = repository().startMines(numMines = 3, stake = 5000.0) + + assertTrue(result is MinesStart.Failure) + verify(exactly = 0) { engine.evaluateMines(any(), any(), any(), any()) } + } + + @Test + fun `startMines rejects an out-of-range mine count`() = + runTest { + val result = repository().startMines(numMines = 30, stake = 10.0) + + assertTrue(result is MinesStart.Failure) + } + + @Test + fun `settleMines on cashout credits the payout and records a win`() = + runTest { + repository().settleMines(minesRound(), safeRevealed = 2, cashedOut = true) + + val expectedPayout = 10.0 * MinesMath.multiplier(numMines = 3, safeRevealed = 2) + coVerify { walletRepository.applyRoundResult(0.0, expectedPayout) } + coVerify { + dao.insertRound( + withArg { + assertTrue(it.won) + assertEquals("Mines", it.game) + assertEquals(expectedPayout, it.payout, 0.0001) + }, + ) + } + } + + @Test + fun `settleMines on a mine hit records a loss without crediting`() = + runTest { + repository().settleMines(minesRound(), safeRevealed = 1, cashedOut = false) + + coVerify(exactly = 0) { walletRepository.applyRoundResult(any(), any()) } + coVerify { + dao.insertRound( + withArg { + assertEquals(false, it.won) + assertEquals(0.0, it.payout, 0.0) + }, + ) + } + } + + private fun minesRound() = + MinesRound( + id = "mines-round", + numMines = 3, + minePositions = setOf(0, 5, 12), + stake = 10.0, + currency = "USDC", + serverSeed = "server", + clientSeed = "client", + nonce = 1L, + hash = HASH, + resultRaw = "0,5,12", + ) + private fun wallet(available: Double) = Wallet( availableBalance = available, diff --git a/app/src/test/java/com/plainstudio/stackcasino/domain/game/MinesMathTest.kt b/app/src/test/java/com/plainstudio/stackcasino/domain/game/MinesMathTest.kt new file mode 100644 index 0000000..d8ea1f6 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/domain/game/MinesMathTest.kt @@ -0,0 +1,44 @@ +package com.plainstudio.stackcasino.domain.game + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class MinesMathTest { + @Test + fun `no reveals pays even money`() { + assertEquals(1.0, MinesMath.multiplier(numMines = 3, safeRevealed = 0), 0.0) + } + + @Test + fun `the multiplier grows with each safe reveal`() { + val one = MinesMath.multiplier(numMines = 3, safeRevealed = 1) + val two = MinesMath.multiplier(numMines = 3, safeRevealed = 2) + assertTrue(two > one) + assertTrue(one > 1.0) + } + + @Test + fun `first safe reveal applies the fair odds and house edge`() { + // 3 mines: fair = 25/22; with the 0.99 house edge that is ~1.125. + assertEquals(1.125, MinesMath.multiplier(numMines = 3, safeRevealed = 1), 0.001) + } + + @Test + fun `more mines pays more for the same reveal count`() { + val low = MinesMath.multiplier(numMines = 3, safeRevealed = 1) + val high = MinesMath.multiplier(numMines = 10, safeRevealed = 1) + assertTrue(high > low) + } + + @Test + fun `an out-of-range mine count is rejected`() { + assertThrows(IllegalArgumentException::class.java) { + MinesMath.multiplier(numMines = 0, safeRevealed = 0) + } + assertThrows(IllegalArgumentException::class.java) { + MinesMath.multiplier(numMines = 25, safeRevealed = 0) + } + } +} diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/mines/MinesViewModelTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/mines/MinesViewModelTest.kt new file mode 100644 index 0000000..28ac972 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/mines/MinesViewModelTest.kt @@ -0,0 +1,130 @@ +package com.plainstudio.stackcasino.feature.mines + +import app.cash.turbine.test +import com.plainstudio.stackcasino.domain.game.GameRepository +import com.plainstudio.stackcasino.domain.game.MinesRound +import com.plainstudio.stackcasino.domain.game.MinesStart +import com.plainstudio.stackcasino.domain.wallet.Wallet +import com.plainstudio.stackcasino.domain.wallet.WalletRepository +import com.plainstudio.stackcasino.testing.MainDispatcherRule +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class MinesViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val gameRepository = mockk(relaxed = true) + private val walletRepository = mockk(relaxed = true) + + @Before + fun setUp() { + every { gameRepository.rounds } returns flowOf(emptyList()) + every { walletRepository.wallet } returns + flowOf(Wallet(availableBalance = 1000.0, lockedBalance = 0.0, polygonAddress = "0x", currencyCode = "USDC")) + coEvery { walletRepository.ensureSeeded() } just Runs + } + + private fun viewModel() = MinesViewModel(gameRepository, walletRepository) + + @Test + fun `presets and mine count update the idle state`() = + runTest { + val viewModel = viewModel() + + viewModel.onPreset(25) + viewModel.onMines(10) + + assertEquals(25, viewModel.state.value.betAmount) + assertEquals(10, viewModel.state.value.mineCount) + } + + @Test + fun `placing a bet then revealing a safe tile raises the multiplier`() = + runTest { + coEvery { gameRepository.startMines(any(), any()) } returns MinesStart.Success(round(mines = setOf(0))) + val viewModel = viewModel() + + viewModel.onPlaceBet() + assertEquals(MinesPhase.Playing, viewModel.state.value.phase) + + viewModel.onRevealTile(1) + + assertEquals(1, viewModel.state.value.safeRevealed) + assertEquals(TileState.Safe, viewModel.state.value.tiles[1]) + assertTrue(viewModel.state.value.canCashout) + } + + @Test + fun `hitting a mine loses and settles the round`() = + runTest { + coEvery { gameRepository.startMines(any(), any()) } returns MinesStart.Success(round(mines = setOf(0))) + coEvery { gameRepository.settleMines(any(), any(), any()) } just Runs + val viewModel = viewModel() + + viewModel.onPlaceBet() + viewModel.onRevealTile(0) + + assertEquals(MinesPhase.Lost, viewModel.state.value.phase) + assertEquals(TileState.Mine, viewModel.state.value.tiles[0]) + coVerify { gameRepository.settleMines(any(), 0, false) } + } + + @Test + fun `cashing out settles the round as a win`() = + runTest { + coEvery { gameRepository.startMines(any(), any()) } returns MinesStart.Success(round(mines = setOf(0))) + coEvery { gameRepository.settleMines(any(), any(), any()) } just Runs + val viewModel = viewModel() + + viewModel.onPlaceBet() + viewModel.onRevealTile(1) + viewModel.onCashout() + + assertEquals(MinesPhase.CashedOut, viewModel.state.value.phase) + coVerify { gameRepository.settleMines(any(), 1, true) } + } + + @Test + fun `a rejected bet emits the reason and stays idle`() = + runTest { + coEvery { gameRepository.startMines(any(), any()) } returns MinesStart.Failure(REASON) + val viewModel = viewModel() + + viewModel.events.test { + viewModel.onPlaceBet() + assertEquals(REASON, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + assertEquals(MinesPhase.Idle, viewModel.state.value.phase) + } + + private fun round(mines: Set) = + MinesRound( + id = "round-id", + numMines = 3, + minePositions = mines, + stake = 10.0, + currency = "USDC", + serverSeed = "s", + clientSeed = "c", + nonce = 1L, + hash = "h", + resultRaw = mines.joinToString(","), + ) + + private companion object { + const val REASON = "Not enough balance for this bet." + } +}