Coverage Summary for Class: ContractPricing (core.flavour)

Class Method, % Branch, % Line, % Instruction, %
ContractPricing 100% (5/5) 70% (14/20) 79.3% (23/29) 80.1% (117/146)
ContractPricing$WhenMappings
Total 100% (5/5) 70% (14/20) 79.3% (23/29) 80.1% (117/146)


 // FILE: core/src/main/kotlin/core/flavour/ContractPricing.kt
 package core.flavour
 
 import core.BalanceSettings
 import core.primitives.Money
 import core.primitives.MoneyCopper
 import core.primitives.Rank
 import core.rng.Rng
 import java.math.BigDecimal
 
 /**
  * Rank-based deterministic pricing for contract drafts.
  *
  * ## Role
  * - Converts target contract rank into (a) sampled payout (money) and
  *   (b) clientDeposit contribution.
  *
  * ## Money Contract (FP-ECON-02)
  * - Canonical storage unit across core is **copper**.
  * - `BalanceSettings.PAYOUT_*` ranges are expressed in **GP** (design bands).
  * - This pricing component samples from GP bands, then converts to
  *   [MoneyCopper] (1 gp = 100 copper).
  * - Any decimal → copper conversion uses **floor** rounding as implemented
  *   in [Money].
  *
  * ## Determinism
  * - Uses explicit RNG draws in a fixed order.
  * - Same seed + same rank → identical results.
  * - Do not reorder draws without updating determinism tests.
  *
  * ## Boundary Rules
  * - Must NOT emit events.
  * - Must NOT mutate state.
  */
 object ContractPricing {
 
     /**
      * Sample the payout value for a contract in copper units.
      *
      * ## Inputs
      * - [rank] selects the GP payout band from [BalanceSettings].
      * - [rng] provides deterministic draws.
      *
      * @param rank Target contract rank.
      * @param rng Deterministic RNG.
      *
      * ## Output
      * @return Sampled payout as [MoneyCopper] (>= 0).
      *
      * ## Notes
      * - Sampling is uniform over the configured GP band.
      * - Conversion: `gp * 100` copper (via [Money.fromGoldDecimal]).
      */
     fun samplePayoutMoney(rank: Rank, rng: Rng): MoneyCopper {
         val payoutGp = when (rank) {
             Rank.F -> rng.nextIntInclusive(BalanceSettings.PAYOUT_F_MIN, BalanceSettings.PAYOUT_F_MAX)
             Rank.E -> rng.nextIntInclusive(BalanceSettings.PAYOUT_E_MIN, BalanceSettings.PAYOUT_E_MAX)
             Rank.D -> rng.nextIntInclusive(BalanceSettings.PAYOUT_D_MIN, BalanceSettings.PAYOUT_D_MAX)
             Rank.C -> rng.nextIntInclusive(BalanceSettings.PAYOUT_C_MIN, BalanceSettings.PAYOUT_C_MAX)
             Rank.B -> rng.nextIntInclusive(BalanceSettings.PAYOUT_B_MIN, BalanceSettings.PAYOUT_B_MAX)
             Rank.A -> sampleAWithTail(rng)
             Rank.S -> rng.nextIntInclusive(BalanceSettings.PAYOUT_S_MIN, BalanceSettings.PAYOUT_S_MAX)
         }
         // Convert GP to copper: 1 GP = 100 copper
         return Money.fromGoldDecimal(BigDecimal(payoutGp))
     }
 
     /**
      * Sample the client's deposit contribution in copper units.
      *
      * ## Behavior
      * - Performs a percent roll (`CLIENT_PAYS_CHANCE_PERCENT`).
      * - If client pays: deposit = floor(payout * CLIENT_PAYS_FRACTION_BP / 10_000)
      *   computed in copper via [Money.mulFractionBp].
      * - Otherwise: deposit = 0.
      *
      * @param payout Sampled payout in copper.
      * @param rng Deterministic RNG.
      * @return Client deposit in copper.
      *
      * ## Invariants
      * - deposit >= 0
      * - deposit <= payout
      */
     fun sampleClientDepositMoney(payout: MoneyCopper, rng: Rng): MoneyCopper {
         val roll = rng.nextInt(BalanceSettings.PERCENT_ROLL_MAX)
         if (roll >= BalanceSettings.CLIENT_PAYS_CHANCE_PERCENT) return Money.ZERO
 
         // Calculate deposit as fraction of payout in copper (no truncation)
         return Money.mulFractionBp(payout, BalanceSettings.CLIENT_PAYS_FRACTION_BP)
     }
 
     /**
      * Legacy API: sample payout in GP as an Int.
      *
      * @param rank Target contract rank.
      * @param rng Deterministic RNG.
      * @return Sampled payout in GP.
      *
      * @deprecated FP-ECON-02: Use [samplePayoutMoney] for copper-safe logic.
      * This method returns GP as Int and should be avoided in domain logic.
      */
     @Deprecated(
         message = "Use samplePayoutMoney() for copper-based calculations",
         replaceWith = ReplaceWith("samplePayoutMoney(rank, rng)")
     )
     fun samplePayoutGp(rank: Rank, rng: Rng): Int {
         return when (rank) {
             Rank.F -> rng.nextIntInclusive(BalanceSettings.PAYOUT_F_MIN, BalanceSettings.PAYOUT_F_MAX)
             Rank.E -> rng.nextIntInclusive(BalanceSettings.PAYOUT_E_MIN, BalanceSettings.PAYOUT_E_MAX)
             Rank.D -> rng.nextIntInclusive(BalanceSettings.PAYOUT_D_MIN, BalanceSettings.PAYOUT_D_MAX)
             Rank.C -> rng.nextIntInclusive(BalanceSettings.PAYOUT_C_MIN, BalanceSettings.PAYOUT_C_MAX)
             Rank.B -> rng.nextIntInclusive(BalanceSettings.PAYOUT_B_MIN, BalanceSettings.PAYOUT_B_MAX)
             Rank.A -> sampleAWithTail(rng)
             Rank.S -> rng.nextIntInclusive(BalanceSettings.PAYOUT_S_MIN, BalanceSettings.PAYOUT_S_MAX)
         }
     }
 
     /**
      * Legacy API: sample deposit in GP as an Int.
      *
      * @param rank Target contract rank.
      * @param rng Deterministic RNG.
      * @return Client deposit in GP.
      *
      * @deprecated FP-ECON-02: Use [sampleClientDepositMoney] for copper-safe
      * deposit logic. This method uses integer division in GP space and may
      * truncate small values to 0.
      */
     @Deprecated(
         message = "Use sampleClientDepositMoney() for copper-based calculations",
         replaceWith = ReplaceWith("sampleClientDepositMoney(samplePayoutMoney(rank, rng), rng)")
     )
     fun sampleClientDepositGp(rank: Rank, rng: Rng): Int {
         val payout = samplePayoutGp(rank, rng)
         val roll = rng.nextInt(BalanceSettings.PERCENT_ROLL_MAX)
         if (roll >= BalanceSettings.CLIENT_PAYS_CHANCE_PERCENT) return 0
 
         val deposit = (payout * BalanceSettings.CLIENT_PAYS_FRACTION_BP) / 10_000
         return deposit.coerceAtLeast(0)
     }
 
     /**
      * Sample Rank A with heavy-tail distribution.
      *
      * Most of the time samples from base range [A_MIN, A_MAX].
      * With PAYOUT_A_TAIL_CHANCE_PERCENT probability, samples from tail range [A_TAIL_MIN, A_TAIL_MAX].
      *
      * @param rng Deterministic RNG.
      */
     private fun sampleAWithTail(rng: Rng): Int {
         val roll = rng.nextInt(BalanceSettings.PERCENT_ROLL_MAX)
         return if (roll < BalanceSettings.PAYOUT_A_TAIL_CHANCE_PERCENT) {
             rng.nextIntInclusive(BalanceSettings.PAYOUT_A_TAIL_MIN, BalanceSettings.PAYOUT_A_TAIL_MAX)
         } else {
             rng.nextIntInclusive(BalanceSettings.PAYOUT_A_MIN, BalanceSettings.PAYOUT_A_MAX)
         }
     }
 }
 
 /**
  * Inclusive Int range sampling using nextInt.
  *
  * @param min Minimum value (inclusive).
  * @param max Maximum value (inclusive).
  * @return Random value in [min, max].
  */
 private fun Rng.nextIntInclusive(min: Int, max: Int): Int {
     require(max >= min) { "max ($max) must be >= min ($min)" }
     val span = (max - min) + 1
     return min + this.nextInt(span)
 }