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 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -226,6 +230,65 @@ class GameRepositoryImpl
)
}

override suspend fun startRouletteSpin(bets: Map<RouletteSpot, Int>): 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<Int> =
csv.split(",").mapNotNull { it.trim().toIntOrNull() }.toSet()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<RouletteSpot, Int>): 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)
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<RouletteSpot, Int>,
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
}
}
Loading
Loading