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

### Added

- 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
engine (committed via the hash) and lets the player reveal tiles: each
safe tile raises the multiplier (`MinesMath`, RTP 99%) and hitting a
mine loses the stake. Cash out credits `stake x multiplier`
(`WalletRepository.applyRoundResult`) and records the round in
`game_round`; balance, session profit and the recent strip read from
Room. The round detail maps Mines rounds (setup, multiplier, real
seeds). Covered by `MinesMathTest`, `MinesViewModelTest` and the new
`startMines`/`settleMines` cases in `GameRepositoryImplTest`.

- Profile P&L on real rounds (cu-15): `ProfileViewModel` now combines the
signed-in identity with `GameRepository.rounds`, replacing the
placeholder P&L and game-activity blocks. The all-time summary (net,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.plainstudio.stackcasino.feature.mines

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,25 @@ class MinesScreenTest {
@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 {
MinesScreen(onBack = onBack)
var state by remember { mutableStateOf(MinesUiState()) }
MinesScreen(
state = state,
onBack = onBack,
onPreset = { state = state.copy(betAmount = it) },
onMines = { state = state.copy(mineCount = it) },
onPlaceBet = {},
onRevealTile = {},
onCashout = {},
onNewBet = {},
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import com.plainstudio.stackcasino.domain.game.CoinSide
import com.plainstudio.stackcasino.domain.game.GameEngine
import com.plainstudio.stackcasino.domain.game.GameRepository
import com.plainstudio.stackcasino.domain.game.GameRound
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.SeedGenerator
import com.plainstudio.stackcasino.domain.wallet.BetSettlement
Expand Down Expand Up @@ -95,6 +98,84 @@ class GameRepositoryImpl
return PlayResult.Success(round)
}

override suspend fun startMines(
numMines: Int,
stake: Double,
): MinesStart {
val wallet = walletRepository.wallet.first()
minesError(numMines, stake, wallet.availableBalance)?.let { return MinesStart.Failure(it) }

val serverSeed = seedGenerator.newSeed()
val clientSeed = seedGenerator.newSeed()
val roundNonce = nonce.incrementAndGet()
val outcome =
runCatching { engine.evaluateMines(serverSeed, clientSeed, roundNonce, numMines) }
.getOrElse { return MinesStart.Failure("The game engine is unavailable.") }

when (walletRepository.applyRoundResult(stake, 0.0)) {
BetSettlement.InsufficientFunds -> return MinesStart.Failure(INSUFFICIENT_FUNDS)
BetSettlement.Success -> Unit
}
return MinesStart.Success(
MinesRound(
id = idGenerator.newId(),
numMines = numMines,
minePositions = parsePositions(outcome.result),
stake = stake,
currency = wallet.currencyCode,
serverSeed = serverSeed,
clientSeed = clientSeed,
nonce = roundNonce,
hash = outcome.hash,
resultRaw = outcome.result.trim(),
),
)
}

override suspend fun settleMines(
round: MinesRound,
safeRevealed: Int,
cashedOut: Boolean,
) {
val multiplier = if (cashedOut) MinesMath.multiplier(round.numMines, safeRevealed) else 0.0
val payout = round.stake * multiplier
if (cashedOut && payout > 0.0) {
walletRepository.applyRoundResult(0.0, payout)
}
dao.insertRound(
GameRoundEntity(
id = round.id,
game = GameKey.Mines.name,
pick = "${round.numMines} mines",
stake = round.stake,
payout = payout,
won = cashedOut,
multiplier = multiplier,
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()

private fun minesError(
numMines: Int,
stake: Double,
available: Double,
): String? =
if (numMines !in MinesMath.MIN_MINES..MinesMath.MAX_MINES) {
"Pick between 1 and 24 mines."
} else {
stakeError(stake, available)
}

private fun stakeError(
stake: Double,
available: Double,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ class NativeGameEngineAdapter
nonce: Long,
): EngineResult = parse(native.evaluateCoinflip(serverSeed, clientSeed, nonce))

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

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 @@ -31,4 +31,18 @@ interface GameEngine {
clientSeed: String,
nonce: Long,
): EngineResult

/**
* Evaluates a mines round. Returns the comma-separated mine positions
* (0-24 on the 5x5 grid) and the derivation hash.
*
* @param numMines the number of mines to place (1..24).
* @throws IllegalArgumentException for empty seeds, a non-positive nonce or an out-of-range mine count.
*/
fun evaluateMines(
serverSeed: String,
clientSeed: String,
nonce: Long,
numMines: Int,
): EngineResult
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,35 @@ sealed interface PlayResult {
) : PlayResult
}

/**
* A mines round in progress. Drawn at bet placement (the mine positions
* are committed via [hash]); the ViewModel drives the tile reveals and
* settles the round through [GameRepository.settleMines].
*/
data class MinesRound(
val id: String,
val numMines: Int,
val minePositions: Set<Int>,
val stake: Double,
val currency: String,
val serverSeed: String,
val clientSeed: String,
val nonce: Long,
val hash: String,
val resultRaw: String,
)

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

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

/**
* Plays provably-fair game rounds. Orchestrates the native [GameEngine],
* the wallet settlement and the Room round ledger; Room stays the single
Expand All @@ -38,4 +67,27 @@ interface GameRepository {
pick: CoinSide,
stake: Double,
): PlayResult

/**
* Places a mines bet: validates the stake, draws the mine positions
* on the native engine and debits the stake from the wallet. The
* returned [MinesRound] carries the committed board for the ViewModel
* to drive reveals against.
*/
suspend fun startMines(
numMines: Int,
stake: Double,
): MinesStart

/**
* Settles a mines round and records it: on [cashedOut] the payout
* (stake x the multiplier for [safeRevealed] safe tiles) is credited;
* on a mine hit nothing is credited (the stake was debited at start).
* Either way the round is written to the `game_round` ledger.
*/
suspend fun settleMines(
round: MinesRound,
safeRevealed: Int,
cashedOut: Boolean,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.plainstudio.stackcasino.domain.game

/**
* Pure payout math for Mines on the 5x5 grid.
*
* After revealing [safeRevealed] safe tiles with [numMines] mines on the
* board, the fair multiplier is the inverse of the probability of having
* survived that many reveals:
*
* `product over i in [0, safeRevealed) of (GRID - i) / (GRID - numMines - i)`
*
* scaled by [HOUSE_EDGE] (RTP 99%). Kept separate from the engine and the
* repository so the curve is unit-testable in isolation.
*/
object MinesMath {
const val GRID = 25
const val MIN_MINES = 1
const val MAX_MINES = 24
private const val HOUSE_EDGE = 0.99

/**
* The cash-out multiplier after [safeRevealed] safe reveals. Returns
* 1.0 before any reveal.
*/
fun multiplier(
numMines: Int,
safeRevealed: Int,
): Double {
require(numMines in MIN_MINES..MAX_MINES) { "numMines must be between $MIN_MINES and $MAX_MINES" }
val safeTiles = GRID - numMines
require(safeRevealed in 0..safeTiles) { "safeRevealed out of range" }
var multiplier = 1.0
for (i in 0 until safeRevealed) {
multiplier *= (GRID - i).toDouble() / (GRID - numMines - i).toDouble()
}
return if (safeRevealed == 0) 1.0 else multiplier * HOUSE_EDGE
}
}
Loading
Loading