From ed7f0b04408f35a6d7bb5a74032f6853376055c3 Mon Sep 17 00:00:00 2001 From: net <96362337+netqo@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:14:03 -0300 Subject: [PATCH] feat(roulette): make the roulette game playable end-to-end Wire European roulette through the native provably-fair engine with the full betting table. Chips drop 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 stake 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 and credited (WalletRepository.applyRoundResult), with the round recorded in game_round. The winning number is highlighted; the round detail branches by game to show the pocket and the real seeds. The reel uses an Animatable driven by a LaunchedEffect so the spin runs reliably, and its Row is measured unbounded so the strip stays rendered while it scrolls. Controls swap by phase (table / spinning / result). Covered by RouletteMathTest, RouletteViewModelTest and the startRouletteSpin/settleRouletteSpin cases in GameRepositoryImplTest. --- CHANGELOG.md | 13 + .../feature/roulette/RouletteScreenTest.kt | 11 +- .../data/game/GameRepositoryImpl.kt | 63 +++ .../data/game/NativeGameEngineAdapter.kt | 6 + .../stackcasino/domain/game/GameEngine.kt | 12 + .../stackcasino/domain/game/GameRepository.kt | 43 ++ .../stackcasino/domain/game/RouletteBet.kt | 63 +++ .../stackcasino/domain/game/RouletteMath.kt | 31 ++ .../feature/roulette/RouletteScreen.kt | 420 +++++++++++++----- .../feature/roulette/RouletteUiState.kt | 42 ++ .../feature/roulette/RouletteViewModel.kt | 150 +++++++ .../rounddetail/RoundDetailViewModel.kt | 12 + .../stackcasino/navigation/StackNavHost.kt | 33 +- .../data/game/GameRepositoryImplTest.kt | 67 +++ .../domain/game/RouletteMathTest.kt | 56 +++ .../feature/roulette/RouletteViewModelTest.kt | 113 +++++ 16 files changed, 1019 insertions(+), 116 deletions(-) create mode 100644 app/src/main/java/com/plainstudio/stackcasino/domain/game/RouletteBet.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/domain/game/RouletteMath.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteUiState.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/feature/roulette/RouletteViewModel.kt create mode 100644 app/src/test/java/com/plainstudio/stackcasino/domain/game/RouletteMathTest.kt create mode 100644 app/src/test/java/com/plainstudio/stackcasino/feature/roulette/RouletteViewModelTest.kt 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(), + ) +}