diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b9891..7665356 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 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. + `RouletteViewModel` -> `GameRepository.startRouletteSpin` validates and + debits the total and draws the 0-36 pocket on the C++/JNI engine + (committed via the hash); the reel animates to the pocket and on + landing (`onReelSettled`) every bet is settled via `RouletteMath` + (`WalletRepository.applyRoundResult`) and the round is recorded in + `game_round`. The winning number is highlighted and the round detail + shows the pocket with the real seeds. Covered by `RouletteMathTest`, + `RouletteViewModelTest` and the `startRouletteSpin`/`settleRouletteSpin` + cases in `GameRepositoryImplTest`. + - 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 diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/roulette/RouletteScreenTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/roulette/RouletteScreenTest.kt index 427dfbb..7fdb181 100644 --- a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/roulette/RouletteScreenTest.kt +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/roulette/RouletteScreenTest.kt @@ -21,7 +21,16 @@ class RouletteScreenTest { private fun setScreen(onBack: () -> Unit = {}) { composeRule.setContent { StackcasinoTheme { - RouletteScreen(onBack = onBack) + RouletteScreen( + state = RouletteUiState(), + onBack = onBack, + onChip = {}, + onPlace = {}, + onClear = {}, + onSpin = {}, + onNewBet = {}, + onReelSettled = {}, + ) } } } 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 1932dde..a408505 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 @@ -12,6 +12,10 @@ import com.plainstudio.stackcasino.domain.game.MinesMath import com.plainstudio.stackcasino.domain.game.MinesRound import com.plainstudio.stackcasino.domain.game.MinesStart import com.plainstudio.stackcasino.domain.game.PlayResult +import com.plainstudio.stackcasino.domain.game.RouletteMath +import com.plainstudio.stackcasino.domain.game.RouletteSpin +import com.plainstudio.stackcasino.domain.game.RouletteSpot +import com.plainstudio.stackcasino.domain.game.RouletteStart import com.plainstudio.stackcasino.domain.game.SeedGenerator import com.plainstudio.stackcasino.domain.wallet.BetSettlement import com.plainstudio.stackcasino.domain.wallet.WalletRepository @@ -226,6 +230,65 @@ class GameRepositoryImpl ) } + override suspend fun startRouletteSpin(bets: Map): RouletteStart { + val totalStake = bets.values.sum().toDouble() + val wallet = walletRepository.wallet.first() + stakeError(totalStake, wallet.availableBalance)?.let { return RouletteStart.Failure(it) } + + val serverSeed = seedGenerator.newSeed() + val clientSeed = seedGenerator.newSeed() + val roundNonce = nonce.incrementAndGet() + val drawn = + runCatching { + val outcome = engine.evaluateRoulette(serverSeed, clientSeed, roundNonce) + outcome to outcome.result.trim().toInt() + }.getOrElse { return RouletteStart.Failure("The game engine is unavailable.") } + + when (walletRepository.applyRoundResult(totalStake, 0.0)) { + BetSettlement.InsufficientFunds -> return RouletteStart.Failure(INSUFFICIENT_FUNDS) + BetSettlement.Success -> Unit + } + val (outcome, pocket) = drawn + return RouletteStart.Success( + RouletteSpin( + id = idGenerator.newId(), + pocket = pocket, + totalStake = totalStake, + totalReturn = RouletteMath.totalReturn(bets, pocket), + currency = wallet.currencyCode, + serverSeed = serverSeed, + clientSeed = clientSeed, + nonce = roundNonce, + hash = outcome.hash, + resultRaw = outcome.result.trim(), + ), + ) + } + + override suspend fun settleRouletteSpin(spin: RouletteSpin) { + if (spin.totalReturn > 0.0) { + walletRepository.applyRoundResult(0.0, spin.totalReturn) + } + dao.insertRound( + GameRoundEntity( + id = spin.id, + game = GameKey.Roulette.name, + pick = "", + stake = spin.totalStake, + payout = spin.totalReturn, + won = spin.totalReturn > spin.totalStake, + multiplier = if (spin.totalStake > 0.0) spin.totalReturn / spin.totalStake else 0.0, + currency = spin.currency, + serverSeed = spin.serverSeed, + clientSeed = spin.clientSeed, + nonce = spin.nonce, + hash = spin.hash, + resultRaw = spin.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 e3e08d3..fdedb74 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 @@ -36,6 +36,12 @@ class NativeGameEngineAdapter nonce: Long, ): EngineResult = parse(native.evaluateCrashPoint(serverSeed, clientSeed, nonce)) + override fun evaluateRoulette( + serverSeed: String, + clientSeed: String, + nonce: Long, + ): EngineResult = parse(native.evaluateRoulette(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/GameEngine.kt b/app/src/main/java/com/plainstudio/stackcasino/domain/game/GameEngine.kt index c9a212a..f39540b 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 @@ -57,4 +57,16 @@ interface GameEngine { clientSeed: String, nonce: Long, ): EngineResult + + /** + * Evaluates a European roulette spin. Returns the winning pocket + * (0-36) and the derivation hash. + * + * @throws IllegalArgumentException for empty seeds or a non-positive nonce. + */ + fun evaluateRoulette( + 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 69c2f63..6d8fb49 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 @@ -73,6 +73,35 @@ sealed interface CrashStart { ) : CrashStart } +/** + * A roulette spin in progress. Drawn at Spin (the winning [pocket] is + * committed via [hash]); the ViewModel animates the reel to the pocket + * and settles through [GameRepository.settleRouletteSpin]. + */ +data class RouletteSpin( + val id: String, + val pocket: Int, + val totalStake: Double, + val totalReturn: Double, + val currency: String, + val serverSeed: String, + val clientSeed: String, + val nonce: Long, + val hash: String, + val resultRaw: String, +) + +/** Outcome of placing a roulette spin (the staked chips are debited on success). */ +sealed interface RouletteStart { + data class Success( + val spin: RouletteSpin, + ) : RouletteStart + + data class Failure( + val reason: String, + ) : RouletteStart +} + /** * Plays provably-fair game rounds. Orchestrates the native [GameEngine], * the wallet settlement and the Room round ledger; Room stays the single @@ -137,4 +166,18 @@ interface GameRepository { cashoutMultiplier: Double, cashedOut: Boolean, ) + + /** + * Places a roulette spin: validates the staked chips, draws the + * winning pocket on the native engine and debits the total stake. The + * returned [RouletteSpin] carries the committed pocket for the reel. + */ + suspend fun startRouletteSpin(bets: Map): RouletteStart + + /** + * Settles a roulette spin and records it: credits the total return + * (stake plus winnings on every winning bet) and writes the round to + * the `game_round` ledger. + */ + suspend fun settleRouletteSpin(spin: RouletteSpin) } diff --git a/app/src/main/java/com/plainstudio/stackcasino/domain/game/RouletteBet.kt b/app/src/main/java/com/plainstudio/stackcasino/domain/game/RouletteBet.kt new file mode 100644 index 0000000..82974d2 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/domain/game/RouletteBet.kt @@ -0,0 +1,63 @@ +package com.plainstudio.stackcasino.domain.game + +/** + * The kinds of European-roulette outside bet, with the to-one odds each + * pays. A bet returns `stake x (oddsToOne + 1)` when it [wins] (stake plus + * winnings), 0 otherwise. + */ +enum class RouletteBet( + val oddsToOne: Int, + val label: String, +) { + Red(1, "RED"), + Black(1, "BLACK"), + Odd(1, "ODD"), + Even(1, "EVEN"), + Low(1, "1-18"), + High(1, "19-36"), + Dozen1(2, "1ST 12"), + Dozen2(2, "2ND 12"), + Dozen3(2, "3RD 12"), + Column1(2, "2:1"), + Column2(2, "2:1"), + Column3(2, "2:1"), + ; + + /** + * Whether this bet wins for the given [pocket] (0 loses every outside bet). + * + * The numbers below are the fixed European-roulette ranges (1-18, the + * three dozens, the column residues), so they read as the rules + * themselves rather than as magic constants. + */ + @Suppress("CyclomaticComplexMethod", "MagicNumber") + fun wins(pocket: Int): Boolean = + when (this) { + Red -> pocket in RouletteMath.REDS + Black -> pocket != 0 && pocket !in RouletteMath.REDS + Odd -> pocket != 0 && pocket % 2 == 1 + Even -> pocket != 0 && pocket % 2 == 0 + Low -> pocket in 1..18 + High -> pocket in 19..36 + Dozen1 -> pocket in 1..12 + Dozen2 -> pocket in 13..24 + Dozen3 -> pocket in 25..36 + Column1 -> pocket != 0 && pocket % 3 == 1 + Column2 -> pocket != 0 && pocket % 3 == 2 + Column3 -> pocket != 0 && pocket % 3 == 0 + } +} + +/** + * A single placed-chip target on the roulette table: either a straight + * number (0-36, pays 35:1) or one of the [RouletteBet] outside zones. + */ +sealed interface RouletteSpot { + data class Straight( + val number: Int, + ) : RouletteSpot + + data class Outside( + val bet: RouletteBet, + ) : RouletteSpot +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/domain/game/RouletteMath.kt b/app/src/main/java/com/plainstudio/stackcasino/domain/game/RouletteMath.kt new file mode 100644 index 0000000..6292ded --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/domain/game/RouletteMath.kt @@ -0,0 +1,31 @@ +package com.plainstudio.stackcasino.domain.game + +/** + * Pure settlement math for European roulette. Given the winning pocket + * and the chips placed on the table, computes the total amount returned + * to the player (stake plus winnings on every winning bet). Kept separate + * from the engine and the repository so the payouts are unit-testable. + */ +object RouletteMath { + const val POCKETS = 37 + private const val STRAIGHT_RETURN = 36.0 + + /** The 18 red pockets of a European wheel. */ + val REDS = setOf(1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36) + + /** Total returned to the player for [bets] against the winning [pocket]. */ + fun totalReturn( + bets: Map, + pocket: Int, + ): Double = bets.entries.sumOf { (spot, stake) -> returnFor(spot, pocket, stake.toDouble()) } + + private fun returnFor( + spot: RouletteSpot, + pocket: Int, + stake: Double, + ): Double = + when (spot) { + is RouletteSpot.Straight -> if (spot.number == pocket) stake * STRAIGHT_RETURN else 0.0 + is RouletteSpot.Outside -> if (spot.bet.wins(pocket)) stake * (spot.bet.oddsToOne + 1) else 0.0 + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteScreen.kt index 8f9264f..9178322 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteScreen.kt @@ -2,11 +2,15 @@ package com.plainstudio.stackcasino.feature.roulette import android.content.Context import android.content.Intent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft import androidx.compose.material.icons.outlined.IosShare @@ -26,9 +31,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -36,13 +42,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight 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.RouletteBet +import com.plainstudio.stackcasino.domain.game.RouletteSpot import com.plainstudio.stackcasino.ui.theme.AccentViolet import com.plainstudio.stackcasino.ui.theme.SemanticDanger import com.plainstudio.stackcasino.ui.theme.SemanticOk @@ -55,32 +65,42 @@ import com.plainstudio.stackcasino.ui.theme.TextLow import com.plainstudio.stackcasino.ui.theme.TextMedium /** - * Roulette game screen reproducing the mockup - * (mockup/js/screens/roulette.js) as a static shell: the header, recent - * results strip, stats strip, the horizontal wheel reel, the European - * betting table (outside bets, 0-36 and the 2:1 columns) and the - * controls (chip selector, Clear, Spin). + * Roulette game screen (mockup/js/screens/roulette.js). * - * Selecting a chip denomination is local UI state; placing chips on the - * table, the spin and payouts (the provably-fair pocket via - * NativeGameEngine) land with the games card in the final entrega. + * Stateless: [RouletteViewModel] owns the placed chips and the spin + * logic. Tap a table cell to drop the selected chip; Spin draws the + * pocket on the native engine, the reel animates to it and reports back + * via [onReelSettled], which settles the bets. The winning number is + * highlighted on the result. */ @Composable fun RouletteScreen( + state: RouletteUiState, onBack: () -> Unit, + onChip: (Int) -> Unit, + onPlace: (RouletteSpot) -> Unit, + onClear: () -> Unit, + onSpin: () -> Unit, + onNewBet: () -> Unit, + onReelSettled: () -> Unit, modifier: Modifier = Modifier, ) { - var chip by rememberSaveable { mutableIntStateOf(DEFAULT_CHIP) } var showRules by rememberSaveable { mutableStateOf(false) } Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { Column(modifier = Modifier.fillMaxSize()) { RouletteHeader(onBack = onBack, onOpenRules = { showRules = true }) - RecentStrip() - StatsStrip() - Reel() - BettingTable(modifier = Modifier.weight(1f)) - RouletteControls(chip = chip, onChip = { chip = it }) + RecentStrip(pockets = state.recentPockets) + StatsStrip(state = state) + Reel(state = state, onReelSettled = onReelSettled) + BettingTable(state = state, onPlace = onPlace, modifier = Modifier.weight(1f)) + RouletteControls( + state = state, + onChip = onChip, + onClear = onClear, + onSpin = onSpin, + onNewBet = onNewBet, + ) } } @@ -160,7 +180,7 @@ private fun HeaderButton( } @Composable -private fun RecentStrip() { +private fun RecentStrip(pockets: List) { Column( modifier = Modifier.fillMaxWidth().padding(horizontal = ScreenHorizontalPadding, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(4.dp), @@ -168,7 +188,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 (pockets.isEmpty()) "no spins yet" else "${pockets.size} spins", color = TextLow, fontSize = TinyFontSize, letterSpacing = TrackedLetterSpacing, @@ -176,7 +196,7 @@ private fun RecentStrip() { ) } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - RECENT_RESULTS.forEach { number -> HistoryChip(number = number) } + pockets.forEach { number -> HistoryChip(number = number) } } } } @@ -192,14 +212,25 @@ private fun HistoryChip(number: Int) { } @Composable -private fun StatsStrip() { +private fun StatsStrip(state: RouletteUiState) { + 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 = "On Table", value = "$0.00", 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 = "On Table", + value = state.onTableLabel, + valueColor = AccentViolet, + modifier = Modifier.weight(1f), + ) } } @@ -213,9 +244,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) @@ -224,10 +254,14 @@ private fun StatCell( } } -/** Horizontal wheel reel: the canonical wheel order with a center pointer. */ +/** Horizontal wheel reel that animates to the winning pocket on a spin. */ @Composable -private fun Reel() { - Box( +private fun Reel( + state: RouletteUiState, + onReelSettled: () -> Unit, +) { + val density = LocalDensity.current + BoxWithConstraints( modifier = Modifier .fillMaxWidth() @@ -236,8 +270,32 @@ private fun Reel() { .border(width = 1.dp, color = SurfaceOutline) .clipToBounds(), ) { - Row(modifier = Modifier.fillMaxHeight()) { - ROULETTE_WHEEL.forEach { number -> ReelTile(number = number) } + val tileWidthPx = with(density) { ReelTileWidth.toPx() } + val boxWidthPx = with(density) { maxWidth.toPx() } + val reelOffset = remember { Animatable(0f) } + LaunchedEffect(state.phase, state.winningPocket) { + val pocket = state.winningPocket + when { + state.phase == RoulettePhase.Spinning && pocket != null -> { + reelOffset.snapTo(0f) + val flatIndex = REEL_TARGET_LAP * ROULETTE_WHEEL.size + ROULETTE_WHEEL.indexOf(pocket) + val target = boxWidthPx / 2f - (flatIndex * tileWidthPx + tileWidthPx / 2f) + reelOffset.animateTo(target, tween(durationMillis = SPIN_MS, easing = LinearOutSlowInEasing)) + onReelSettled() + } + state.phase == RoulettePhase.Idle -> reelOffset.snapTo(0f) + } + } + Row( + modifier = + Modifier + .wrapContentWidth(align = Alignment.Start, unbounded = true) + .fillMaxHeight() + .graphicsLayer { translationX = reelOffset.value }, + ) { + repeat(REEL_LAPS) { + ROULETTE_WHEEL.forEach { number -> ReelTile(number = number) } + } } Box( modifier = @@ -261,60 +319,90 @@ private fun ReelTile(number: Int) { } @Composable -private fun BettingTable(modifier: Modifier = Modifier) { +private fun BettingTable( + state: RouletteUiState, + onPlace: (RouletteSpot) -> Unit, + modifier: Modifier = Modifier, +) { Row( modifier = modifier.fillMaxWidth().padding(horizontal = ScreenHorizontalPadding, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(GridGap), ) { - OutsideBets(modifier = Modifier.weight(OUTSIDE_BETS_WEIGHT).fillMaxHeight()) - NumbersBlock(modifier = Modifier.weight(NUMBERS_BLOCK_WEIGHT).fillMaxHeight()) + OutsideBets(state = state, onPlace = onPlace, modifier = Modifier.weight(OUTSIDE_BETS_WEIGHT).fillMaxHeight()) + NumbersBlock(state = state, onPlace = onPlace, modifier = Modifier.weight(NUMBERS_BLOCK_WEIGHT).fillMaxHeight()) } } @Composable -private fun OutsideBets(modifier: Modifier = Modifier) { +private fun OutsideBets( + state: RouletteUiState, + onPlace: (RouletteSpot) -> Unit, + modifier: Modifier = Modifier, +) { Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(GridGap)) { Column(modifier = Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(GridGap)) { - EVEN_MONEY_BETS.forEach { bet -> - TableCell( - background = bet.background, - border = bet.border, + EVEN_MONEY_CELLS.forEach { cell -> + OutsideCellView( + cell = cell, + state = state, + onPlace = onPlace, modifier = Modifier.fillMaxWidth().weight(1f), - ) { - Text( - text = bet.label, - color = bet.labelColor, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.rotate(VERTICAL_LABEL_ROTATION), - ) - } + ) } } Column(modifier = Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(GridGap)) { - DOZEN_BETS.forEach { label -> - TableCell( - background = SurfaceRaised, - border = SurfaceOutline, + DOZEN_CELLS.forEach { cell -> + OutsideCellView( + cell = cell, + state = state, + onPlace = onPlace, modifier = Modifier.fillMaxWidth().weight(1f), - ) { - Text( - text = label, - color = TextMedium, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.rotate(VERTICAL_LABEL_ROTATION), - ) - } + ) } } } } @Composable -private fun NumbersBlock(modifier: Modifier = Modifier) { +private fun OutsideCellView( + cell: OutsideCell, + state: RouletteUiState, + onPlace: (RouletteSpot) -> Unit, + modifier: Modifier = Modifier, +) { + val spot = RouletteSpot.Outside(cell.bet) + val won = state.phase == RoulettePhase.Result && state.winningPocket?.let { cell.bet.wins(it) } == true + TableCell( + background = cell.background, + border = cell.border, + amount = state.bets[spot] ?: 0, + highlighted = won, + onClick = { onPlace(spot) }, + modifier = modifier, + ) { + Text( + text = cell.label, + color = cell.labelColor, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.rotate(VERTICAL_LABEL_ROTATION), + ) + } +} + +@Composable +private fun NumbersBlock( + state: RouletteUiState, + onPlace: (RouletteSpot) -> Unit, + modifier: Modifier = Modifier, +) { Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(GridGap)) { - NumberCell(number = 0, modifier = Modifier.fillMaxWidth().height(ZeroCellHeight)) + NumberCell( + number = 0, + state = state, + onPlace = onPlace, + modifier = Modifier.fillMaxWidth().height(ZeroCellHeight), + ) Column(modifier = Modifier.fillMaxWidth().weight(1f), verticalArrangement = Arrangement.spacedBy(GridGap)) { repeat(NUMBER_ROWS) { row -> Row( @@ -322,7 +410,12 @@ private fun NumbersBlock(modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.spacedBy(GridGap), ) { repeat(NUMBER_COLS) { col -> - NumberCell(number = row * NUMBER_COLS + col + 1, modifier = Modifier.weight(1f).fillMaxHeight()) + NumberCell( + number = row * NUMBER_COLS + col + 1, + state = state, + onPlace = onPlace, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) } } } @@ -331,10 +424,15 @@ private fun NumbersBlock(modifier: Modifier = Modifier) { modifier = Modifier.fillMaxWidth().height(ColumnBetHeight), horizontalArrangement = Arrangement.spacedBy(GridGap), ) { - repeat(NUMBER_COLS) { + COLUMN_BETS.forEach { bet -> + val spot = RouletteSpot.Outside(bet) TableCell( background = SurfaceRaised, border = SurfaceOutline, + amount = state.bets[spot] ?: 0, + highlighted = + state.phase == RoulettePhase.Result && state.winningPocket?.let { bet.wins(it) } == true, + onClick = { onPlace(spot) }, modifier = Modifier.weight(1f).fillMaxHeight(), ) { Text(text = "2:1", color = TextMedium, fontSize = 10.sp, fontWeight = FontWeight.SemiBold) @@ -347,12 +445,23 @@ private fun NumbersBlock(modifier: Modifier = Modifier) { @Composable private fun NumberCell( number: Int, + state: RouletteUiState, + onPlace: (RouletteSpot) -> Unit, modifier: Modifier = Modifier, ) { val color = pocketColor(number) val background = if (color == PocketColor.Black) BLACK_POCKET_FILL else color.fill.copy(alpha = NUMBER_FILL_ALPHA) val border = if (color == PocketColor.Black) SurfaceOutline else color.fill.copy(alpha = NUMBER_BORDER_ALPHA) - TableCell(background = background, border = border, modifier = modifier) { + val spot = RouletteSpot.Straight(number) + val won = state.phase == RoulettePhase.Result && state.winningPocket == number + TableCell( + background = background, + border = border, + amount = state.bets[spot] ?: 0, + highlighted = won, + onClick = { onPlace(spot) }, + modifier = modifier, + ) { Text(text = "$number", color = TextHigh, fontSize = 12.sp, fontWeight = FontWeight.Bold, style = TabularNums) } } @@ -361,21 +470,50 @@ private fun NumberCell( private fun TableCell( background: Color, border: Color, + amount: Int, + highlighted: Boolean, + onClick: () -> Unit, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { Box( - modifier = modifier.background(background).border(width = 1.dp, color = border), + modifier = + modifier + .background(background) + .border(width = if (highlighted) 2.dp else 1.dp, color = if (highlighted) SemanticOk else border) + .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { content() + if (amount > 0) { + Box( + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(1.dp) + .size(ChipBadgeSize) + .background(AccentViolet), + contentAlignment = Alignment.Center, + ) { + Text( + text = "$amount", + color = Color.White, + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + style = TabularNums, + ) + } + } } } @Composable private fun RouletteControls( - chip: Int, + state: RouletteUiState, onChip: (Int) -> Unit, + onClear: () -> Unit, + onSpin: () -> Unit, + onNewBet: () -> Unit, ) { Column( modifier = @@ -385,19 +523,41 @@ private fun RouletteControls( ), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { - CHIP_VALUES.forEach { value -> - ChipButton( - value = value, - selected = value == chip, - onClick = { onChip(value) }, - modifier = Modifier.weight(1f), + when (state.phase) { + RoulettePhase.Result -> { + Text( + text = state.resultLabel, + color = if (state.resultLabel.contains("won")) SemanticOk else SemanticDanger, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + letterSpacing = TrackedLetterSpacing, ) + ActionButton(label = "New Bet", background = AccentViolet, enabled = true, onClick = onNewBet) + } + RoulettePhase.Spinning -> + ActionButton(label = "Spinning...", background = AccentViolet, enabled = false, onClick = {}) + RoulettePhase.Idle -> { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + CHIP_VALUES.forEach { value -> + ChipButton( + value = value, + selected = value == state.chip, + onClick = { onChip(value) }, + modifier = Modifier.weight(1f), + ) + } + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + ClearButton(onClick = onClear, modifier = Modifier.weight(1f)) + ActionButton( + label = "SPIN", + background = AccentViolet, + enabled = state.canSpin, + onClick = onSpin, + modifier = Modifier.weight(SPIN_BUTTON_WEIGHT), + ) + } } - } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { - ClearButton(modifier = Modifier.weight(1f)) - SpinButton(modifier = Modifier.weight(SPIN_BUTTON_WEIGHT)) } } } @@ -432,13 +592,16 @@ private fun ChipButton( } @Composable -private fun ClearButton(modifier: Modifier = Modifier) { +private fun ClearButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { Box( modifier = modifier .background(SurfaceRaised) .border(width = 1.dp, color = SurfaceOutline) - .clickable { /* clearing the table ships with the games card */ } + .clickable(onClick = onClick) .padding(vertical = 10.dp), contentAlignment = Alignment.Center, ) { @@ -452,15 +615,25 @@ private fun ClearButton(modifier: Modifier = Modifier) { } } -/** Spin button, disabled in the shell until a bet is placed (round logic ships with the games card). */ @Composable -private fun SpinButton(modifier: Modifier = Modifier) { +private fun ActionButton( + label: String, + background: Color, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { Box( - modifier = modifier.background(AccentViolet.copy(alpha = DISABLED_ALPHA)).padding(vertical = 10.dp), + modifier = + modifier + .fillMaxWidth() + .background(background.copy(alpha = if (enabled) 1f else DISABLED_ALPHA)) + .clickable(enabled = enabled, onClick = onClick) + .padding(vertical = 10.dp), contentAlignment = Alignment.Center, ) { Text( - text = "SPIN", + text = label, color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, @@ -500,15 +673,46 @@ private fun pocketColor(number: Int): PocketColor = else -> PocketColor.Black } -private data class EvenMoneyBet( +private data class OutsideCell( + val bet: RouletteBet, val label: String, val background: Color, val border: Color, val labelColor: Color, ) -private val ROULETTE_REDS = - setOf(1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36) +private fun plainOutside( + bet: RouletteBet, + label: String, +) = OutsideCell(bet, label, SurfaceRaised, SurfaceOutline, TextMedium) + +private val EVEN_MONEY_CELLS = + listOf( + plainOutside(RouletteBet.Low, "1-18"), + plainOutside(RouletteBet.Even, "EVEN"), + OutsideCell( + RouletteBet.Red, + "RED", + SemanticDanger.copy(alpha = RED_CELL_FILL_ALPHA), + SemanticDanger.copy(alpha = RED_CELL_BORDER_ALPHA), + TextHigh, + ), + OutsideCell(RouletteBet.Black, "BLACK", BLACK_POCKET_FILL, SurfaceOutline, TextHigh), + plainOutside(RouletteBet.Odd, "ODD"), + plainOutside(RouletteBet.High, "19-36"), + ) + +private val DOZEN_CELLS = + listOf( + plainOutside(RouletteBet.Dozen1, "1ST 12"), + plainOutside(RouletteBet.Dozen2, "2ND 12"), + plainOutside(RouletteBet.Dozen3, "3RD 12"), + ) + +// Bottom 2:1 cells, left to right under the three number columns. +private val COLUMN_BETS = listOf(RouletteBet.Column1, RouletteBet.Column2, RouletteBet.Column3) + +private val ROULETTE_REDS = setOf(1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36) // Canonical European single-zero wheel order, used for the reel strip. private val ROULETTE_WHEEL = @@ -552,30 +756,7 @@ private val ROULETTE_WHEEL = 26, ) -private val EVEN_MONEY_BETS = - listOf( - plainBet("1-18"), - plainBet("EVEN"), - EvenMoneyBet( - label = "RED", - background = SemanticDanger.copy(alpha = RED_CELL_FILL_ALPHA), - border = SemanticDanger.copy(alpha = RED_CELL_BORDER_ALPHA), - labelColor = TextHigh, - ), - EvenMoneyBet(label = "BLACK", background = BLACK_POCKET_FILL, border = SurfaceOutline, labelColor = TextHigh), - plainBet("ODD"), - plainBet("19-36"), - ) - -private fun plainBet(label: String) = - EvenMoneyBet(label = label, background = SurfaceRaised, border = SurfaceOutline, labelColor = TextMedium) - -private val DOZEN_BETS = listOf("1ST 12", "2ND 12", "3RD 12") private val CHIP_VALUES = listOf(1, 5, 25, 100) -private const val DEFAULT_CHIP = 5 - -// History newest first: 17 (black), 0 (green), 26 (black), 3 (red). -private val RECENT_RESULTS = listOf(17, 0, 26, 3) private const val NUMBER_ROWS = 12 private const val NUMBER_COLS = 3 @@ -589,6 +770,13 @@ private const val SPIN_BUTTON_WEIGHT = 3f private const val SHARE_MESSAGE = "Roulette on Stack Casino - provably fair, European single zero, RTP 97.3%." +private const val SPIN_MS = 4200 + +// The reel renders REEL_LAPS copies of the wheel; the spin lands on the +// pocket inside lap REEL_TARGET_LAP so it scrolls through several wheels. +private const val REEL_LAPS = 6 +private const val REEL_TARGET_LAP = 4 + private val TabularNums = TextStyle(fontFeatureSettings = "tnum") private val ScreenHorizontalPadding = 16.dp @@ -604,6 +792,7 @@ private val ReelPointerWidth = 2.dp private val GridGap = 2.dp private val ZeroCellHeight = 24.dp private val ColumnBetHeight = 24.dp +private val ChipBadgeSize = 14.dp private val TinyFontSize = 9.sp private val MetaFontSize = 10.sp @@ -620,6 +809,15 @@ private const val REEL_POINTER_ALPHA = 0.90f @Composable private fun RouletteScreenPreview() { StackcasinoTheme { - RouletteScreen(onBack = {}) + RouletteScreen( + state = RouletteUiState(balanceLabel = "$1,234.56", sessionProfitLabel = "+$15.10"), + onBack = {}, + onChip = {}, + onPlace = {}, + onClear = {}, + onSpin = {}, + onNewBet = {}, + onReelSettled = {}, + ) } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteUiState.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteUiState.kt new file mode 100644 index 0000000..849fa6f --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteUiState.kt @@ -0,0 +1,42 @@ +package com.plainstudio.stackcasino.feature.roulette + +import com.plainstudio.stackcasino.domain.game.RouletteSpot + +/** Lifecycle of a roulette round. */ +enum class RoulettePhase { + /** Chips can be placed / cleared and the spin started. */ + Idle, + + /** The pocket is drawn; the reel is animating to it. */ + Spinning, + + /** The reel settled on the winning pocket and the bets were paid. */ + Result, +} + +/** + * Everything the roulette screen renders. The placed chips ([bets]) plus + * the live phase are the round state; balance, session profit and the + * recent strip come from the Room-backed wallet + round streams. + */ +data class RouletteUiState( + val phase: RoulettePhase = RoulettePhase.Idle, + val chip: Int = DEFAULT_CHIP, + val bets: Map = emptyMap(), + val balanceLabel: String = "$0.00", + val sessionProfitLabel: String = "+$0.00", + val recentPockets: List = emptyList(), + val winningPocket: Int? = null, + val onTableLabel: String = "$0.00", + val resultLabel: String = "", +) { + val totalStaked: Int get() = bets.values.sum() + + val canSpin: Boolean get() = phase == RoulettePhase.Idle && totalStaked > 0 + + val isIdle: Boolean get() = phase == RoulettePhase.Idle + + companion object { + const val DEFAULT_CHIP = 5 + } +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteViewModel.kt new file mode 100644 index 0000000..ca25883 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteViewModel.kt @@ -0,0 +1,150 @@ +package com.plainstudio.stackcasino.feature.roulette + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.plainstudio.stackcasino.domain.game.GameRepository +import com.plainstudio.stackcasino.domain.game.RouletteSpin +import com.plainstudio.stackcasino.domain.game.RouletteSpot +import com.plainstudio.stackcasino.domain.game.RouletteStart +import com.plainstudio.stackcasino.domain.wallet.WalletRepository +import com.plainstudio.stackcasino.model.GameKey +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.Locale +import javax.inject.Inject + +/** + * Drives the roulette screen. Owns the placed chips and the spin + * lifecycle; maps the Room-backed wallet + round streams into the + * balance, session profit and recent pockets. Placing a spin debits the + * staked chips and draws the pocket; the screen animates the reel and + * calls [onReelSettled], which credits the winnings and records the + * round. Failures surface as one-shot [events]. + */ +@HiltViewModel +class RouletteViewModel + @Inject + constructor( + private val gameRepository: GameRepository, + private val walletRepository: WalletRepository, + ) : ViewModel() { + private val _state = MutableStateFlow(RouletteUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + private var spin: RouletteSpin? = null + + init { + viewModelScope.launch { walletRepository.ensureSeeded() } + viewModelScope.launch { + combine(walletRepository.wallet, gameRepository.rounds) { wallet, rounds -> + val pockets = + rounds + .filter { it.game == GameKey.Roulette } + .take(RECENT_COUNT) + .mapNotNull { it.resultRaw.toIntOrNull() } + Stats( + balance = wallet.availableBalance, + recent = pockets, + profit = rounds.filter { it.game == GameKey.Roulette }.sumOf { it.netProfit }, + ) + }.collect { stats -> + _state.update { + it.copy( + balanceLabel = "$" + money(stats.balance), + sessionProfitLabel = signedMoney(stats.profit), + recentPockets = stats.recent, + ) + } + } + } + } + + fun onChip(value: Int) { + if (_state.value.isIdle) _state.update { it.copy(chip = value) } + } + + fun onPlace(spot: RouletteSpot) { + if (!_state.value.isIdle) return + _state.update { + val updated = it.bets + (spot to (it.bets[spot] ?: 0) + it.chip) + it.copy(bets = updated, onTableLabel = "$" + money(updated.values.sum().toDouble())) + } + } + + fun onClear() { + if (_state.value.isIdle) _state.update { it.copy(bets = emptyMap(), onTableLabel = "$0.00") } + } + + fun onSpin() { + val current = _state.value + if (!current.canSpin) return + viewModelScope.launch { + when (val result = gameRepository.startRouletteSpin(current.bets)) { + is RouletteStart.Success -> { + spin = result.spin + _state.update { + it.copy( + phase = RoulettePhase.Spinning, + winningPocket = result.spin.pocket, + resultLabel = "", + ) + } + } + is RouletteStart.Failure -> _events.tryEmit(result.reason) + } + } + } + + /** Called by the screen once the reel finishes animating to the pocket. */ + fun onReelSettled() { + val settled = spin ?: return + if (_state.value.phase != RoulettePhase.Spinning) return + spin = null + _state.update { it.copy(phase = RoulettePhase.Result, resultLabel = settled.resultLabel()) } + viewModelScope.launch { gameRepository.settleRouletteSpin(settled) } + } + + /** Clears the table back to a fresh bet after a result. */ + fun onNewBet() { + if (_state.value.phase == RoulettePhase.Spinning) return + _state.update { + it.copy( + phase = RoulettePhase.Idle, + bets = emptyMap(), + onTableLabel = "$0.00", + winningPocket = null, + resultLabel = "", + ) + } + } + + private fun RouletteSpin.resultLabel(): String { + val net = totalReturn - totalStake + return if (net > 0.0) "Landed $pocket - won $" + money(totalReturn) else "Landed $pocket - no win" + } + + 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 = 8 + } + } 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 891755b..47dba51 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 @@ -93,6 +93,12 @@ class RoundDetailViewModel RoundStat("Cashed Out", if (won) "${multiplierText()}x" else "-", resultTone), RoundStat("Crash Point", "${crashText()}x", StatTone.Danger), ) + GameKey.Roulette -> + listOf( + RoundStat("Bet", "$${money(stake)}", StatTone.Neutral), + RoundStat("Winning Pocket", resultRaw, StatTone.Accent), + RoundStat("Multiplier", "${multiplierText()}x", resultTone), + ) else -> listOf( RoundStat("Bet", "$${money(stake)}", StatTone.Neutral), @@ -133,6 +139,12 @@ class RoundDetailViewModel } else { TimelineEvent("Crashed at ${crashText()}x", at, TimelineTone.Danger) } + GameKey.Roulette -> + TimelineEvent( + if (won) "Won on pocket $resultRaw" else "Landed on pocket $resultRaw", + 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 1d7b94d..3f0163d 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt @@ -38,6 +38,7 @@ import com.plainstudio.stackcasino.feature.notifications.NotificationsViewModel import com.plainstudio.stackcasino.feature.profile.ProfileScreen import com.plainstudio.stackcasino.feature.profile.SettingsViewModel import com.plainstudio.stackcasino.feature.roulette.RouletteScreen +import com.plainstudio.stackcasino.feature.roulette.RouletteViewModel import com.plainstudio.stackcasino.feature.rounddetail.RoundDetailScreen import com.plainstudio.stackcasino.feature.rounddetail.RoundDetailViewModel import com.plainstudio.stackcasino.feature.wallet.WalletScreen @@ -121,14 +122,15 @@ fun StackNavHost( ) } addSecondaryRoutes(navController) + addGameRoutes(navController) addParametricRoutes(navController) } } /** - * Full-screen detail / game destinations reached from the primary tabs - * (KYC, House Wallet, the games). Pulled out of [StackNavHost] so the - * entry function stays under the detekt LongMethod budget. + * Full-screen detail destinations reached from the primary tabs (KYC, + * House Wallet). Pulled out of [StackNavHost] so the entry function stays + * under the detekt LongMethod budget. */ private fun NavGraphBuilder.addSecondaryRoutes(navController: NavHostController) { composable(Route.Kyc.path) { @@ -137,6 +139,14 @@ private fun NavGraphBuilder.addSecondaryRoutes(navController: NavHostController) composable(Route.HouseWallet.path) { HouseWalletScreen(onBack = { navController.popBackStack() }) } +} + +/** + * The five game destinations. Each wires its `@HiltViewModel` to its + * stateless screen; kept apart from [addSecondaryRoutes] so both stay + * under the detekt LongMethod budget. + */ +private fun NavGraphBuilder.addGameRoutes(navController: NavHostController) { composable(Route.Coinflip.path) { val viewModel: CoinflipViewModel = hiltViewModel() val state by viewModel.state.collectAsStateWithLifecycle() @@ -191,7 +201,22 @@ private fun NavGraphBuilder.addSecondaryRoutes(navController: NavHostController) ) } composable(Route.Roulette.path) { - RouletteScreen(onBack = { navController.popBackStack() }) + val viewModel: RouletteViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val toast = LocalToastController.current + LaunchedEffect(viewModel) { + viewModel.events.collect { message -> toast.show(ToastData(ToastType.Error, message)) } + } + RouletteScreen( + state = state, + onBack = { navController.popBackStack() }, + onChip = viewModel::onChip, + onPlace = viewModel::onPlace, + onClear = viewModel::onClear, + onSpin = viewModel::onSpin, + onNewBet = viewModel::onNewBet, + onReelSettled = viewModel::onReelSettled, + ) } composable(Route.Blackjack.path) { BlackjackScreen(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 177898b..c4bbd2c 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 @@ -11,6 +11,10 @@ import com.plainstudio.stackcasino.domain.game.MinesMath import com.plainstudio.stackcasino.domain.game.MinesRound import com.plainstudio.stackcasino.domain.game.MinesStart import com.plainstudio.stackcasino.domain.game.PlayResult +import com.plainstudio.stackcasino.domain.game.RouletteBet +import com.plainstudio.stackcasino.domain.game.RouletteSpin +import com.plainstudio.stackcasino.domain.game.RouletteSpot +import com.plainstudio.stackcasino.domain.game.RouletteStart import com.plainstudio.stackcasino.domain.game.SeedGenerator import com.plainstudio.stackcasino.domain.wallet.BetSettlement import com.plainstudio.stackcasino.domain.wallet.Wallet @@ -290,6 +294,69 @@ class GameRepositoryImplTest { } } + @Test + fun `startRouletteSpin draws the pocket and debits the total stake`() = + runTest { + every { engine.evaluateRoulette(any(), any(), any()) } returns EngineResult("7", HASH) + val bets = + mapOf( + RouletteSpot.Straight(7) to 10, + RouletteSpot.Outside(RouletteBet.Red) to 5, + ) + + val result = repository().startRouletteSpin(bets) + + assertTrue(result is RouletteStart.Success) + val spin = (result as RouletteStart.Success).spin + assertEquals(7, spin.pocket) + assertEquals(15.0, spin.totalStake, 0.0) + // 7 is red: straight 10*36 + red 5*2 = 370 + assertEquals(370.0, spin.totalReturn, 0.0) + coVerify { walletRepository.applyRoundResult(15.0, 0.0) } + } + + @Test + fun `startRouletteSpin rejects an empty table without drawing`() = + runTest { + val result = repository().startRouletteSpin(emptyMap()) + + assertTrue(result is RouletteStart.Failure) + verify(exactly = 0) { engine.evaluateRoulette(any(), any(), any()) } + } + + @Test + fun `settleRouletteSpin credits the return and records the round`() = + runTest { + repository().settleRouletteSpin(rouletteSpin(totalStake = 10.0, totalReturn = 370.0)) + + coVerify { walletRepository.applyRoundResult(0.0, 370.0) } + coVerify { + dao.insertRound( + withArg { + assertTrue(it.won) + assertEquals("Roulette", it.game) + assertEquals(370.0, it.payout, 0.0) + }, + ) + } + } + + private fun rouletteSpin( + totalStake: Double, + totalReturn: Double, + ) = RouletteSpin( + id = "roulette-spin", + pocket = 7, + totalStake = totalStake, + totalReturn = totalReturn, + currency = "USDC", + serverSeed = "server", + clientSeed = "client", + nonce = 1L, + hash = HASH, + resultRaw = "7", + ) + private fun crashRound() = CrashRound( id = "crash-round", diff --git a/app/src/test/java/com/plainstudio/stackcasino/domain/game/RouletteMathTest.kt b/app/src/test/java/com/plainstudio/stackcasino/domain/game/RouletteMathTest.kt new file mode 100644 index 0000000..36cd647 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/domain/game/RouletteMathTest.kt @@ -0,0 +1,56 @@ +package com.plainstudio.stackcasino.domain.game + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RouletteMathTest { + @Test + fun `a straight number returns 36x on a hit and nothing on a miss`() { + val bets = mapOf(RouletteSpot.Straight(7) to 10) + assertEquals(360.0, RouletteMath.totalReturn(bets, pocket = 7), 0.0) + assertEquals(0.0, RouletteMath.totalReturn(bets, pocket = 8), 0.0) + } + + @Test + fun `red pays even money on a red pocket`() { + val bets = mapOf(RouletteSpot.Outside(RouletteBet.Red) to 10) + assertEquals(20.0, RouletteMath.totalReturn(bets, pocket = 1), 0.0) + assertEquals(0.0, RouletteMath.totalReturn(bets, pocket = 2), 0.0) + } + + @Test + fun `a dozen pays 2 to 1`() { + val bets = mapOf(RouletteSpot.Outside(RouletteBet.Dozen1) to 10) + assertEquals(30.0, RouletteMath.totalReturn(bets, pocket = 5), 0.0) + assertEquals(0.0, RouletteMath.totalReturn(bets, pocket = 20), 0.0) + } + + @Test + fun `a column wins on its pockets`() { + val bets = mapOf(RouletteSpot.Outside(RouletteBet.Column1) to 10) + assertEquals(30.0, RouletteMath.totalReturn(bets, pocket = 4), 0.0) + assertEquals(0.0, RouletteMath.totalReturn(bets, pocket = 5), 0.0) + } + + @Test + fun `zero loses every outside bet`() { + val bets = + mapOf( + RouletteSpot.Outside(RouletteBet.Red) to 10, + RouletteSpot.Outside(RouletteBet.Even) to 10, + RouletteSpot.Outside(RouletteBet.Low) to 10, + ) + assertEquals(0.0, RouletteMath.totalReturn(bets, pocket = 0), 0.0) + } + + @Test + fun `the total sums returns across multiple bets`() { + val bets = + mapOf( + RouletteSpot.Straight(7) to 10, + RouletteSpot.Outside(RouletteBet.Red) to 5, + ) + // 7 is red: straight 10*36 + red 5*2 = 370 + assertEquals(370.0, RouletteMath.totalReturn(bets, pocket = 7), 0.0) + } +} diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/roulette/RouletteViewModelTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/roulette/RouletteViewModelTest.kt new file mode 100644 index 0000000..4f789a5 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/roulette/RouletteViewModelTest.kt @@ -0,0 +1,113 @@ +package com.plainstudio.stackcasino.feature.roulette + +import com.plainstudio.stackcasino.domain.game.GameRepository +import com.plainstudio.stackcasino.domain.game.RouletteBet +import com.plainstudio.stackcasino.domain.game.RouletteSpin +import com.plainstudio.stackcasino.domain.game.RouletteSpot +import com.plainstudio.stackcasino.domain.game.RouletteStart +import com.plainstudio.stackcasino.domain.wallet.Wallet +import com.plainstudio.stackcasino.domain.wallet.WalletRepository +import com.plainstudio.stackcasino.testing.MainDispatcherRule +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class RouletteViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val gameRepository = mockk(relaxed = true) + private val walletRepository = mockk(relaxed = true) + + @Before + fun setUp() { + every { gameRepository.rounds } returns flowOf(emptyList()) + every { walletRepository.wallet } returns + flowOf(Wallet(availableBalance = 1000.0, lockedBalance = 0.0, polygonAddress = "0x", currencyCode = "USDC")) + coEvery { walletRepository.ensureSeeded() } just Runs + } + + private fun viewModel() = RouletteViewModel(gameRepository, walletRepository) + + @Test + fun `placing chips accumulates the stake on the spot`() = + runTest { + val viewModel = viewModel() + + viewModel.onChip(25) + viewModel.onPlace(RouletteSpot.Outside(RouletteBet.Red)) + viewModel.onPlace(RouletteSpot.Outside(RouletteBet.Red)) + + assertEquals(50, viewModel.state.value.bets[RouletteSpot.Outside(RouletteBet.Red)]) + assertEquals(50, viewModel.state.value.totalStaked) + assertTrue(viewModel.state.value.canSpin) + } + + @Test + fun `clearing removes the placed chips`() = + runTest { + val viewModel = viewModel() + viewModel.onPlace(RouletteSpot.Straight(7)) + + viewModel.onClear() + + assertTrue( + viewModel.state.value.bets + .isEmpty(), + ) + assertEquals(0, viewModel.state.value.totalStaked) + } + + @Test + fun `spinning draws the pocket and settling pays the bets`() = + runTest { + coEvery { gameRepository.startRouletteSpin(any()) } returns RouletteStart.Success(spin(pocket = 7)) + coEvery { gameRepository.settleRouletteSpin(any()) } just Runs + val viewModel = viewModel() + viewModel.onPlace(RouletteSpot.Straight(7)) + + viewModel.onSpin() + assertEquals(RoulettePhase.Spinning, viewModel.state.value.phase) + assertEquals(7, viewModel.state.value.winningPocket) + + viewModel.onReelSettled() + + assertEquals(RoulettePhase.Result, viewModel.state.value.phase) + coVerify { gameRepository.settleRouletteSpin(any()) } + } + + @Test + fun `spinning with no chips does nothing`() = + runTest { + val viewModel = viewModel() + + viewModel.onSpin() + + assertEquals(RoulettePhase.Idle, viewModel.state.value.phase) + coVerify(exactly = 0) { gameRepository.startRouletteSpin(any()) } + } + + private fun spin(pocket: Int) = + RouletteSpin( + id = "spin-id", + pocket = pocket, + totalStake = 10.0, + totalReturn = 360.0, + currency = "USDC", + serverSeed = "s", + clientSeed = "c", + nonce = 1L, + hash = "h", + resultRaw = pocket.toString(), + ) +}