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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- 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
engine (committed via the hash); a ticker animates the multiplier along
an exponential curve (`CrashCurve`). Manual cash out or an optional
auto-cashout target credits `stake x multiplier`
(`WalletRepository.applyRoundResult`); reaching the crash point first
loses the stake. The live multiplier is a dedicated `StateFlow` so only
the chart and hero recompose per frame (the recomposition story for the
Profiler report). The round persists to `game_round` and the round
detail rebuilds the cashout-vs-crash position bar with the real seeds.
Covered by `CrashCurveTest`, `CrashViewModelTest` and the
`startCrash`/`settleCrash` cases in `GameRepositoryImplTest`.

- Playable Mines (cu-08): the second game wired end-to-end, with
interactive multi-tile play. `MinesViewModel` -> `GameRepository`
validates and debits the stake, draws the mine positions on the C++/JNI
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.plainstudio.stackcasino.feature.crash

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,10 +21,26 @@ class CrashScreenTest {
@get:Rule
val composeRule = createComposeRule()

/**
* Hosts the stateless screen with a local state holder so a bet
* preset tap flows back through `onPreset` and recomposes, mirroring
* how the ViewModel drives it in production.
*/
private fun setScreen(onBack: () -> Unit = {}) {
composeRule.setContent {
StackcasinoTheme {
CrashScreen(onBack = onBack)
var state by remember { mutableStateOf(CrashUiState()) }
CrashScreen(
state = state,
multiplier = 1.0,
onBack = onBack,
onBetChange = { state = state.copy(betText = it) },
onAutoChange = { state = state.copy(autoText = it) },
onPreset = { state = state.copy(betText = "$it.00") },
onPlaceBet = {},
onCashout = {},
onNewBet = {},
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,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.CoinSide
import com.plainstudio.stackcasino.domain.game.CrashRound
import com.plainstudio.stackcasino.domain.game.CrashStart
import com.plainstudio.stackcasino.domain.game.GameEngine
import com.plainstudio.stackcasino.domain.game.GameRepository
import com.plainstudio.stackcasino.domain.game.GameRound
Expand Down Expand Up @@ -162,6 +164,68 @@ class GameRepositoryImpl
)
}

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

val serverSeed = seedGenerator.newSeed()
val clientSeed = seedGenerator.newSeed()
val roundNonce = nonce.incrementAndGet()
val drawn =
runCatching {
val outcome = engine.evaluateCrashPoint(serverSeed, clientSeed, roundNonce)
outcome to outcome.result.trim().toDouble()
}.getOrElse { return CrashStart.Failure("The game engine is unavailable.") }

when (walletRepository.applyRoundResult(stake, 0.0)) {
BetSettlement.InsufficientFunds -> return CrashStart.Failure(INSUFFICIENT_FUNDS)
BetSettlement.Success -> Unit
}
val (outcome, crashPoint) = drawn
return CrashStart.Success(
CrashRound(
id = idGenerator.newId(),
stake = stake,
currency = wallet.currencyCode,
crashPoint = crashPoint,
serverSeed = serverSeed,
clientSeed = clientSeed,
nonce = roundNonce,
hash = outcome.hash,
resultRaw = outcome.result.trim(),
),
)
}

override suspend fun settleCrash(
round: CrashRound,
cashoutMultiplier: Double,
cashedOut: Boolean,
) {
val payout = if (cashedOut) round.stake * cashoutMultiplier else 0.0
if (cashedOut && payout > 0.0) {
walletRepository.applyRoundResult(0.0, payout)
}
dao.insertRound(
GameRoundEntity(
id = round.id,
game = GameKey.Crash.name,
pick = "",
stake = round.stake,
payout = payout,
won = cashedOut,
multiplier = if (cashedOut) cashoutMultiplier else 0.0,
currency = round.currency,
serverSeed = round.serverSeed,
clientSeed = round.clientSeed,
nonce = round.nonce,
hash = round.hash,
resultRaw = round.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 @@ -30,6 +30,12 @@ class NativeGameEngineAdapter
numMines: Int,
): EngineResult = parse(native.evaluateMines(serverSeed, clientSeed, nonce, numMines))

override fun evaluateCrashPoint(
serverSeed: String,
clientSeed: String,
nonce: Long,
): EngineResult = parse(native.evaluateCrashPoint(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,18 @@
package com.plainstudio.stackcasino.domain.game

import kotlin.math.exp

/**
* The growing multiplier curve a crash round follows over time. Pure and
* unit-testable so the ViewModel's ticker just samples it: the displayed
* multiplier is `e^(elapsedMs / TAU_MS)`, starting at 1.00 and
* accelerating (about 2x at 2s, 4x at 4s) until it reaches the round's
* crash point.
*/
object CrashCurve {
/** Time constant: tuned so the curve hits 2.0x at ~2 seconds. */
const val TAU_MS = 2885.0

/** The multiplier shown after [elapsedMs] of the round. */
fun multiplierAt(elapsedMs: Long): Double = exp(elapsedMs / TAU_MS)
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,16 @@ interface GameEngine {
nonce: Long,
numMines: Int,
): EngineResult

/**
* Evaluates a crash round. Returns the crash point (a two-decimal
* multiplier string, e.g. "3.18") and the derivation hash.
*
* @throws IllegalArgumentException for empty seeds or a non-positive nonce.
*/
fun evaluateCrashPoint(
serverSeed: String,
clientSeed: String,
nonce: Long,
): EngineResult
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,34 @@ sealed interface MinesStart {
) : MinesStart
}

/**
* A crash round in progress. The crash point is drawn at bet placement
* (committed via [hash]); the ViewModel animates the multiplier up to it
* and settles through [GameRepository.settleCrash].
*/
data class CrashRound(
val id: String,
val stake: Double,
val currency: String,
val crashPoint: Double,
val serverSeed: String,
val clientSeed: String,
val nonce: Long,
val hash: String,
val resultRaw: String,
)

/** Outcome of placing a crash bet (the stake is debited on success). */
sealed interface CrashStart {
data class Success(
val round: CrashRound,
) : CrashStart

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

/**
* 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 @@ -90,4 +118,23 @@ interface GameRepository {
safeRevealed: Int,
cashedOut: Boolean,
)

/**
* Places a crash bet: validates the stake, draws the crash point on
* the native engine and debits the stake. The returned [CrashRound]
* carries the committed crash point for the ViewModel to animate to.
*/
suspend fun startCrash(stake: Double): CrashStart

/**
* Settles a crash round and records it: on [cashedOut] the payout
* (stake x [cashoutMultiplier]) is credited; on a crash nothing is
* credited (the stake was debited at start). Either way the round is
* written to the `game_round` ledger.
*/
suspend fun settleCrash(
round: CrashRound,
cashoutMultiplier: Double,
cashedOut: Boolean,
)
}
Loading
Loading