Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Playable Blackjack (cu-06): the fifth and final game, completing the
games card. `BlackjackViewModel` -> `GameRepository.startBlackjack`
draws the shuffled 52-card deck on the C++/JNI engine (committed via the
hash); the opening cards are dealt one at a time with a delay (player /
dealer alternating, the hole card face down). Hit / Stand / Double run
on `BlackjackMath` (soft-ace totals, dealer stands on 17, natural
blackjack pays 3:2), with the dealer revealing and drawing card by card.
The bet settles net at the end (`WalletRepository.applyRoundResult`, so
Double doubles cleanly) and the round is recorded in `game_round` with
the player/dealer totals and the real seeds. Split is out of scope.
Covered by `BlackjackMathTest`, `BlackjackViewModelTest` and the
`startBlackjack`/`settleBlackjack` cases in `GameRepositoryImplTest`.

- Playable Roulette (cu-05): the fourth game, with the full European
betting table. Chips are placed on the table zones (straight 35:1,
even-money 1:1, dozens/columns 2:1), accumulating the stake per cell.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.plainstudio.stackcasino.feature.blackjack

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
Expand All @@ -17,24 +21,45 @@ class BlackjackScreenTest {
@get:Rule
val composeRule = createComposeRule()

private fun setScreen(onBack: () -> Unit = {}) {
private fun setScreen(
initial: BlackjackUiState = BlackjackUiState(),
onBack: () -> Unit = {},
) {
composeRule.setContent {
StackcasinoTheme {
BlackjackScreen(onBack = onBack)
var state by remember { mutableStateOf(initial) }
BlackjackScreen(
state = state,
onBack = onBack,
onBetChange = { state = state.copy(betText = it) },
onPreset = { state = state.copy(betText = "$it.00") },
onDeal = {},
onHit = {},
onStand = {},
onDouble = {},
onNewBet = {},
)
}
}
}

@Test
fun renders_table_and_actions() {
fun renders_the_table_and_deal_in_idle() {
setScreen()

composeRule.onNodeWithText("DEALER").assertIsDisplayed()
composeRule.onNodeWithText("YOU").assertIsDisplayed()
composeRule.onNodeWithText("HIT").assertIsDisplayed()
composeRule.onNodeWithText("DEAL").assertIsDisplayed()
}

@Test
fun renders_the_actions_during_the_player_turn() {
setScreen(initial = BlackjackUiState(phase = BlackjackPhase.PlayerTurn, canHit = true, canStand = true))

composeRule.onNodeWithText("HIT").assertIsDisplayed()
composeRule.onNodeWithText("STAND").assertIsDisplayed()
}

@Test
fun selecting_a_bet_preset_updates_the_field() {
setScreen()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.plainstudio.stackcasino.data.game

import com.plainstudio.stackcasino.data.local.GameRoundDao
import com.plainstudio.stackcasino.data.local.GameRoundEntity
import com.plainstudio.stackcasino.domain.game.BlackjackRound
import com.plainstudio.stackcasino.domain.game.BlackjackStart
import com.plainstudio.stackcasino.domain.game.CoinSide
import com.plainstudio.stackcasino.domain.game.CrashRound
import com.plainstudio.stackcasino.domain.game.CrashStart
Expand Down Expand Up @@ -289,6 +291,64 @@ class GameRepositoryImpl
)
}

override suspend fun startBlackjack(stake: Double): BlackjackStart {
val wallet = walletRepository.wallet.first()
stakeError(stake, wallet.availableBalance)?.let { return BlackjackStart.Failure(it) }

val serverSeed = seedGenerator.newSeed()
val clientSeed = seedGenerator.newSeed()
val roundNonce = nonce.incrementAndGet()
val drawn =
runCatching {
val outcome = engine.evaluateBlackjackDeck(serverSeed, clientSeed, roundNonce)
// Keep the deck order (a Set would reshuffle it).
outcome to outcome.result.split(",").map { it.trim().toInt() }
}.getOrElse { return BlackjackStart.Failure("The game engine is unavailable.") }

val (outcome, deck) = drawn
return BlackjackStart.Success(
BlackjackRound(
id = idGenerator.newId(),
deck = deck,
stake = stake,
currency = wallet.currencyCode,
serverSeed = serverSeed,
clientSeed = clientSeed,
nonce = roundNonce,
hash = outcome.hash,
),
)
}

override suspend fun settleBlackjack(
round: BlackjackRound,
effectiveStake: Double,
payout: Double,
playerTotal: Int,
dealerTotal: Int,
outcome: String,
) {
walletRepository.applyRoundResult(effectiveStake, payout)
dao.insertRound(
GameRoundEntity(
id = round.id,
game = GameKey.Blackjack.name,
pick = outcome,
stake = effectiveStake,
payout = payout,
won = payout > effectiveStake,
multiplier = if (effectiveStake > 0.0) payout / effectiveStake else 0.0,
currency = round.currency,
serverSeed = round.serverSeed,
clientSeed = round.clientSeed,
nonce = round.nonce,
hash = round.hash,
resultRaw = "$playerTotal/$dealerTotal",
timestamp = timeProvider.now().time,
),
)
}

private fun parsePositions(csv: String): Set<Int> =
csv.split(",").mapNotNull { it.trim().toIntOrNull() }.toSet()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ class NativeGameEngineAdapter
nonce: Long,
): EngineResult = parse(native.evaluateRoulette(serverSeed, clientSeed, nonce))

override fun evaluateBlackjackDeck(
serverSeed: String,
clientSeed: String,
nonce: Long,
): EngineResult = parse(native.evaluateBlackjackDeck(serverSeed, clientSeed, nonce))

private fun parse(raw: String): EngineResult {
val separator = raw.indexOf(SEPARATOR)
require(separator >= 0) { "Malformed engine output: $raw" }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.plainstudio.stackcasino.domain.game

/**
* Pure blackjack hand math over the engine's card indices (0-51, where
* `rank = card % 13` with 0 = Ace .. 12 = King and `suit = card / 13`).
* Kept separate from the engine, repository and ViewModel so the scoring
* (soft aces, bust, dealer rule) is unit-testable.
*/
object BlackjackMath {
const val DECK_SIZE = 52
const val BLACKJACK = 21
private const val RANKS = 13
private const val DEALER_STANDS_ON = 17
private const val ACE_HIGH = 11
private const val ACE_DROP = 10
private const val FACE_VALUE = 10
private const val LAST_NUMBER_RANK = 9

/** Rank index 0-12 (0 = Ace, 10 = Jack, 11 = Queen, 12 = King). */
fun rank(card: Int): Int = card % RANKS

/** Suit index 0-3. */
fun suit(card: Int): Int = card / RANKS

/** Card value counting every Ace as 11 (soft); 2-10 face, J/Q/K = 10. */
fun baseValue(card: Int): Int =
when (val r = rank(card)) {
0 -> ACE_HIGH
in 1..LAST_NUMBER_RANK -> r + 1
else -> FACE_VALUE
}

/** Best hand total: Aces drop from 11 to 1 while the hand would bust. */
fun total(cards: List<Int>): Int {
var sum = cards.sumOf { baseValue(it) }
var aces = cards.count { rank(it) == 0 }
while (sum > BLACKJACK && aces > 0) {
sum -= ACE_DROP
aces--
}
return sum
}

fun isBust(cards: List<Int>): Boolean = total(cards) > BLACKJACK

/** A natural: exactly two cards totalling 21. */
fun isBlackjack(cards: List<Int>): Boolean = cards.size == 2 && total(cards) == BLACKJACK

/** The dealer draws while under 17 (stands on all 17). */
fun dealerHits(cards: List<Int>): Boolean = total(cards) < DEALER_STANDS_ON
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,17 @@ interface GameEngine {
clientSeed: String,
nonce: Long,
): EngineResult

/**
* Evaluates a blackjack shoe. Returns the comma-separated shuffled
* 52-card deck (each card 0-51) and the derivation hash; the caller
* deals from the deck in order.
*
* @throws IllegalArgumentException for empty seeds or a non-positive nonce.
*/
fun evaluateBlackjackDeck(
serverSeed: String,
clientSeed: String,
nonce: Long,
): EngineResult
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,33 @@ sealed interface RouletteStart {
) : RouletteStart
}

/**
* A blackjack round in progress. The shuffled [deck] is drawn at Deal
* (committed via [hash]); the ViewModel deals from it in order and
* settles through [GameRepository.settleBlackjack].
*/
data class BlackjackRound(
val id: String,
val deck: List<Int>,
val stake: Double,
val currency: String,
val serverSeed: String,
val clientSeed: String,
val nonce: Long,
val hash: String,
)

/** Outcome of dealing a blackjack round (no debit yet; settled at the end). */
sealed interface BlackjackStart {
data class Success(
val round: BlackjackRound,
) : BlackjackStart

data class Failure(
val reason: String,
) : BlackjackStart
}

/**
* Plays provably-fair game rounds. Orchestrates the native [GameEngine],
* the wallet settlement and the Room round ledger; Room stays the single
Expand Down Expand Up @@ -180,4 +207,25 @@ interface GameRepository {
* the `game_round` ledger.
*/
suspend fun settleRouletteSpin(spin: RouletteSpin)

/**
* Deals a blackjack round: validates the stake (no debit yet) and
* draws the shuffled deck on the native engine. The returned
* [BlackjackRound] carries the deck for the ViewModel to deal from.
*/
suspend fun startBlackjack(stake: Double): BlackjackStart

/**
* Settles a finished blackjack round and records it: applies the net
* in one write (debits [effectiveStake], credits [payout]) and writes
* the round (with the player/dealer totals) to the `game_round` ledger.
*/
suspend fun settleBlackjack(
round: BlackjackRound,
effectiveStake: Double,
payout: Double,
playerTotal: Int,
dealerTotal: Int,
outcome: String,
)
}
Loading
Loading