Coverage Summary for Class: InvariantVerifierKt (core.invariants)

Class Method, % Branch, % Line, % Instruction, %
InvariantVerifierKt 75% (3/4) 87.3% (110/126) 90.7% (146/161) 91.1% (844/926)
InvariantVerifierKt$verifyInvariants$$inlined$sortedBy$1 0% (0/1)
InvariantVerifierKt$verifyInvariants$$inlined$sortedBy$2 0% (0/1)
InvariantVerifierKt$verifyInvariants$$inlined$sortedBy$3 0% (0/1)
InvariantVerifierKt$verifyInvariants$$inlined$sortedBy$4 0% (0/1)
InvariantVerifierKt$verifyInvariants$$inlined$sortedBy$5 0% (0/1)
InvariantVerifierKt$verifyInvariants$$inlined$sortedBy$6 0% (0/1)
Total 30% (3/10) 87.3% (110/126) 90.7% (146/161) 91.1% (844/926)


 // FILE: core/src/main/kotlin/core/invariants/InvariantVerifier.kt
 package core.invariants
 
 import core.primitives.*
 import core.state.*
 
 /**
  * Verifies a subset of state consistency invariants and reports violations.
  *
  * ## Contract
  * - Reads the provided `state` and returns a list of `InvariantViolation` entries.
  * - Each `InvariantViolation.details` string is constructed deterministically from observed values.
  * - Violation emission order is deterministic:
  *   - Checks are executed in a fixed sequence (as written in this function).
  *   - Where multiple entities can violate the same invariant, the function sorts by stable keys
  *     (numeric id projections) before emitting violations.
  *
  * ## Determinism
  * - Deterministic for a given `state` (no RNG).
  */
 fun verifyInvariants(state: GameState): List<InvariantViolation> {
     val violations = mutableListOf<InvariantViolation>()
 
     fun add(id: InvariantId, details: String) {
         violations.add(InvariantViolation(id, details))
     }
 
     /**
      * Stable numeric projection for id-like values.
      *
      * We intentionally avoid requiring ids to be Comparable or to expose a specific `.value` field.
      * For Kotlin value classes / wrappers, `hashCode()` is stable and (in typical id wrappers)
      * equals the underlying numeric value.
      */
     fun num(x: Any?): Int = when (x) {
         null -> 0
         is Int -> x
         is Long -> x.toInt()
         is UInt -> x.toInt()
         is ULong -> x.toInt()
         is Short -> x.toInt()
         is Byte -> x.toInt()
         else -> x.hashCode()
     }
 
     fun fmtReturn(p: ReturnPacket): String =
         "{active=${num(p.activeContractId)},board=${num(p.boardContractId)}," +
                 "requiresClose=${p.requiresPlayerClose},trophies=${p.trophiesCount},q=${p.trophiesQuality}}"
 
     // --- 1) Precompute max ids for monotonicity checks ---
     val maxInboxContractId = state.contracts.inbox.maxOfOrNull { num(it.id) } ?: 0
     val maxBoardContractId = state.contracts.board.maxOfOrNull { num(it.id) } ?: 0
     val maxContractId = maxOf(maxInboxContractId, maxBoardContractId)
 
     val maxActiveId = state.contracts.active.maxOfOrNull { num(it.id) } ?: 0
     val maxHeroId = state.heroes.roster.maxOfOrNull { num(it.id) } ?: 0
 
     val nextContractIdNum = num(state.meta.ids.nextContractId)
     val nextActiveContractIdNum = num(state.meta.ids.nextActiveContractId)
     val nextHeroIdNum = num(state.meta.ids.nextHeroId)
 
     // --- 2) ID invariants ---
     if (nextContractIdNum <= 0) {
         add(InvariantId.IDS__NEXT_CONTRACT_ID_POSITIVE, "nextContractId=$nextContractIdNum must be > 0")
     }
     if (nextActiveContractIdNum <= 0) {
         add(InvariantId.IDS__NEXT_ACTIVE_CONTRACT_ID_POSITIVE, "nextActiveContractId=$nextActiveContractIdNum must be > 0")
     }
     if (nextHeroIdNum <= 0) {
         add(InvariantId.IDS__NEXT_HERO_ID_POSITIVE, "nextHeroId=$nextHeroIdNum must be > 0")
     }
 
     if (nextContractIdNum <= maxContractId) {
         add(
             InvariantId.IDS__NEXT_CONTRACT_ID_GT_MAX_CONTRACT_ID,
             "nextContractId=$nextContractIdNum must be > maxContractId=$maxContractId"
         )
     }
     if (nextActiveContractIdNum <= maxActiveId) {
         add(
             InvariantId.IDS__NEXT_ACTIVE_CONTRACT_ID_GT_MAX_ACTIVE_ID,
             "nextActiveContractId=$nextActiveContractIdNum must be > maxActiveId=$maxActiveId"
         )
     }
     if (nextHeroIdNum <= maxHeroId) {
         add(
             InvariantId.IDS__NEXT_HERO_ID_GT_MAX_HERO_ID,
             "nextHeroId=$nextHeroIdNum must be > maxHeroId=$maxHeroId"
         )
     }
 
     // --- 3) Contracts: LOCKED board must have exactly one non-closed active referencing it ---
     val actives = state.contracts.active
     val returns = state.contracts.returns
 
     state.contracts.board
         .asSequence()
         .filter { it.status == BoardStatus.LOCKED }
         .sortedBy { num(it.id) }
         .forEach { board ->
             val nonClosedActiveIds = actives
                 .asSequence()
                 .filter { it.boardContractId == board.id }
                 .filter { it.status != ActiveStatus.CLOSED }
                 .map { num(it.id) }
                 .sorted()
                 .toList()
 
             if (nonClosedActiveIds.size != 1) {
                 add(
                     InvariantId.CONTRACTS__LOCKED_BOARD_HAS_NON_CLOSED_ACTIVE,
                     "boardId=${num(board.id)} expectedExactlyOneNonClosedActive but found=${nonClosedActiveIds.size}; activeIds=$nonClosedActiveIds"
                 )
             }
         }
 
     // --- 4) Contracts: RETURN_READY active must have exactly one return packet ---
     actives
         .asSequence()
         .filter { it.status == ActiveStatus.RETURN_READY }
         .sortedBy { num(it.id) }
         .forEach { active ->
             val packets = returns.filter { it.activeContractId == active.id }
             if (packets.size != 1) {
                 add(
                     InvariantId.CONTRACTS__RETURN_READY_HAS_RETURN_PACKET,
                     "activeId=${num(active.id)} is RETURN_READY but returnPacketsFound=${packets.size}"
                 )
             }
         }
 
     // --- 5) Contracts: Return packet points to existing active (ONLY when close is required) ---
     // PoC nuance:
     // - If requiresPlayerClose=false, the reducer may auto-finalize and drop the active contract.
     //   Such packets are informational and must NOT fail referential checks.
     val activeIdNums = actives.asSequence().map { num(it.id) }.toSet()
 
     val invReferentialIntegrity: InvariantId? =
         runCatching { InvariantId.valueOf("INV_REFERENTIAL_INTEGRITY") }.getOrNull()
 
     returns
         .asSequence()
         .filter { it.requiresPlayerClose }
         .sortedWith(compareBy<ReturnPacket>({ num(it.boardContractId) }, { num(it.activeContractId) }))
         .forEach { p ->
             val aId = num(p.activeContractId)
             if (!activeIdNums.contains(aId)) {
                 add(
                     InvariantId.CONTRACTS__RETURN_PACKET_POINTS_TO_EXISTING_ACTIVE,
                     "returnPacket.activeId=$aId not found in actives; packet=${fmtReturn(p)}"
                 )
                 if (invReferentialIntegrity != null) {
                     add(
                         invReferentialIntegrity,
                         "return.activeId=$aId missing; return=${fmtReturn(p)}"
                     )
                 }
             }
         }
 
     // --- 6) Contracts: Active daysRemaining must be non-negative ---
     actives
         .asSequence()
         .sortedBy { num(it.id) }
         .forEach { a ->
             if (a.daysRemaining < 0) {
                 add(
                     InvariantId.CONTRACTS__ACTIVE_DAYS_REMAINING_NON_NEGATIVE,
                     "activeId=${num(a.id)} daysRemaining=${a.daysRemaining} < 0"
                 )
             }
         }
 
     // --- 7) Contracts: WIP daysRemaining must be in 1..2 ---
     actives
         .asSequence()
         .filter { it.status == ActiveStatus.WIP }
         .sortedBy { num(it.id) }
         .forEach { a ->
             if (a.daysRemaining !in 1..2) {
                 add(
                     InvariantId.CONTRACTS__WIP_DAYS_REMAINING_IN_1_2,
                     "activeId=${num(a.id)} is WIP but daysRemaining=${a.daysRemaining} not in [1,2]"
                 )
             }
         }
 
     // --- 8) Heroes invariants ---
     // Use numeric keys to avoid assuming HeroId is Int/Comparable/etc.
     val heroesByNum = state.heroes.roster.associateBy { num(it.id) }
 
     // (a) Active in WIP/RETURN_READY => all assigned heroes exist and are ON_MISSION
     actives
         .asSequence()
         .filter { it.status == ActiveStatus.WIP || it.status == ActiveStatus.RETURN_READY }
         .sortedBy { num(it.id) }
         .forEach { a ->
             a.heroIds.forEach { hid ->
                 val heroNum = num(hid)
                 val hero = heroesByNum[heroNum]
                 if (hero == null || hero.status != HeroStatus.ON_MISSION) {
                     add(
                         InvariantId.HEROES__ACTIVE_WIP_OR_RETURN_READY_HERO_STATUS_ON_MISSION,
                         "activeId=${num(a.id)} heroId=$heroNum expected ON_MISSION but was ${hero?.status}"
                     )
                 }
             }
         }
 
     // (b) Hero status=ON_MISSION must appear in exactly one WIP/RETURN_READY active
     val usage = mutableMapOf<Int, Int>()
     actives
         .asSequence()
         .filter { it.status == ActiveStatus.WIP || it.status == ActiveStatus.RETURN_READY }
         .forEach { a ->
             a.heroIds.forEach { hid ->
                 val k = num(hid)
                 usage[k] = (usage[k] ?: 0) + 1
             }
         }
 
     state.heroes.roster
         .asSequence()
         .filter { it.status == HeroStatus.ON_MISSION }
         .sortedBy { num(it.id) }
         .forEach { h ->
             val k = num(h.id)
             val c = usage[k] ?: 0
             if (c != 1) {
                 add(
                     InvariantId.HEROES__ON_MISSION_IN_EXACTLY_ONE_ACTIVE_CONTRACT,
                     "heroId=$k is ON_MISSION but referencedByActives=$c"
                 )
             }
         }
 
     // --- 9) Economy/Region/Guild invariants ---
     val money = state.economy.moneyCopper
     val reserved = state.economy.reservedCopper
     val trophies = state.economy.trophiesStock
 
     if (money < 0) add(InvariantId.ECONOMY__MONEY_NON_NEGATIVE, "moneyCopper=$money < 0")
     if (reserved < 0) add(InvariantId.ECONOMY__RESERVED_NON_NEGATIVE, "reservedCopper=$reserved < 0")
     if (trophies < 0) add(InvariantId.ECONOMY__TROPHIES_NON_NEGATIVE, "trophiesStock=$trophies < 0")
 
     val available = money - reserved
     if (available < 0) {
         add(
             InvariantId.ECONOMY__AVAILABLE_NON_NEGATIVE,
             "availableCopper=$available (money=$money,reserved=$reserved) < 0"
         )
     }
 
     val stability = state.region.stability
     if (stability !in 0..100) {
         add(InvariantId.REGION__STABILITY_0_100, "stability=$stability not in [0,100]")
     }
 
     val reputation = state.guild.reputation
     if (reputation !in 0..100) {
         add(InvariantId.GUILD__REPUTATION_0_100, "reputation=$reputation not in [0,100]")
     }
 
     return violations
 }