Coverage Summary for Class: OutcomeResolution (core.pipeline)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| OutcomeResolution |
100%
(4/4)
|
90%
(9/10)
|
96.3%
(26/27)
|
99%
(96/97)
|
| OutcomeResolution$WhenMappings |
|
| Total |
100%
(4/4)
|
90%
(9/10)
|
96.3%
(26/27)
|
99%
(96/97)
|
// FILE: core/src/main/kotlin/core/pipeline/OutcomeResolution.kt
package core.pipeline
import core.BalanceSettings
import core.calculateHeroPower
import core.primitives.Outcome
import core.primitives.Quality
import core.rng.Rng
import core.state.Hero
/**
* Outcome resolution decision model.
*
* ## Semantic Ownership
* Answers exactly one question: **Why SUCCESS / PARTIAL / FAIL / DEATH?**
*
* ## Stability Gradient
* STABLE: Pure decision logic with explicit rules and formulas.
*
* ## Determinism
* - Uses exactly one RNG roll per decision.
* - All inputs are explicit; no hidden state.
* - Draw order preserved for replay compatibility.
*
* ## Boundary Rules
* - Must NOT touch economy.
* - Must NOT emit events.
* - Must NOT mutate state.
*/
object OutcomeResolution {
/**
* Decides the outcome for a completed contract.
*
* @param hero The hero who completed the contract (null = fallback power).
* @param contractDifficulty Base difficulty of the contract.
* @param rng Deterministic RNG for the roll.
* @return [OutcomeDecision] containing outcome, trophies count, and quality.
*/
fun decide(hero: Hero?, contractDifficulty: Int, rng: Rng): OutcomeDecision {
val outcome = resolveOutcome(hero, contractDifficulty, rng)
val trophiesCount = resolveTrophiesCount(outcome, rng)
val trophiesQuality = resolveTrophiesQuality(rng)
return OutcomeDecision(
outcome = outcome,
baseTrophiesCount = trophiesCount,
trophiesQuality = trophiesQuality
)
}
/**
* Core outcome resolution formula.
*
* Uses one RNG roll and fixed probability buckets.
*/
private fun resolveOutcome(hero: Hero?, contractDifficulty: Int, rng: Rng): Outcome {
val heroPower = calculateHeroPower(hero)
val rawSuccessChance = (heroPower - contractDifficulty + BalanceSettings.SUCCESS_FORMULA_OFFSET) *
BalanceSettings.SUCCESS_FORMULA_MULTIPLIER
// Clamp success chance to valid range, leaving room for FAIL_CHANCE_MIN
val maxSuccessForFail = BalanceSettings.PERCENT_ROLL_MAX - BalanceSettings.PARTIAL_CHANCE_FIXED - BalanceSettings.FAIL_CHANCE_MIN
val pSuccess = rawSuccessChance.coerceIn(BalanceSettings.SUCCESS_CHANCE_MIN, maxSuccessForFail)
// Fixed partial chance
val pPartial = BalanceSettings.PARTIAL_CHANCE_FIXED
// Fail is the remainder, guaranteed >= FAIL_CHANCE_MIN by construction
val roll = rng.nextInt(BalanceSettings.PERCENT_ROLL_MAX)
return when {
roll < pSuccess -> Outcome.SUCCESS
roll < pSuccess + pPartial -> Outcome.PARTIAL
else -> {
// DEATH tail: fixed high-roll death chance without extra RNG draws
if (roll >= (BalanceSettings.PERCENT_ROLL_MAX - 5)) {
// PoC/MVP: sometimes emit MISSING as a narrative alias for DEATH while keeping backend effects identical.
val deathOutcome = Outcome.DEATH
// Use same RNG stream to preserve draw order determinism — use another nextInt on the same RNG.
val subRoll = rng.nextInt(BalanceSettings.PERCENT_ROLL_MAX)
if (subRoll < BalanceSettings.MISSING_CHANCE_PERCENT) Outcome.MISSING else deathOutcome
} else {
Outcome.FAIL
}
}
}
}
/**
* Determines trophy count based on outcome.
*/
private fun resolveTrophiesCount(outcome: Outcome, rng: Rng): Int {
return when (outcome) {
Outcome.SUCCESS -> 1 + rng.nextInt(3)
Outcome.PARTIAL -> 1
else -> 0
}
}
/**
* Determines trophy quality.
*/
private fun resolveTrophiesQuality(rng: Rng): Quality {
return Quality.entries[rng.nextInt(Quality.entries.size)]
}
}
/**
* Decision output from outcome resolution.
*
* This DTO is RNG-free and can be tested in isolation.
*/
data class OutcomeDecision(
/** The resolved outcome (SUCCESS, PARTIAL, FAIL, DEATH). */
val outcome: Outcome,
/** Base trophies count before theft processing. */
val baseTrophiesCount: Int,
/** Quality of the trophies. */
val trophiesQuality: Quality
) {
/** Whether this outcome requires manual player close (PARTIAL only). */
val requiresPlayerClose: Boolean get() = outcome == Outcome.PARTIAL
}