diff --git a/CHANGELOG.md b/CHANGELOG.md index 7665356..0e409b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Playable Blackjack (cu-06): the fifth and final game, completing the + games card. `BlackjackViewModel` -> `GameRepository.startBlackjack` + draws the shuffled 52-card deck on the C++/JNI engine (committed via the + hash); the opening cards are dealt one at a time with a delay (player / + dealer alternating, the hole card face down). Hit / Stand / Double run + on `BlackjackMath` (soft-ace totals, dealer stands on 17, natural + blackjack pays 3:2), with the dealer revealing and drawing card by card. + The bet settles net at the end (`WalletRepository.applyRoundResult`, so + Double doubles cleanly) and the round is recorded in `game_round` with + the player/dealer totals and the real seeds. Split is out of scope. + Covered by `BlackjackMathTest`, `BlackjackViewModelTest` and the + `startBlackjack`/`settleBlackjack` cases in `GameRepositoryImplTest`. + - Playable Roulette (cu-05): the fourth game, with the full European betting table. Chips are placed on the table zones (straight 35:1, even-money 1:1, dozens/columns 2:1), accumulating the stake per cell. diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackScreenTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackScreenTest.kt index 4f337a9..5071613 100644 --- a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackScreenTest.kt +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackScreenTest.kt @@ -1,5 +1,9 @@ package com.plainstudio.stackcasino.feature.blackjack +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,24 +21,45 @@ class BlackjackScreenTest { @get:Rule val composeRule = createComposeRule() - private fun setScreen(onBack: () -> Unit = {}) { + private fun setScreen( + initial: BlackjackUiState = BlackjackUiState(), + onBack: () -> Unit = {}, + ) { composeRule.setContent { StackcasinoTheme { - BlackjackScreen(onBack = onBack) + var state by remember { mutableStateOf(initial) } + BlackjackScreen( + state = state, + onBack = onBack, + onBetChange = { state = state.copy(betText = it) }, + onPreset = { state = state.copy(betText = "$it.00") }, + onDeal = {}, + onHit = {}, + onStand = {}, + onDouble = {}, + onNewBet = {}, + ) } } } @Test - fun renders_table_and_actions() { + fun renders_the_table_and_deal_in_idle() { setScreen() composeRule.onNodeWithText("DEALER").assertIsDisplayed() composeRule.onNodeWithText("YOU").assertIsDisplayed() - composeRule.onNodeWithText("HIT").assertIsDisplayed() composeRule.onNodeWithText("DEAL").assertIsDisplayed() } + @Test + fun renders_the_actions_during_the_player_turn() { + setScreen(initial = BlackjackUiState(phase = BlackjackPhase.PlayerTurn, canHit = true, canStand = true)) + + composeRule.onNodeWithText("HIT").assertIsDisplayed() + composeRule.onNodeWithText("STAND").assertIsDisplayed() + } + @Test fun selecting_a_bet_preset_updates_the_field() { setScreen() 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 a408505..3a615ef 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 @@ -2,6 +2,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.BlackjackRound +import com.plainstudio.stackcasino.domain.game.BlackjackStart import com.plainstudio.stackcasino.domain.game.CoinSide import com.plainstudio.stackcasino.domain.game.CrashRound import com.plainstudio.stackcasino.domain.game.CrashStart @@ -289,6 +291,64 @@ class GameRepositoryImpl ) } + override suspend fun startBlackjack(stake: Double): BlackjackStart { + val wallet = walletRepository.wallet.first() + stakeError(stake, wallet.availableBalance)?.let { return BlackjackStart.Failure(it) } + + val serverSeed = seedGenerator.newSeed() + val clientSeed = seedGenerator.newSeed() + val roundNonce = nonce.incrementAndGet() + val drawn = + runCatching { + val outcome = engine.evaluateBlackjackDeck(serverSeed, clientSeed, roundNonce) + // Keep the deck order (a Set would reshuffle it). + outcome to outcome.result.split(",").map { it.trim().toInt() } + }.getOrElse { return BlackjackStart.Failure("The game engine is unavailable.") } + + val (outcome, deck) = drawn + return BlackjackStart.Success( + BlackjackRound( + id = idGenerator.newId(), + deck = deck, + stake = stake, + currency = wallet.currencyCode, + serverSeed = serverSeed, + clientSeed = clientSeed, + nonce = roundNonce, + hash = outcome.hash, + ), + ) + } + + override suspend fun settleBlackjack( + round: BlackjackRound, + effectiveStake: Double, + payout: Double, + playerTotal: Int, + dealerTotal: Int, + outcome: String, + ) { + walletRepository.applyRoundResult(effectiveStake, payout) + dao.insertRound( + GameRoundEntity( + id = round.id, + game = GameKey.Blackjack.name, + pick = outcome, + stake = effectiveStake, + payout = payout, + won = payout > effectiveStake, + multiplier = if (effectiveStake > 0.0) payout / effectiveStake else 0.0, + currency = round.currency, + serverSeed = round.serverSeed, + clientSeed = round.clientSeed, + nonce = round.nonce, + hash = round.hash, + resultRaw = "$playerTotal/$dealerTotal", + 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 fdedb74..1798d50 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 @@ -42,6 +42,12 @@ class NativeGameEngineAdapter nonce: Long, ): EngineResult = parse(native.evaluateRoulette(serverSeed, clientSeed, nonce)) + override fun evaluateBlackjackDeck( + serverSeed: String, + clientSeed: String, + nonce: Long, + ): EngineResult = parse(native.evaluateBlackjackDeck(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/BlackjackMath.kt b/app/src/main/java/com/plainstudio/stackcasino/domain/game/BlackjackMath.kt new file mode 100644 index 0000000..d3bdd7b --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/domain/game/BlackjackMath.kt @@ -0,0 +1,51 @@ +package com.plainstudio.stackcasino.domain.game + +/** + * Pure blackjack hand math over the engine's card indices (0-51, where + * `rank = card % 13` with 0 = Ace .. 12 = King and `suit = card / 13`). + * Kept separate from the engine, repository and ViewModel so the scoring + * (soft aces, bust, dealer rule) is unit-testable. + */ +object BlackjackMath { + const val DECK_SIZE = 52 + const val BLACKJACK = 21 + private const val RANKS = 13 + private const val DEALER_STANDS_ON = 17 + private const val ACE_HIGH = 11 + private const val ACE_DROP = 10 + private const val FACE_VALUE = 10 + private const val LAST_NUMBER_RANK = 9 + + /** Rank index 0-12 (0 = Ace, 10 = Jack, 11 = Queen, 12 = King). */ + fun rank(card: Int): Int = card % RANKS + + /** Suit index 0-3. */ + fun suit(card: Int): Int = card / RANKS + + /** Card value counting every Ace as 11 (soft); 2-10 face, J/Q/K = 10. */ + fun baseValue(card: Int): Int = + when (val r = rank(card)) { + 0 -> ACE_HIGH + in 1..LAST_NUMBER_RANK -> r + 1 + else -> FACE_VALUE + } + + /** Best hand total: Aces drop from 11 to 1 while the hand would bust. */ + fun total(cards: List): Int { + var sum = cards.sumOf { baseValue(it) } + var aces = cards.count { rank(it) == 0 } + while (sum > BLACKJACK && aces > 0) { + sum -= ACE_DROP + aces-- + } + return sum + } + + fun isBust(cards: List): Boolean = total(cards) > BLACKJACK + + /** A natural: exactly two cards totalling 21. */ + fun isBlackjack(cards: List): Boolean = cards.size == 2 && total(cards) == BLACKJACK + + /** The dealer draws while under 17 (stands on all 17). */ + fun dealerHits(cards: List): Boolean = total(cards) < DEALER_STANDS_ON +} 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 f39540b..79e96c4 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 @@ -69,4 +69,17 @@ interface GameEngine { clientSeed: String, nonce: Long, ): EngineResult + + /** + * Evaluates a blackjack shoe. Returns the comma-separated shuffled + * 52-card deck (each card 0-51) and the derivation hash; the caller + * deals from the deck in order. + * + * @throws IllegalArgumentException for empty seeds or a non-positive nonce. + */ + fun evaluateBlackjackDeck( + 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 6d8fb49..716b2cf 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 @@ -102,6 +102,33 @@ sealed interface RouletteStart { ) : RouletteStart } +/** + * A blackjack round in progress. The shuffled [deck] is drawn at Deal + * (committed via [hash]); the ViewModel deals from it in order and + * settles through [GameRepository.settleBlackjack]. + */ +data class BlackjackRound( + val id: String, + val deck: List, + val stake: Double, + val currency: String, + val serverSeed: String, + val clientSeed: String, + val nonce: Long, + val hash: String, +) + +/** Outcome of dealing a blackjack round (no debit yet; settled at the end). */ +sealed interface BlackjackStart { + data class Success( + val round: BlackjackRound, + ) : BlackjackStart + + data class Failure( + val reason: String, + ) : BlackjackStart +} + /** * Plays provably-fair game rounds. Orchestrates the native [GameEngine], * the wallet settlement and the Room round ledger; Room stays the single @@ -180,4 +207,25 @@ interface GameRepository { * the `game_round` ledger. */ suspend fun settleRouletteSpin(spin: RouletteSpin) + + /** + * Deals a blackjack round: validates the stake (no debit yet) and + * draws the shuffled deck on the native engine. The returned + * [BlackjackRound] carries the deck for the ViewModel to deal from. + */ + suspend fun startBlackjack(stake: Double): BlackjackStart + + /** + * Settles a finished blackjack round and records it: applies the net + * in one write (debits [effectiveStake], credits [payout]) and writes + * the round (with the player/dealer totals) to the `game_round` ledger. + */ + suspend fun settleBlackjack( + round: BlackjackRound, + effectiveStake: Double, + payout: Double, + playerTotal: Int, + dealerTotal: Int, + outcome: String, + ) } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackScreen.kt index 720fb9d..a230179 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -33,7 +34,6 @@ 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.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector @@ -45,6 +45,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.plainstudio.stackcasino.domain.game.BlackjackMath import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SemanticDanger import com.plainstudio.stackcasino.ui.theme.SemanticInfo @@ -59,33 +60,44 @@ import com.plainstudio.stackcasino.ui.theme.TextLow import com.plainstudio.stackcasino.ui.theme.TextMedium /** - * Blackjack game screen reproducing the mockup - * (mockup/js/screens/blackjack.js) as a static shell in its idle state: - * the header, recent results strip, stats strip, the empty dealer / - * player table with the status banner and the controls (editable bet - * amount with presets, the disabled HIT / STAND / DOUBLE / SPLIT actions - * and Deal). + * Blackjack game screen (mockup/js/screens/blackjack.js). * - * The bet amount and presets are local UI state; the deal, the hands, - * scoring and payouts (the provably-fair shoe via NativeGameEngine) land - * with the games card in the final entrega, which is why the per-hand - * actions stay disabled here. + * Stateless: [BlackjackViewModel] owns the dealt hands and the round + * logic; the screen renders the hoisted [state] and reports the bet, the + * deal and the per-hand actions through callbacks. The dealer hole card + * stays face down until the hand resolves. */ @Composable fun BlackjackScreen( + state: BlackjackUiState, onBack: () -> Unit, + onBetChange: (String) -> Unit, + onPreset: (Int) -> Unit, + onDeal: () -> Unit, + onHit: () -> Unit, + onStand: () -> Unit, + onDouble: () -> Unit, + onNewBet: () -> Unit, modifier: Modifier = Modifier, ) { - var betText by rememberSaveable { mutableStateOf(DEFAULT_BET) } var showRules by rememberSaveable { mutableStateOf(false) } Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { Column(modifier = Modifier.fillMaxSize()) { BlackjackHeader(onBack = onBack, onOpenRules = { showRules = true }) - RecentStrip() - StatsStrip(bet = betText) - Table(modifier = Modifier.weight(1f)) - BlackjackControls(betText = betText, onBetChange = { betText = it }) + RecentStrip(results = state.recentResults) + StatsStrip(state = state) + Table(state = state, modifier = Modifier.weight(1f)) + BlackjackControls( + state = state, + onBetChange = onBetChange, + onPreset = onPreset, + onDeal = onDeal, + onHit = onHit, + onStand = onStand, + onDouble = onDouble, + onNewBet = onNewBet, + ) } } @@ -165,7 +177,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(4.dp), @@ -173,7 +185,7 @@ private fun RecentStrip() { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text(text = "Recent", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) Text( - text = "${RECENT_RESULTS.size} rounds", + text = if (results.isEmpty()) "no rounds yet" else "${results.size} rounds", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing, @@ -181,36 +193,47 @@ private fun RecentStrip() { ) } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - RECENT_RESULTS.forEach { outcome -> HistoryChip(outcome = outcome) } + results.forEach { result -> HistoryChip(result = result) } } } } @Composable -private fun HistoryChip(outcome: Outcome) { +private fun HistoryChip(result: BlackjackResult) { + val (label, color) = + when (result) { + BlackjackResult.Win -> "W" to SemanticOk + BlackjackResult.Loss -> "L" to SemanticDanger + BlackjackResult.Push -> "P" to TextMedium + } Box( - modifier = Modifier.size(HistoryChipSize).background(outcome.color), + modifier = Modifier.size(HistoryChipSize).background(color), contentAlignment = Alignment.Center, ) { - Text( - text = outcome.label, - color = Color.White, - fontSize = if (outcome.label.length > 1) 8.sp else 10.sp, - fontWeight = FontWeight.Bold, - style = CenteredGlyph, - ) + Text(text = label, color = Color.White, fontSize = 10.sp, fontWeight = FontWeight.Bold, style = CenteredGlyph) } } @Composable -private fun StatsStrip(bet: String) { +private fun StatsStrip(state: BlackjackUiState) { + 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 = "Bet", value = "$$bet", valueColor = AccentViolet, 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 = "Bet", + value = "$" + (state.betText), + valueColor = AccentViolet, + modifier = Modifier.weight(1f), + ) } } @@ -224,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) @@ -236,36 +258,96 @@ private fun StatCell( } @Composable -private fun Table(modifier: Modifier = Modifier) { +private fun Table( + state: BlackjackUiState, + modifier: Modifier = Modifier, +) { Column( modifier = modifier.fillMaxWidth().padding(horizontal = ScreenHorizontalPadding, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(text = "DEALER", color = TextLow, fontSize = MetaFontSize, letterSpacing = TrackedLetterSpacing) - ScoreBadge(score = "—") - } - Box(modifier = Modifier.fillMaxWidth().heightIn(min = HandMinHeight)) - } + HandBlock(label = "DEALER", score = state.dealerTotalLabel, cards = state.dealerCards) Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { + val centerText = if (state.phase == BlackjackPhase.Result) state.resultLabel else state.statusLabel + val centerColor = + when { + state.phase != BlackjackPhase.Result -> TextLow + state.resultLabel.contains("+") -> SemanticOk + state.resultLabel.contains("-") -> SemanticDanger + else -> TextMedium + } Text( - text = "Place a bet and deal", - color = TextLow, - fontSize = 11.sp, + text = centerText, + color = centerColor, + fontSize = 13.sp, + fontWeight = if (state.phase == BlackjackPhase.Result) FontWeight.Bold else FontWeight.Normal, letterSpacing = TrackedLetterSpacing, ) } - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Box(modifier = Modifier.fillMaxWidth().heightIn(min = HandMinHeight)) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(text = "YOU", color = TextLow, fontSize = MetaFontSize, letterSpacing = TrackedLetterSpacing) - ScoreBadge(score = "—") - } + HandBlock(label = "YOU", score = state.playerTotalLabel, cards = state.playerCards) + } +} + +@Composable +private fun HandBlock( + label: String, + score: String, + cards: List, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = label, color = TextLow, fontSize = MetaFontSize, letterSpacing = TrackedLetterSpacing) + ScoreBadge(score = score) + } + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = HandMinHeight), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + cards.forEach { card -> CardView(card = card) } } } } +@Composable +private fun CardView(card: BlackjackCard) { + if (card.faceDown) { + Box( + modifier = + Modifier + .size(width = CardWidth, height = CardHeight) + .background(AccentViolet.copy(alpha = CARD_BACK_ALPHA)) + .border(width = 1.dp, color = AccentViolet), + ) + return + } + val color = if (BlackjackMath.suit(card.card) in RED_SUITS) SemanticDanger else CARD_BLACK + Column( + modifier = + Modifier + .size(width = CardWidth, height = CardHeight) + .background(Color.White) + .border(width = 1.dp, color = SurfaceOutline) + .padding(4.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = rankLabel(card.card), + color = color, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + style = TabularNums, + ) + Text( + text = suitGlyph(card.card), + color = color, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.End), + ) + } +} + @Composable private fun ScoreBadge(score: String) { Box( @@ -281,10 +363,15 @@ private fun ScoreBadge(score: String) { @Composable private fun BlackjackControls( - betText: String, + state: BlackjackUiState, onBetChange: (String) -> Unit, + onPreset: (Int) -> Unit, + onDeal: () -> Unit, + onHit: () -> Unit, + onStand: () -> Unit, + onDouble: () -> Unit, + onNewBet: () -> Unit, ) { - val currentBet = betText.toFloatOrNull() Column( modifier = Modifier.fillMaxWidth().padding( @@ -293,20 +380,39 @@ private fun BlackjackControls( ), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(text = "Bet Amount", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing) - BetField(value = betText, onValueChange = onBetChange) - Row(modifier = Modifier.fillMaxWidth(), 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) { + BlackjackPhase.PlayerTurn, BlackjackPhase.Dealing, BlackjackPhase.DealerTurn -> + ActionGrid( + canHit = state.canHit, + canStand = state.canStand, + canDouble = state.canDouble, + onHit = onHit, + onStand = onStand, + onDouble = onDouble, ) + BlackjackPhase.Result -> + PrimaryButton(label = "New Bet", onClick = onNewBet) + BlackjackPhase.Idle -> { + Text( + text = "Bet Amount", + color = TextLow, + fontSize = TinyFontSize, + letterSpacing = TrackedLetterSpacing, + ) + BetField(value = state.betText, onValueChange = onBetChange) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + BET_PRESETS.forEach { amount -> + PresetButton( + amount = amount, + selected = amount.toFloat() == state.betText.toFloatOrNull(), + onClick = { onPreset(amount) }, + modifier = Modifier.weight(1f), + ) + } + } + PrimaryButton(label = "DEAL", onClick = onDeal) } } - ActionGrid() - DealButton() } } @@ -361,20 +467,48 @@ private fun PresetButton( } } -/** - * The 2x2 grid of per-hand actions. All disabled in the idle shell; the - * hand logic that enables them ships with the games card. - */ @Composable -private fun ActionGrid() { +private fun ActionGrid( + canHit: Boolean, + canStand: Boolean, + canDouble: Boolean, + onHit: () -> Unit, + onStand: () -> Unit, + onDouble: () -> Unit, +) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ActionButton(label = "HIT", color = SemanticOk, modifier = Modifier.weight(1f)) - ActionButton(label = "STAND", color = SemanticDanger, modifier = Modifier.weight(1f)) + ActionButton( + label = "HIT", + color = SemanticOk, + enabled = canHit, + onClick = onHit, + modifier = Modifier.weight(1f), + ) + ActionButton( + label = "STAND", + color = SemanticDanger, + enabled = canStand, + onClick = onStand, + modifier = Modifier.weight(1f), + ) } Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ActionButton(label = "DOUBLE", color = SemanticWarn, modifier = Modifier.weight(1f)) - ActionButton(label = "SPLIT", color = SemanticInfo, modifier = Modifier.weight(1f)) + ActionButton( + label = "DOUBLE", + color = SemanticWarn, + enabled = canDouble, + onClick = onDouble, + modifier = Modifier.weight(1f), + ) + // Split is out of scope for the single-hand game; the slot stays disabled. + ActionButton( + label = "SPLIT", + color = SemanticInfo, + enabled = false, + onClick = {}, + modifier = Modifier.weight(1f), + ) } } } @@ -383,20 +517,33 @@ private fun ActionGrid() { private fun ActionButton( label: String, color: Color, + enabled: Boolean, + onClick: () -> Unit, modifier: Modifier = Modifier, ) { Box( modifier = modifier - .alpha(DISABLED_ALPHA) - .background(color.copy(alpha = ACTION_FILL_ALPHA)) - .border(width = 2.dp, color = color.copy(alpha = ACTION_BORDER_ALPHA)) + .background(color.copy(alpha = if (enabled) ACTION_FILL_ALPHA else ACTION_FILL_ALPHA * DISABLED_ALPHA)) + .border( + width = 2.dp, + color = + color.copy( + alpha = + if (enabled) { + ACTION_BORDER_ALPHA + } else { + ACTION_BORDER_ALPHA * + DISABLED_ALPHA + }, + ), + ).clickable(enabled = enabled, onClick = onClick) .padding(vertical = 10.dp), contentAlignment = Alignment.Center, ) { Text( text = label, - color = color, + color = color.copy(alpha = if (enabled) 1f else DISABLED_ALPHA), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = TrackedLetterSpacing, @@ -405,18 +552,21 @@ private fun ActionButton( } @Composable -private fun DealButton() { +private fun PrimaryButton( + label: String, + onClick: () -> Unit, +) { Box( modifier = Modifier .fillMaxWidth() .background(AccentViolet) - .clickable { /* dealing ships with the games card */ } + .clickable(onClick = onClick) .padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { Text( - text = "DEAL", + text = label, color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, @@ -435,26 +585,20 @@ private fun Context.shareText(text: String) { runCatching { startActivity(Intent.createChooser(send, null)) } } -/** Formats a preset amount as the two-decimal string shown in the field. */ -private fun formatPreset(amount: Int): String = "$amount.00" +private fun rankLabel(card: Int): String = RANK_LABELS[BlackjackMath.rank(card)] -/** A past-round result and how it is rendered in the recent strip. */ -private enum class Outcome( - val label: String, - val color: Color, -) { - Win("W", SemanticOk), - Bust("L", SemanticDanger), - Blackjack("BJ", SemanticOk), - Push("P", TextMedium), -} +private fun suitGlyph(card: Int): String = SUIT_GLYPHS[BlackjackMath.suit(card)] + +private val RANK_LABELS = listOf("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K") + +// Suit glyphs via unicode escapes (spades, hearts, diamonds, clubs) so the source stays ASCII. +private val SUIT_GLYPHS = listOf("\u2660", "\u2665", "\u2666", "\u2663") +private val RED_SUITS = setOf(1, 2) +private val CARD_BLACK = Color(0xFF1A1A22) -// History newest first: win, bust, blackjack, push. -private val RECENT_RESULTS = listOf(Outcome.Win, Outcome.Bust, Outcome.Blackjack, Outcome.Push) -private val BET_PRESETS = listOf(5, 10, 25, 100) -private const val DEFAULT_BET = "10.00" private const val SHARE_MESSAGE = "Blackjack on Stack Casino - provably fair, classic, RTP 99.5%." +private val BET_PRESETS = listOf(5, 10, 25, 100) private val TabularNums = TextStyle(fontFeatureSettings = "tnum") private val BetTextStyle = TextStyle(color = TextHigh, fontSize = 14.sp, fontFeatureSettings = "tnum") @@ -469,7 +613,9 @@ private val HeaderButtonSize = 36.dp private val HeaderIconSize = 16.dp private val SubtitleIconSize = 10.dp private val HistoryChipSize = 20.dp -private val HandMinHeight = 80.dp +private val HandMinHeight = 72.dp +private val CardWidth = 44.dp +private val CardHeight = 62.dp private val TinyFontSize = 9.sp private val MetaFontSize = 10.sp @@ -479,11 +625,22 @@ private const val PRESET_SELECTED_ALPHA = 0.10f private const val DISABLED_ALPHA = 0.40f private const val ACTION_FILL_ALPHA = 0.10f private const val ACTION_BORDER_ALPHA = 0.50f +private const val CARD_BACK_ALPHA = 0.30f @Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 800) @Composable private fun BlackjackScreenPreview() { StackcasinoTheme { - BlackjackScreen(onBack = {}) + BlackjackScreen( + state = BlackjackUiState(balanceLabel = "$1,234.56", sessionProfitLabel = "+$15.10"), + onBack = {}, + onBetChange = {}, + onPreset = {}, + onDeal = {}, + onHit = {}, + onStand = {}, + onDouble = {}, + onNewBet = {}, + ) } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackUiState.kt new file mode 100644 index 0000000..61986fe --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackUiState.kt @@ -0,0 +1,56 @@ +package com.plainstudio.stackcasino.feature.blackjack + +/** Lifecycle of a blackjack round. */ +enum class BlackjackPhase { + /** No hand dealt; the bet is editable and Deal is available. */ + Idle, + + /** The opening cards are being dealt one at a time. */ + Dealing, + + /** The hand is dealt and the player can hit / stand / double. */ + PlayerTurn, + + /** The hole card is revealed and the dealer is drawing. */ + DealerTurn, + + /** The hand resolved; the dealer hand is revealed and the bet settled. */ + Result, +} + +/** A past round result, for the recent strip chips. */ +enum class BlackjackResult { Win, Loss, Push } + +/** A card on the table; [faceDown] hides the dealer hole card during the player's turn. */ +data class BlackjackCard( + val card: Int, + val faceDown: Boolean = false, +) + +/** + * Everything the blackjack screen renders. The hands and phase are the + * round state; balance, session profit and the recent strip come from the + * Room-backed wallet + round streams. + */ +data class BlackjackUiState( + val phase: BlackjackPhase = BlackjackPhase.Idle, + val betText: String = DEFAULT_BET, + val balanceLabel: String = "$0.00", + val sessionProfitLabel: String = "+$0.00", + val recentResults: List = emptyList(), + val dealerCards: List = emptyList(), + val playerCards: List = emptyList(), + val dealerTotalLabel: String = "-", + val playerTotalLabel: String = "-", + val statusLabel: String = "Place a bet and deal", + val resultLabel: String = "", + val canHit: Boolean = false, + val canStand: Boolean = false, + val canDouble: Boolean = false, +) { + val isIdle: Boolean get() = phase == BlackjackPhase.Idle + + companion object { + const val DEFAULT_BET = "10.00" + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackViewModel.kt new file mode 100644 index 0000000..4b95f37 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackViewModel.kt @@ -0,0 +1,306 @@ +package com.plainstudio.stackcasino.feature.blackjack + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.plainstudio.stackcasino.domain.game.BlackjackMath +import com.plainstudio.stackcasino.domain.game.BlackjackRound +import com.plainstudio.stackcasino.domain.game.BlackjackStart +import com.plainstudio.stackcasino.domain.game.GameRepository +import com.plainstudio.stackcasino.domain.game.GameRound +import com.plainstudio.stackcasino.domain.wallet.WalletRepository +import com.plainstudio.stackcasino.model.GameKey +import dagger.hilt.android.lifecycle.HiltViewModel +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 blackjack screen. Owns the dealt hands and the round + * lifecycle; maps the Room-backed wallet + round streams into the + * balance, session profit and recent strip. Deal draws the shuffled deck + * via [GameRepository] and lays the cards out one at a time with a delay + * (the dealer also draws card by card); the bet is settled net at the end + * (so Double is a single doubled settle). Failures surface as [events]. + * + * Split is intentionally out of scope; Hit / Stand / Double cover the + * classic single-hand game. + */ +@HiltViewModel +class BlackjackViewModel + @Inject + constructor( + private val gameRepository: GameRepository, + private val walletRepository: WalletRepository, + ) : ViewModel() { + private val _state = MutableStateFlow(BlackjackUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + private var round: BlackjackRound? = null + private var deckIndex = 0 + private var effectiveStake = 0.0 + private var availableBalance = 0.0 + private var player = emptyList() + private var dealer = emptyList() + + init { + viewModelScope.launch { walletRepository.ensureSeeded() } + viewModelScope.launch { + combine(walletRepository.wallet, gameRepository.rounds) { wallet, rounds -> + val hands = rounds.filter { it.game == GameKey.Blackjack } + Stats( + balance = wallet.availableBalance, + recent = hands.take(RECENT_COUNT).map { it.toResult() }, + profit = hands.sumOf { it.netProfit }, + ) + }.collect { stats -> + availableBalance = stats.balance + _state.update { + it.copy( + balanceLabel = "$" + money(stats.balance), + sessionProfitLabel = signedMoney(stats.profit), + recentResults = stats.recent, + ) + } + } + } + } + + fun onBetChange(value: String) { + if (_state.value.isIdle) _state.update { it.copy(betText = value) } + } + + fun onPreset(amount: Int) { + if (_state.value.isIdle) _state.update { it.copy(betText = String.format(Locale.US, "%d.00", amount)) } + } + + fun onDeal() { + if (!_state.value.isIdle) return + val stake = _state.value.betText.toDoubleOrNull() + if (stake == null || stake <= 0.0) { + _events.tryEmit("Enter a bet greater than 0.") + return + } + viewModelScope.launch { + when (val result = gameRepository.startBlackjack(stake)) { + is BlackjackStart.Success -> deal(result.round, stake) + is BlackjackStart.Failure -> _events.tryEmit(result.reason) + } + } + } + + fun onHit() { + if (_state.value.phase != BlackjackPhase.PlayerTurn) return + player = player + draw() + if (BlackjackMath.isBust(player)) { + viewModelScope.launch { finish(playerBust = true) } + } else { + renderPlayerTurn(canDouble = false) + } + } + + fun onStand() { + if (_state.value.phase != BlackjackPhase.PlayerTurn) return + viewModelScope.launch { finish(playerBust = false) } + } + + fun onDouble() { + if (_state.value.phase != BlackjackPhase.PlayerTurn || + player.size != 2 || + availableBalance < effectiveStake * 2 + ) { + return + } + effectiveStake *= 2 + player = player + draw() + viewModelScope.launch { finish(playerBust = BlackjackMath.isBust(player)) } + } + + /** Clears a finished hand back to the bet controls. */ + fun onNewBet() { + if (_state.value.phase != BlackjackPhase.Result) return + round = null + player = emptyList() + dealer = emptyList() + _state.update { + it.copy( + phase = BlackjackPhase.Idle, + dealerCards = emptyList(), + playerCards = emptyList(), + dealerTotalLabel = "-", + playerTotalLabel = "-", + statusLabel = "Place a bet and deal", + resultLabel = "", + canHit = false, + canStand = false, + canDouble = false, + ) + } + } + + private suspend fun deal( + newRound: BlackjackRound, + stake: Double, + ) { + round = newRound + deckIndex = 0 + effectiveStake = stake + player = emptyList() + dealer = emptyList() + // Player, dealer, player, dealer - one card at a time. + repeat(2) { + player = player + draw() + renderDealing() + delay(DEAL_MS) + dealer = dealer + draw() + renderDealing() + delay(DEAL_MS) + } + if (BlackjackMath.isBlackjack(player) || BlackjackMath.isBlackjack(dealer)) { + finish(playerBust = false) + } else { + renderPlayerTurn(canDouble = availableBalance >= stake * 2) + } + } + + private suspend fun finish(playerBust: Boolean) { + renderHands(BlackjackPhase.DealerTurn, holeHidden = false, status = "Dealer plays") + delay(REVEAL_MS) + if (!playerBust && !BlackjackMath.isBlackjack(player)) { + while (BlackjackMath.dealerHits(dealer)) { + dealer = dealer + draw() + renderHands(BlackjackPhase.DealerTurn, holeHidden = false, status = "Dealer plays") + delay(DEAL_MS) + } + } + val playerTotal = BlackjackMath.total(player) + val dealerTotal = BlackjackMath.total(dealer) + val (payout, label) = outcome(playerBust, playerTotal, dealerTotal) + renderResult(playerTotal, dealerTotal, payout, label) + val settled = round ?: return + round = null + gameRepository.settleBlackjack(settled, effectiveStake, payout, playerTotal, dealerTotal, label) + } + + private fun outcome( + playerBust: Boolean, + playerTotal: Int, + dealerTotal: Int, + ): Pair { + val playerBlackjack = BlackjackMath.isBlackjack(player) + val dealerBlackjack = BlackjackMath.isBlackjack(dealer) + return when { + playerBust -> 0.0 to "Bust" + playerBlackjack && dealerBlackjack -> effectiveStake to "Push" + playerBlackjack -> effectiveStake * BLACKJACK_PAYOUT to "Blackjack" + dealerBlackjack -> 0.0 to "Dealer blackjack" + BlackjackMath.isBust(dealer) -> effectiveStake * WIN_PAYOUT to "Win" + playerTotal > dealerTotal -> effectiveStake * WIN_PAYOUT to "Win" + playerTotal < dealerTotal -> 0.0 to "Dealer wins" + else -> effectiveStake to "Push" + } + } + + private fun renderDealing() = renderHands(BlackjackPhase.Dealing, holeHidden = true, status = "Dealing...") + + private fun renderPlayerTurn(canDouble: Boolean) { + renderHands(BlackjackPhase.PlayerTurn, holeHidden = true, status = "Your move") + _state.update { it.copy(canHit = true, canStand = true, canDouble = canDouble) } + } + + private fun renderHands( + phase: BlackjackPhase, + holeHidden: Boolean, + status: String, + ) { + _state.update { + it.copy( + phase = phase, + dealerCards = + dealer.mapIndexed { index, card -> + BlackjackCard( + card, + faceDown = + holeHidden && index == 1, + ) + }, + playerCards = player.map { card -> BlackjackCard(card) }, + dealerTotalLabel = dealerScore(holeHidden), + playerTotalLabel = if (player.isEmpty()) "-" else BlackjackMath.total(player).toString(), + statusLabel = status, + resultLabel = "", + canHit = false, + canStand = false, + canDouble = false, + ) + } + } + + private fun dealerScore(holeHidden: Boolean): String = + when { + dealer.isEmpty() -> "-" + holeHidden -> BlackjackMath.baseValue(dealer[0]).toString() + else -> BlackjackMath.total(dealer).toString() + } + + private fun renderResult( + playerTotal: Int, + dealerTotal: Int, + payout: Double, + label: String, + ) { + val net = payout - effectiveStake + _state.update { + it.copy( + phase = BlackjackPhase.Result, + dealerCards = dealer.map { card -> BlackjackCard(card) }, + playerCards = player.map { card -> BlackjackCard(card) }, + dealerTotalLabel = dealerTotal.toString(), + playerTotalLabel = playerTotal.toString(), + statusLabel = "", + resultLabel = if (net == 0.0) label else "$label ${signedMoney(net)}", + canHit = false, + canStand = false, + canDouble = false, + ) + } + } + + private fun draw(): Int = round!!.deck[deckIndex++] + + private fun GameRound.toResult(): BlackjackResult = + when { + payout > stake -> BlackjackResult.Win + payout == stake -> BlackjackResult.Push + else -> BlackjackResult.Loss + } + + 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 WIN_PAYOUT = 2.0 + const val BLACKJACK_PAYOUT = 2.5 + const val DEAL_MS = 380L + const val REVEAL_MS = 550L + } + } 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 47dba51..04a6cf1 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 @@ -99,6 +99,14 @@ class RoundDetailViewModel RoundStat("Winning Pocket", resultRaw, StatTone.Accent), RoundStat("Multiplier", "${multiplierText()}x", resultTone), ) + GameKey.Blackjack -> { + val totals = resultRaw.split("/") + listOf( + RoundStat("Bet", "$${money(stake)}", StatTone.Neutral), + RoundStat("Your Hand", totals.getOrElse(0) { "-" }, StatTone.Accent), + RoundStat("Dealer", totals.getOrElse(1) { "-" }, resultTone), + ) + } else -> listOf( RoundStat("Bet", "$${money(stake)}", StatTone.Neutral), @@ -145,6 +153,12 @@ class RoundDetailViewModel at, if (won) TimelineTone.Ok else TimelineTone.Danger, ) + GameKey.Blackjack -> + TimelineEvent( + pick.ifBlank { if (won) "Win" else "Loss" }, + at, + if (won) TimelineTone.Ok else TimelineTone.Danger, + ) else -> if (won) { TimelineEvent("Cashed out at ${multiplierText()}x", at, TimelineTone.Ok) 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 3f0163d..50269a0 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt @@ -18,6 +18,7 @@ import androidx.navigation.navArgument import com.plainstudio.stackcasino.feature.assistant.AssistantScreen import com.plainstudio.stackcasino.feature.auth.LoginScreen import com.plainstudio.stackcasino.feature.blackjack.BlackjackScreen +import com.plainstudio.stackcasino.feature.blackjack.BlackjackViewModel import com.plainstudio.stackcasino.feature.coinflip.CoinflipScreen import com.plainstudio.stackcasino.feature.coinflip.CoinflipViewModel import com.plainstudio.stackcasino.feature.crash.CrashScreen @@ -123,6 +124,7 @@ fun StackNavHost( } addSecondaryRoutes(navController) addGameRoutes(navController) + addTableGameRoutes(navController) addParametricRoutes(navController) } } @@ -200,6 +202,13 @@ private fun NavGraphBuilder.addGameRoutes(navController: NavHostController) { onNewBet = viewModel::onNewBet, ) } +} + +/** + * The table games (Roulette, Blackjack), split from [addGameRoutes] so + * both stay under the detekt LongMethod budget. + */ +private fun NavGraphBuilder.addTableGameRoutes(navController: NavHostController) { composable(Route.Roulette.path) { val viewModel: RouletteViewModel = hiltViewModel() val state by viewModel.state.collectAsStateWithLifecycle() @@ -219,7 +228,23 @@ private fun NavGraphBuilder.addGameRoutes(navController: NavHostController) { ) } composable(Route.Blackjack.path) { - BlackjackScreen(onBack = { navController.popBackStack() }) + val viewModel: BlackjackViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val toast = LocalToastController.current + LaunchedEffect(viewModel) { + viewModel.events.collect { message -> toast.show(ToastData(ToastType.Error, message)) } + } + BlackjackScreen( + state = state, + onBack = { navController.popBackStack() }, + onBetChange = viewModel::onBetChange, + onPreset = viewModel::onPreset, + onDeal = viewModel::onDeal, + onHit = viewModel::onHit, + onStand = viewModel::onStand, + onDouble = viewModel::onDouble, + onNewBet = viewModel::onNewBet, + ) } } 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 c4bbd2c..660ff75 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 @@ -2,6 +2,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.BlackjackRound +import com.plainstudio.stackcasino.domain.game.BlackjackStart import com.plainstudio.stackcasino.domain.game.CoinSide import com.plainstudio.stackcasino.domain.game.CrashRound import com.plainstudio.stackcasino.domain.game.CrashStart @@ -341,6 +343,57 @@ class GameRepositoryImplTest { } } + @Test + fun `startBlackjack draws the deck in order without debiting`() = + runTest { + every { engine.evaluateBlackjackDeck(any(), any(), any()) } returns EngineResult("9,11,8,7,0", HASH) + + val result = repository().startBlackjack(stake = 50.0) + + assertTrue(result is BlackjackStart.Success) + val round = (result as BlackjackStart.Success).round + assertEquals(listOf(9, 11, 8, 7, 0), round.deck) + // No debit at deal; blackjack settles net at the end. + coVerify(exactly = 0) { walletRepository.applyRoundResult(any(), any()) } + } + + @Test + fun `settleBlackjack applies the net and records the totals`() = + runTest { + repository().settleBlackjack( + blackjackRound(), + effectiveStake = 10.0, + payout = 20.0, + playerTotal = 20, + dealerTotal = 17, + outcome = "Win", + ) + + coVerify { walletRepository.applyRoundResult(10.0, 20.0) } + coVerify { + dao.insertRound( + withArg { + assertTrue(it.won) + assertEquals("Blackjack", it.game) + assertEquals("20/17", it.resultRaw) + assertEquals("Win", it.pick) + }, + ) + } + } + + private fun blackjackRound() = + BlackjackRound( + id = "bj-round", + deck = listOf(9, 11, 8, 7), + stake = 10.0, + currency = "USDC", + serverSeed = "server", + clientSeed = "client", + nonce = 1L, + hash = HASH, + ) + private fun rouletteSpin( totalStake: Double, totalReturn: Double, diff --git a/app/src/test/java/com/plainstudio/stackcasino/domain/game/BlackjackMathTest.kt b/app/src/test/java/com/plainstudio/stackcasino/domain/game/BlackjackMathTest.kt new file mode 100644 index 0000000..dc51318 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/domain/game/BlackjackMathTest.kt @@ -0,0 +1,56 @@ +package com.plainstudio.stackcasino.domain.game + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Card indices: rank = card % 13 (0 = Ace .. 9 = Ten, 10 = J, 11 = Q, + * 12 = K). So card 0 = Ace, card 9 = Ten, card 12 = King, card 1 = Two. + */ +class BlackjackMathTest { + @Test + fun `aces are worth eleven and faces ten`() { + assertEquals(11, BlackjackMath.baseValue(0)) + assertEquals(10, BlackjackMath.baseValue(9)) + assertEquals(10, BlackjackMath.baseValue(12)) + assertEquals(2, BlackjackMath.baseValue(1)) + } + + @Test + fun `an ace plus a ten is a natural blackjack`() { + val hand = listOf(0, 9) + assertEquals(21, BlackjackMath.total(hand)) + assertTrue(BlackjackMath.isBlackjack(hand)) + } + + @Test + fun `aces drop from eleven to one to avoid busting`() { + // A + A + Ten -> 11+11+10 = 32, both aces drop -> 12 + assertEquals(12, BlackjackMath.total(listOf(0, 0, 9))) + // A + Ten + Two -> 11+10+2 = 23, one ace drops -> 13 + assertEquals(13, BlackjackMath.total(listOf(0, 9, 1))) + } + + @Test + fun `three tens bust`() { + assertTrue(BlackjackMath.isBust(listOf(9, 9, 9))) + assertFalse(BlackjackMath.isBust(listOf(9, 9))) + } + + @Test + fun `the dealer stands on seventeen`() { + // Nine + Eight = 17 -> stands + assertFalse(BlackjackMath.dealerHits(listOf(8, 7))) + // Two + Three = 5 -> hits + assertTrue(BlackjackMath.dealerHits(listOf(1, 2))) + } + + @Test + fun `a three-card 21 is not a blackjack`() { + // Seven + Seven + Seven = 21 but three cards + assertEquals(21, BlackjackMath.total(listOf(6, 6, 6))) + assertFalse(BlackjackMath.isBlackjack(listOf(6, 6, 6))) + } +} diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackViewModelTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackViewModelTest.kt new file mode 100644 index 0000000..da6b8f8 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/blackjack/BlackjackViewModelTest.kt @@ -0,0 +1,124 @@ +package com.plainstudio.stackcasino.feature.blackjack + +import app.cash.turbine.test +import com.plainstudio.stackcasino.domain.game.BlackjackRound +import com.plainstudio.stackcasino.domain.game.BlackjackStart +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.Before +import org.junit.Rule +import org.junit.Test + +/** + * Decks are crafted with known cards (value reminder: card 9 = Ten, + * 11 = Queen, 8 = Nine, 7 = Eight, 0 = Ace). Deal order is + * player[deck0, deck1], dealer[deck2, deck3], then draws from deck4. + */ +class BlackjackViewModelTest { + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(testDispatcher) + + 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 + coEvery { gameRepository.settleBlackjack(any(), any(), any(), any(), any(), any()) } just Runs + } + + private fun viewModel() = BlackjackViewModel(gameRepository, walletRepository) + + @Test + fun `standing beats a lower dealer hand`() = + runTest(testDispatcher.scheduler) { + // Alternating deal player[deck0,deck2]=Ten,Queen=20 vs dealer[deck1,deck3]=Nine,Eight=17 + coEvery { gameRepository.startBlackjack(any()) } returns success(listOf(9, 8, 11, 7)) + val viewModel = viewModel() + + viewModel.onDeal() + advanceUntilIdle() + assertEquals(BlackjackPhase.PlayerTurn, viewModel.state.value.phase) + + viewModel.onStand() + advanceUntilIdle() + + assertEquals(BlackjackPhase.Result, viewModel.state.value.phase) + coVerify { gameRepository.settleBlackjack(any(), 10.0, 20.0, 20, 17, "Win") } + } + + @Test + fun `hitting into a bust loses`() = + runTest(testDispatcher.scheduler) { + // Player 20 (deck0,deck2), hits deck4 = Ten -> 30 bust + coEvery { gameRepository.startBlackjack(any()) } returns success(listOf(9, 8, 11, 7, 9)) + val viewModel = viewModel() + + viewModel.onDeal() + advanceUntilIdle() + viewModel.onHit() + advanceUntilIdle() + + assertEquals(BlackjackPhase.Result, viewModel.state.value.phase) + coVerify { gameRepository.settleBlackjack(any(), 10.0, 0.0, 30, 17, "Bust") } + } + + @Test + fun `a natural blackjack resolves on the deal and pays 3 to 2`() = + runTest(testDispatcher.scheduler) { + // Player[deck0,deck2] = Ace+Ten = 21 blackjack, dealer[deck1,deck3] = 17 + coEvery { gameRepository.startBlackjack(any()) } returns success(listOf(0, 8, 9, 7)) + val viewModel = viewModel() + + viewModel.onDeal() + advanceUntilIdle() + + assertEquals(BlackjackPhase.Result, viewModel.state.value.phase) + coVerify { gameRepository.settleBlackjack(any(), 10.0, 25.0, 21, 17, "Blackjack") } + } + + @Test + fun `an invalid bet emits the reason`() = + runTest(testDispatcher.scheduler) { + val viewModel = viewModel() + viewModel.onBetChange("0") + + viewModel.events.test { + viewModel.onDeal() + assertEquals("Enter a bet greater than 0.", awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + private fun success(deck: List) = + BlackjackStart.Success( + BlackjackRound( + id = "round-id", + deck = deck, + stake = 10.0, + currency = "USDC", + serverSeed = "s", + clientSeed = "c", + nonce = 1L, + hash = "h", + ), + ) +}