diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e90c1e..53b9891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Playable Crash (cu-07): the third game, wired end-to-end with a + real-time climbing multiplier. `CrashViewModel` -> `GameRepository` + validates and debits the stake and draws the crash point on the C++/JNI + engine (committed via the hash); a ticker animates the multiplier along + an exponential curve (`CrashCurve`). Manual cash out or an optional + auto-cashout target credits `stake x multiplier` + (`WalletRepository.applyRoundResult`); reaching the crash point first + loses the stake. The live multiplier is a dedicated `StateFlow` so only + the chart and hero recompose per frame (the recomposition story for the + Profiler report). The round persists to `game_round` and the round + detail rebuilds the cashout-vs-crash position bar with the real seeds. + Covered by `CrashCurveTest`, `CrashViewModelTest` and the + `startCrash`/`settleCrash` cases in `GameRepositoryImplTest`. + - 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 diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/crash/CrashScreenTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/crash/CrashScreenTest.kt index 545e60c..732af04 100644 --- a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/crash/CrashScreenTest.kt +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/crash/CrashScreenTest.kt @@ -1,5 +1,9 @@ package com.plainstudio.stackcasino.feature.crash +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,26 @@ class CrashScreenTest { @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 { - CrashScreen(onBack = onBack) + var state by remember { mutableStateOf(CrashUiState()) } + CrashScreen( + state = state, + multiplier = 1.0, + onBack = onBack, + onBetChange = { state = state.copy(betText = it) }, + onAutoChange = { state = state.copy(autoText = it) }, + onPreset = { state = state.copy(betText = "$it.00") }, + onPlaceBet = {}, + 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 d4465ec..1932dde 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 @@ -3,6 +3,8 @@ package com.plainstudio.stackcasino.data.game import com.plainstudio.stackcasino.data.local.GameRoundDao import com.plainstudio.stackcasino.data.local.GameRoundEntity import com.plainstudio.stackcasino.domain.game.CoinSide +import com.plainstudio.stackcasino.domain.game.CrashRound +import com.plainstudio.stackcasino.domain.game.CrashStart import com.plainstudio.stackcasino.domain.game.GameEngine import com.plainstudio.stackcasino.domain.game.GameRepository import com.plainstudio.stackcasino.domain.game.GameRound @@ -162,6 +164,68 @@ class GameRepositoryImpl ) } + override suspend fun startCrash(stake: Double): CrashStart { + val wallet = walletRepository.wallet.first() + stakeError(stake, wallet.availableBalance)?.let { return CrashStart.Failure(it) } + + val serverSeed = seedGenerator.newSeed() + val clientSeed = seedGenerator.newSeed() + val roundNonce = nonce.incrementAndGet() + val drawn = + runCatching { + val outcome = engine.evaluateCrashPoint(serverSeed, clientSeed, roundNonce) + outcome to outcome.result.trim().toDouble() + }.getOrElse { return CrashStart.Failure("The game engine is unavailable.") } + + when (walletRepository.applyRoundResult(stake, 0.0)) { + BetSettlement.InsufficientFunds -> return CrashStart.Failure(INSUFFICIENT_FUNDS) + BetSettlement.Success -> Unit + } + val (outcome, crashPoint) = drawn + return CrashStart.Success( + CrashRound( + id = idGenerator.newId(), + stake = stake, + currency = wallet.currencyCode, + crashPoint = crashPoint, + serverSeed = serverSeed, + clientSeed = clientSeed, + nonce = roundNonce, + hash = outcome.hash, + resultRaw = outcome.result.trim(), + ), + ) + } + + override suspend fun settleCrash( + round: CrashRound, + cashoutMultiplier: Double, + cashedOut: Boolean, + ) { + val payout = if (cashedOut) round.stake * cashoutMultiplier else 0.0 + if (cashedOut && payout > 0.0) { + walletRepository.applyRoundResult(0.0, payout) + } + dao.insertRound( + GameRoundEntity( + id = round.id, + game = GameKey.Crash.name, + pick = "", + stake = round.stake, + payout = payout, + won = cashedOut, + multiplier = if (cashedOut) cashoutMultiplier else 0.0, + 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() 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 938ee5d..e3e08d3 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 @@ -30,6 +30,12 @@ class NativeGameEngineAdapter numMines: Int, ): EngineResult = parse(native.evaluateMines(serverSeed, clientSeed, nonce, numMines)) + override fun evaluateCrashPoint( + serverSeed: String, + clientSeed: String, + nonce: Long, + ): EngineResult = parse(native.evaluateCrashPoint(serverSeed, clientSeed, nonce)) + 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/CrashCurve.kt b/app/src/main/java/com/plainstudio/stackcasino/domain/game/CrashCurve.kt new file mode 100644 index 0000000..58fab44 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/domain/game/CrashCurve.kt @@ -0,0 +1,18 @@ +package com.plainstudio.stackcasino.domain.game + +import kotlin.math.exp + +/** + * The growing multiplier curve a crash round follows over time. Pure and + * unit-testable so the ViewModel's ticker just samples it: the displayed + * multiplier is `e^(elapsedMs / TAU_MS)`, starting at 1.00 and + * accelerating (about 2x at 2s, 4x at 4s) until it reaches the round's + * crash point. + */ +object CrashCurve { + /** Time constant: tuned so the curve hits 2.0x at ~2 seconds. */ + const val TAU_MS = 2885.0 + + /** The multiplier shown after [elapsedMs] of the round. */ + fun multiplierAt(elapsedMs: Long): Double = exp(elapsedMs / TAU_MS) +} 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 3f509de..c9a212a 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 @@ -45,4 +45,16 @@ interface GameEngine { nonce: Long, numMines: Int, ): EngineResult + + /** + * Evaluates a crash round. Returns the crash point (a two-decimal + * multiplier string, e.g. "3.18") and the derivation hash. + * + * @throws IllegalArgumentException for empty seeds or a non-positive nonce. + */ + fun evaluateCrashPoint( + serverSeed: String, + clientSeed: String, + nonce: Long, + ): 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 a761748..69c2f63 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 @@ -45,6 +45,34 @@ sealed interface MinesStart { ) : MinesStart } +/** + * A crash round in progress. The crash point is drawn at bet placement + * (committed via [hash]); the ViewModel animates the multiplier up to it + * and settles through [GameRepository.settleCrash]. + */ +data class CrashRound( + val id: String, + val stake: Double, + val currency: String, + val crashPoint: Double, + val serverSeed: String, + val clientSeed: String, + val nonce: Long, + val hash: String, + val resultRaw: String, +) + +/** Outcome of placing a crash bet (the stake is debited on success). */ +sealed interface CrashStart { + data class Success( + val round: CrashRound, + ) : CrashStart + + data class Failure( + val reason: String, + ) : CrashStart +} + /** * Plays provably-fair game rounds. Orchestrates the native [GameEngine], * the wallet settlement and the Room round ledger; Room stays the single @@ -90,4 +118,23 @@ interface GameRepository { safeRevealed: Int, cashedOut: Boolean, ) + + /** + * Places a crash bet: validates the stake, draws the crash point on + * the native engine and debits the stake. The returned [CrashRound] + * carries the committed crash point for the ViewModel to animate to. + */ + suspend fun startCrash(stake: Double): CrashStart + + /** + * Settles a crash round and records it: on [cashedOut] the payout + * (stake x [cashoutMultiplier]) is credited; on a crash nothing is + * credited (the stake was debited at start). Either way the round is + * written to the `game_round` ledger. + */ + suspend fun settleCrash( + round: CrashRound, + cashoutMultiplier: Double, + cashedOut: Boolean, + ) } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashScreen.kt index 669d005..ab6b15c 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashScreen.kt @@ -36,8 +36,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle @@ -56,40 +58,48 @@ import com.plainstudio.stackcasino.ui.theme.SurfaceRaised import com.plainstudio.stackcasino.ui.theme.TextHigh import com.plainstudio.stackcasino.ui.theme.TextLow import com.plainstudio.stackcasino.ui.theme.TextMedium +import java.util.Locale +import kotlin.math.ln +import kotlin.math.pow /** - * Crash game screen reproducing the mockup (mockup/js/screens/crash.js) - * as a static shell: the header, recent results strip, stats strip, the - * play area (gridded chart with the 1.00x multiplier hero and status) - * and the bet controls (bet amount, presets, auto-cashout field, Place - * Bet). + * Crash game screen (mockup/js/screens/crash.js). * - * The bet amount and auto-cashout fields are editable and the presets - * fill the bet amount; all of it is local UI state. The round itself - * (the provably-fair crash point via NativeGameEngine, the exponential - * multiplier curve, cashout and payouts) lands with the games card in - * the final entrega. + * Stateless: [CrashViewModel] owns the round logic and the climbing + * multiplier. The slow [state] drives the controls / strips; the fast + * [multiplier] (a separate flow) drives only the chart and the hero + * readout, so the per-frame ticker recomposes as little as possible. */ @Composable fun CrashScreen( + state: CrashUiState, + multiplier: Double, onBack: () -> Unit, + onBetChange: (String) -> Unit, + onAutoChange: (String) -> Unit, + onPreset: (Int) -> Unit, + onPlaceBet: () -> Unit, + onCashout: () -> Unit, + onNewBet: () -> Unit, modifier: Modifier = Modifier, ) { - var betText by rememberSaveable { mutableStateOf(DEFAULT_BET) } - var autoText by rememberSaveable { mutableStateOf("") } var showRules by rememberSaveable { mutableStateOf(false) } Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { Column(modifier = Modifier.fillMaxSize()) { CrashHeader(onBack = onBack, onOpenRules = { showRules = true }) - RecentStrip() - StatsStrip() - CrashChart(modifier = Modifier.weight(1f)) + RecentStrip(results = state.recentResults) + StatsStrip(state = state, multiplier = multiplier) + CrashChart(state = state, multiplier = multiplier, modifier = Modifier.weight(1f)) CrashControls( - betText = betText, - onBetChange = { betText = it }, - autoText = autoText, - onAutoChange = { autoText = it }, + state = state, + multiplier = multiplier, + onBetChange = onBetChange, + onAutoChange = onAutoChange, + onPreset = onPreset, + onPlaceBet = onPlaceBet, + onCashout = onCashout, + onNewBet = onNewBet, ) } } @@ -170,7 +180,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), @@ -178,7 +188,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, @@ -186,7 +196,7 @@ private fun RecentStrip() { ) } Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { - RECENT_RESULTS.forEach { cashed -> RecentChip(cashed = cashed) } + results.forEach { cashed -> RecentChip(cashed = cashed) } } } } @@ -202,14 +212,30 @@ private fun RecentChip(cashed: Boolean) { } @Composable -private fun StatsStrip() { +private fun StatsStrip( + state: CrashUiState, + multiplier: Double, +) { + val bet = state.betText.toDoubleOrNull() ?: 0.0 + val potential = if (state.isRunning) bet * multiplier else bet + 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 = "Potential", value = "$10.00", 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 = "Potential", + value = "$" + money(potential), + valueColor = SemanticOk, + modifier = Modifier.weight(1f), + ) } } @@ -223,9 +249,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) @@ -235,7 +260,23 @@ private fun StatCell( } @Composable -private fun CrashChart(modifier: Modifier = Modifier) { +private fun CrashChart( + state: CrashUiState, + multiplier: Double, + modifier: Modifier = Modifier, +) { + val curveColor = + when (state.phase) { + CrashPhase.Crashed -> SemanticDanger + CrashPhase.CashedOut -> SemanticOk + else -> AccentViolet + } + val heroColor = + when (state.phase) { + CrashPhase.Crashed -> SemanticDanger + CrashPhase.CashedOut -> SemanticOk + else -> TextHigh + } Box( modifier = modifier @@ -246,25 +287,58 @@ private fun CrashChart(modifier: Modifier = Modifier) { contentAlignment = Alignment.Center, ) { GridBackground() + CurveCanvas(multiplier = multiplier, color = curveColor) Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = "1.00×", - color = TextHigh, + text = formatMultiplier(multiplier), + color = heroColor, fontSize = HeroFontSize, fontWeight = FontWeight.Black, style = HeroStyle, ) Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Set your bet to begin", - color = TextLow, + text = if (state.phase == CrashPhase.Idle) state.statusLabel else chartCaption(state), + color = if (state.phase == CrashPhase.Idle) TextLow else heroColor, fontSize = MetaFontSize, + fontWeight = if (state.phase == CrashPhase.Idle) FontWeight.Normal else FontWeight.Bold, letterSpacing = TrackedLetterSpacing, ) } } } +private fun chartCaption(state: CrashUiState): String = + when (state.phase) { + CrashPhase.Running -> state.statusLabel + else -> state.resultLabel + } + +/** Rising exponential curve whose head tracks the current multiplier. */ +@Composable +private fun CurveCanvas( + multiplier: Double, + color: Color, +) { + Canvas(modifier = Modifier.fillMaxSize()) { + val progress = (ln(multiplier).coerceAtLeast(0.0) / ln(CHART_MAX)).coerceIn(0.0, 1.0).toFloat() + if (progress <= 0f) return@Canvas + val width = size.width + val height = size.height + val path = Path().apply { moveTo(0f, height) } + val steps = CURVE_STEPS + for (i in 1..steps) { + val t = i / steps.toFloat() + val x = t * width + val y = height - height * progress * t.toDouble().pow(CURVE_EXPONENT).toFloat() + path.lineTo(x, y) + } + drawPath(path = path, color = color.copy(alpha = CURVE_STROKE_ALPHA), style = Stroke(width = 2.dp.toPx())) + val headY = height - height * progress + drawCircle(color = color, radius = 4.dp.toPx(), center = Offset(width, headY)) + } +} + /** Faint 24dp grid evoking the mockup's `.grid-bg` chart backdrop. */ @Composable private fun GridBackground() { @@ -287,12 +361,15 @@ private fun GridBackground() { @Composable private fun CrashControls( - betText: String, + state: CrashUiState, + multiplier: Double, onBetChange: (String) -> Unit, - autoText: String, onAutoChange: (String) -> Unit, + onPreset: (Int) -> Unit, + onPlaceBet: () -> Unit, + onCashout: () -> Unit, + onNewBet: () -> Unit, ) { - val currentBet = betText.toFloatOrNull() Column( modifier = Modifier.fillMaxWidth().padding( @@ -301,37 +378,76 @@ private fun CrashControls( ), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = "Bet Amount", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) - Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { - BET_PRESETS.forEach { amount -> - PresetButton( - amount = amount, - selected = amount.toFloat() == currentBet, - onClick = { onBetChange(formatPreset(amount)) }, - modifier = Modifier.weight(1f), - ) - } + when (state.phase) { + CrashPhase.Running -> { + val bet = state.betText.toDoubleOrNull() ?: 0.0 + ActionButton( + label = "Cash Out $" + money(bet * multiplier), + background = SemanticOk, + onClick = onCashout, + ) + } + CrashPhase.CashedOut, CrashPhase.Crashed -> { + Text( + text = state.resultLabel, + color = if (state.phase == CrashPhase.CashedOut) SemanticOk else SemanticDanger, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + letterSpacing = TrackedLetterSpacing, + ) + ActionButton(label = "New Bet", background = AccentViolet, onClick = onNewBet) } + CrashPhase.Idle -> + SetupControls( + state = state, + onBetChange = onBetChange, + onAutoChange = onAutoChange, + onPreset = onPreset, + onPlaceBet = onPlaceBet, + ) } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - BetField(value = betText, onValueChange = onBetChange, modifier = Modifier.weight(1f)) - AutoCashoutField( - value = autoText, - onValueChange = onAutoChange, - modifier = Modifier.width(AutoCashoutWidth), - ) + } +} + +@Composable +private fun SetupControls( + state: CrashUiState, + onBetChange: (String) -> Unit, + onAutoChange: (String) -> Unit, + onPreset: (Int) -> Unit, + onPlaceBet: () -> Unit, +) { + val currentBet = state.betText.toFloatOrNull() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = "Bet Amount", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) + Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + BET_PRESETS.forEach { amount -> + PresetButton( + amount = amount, + selected = amount.toFloat() == currentBet, + onClick = { onPreset(amount) }, + modifier = Modifier.weight(1f), + ) + } } - PlaceBetButton() } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BetField(value = state.betText, onValueChange = onBetChange, modifier = Modifier.weight(1f)) + AutoCashoutField( + value = state.autoText, + onValueChange = onAutoChange, + modifier = Modifier.width(AutoCashoutWidth), + ) + } + ActionButton(label = "Place Bet", background = AccentViolet, onClick = onPlaceBet) } @Composable @@ -343,9 +459,8 @@ private fun BetField( Row( modifier = modifier - .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), @@ -386,10 +501,7 @@ private fun PresetButton( } } -/** - * Auto-cashout target field: the multiplier the round auto-cashes at. - * Editable here; acting on the value is wired with the games card. - */ +/** Auto-cashout target: the multiplier the round auto-cashes at. */ @Composable private fun AutoCashoutField( value: String, @@ -425,19 +537,22 @@ private fun AutoCashoutField( } @Composable -private fun PlaceBetButton() { +private fun ActionButton( + label: String, + background: Color, + onClick: () -> Unit, +) { Box( modifier = Modifier .fillMaxWidth() - .background(AccentViolet) - .clickable { - // round logic ships with the games card - }.padding(vertical = 12.dp), + .background(background) + .clickable(onClick = onClick) + .padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { Text( - text = "Place Bet", + text = label, color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, @@ -456,16 +571,14 @@ private fun Context.shareText(text: String) { runCatching { startActivity(Intent.createChooser(send, null)) } } +private fun money(value: Double): String = String.format(Locale.US, "%,.2f", value) + +private fun formatMultiplier(value: Double): String = String.format(Locale.US, "%.2f", value) + "×" + private const val SHARE_MESSAGE = "Crash on Stack Casino - provably fair, exponential multiplier, RTP 99%." -/** Formats a preset amount as the two-decimal string shown in the field. */ -private fun formatPreset(amount: Int): String = "$amount.00" - -// History order matches the mockup seed: cashed, bust, cashed, cashed. -private val RECENT_RESULTS = listOf(true, false, true, true) private val BET_PRESETS = listOf(5, 10, 25, 100) -private const val DEFAULT_BET = "10.00" private val TabularNums = TextStyle(fontFeatureSettings = "tnum") private val BetTextStyle = TextStyle(color = TextHigh, fontSize = 14.sp, fontFeatureSettings = "tnum") @@ -495,11 +608,25 @@ private const val PRESET_SELECTED_ALPHA = 0.10f private const val GRID_LINE_ALPHA = 0.035f private const val CHART_BACKGROUND_ALPHA = 0.40f private const val HERO_SHADOW_ALPHA = 0.65f +private const val CHART_MAX = 20.0 +private const val CURVE_STEPS = 24 +private const val CURVE_EXPONENT = 2.2 +private const val CURVE_STROKE_ALPHA = 0.85f @Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 800) @Composable private fun CrashScreenPreview() { StackcasinoTheme { - CrashScreen(onBack = {}) + CrashScreen( + state = CrashUiState(balanceLabel = "$1,234.56", sessionProfitLabel = "+$15.10"), + multiplier = 1.0, + onBack = {}, + onBetChange = {}, + onAutoChange = {}, + onPreset = {}, + onPlaceBet = {}, + onCashout = {}, + onNewBet = {}, + ) } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashUiState.kt new file mode 100644 index 0000000..344920f --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashUiState.kt @@ -0,0 +1,39 @@ +package com.plainstudio.stackcasino.feature.crash + +/** Lifecycle of a crash round. */ +enum class CrashPhase { + /** No bet placed; the bet + auto-cashout controls are editable. */ + Idle, + + /** Bet placed; the multiplier is climbing and the player can cash out. */ + Running, + + /** The player cashed out (or auto-cashed) before the crash. */ + CashedOut, + + /** The multiplier reached the crash point before a cash out. */ + Crashed, +} + +/** + * The slow-changing crash state. The fast, per-frame multiplier is a + * separate `StateFlow` on the ViewModel so only the chart and the + * hero readout recompose while a round runs (keeping recomposition off the + * rest of the screen - the point the Profiler report makes for Crash). + */ +data class CrashUiState( + val phase: CrashPhase = CrashPhase.Idle, + val betText: String = DEFAULT_BET, + val autoText: String = "", + val balanceLabel: String = "$0.00", + val sessionProfitLabel: String = "+$0.00", + val recentResults: List = emptyList(), + val statusLabel: String = "Set your bet to begin", + val resultLabel: String = "", +) { + val isRunning: Boolean get() = phase == CrashPhase.Running + + companion object { + const val DEFAULT_BET = "10.00" + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashViewModel.kt new file mode 100644 index 0000000..4c7fbdf --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/crash/CrashViewModel.kt @@ -0,0 +1,214 @@ +package com.plainstudio.stackcasino.feature.crash + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.plainstudio.stackcasino.domain.game.CrashCurve +import com.plainstudio.stackcasino.domain.game.CrashRound +import com.plainstudio.stackcasino.domain.game.CrashStart +import com.plainstudio.stackcasino.domain.game.GameRepository +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.Job +import kotlinx.coroutines.delay +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 crash screen. Owns the live round (the committed crash point + * and the climbing multiplier) and maps the Room-backed wallet + round + * streams into the balance, session profit and recent strip. The bet and + * cash out delegate to [GameRepository]; failures surface as [events]. + * + * The per-frame multiplier is exposed as a dedicated [multiplier] flow so + * the ticker only recomposes the chart and the hero readout, not the rest + * of the screen. + */ +@HiltViewModel +class CrashViewModel + @Inject + constructor( + private val gameRepository: GameRepository, + private val walletRepository: WalletRepository, + ) : ViewModel() { + private val _state = MutableStateFlow(CrashUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _multiplier = MutableStateFlow(1.0) + val multiplier: StateFlow = _multiplier.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + private var round: CrashRound? = null + private var tickerJob: Job? = null + private var elapsedMs = 0L + + init { + viewModelScope.launch { walletRepository.ensureSeeded() } + viewModelScope.launch { + combine(walletRepository.wallet, gameRepository.rounds) { wallet, rounds -> + val crash = rounds.filter { it.game == GameKey.Crash } + Stats( + balance = wallet.availableBalance, + recent = crash.take(RECENT_COUNT).map { it.outcome == RoundOutcome.Win }, + profit = crash.sumOf { it.netProfit }, + ) + }.collect { stats -> + _state.update { + it.copy( + balanceLabel = "$" + money(stats.balance), + sessionProfitLabel = signedMoney(stats.profit), + recentResults = stats.recent, + ) + } + } + } + } + + fun onBetChange(value: String) { + if (!_state.value.isRunning) _state.update { it.copy(betText = value) } + } + + fun onAutoChange(value: String) { + if (!_state.value.isRunning) _state.update { it.copy(autoText = value) } + } + + fun onPreset(amount: Int) { + if (!_state.value.isRunning) _state.update { it.copy(betText = String.format(Locale.US, "%d.00", amount)) } + } + + fun onPlaceBet() { + val current = _state.value + if (current.isRunning) return + val stake = current.betText.toDoubleOrNull() + if (stake == null || stake <= 0.0) { + emit("Enter a bet greater than 0.") + return + } + viewModelScope.launch { + when (val result = gameRepository.startCrash(stake)) { + is CrashStart.Success -> beginRound(result.round) + is CrashStart.Failure -> emit(result.reason) + } + } + } + + fun onCashout() { + val active = round ?: return + if (_state.value.phase != CrashPhase.Running) return + tickerJob?.cancel() + cashOut(active, _multiplier.value) + } + + /** Clears a finished round back to the setup controls, keeping the bet + auto fields. */ + fun onNewBet() { + if (_state.value.isRunning) return + _multiplier.value = 1.0 + _state.update { + it.copy(phase = CrashPhase.Idle, statusLabel = "Set your bet to begin", resultLabel = "") + } + } + + private fun beginRound(newRound: CrashRound) { + round = newRound + elapsedMs = 0L + _multiplier.value = 1.0 + _state.update { + it.copy( + phase = CrashPhase.Running, + statusLabel = "Cash out before it crashes", + resultLabel = "", + ) + } + tickerJob = + viewModelScope.launch { + runTicker(newRound) + } + } + + private suspend fun runTicker(active: CrashRound) { + while (true) { + delay(TICK_MS) + elapsedMs += TICK_MS + val value = CrashCurve.multiplierAt(elapsedMs) + if (value >= active.crashPoint) { + bust(active) + return + } + val autoTarget = _state.value.autoText.toDoubleOrNull() + if (autoTarget != null && autoTarget > 1.0 && value >= autoTarget) { + cashOut(active, autoTarget) + return + } + _multiplier.value = value + } + } + + private fun cashOut( + active: CrashRound, + atMultiplier: Double, + ) { + _multiplier.value = atMultiplier + _state.update { + it.copy( + phase = CrashPhase.CashedOut, + statusLabel = "", + resultLabel = "Cashed out ${formatMultiplier(atMultiplier)}", + ) + } + settle(active, atMultiplier, cashedOut = true) + } + + private fun bust(active: CrashRound) { + _multiplier.value = active.crashPoint + _state.update { + it.copy( + phase = CrashPhase.Crashed, + statusLabel = "", + resultLabel = "Crashed at ${formatMultiplier(active.crashPoint)}", + ) + } + settle(active, atMultiplier = 0.0, cashedOut = false) + } + + private fun settle( + active: CrashRound, + atMultiplier: Double, + cashedOut: Boolean, + ) { + round = null + viewModelScope.launch { gameRepository.settleCrash(active, atMultiplier, cashedOut) } + } + + private fun emit(message: String) { + _events.tryEmit(message) + } + + 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 + const val TICK_MS = 16L + } + } 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 d10909c..891755b 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 @@ -63,8 +63,7 @@ class RoundDetailViewModel netProfitLabel = signedMoney(netProfit), summaryLabel = "Bet $${money(stake)} · Payout $${money(payout)}", stats = stats(won), - // The crash bar is crash-specific; the other games have no cashout-vs-crash viz. - crashBar = null, + crashBar = crashBar(won), safeByLabel = null, timeline = buildTimeline(won), provablyFair = @@ -88,6 +87,12 @@ class RoundDetailViewModel RoundStat("Prediction", pick, StatTone.Accent), RoundStat("Result", CoinSide.fromEngineResult(resultRaw).name, resultTone), ) + GameKey.Crash -> + listOf( + RoundStat("Bet", "$${money(stake)}", StatTone.Neutral), + RoundStat("Cashed Out", if (won) "${multiplierText()}x" else "-", resultTone), + RoundStat("Crash Point", "${crashText()}x", StatTone.Danger), + ) else -> listOf( RoundStat("Bet", "$${money(stake)}", StatTone.Neutral), @@ -99,23 +104,10 @@ class RoundDetailViewModel 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), - outcomeEvent, + outcomeEvent(won, at), if (won) { TimelineEvent("Payout credited · $${money(payout)}", at, TimelineTone.Ok) } else { @@ -124,8 +116,46 @@ class RoundDetailViewModel ) } + private fun GameRound.outcomeEvent( + won: Boolean, + at: String, + ): TimelineEvent = + when (game) { + GameKey.Coinflip -> + TimelineEvent( + "Landed ${CoinSide.fromEngineResult(resultRaw).name}", + at, + if (won) TimelineTone.Ok else TimelineTone.Danger, + ) + GameKey.Crash -> + if (won) { + TimelineEvent("Cashed out at ${multiplierText()}x", at, TimelineTone.Ok) + } else { + TimelineEvent("Crashed at ${crashText()}x", at, TimelineTone.Danger) + } + else -> + if (won) { + TimelineEvent("Cashed out at ${multiplierText()}x", at, TimelineTone.Ok) + } else { + TimelineEvent("Hit a mine", at, TimelineTone.Danger) + } + } + + private fun GameRound.crashBar(won: Boolean): CrashBar? { + if (game != GameKey.Crash) return null + val crashPoint = resultRaw.toDoubleOrNull() ?: return null + val fraction = if (won && crashPoint > 0.0) (multiplier / crashPoint).toFloat().coerceIn(0f, 1f) else 1f + return CrashBar( + cashoutFraction = fraction, + cashoutLabel = if (won) "${multiplierText()}x" else "-", + crashLabel = "${crashText()}x", + ) + } + private fun GameRound.multiplierText(): String = String.format(Locale.US, "%.2f", multiplier) + private fun GameRound.crashText(): String = String.format(Locale.US, "%.2f", resultRaw.toDoubleOrNull() ?: 0.0) + 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 add19f1..1d7b94d 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt @@ -21,6 +21,7 @@ import com.plainstudio.stackcasino.feature.blackjack.BlackjackScreen import com.plainstudio.stackcasino.feature.coinflip.CoinflipScreen import com.plainstudio.stackcasino.feature.coinflip.CoinflipViewModel import com.plainstudio.stackcasino.feature.crash.CrashScreen +import com.plainstudio.stackcasino.feature.crash.CrashViewModel import com.plainstudio.stackcasino.feature.history.HistoryScreen import com.plainstudio.stackcasino.feature.history.HistoryViewModel import com.plainstudio.stackcasino.feature.housewallet.HouseWalletScreen @@ -170,7 +171,24 @@ private fun NavGraphBuilder.addSecondaryRoutes(navController: NavHostController) ) } composable(Route.Crash.path) { - CrashScreen(onBack = { navController.popBackStack() }) + val viewModel: CrashViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val multiplier by viewModel.multiplier.collectAsStateWithLifecycle() + val toast = LocalToastController.current + LaunchedEffect(viewModel) { + viewModel.events.collect { message -> toast.show(ToastData(ToastType.Error, message)) } + } + CrashScreen( + state = state, + multiplier = multiplier, + onBack = { navController.popBackStack() }, + onBetChange = viewModel::onBetChange, + onAutoChange = viewModel::onAutoChange, + onPreset = viewModel::onPreset, + onPlaceBet = viewModel::onPlaceBet, + onCashout = viewModel::onCashout, + onNewBet = viewModel::onNewBet, + ) } composable(Route.Roulette.path) { RouletteScreen(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 702286b..177898b 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 @@ -3,6 +3,8 @@ package com.plainstudio.stackcasino.data.game import com.plainstudio.stackcasino.data.local.GameRoundDao import com.plainstudio.stackcasino.data.local.GameRoundEntity import com.plainstudio.stackcasino.domain.game.CoinSide +import com.plainstudio.stackcasino.domain.game.CrashRound +import com.plainstudio.stackcasino.domain.game.CrashStart import com.plainstudio.stackcasino.domain.game.EngineResult import com.plainstudio.stackcasino.domain.game.GameEngine import com.plainstudio.stackcasino.domain.game.MinesMath @@ -234,6 +236,73 @@ class GameRepositoryImplTest { } } + @Test + fun `startCrash draws the crash point and debits the stake`() = + runTest { + every { engine.evaluateCrashPoint(any(), any(), any()) } returns EngineResult("3.18", HASH) + + val result = repository().startCrash(stake = 50.0) + + assertTrue(result is CrashStart.Success) + assertEquals(3.18, (result as CrashStart.Success).round.crashPoint, 0.0001) + coVerify { walletRepository.applyRoundResult(50.0, 0.0) } + } + + @Test + fun `startCrash rejects a stake above the balance without drawing`() = + runTest { + val result = repository().startCrash(stake = 5000.0) + + assertTrue(result is CrashStart.Failure) + verify(exactly = 0) { engine.evaluateCrashPoint(any(), any(), any()) } + } + + @Test + fun `settleCrash on cashout credits the payout and records a win`() = + runTest { + repository().settleCrash(crashRound(), cashoutMultiplier = 2.5, cashedOut = true) + + coVerify { walletRepository.applyRoundResult(0.0, 25.0) } + coVerify { + dao.insertRound( + withArg { + assertTrue(it.won) + assertEquals("Crash", it.game) + assertEquals(2.5, it.multiplier, 0.0) + }, + ) + } + } + + @Test + fun `settleCrash on a crash records a loss without crediting`() = + runTest { + repository().settleCrash(crashRound(), cashoutMultiplier = 0.0, 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 crashRound() = + CrashRound( + id = "crash-round", + stake = 10.0, + currency = "USDC", + crashPoint = 3.18, + serverSeed = "server", + clientSeed = "client", + nonce = 1L, + hash = HASH, + resultRaw = "3.18", + ) + private fun minesRound() = MinesRound( id = "mines-round", diff --git a/app/src/test/java/com/plainstudio/stackcasino/domain/game/CrashCurveTest.kt b/app/src/test/java/com/plainstudio/stackcasino/domain/game/CrashCurveTest.kt new file mode 100644 index 0000000..fa610ea --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/domain/game/CrashCurveTest.kt @@ -0,0 +1,25 @@ +package com.plainstudio.stackcasino.domain.game + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CrashCurveTest { + @Test + fun `starts at one`() { + assertEquals(1.0, CrashCurve.multiplierAt(0), 0.0) + } + + @Test + fun `reaches about 2x at two seconds`() { + assertEquals(2.0, CrashCurve.multiplierAt(2_000), 0.01) + } + + @Test + fun `is strictly increasing over time`() { + val early = CrashCurve.multiplierAt(500) + val later = CrashCurve.multiplierAt(1_500) + assertTrue(later > early) + assertTrue(early > 1.0) + } +} diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/crash/CrashViewModelTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/crash/CrashViewModelTest.kt new file mode 100644 index 0000000..642d0b8 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/crash/CrashViewModelTest.kt @@ -0,0 +1,113 @@ +package com.plainstudio.stackcasino.feature.crash + +import app.cash.turbine.test +import com.plainstudio.stackcasino.domain.game.CrashRound +import com.plainstudio.stackcasino.domain.game.CrashStart +import com.plainstudio.stackcasino.domain.game.GameRepository +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.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class CrashViewModelTest { + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(testDispatcher) + + private val gameRepository = mockk(relaxed = true) + private val walletRepository = mockk(relaxed = true) + + private 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() = CrashViewModel(gameRepository, walletRepository) + + @Test + fun `a preset updates the bet field`() = + runTest(testDispatcher.scheduler) { + setUp() + val viewModel = viewModel() + + viewModel.onPreset(25) + + assertEquals("25.00", viewModel.state.value.betText) + } + + @Test + fun `an invalid bet emits the reason and stays idle`() = + runTest(testDispatcher.scheduler) { + setUp() + val viewModel = viewModel() + viewModel.onBetChange("0") + + viewModel.events.test { + viewModel.onPlaceBet() + assertEquals("Enter a bet greater than 0.", awaitItem()) + cancelAndIgnoreRemainingEvents() + } + assertEquals(CrashPhase.Idle, viewModel.state.value.phase) + } + + @Test + fun `placing a bet then cashing out settles a win`() = + runTest(testDispatcher.scheduler) { + setUp() + coEvery { gameRepository.startCrash(any()) } returns CrashStart.Success(round(crashPoint = 100.0)) + coEvery { gameRepository.settleCrash(any(), any(), any()) } just Runs + val viewModel = viewModel() + + viewModel.onPlaceBet() + assertEquals(CrashPhase.Running, viewModel.state.value.phase) + + viewModel.onCashout() + + assertEquals(CrashPhase.CashedOut, viewModel.state.value.phase) + coVerify { gameRepository.settleCrash(any(), any(), true) } + } + + @Test + fun `reaching the crash point settles a loss`() = + runTest(testDispatcher.scheduler) { + setUp() + // A 1.00x crash point busts on the first tick. + coEvery { gameRepository.startCrash(any()) } returns CrashStart.Success(round(crashPoint = 1.0)) + coEvery { gameRepository.settleCrash(any(), any(), any()) } just Runs + val viewModel = viewModel() + + viewModel.onPlaceBet() + advanceUntilIdle() + + assertEquals(CrashPhase.Crashed, viewModel.state.value.phase) + coVerify { gameRepository.settleCrash(any(), 0.0, false) } + } + + private fun round(crashPoint: Double) = + CrashRound( + id = "crash-round", + stake = 10.0, + currency = "USDC", + crashPoint = crashPoint, + serverSeed = "s", + clientSeed = "c", + nonce = 1L, + hash = "h", + resultRaw = String.format(java.util.Locale.US, "%.2f", crashPoint), + ) +}