Coverage Summary for Class: ContractHandlersKt (core.handlers)

Class Class, % Method, % Branch, % Line, % Instruction, %
ContractHandlersKt 100% (1/1) 100% (5/5) 80.4% (37/46) 95.1% (212/223) 96.5% (979/1014)


 // FILE: core/src/main/kotlin/core/handlers/ContractHandlers.kt
 package core.handlers
 
 import core.*
 import core.pipeline.*
 import core.primitives.*
 import core.rng.Rng
 import core.state.*
 
 /**
  * Contract command handlers.
  *
  * ## Semantic Ownership
  * Handles all contract lifecycle commands: create, post, update, cancel, close.
  *
  * ## Visibility
  * Internal to core module - only Reducer.kt should call these.
  *
  * ## Contract
  * - All handlers receive SeqContext for event emission
  * - All handlers return new GameState (never mutate)
  * - RNG is passed but may not be used by all handlers
  */
 
 /**
  * Posts a draft contract from inbox to board.
  *
  * @param state Current state.
  * @param cmd PostContract command.
  * @param _rng Deterministic RNG (unused in this handler).
  * @param ctx Event emission context.
  */
 internal fun handlePostContract(
     state: GameState,
     cmd: PostContract,
     _rng: Rng,
     ctx: SeqContext
 ): GameState {
     val draft = state.contracts.inbox.firstOrNull { it.id.value.toLong() == cmd.inboxId } ?: return state
 
     val boardContract = BoardContract(
         id = draft.id,
         postedDay = state.meta.dayIndex,
         title = draft.title,
         rank = draft.rankSuggested,
         fee = cmd.fee,
         salvage = cmd.salvage,
         baseDifficulty = draft.baseDifficulty,
         status = BoardStatus.OPEN,
         clientDeposit = draft.clientDeposit
     )
 
     // Settlement: Economy delta for posting
     val economyDelta = EconomySettlement.computePostContractDelta(draft.clientDeposit)
 
     val newState = state.copy(
         contracts = state.contracts.copy(
             inbox = state.contracts.inbox.filter { it.id.value.toLong() != cmd.inboxId },
             board = state.contracts.board + boardContract
         ),
         economy = economyDelta.applyTo(state.economy)
     )
 
     ctx.emit(
         ContractPosted(
             day = newState.meta.dayIndex,
             revision = newState.meta.revision,
             cmdId = cmd.cmdId,
             seq = 0L,
             boardContractId = boardContract.id.value,
             fromInboxId = draft.id.value,
             rank = draft.rankSuggested,
             fee = cmd.fee,
             salvage = cmd.salvage,
             clientDeposit = draft.clientDeposit
         )
     )
 
     return newState
 }
 
 /**
  * Creates a new contract draft in the inbox.
  *
  * @param state Current state.
  * @param cmd CreateContract command.
  * @param _rng Deterministic RNG (unused in this handler).
  * @param ctx Event emission context.
  */
 internal fun handleCreateContract(
     state: GameState,
     cmd: CreateContract,
     _rng: Rng,
     ctx: SeqContext
 ): GameState {
     val newId = state.meta.ids.nextContractId
     // Use reward as clientDeposit (client's contribution towards the fee)
     val clientDeposit = cmd.reward.coerceAtLeast(0)
 
     val draft = ContractDraft(
         id = ContractId(newId),
         createdDay = state.meta.dayIndex,
         nextAutoResolveDay = state.meta.dayIndex + BalanceSettings.AUTO_RESOLVE_INTERVAL_DAYS,
         title = cmd.title,
         rankSuggested = cmd.rank,
         feeOffered = 0,
         salvage = cmd.salvage,
         baseDifficulty = cmd.difficulty,
         proofHint = "proof",
         clientDeposit = clientDeposit
     )
 
     ctx.emit(
         ContractDraftCreated(
             day = state.meta.dayIndex,
             revision = state.meta.revision,
             cmdId = cmd.cmdId,
             seq = 0L,
             draftId = newId,
             title = cmd.title,
             rank = cmd.rank,
             difficulty = cmd.difficulty,
             reward = cmd.reward, // Emit original value for event compatibility
             salvage = cmd.salvage
         )
     )
 
     return state.copy(
         contracts = state.contracts.copy(inbox = state.contracts.inbox + draft),
         meta = state.meta.copy(ids = state.meta.ids.copy(nextContractId = newId + 1))
     )
 }
 
 /**
  * Updates contract terms in inbox or on board.
  *
  * @param state Current state.
  * @param cmd UpdateContractTerms command.
  * @param _rng Deterministic RNG (unused in this handler).
  * @param ctx Event emission context.
  */
 internal fun handleUpdateContractTerms(
     state: GameState,
     cmd: UpdateContractTerms,
     _rng: Rng,
     ctx: SeqContext
 ): GameState {
     val inboxContract = state.contracts.inbox.firstOrNull { it.id.value.toLong() == cmd.contractId }
     val boardContract = state.contracts.board.firstOrNull { it.id.value.toLong() == cmd.contractId }
 
     return when {
         inboxContract != null -> {
             val feeApplied = cmd.newFee ?: inboxContract.feeOffered
             val salvageApplied = cmd.newSalvage ?: inboxContract.salvage
 
             val updatedDraft = inboxContract.copy(feeOffered = feeApplied, salvage = salvageApplied)
 
             ctx.emit(
                 ContractTermsUpdated(
                     day = state.meta.dayIndex,
                     revision = state.meta.revision,
                     cmdId = cmd.cmdId,
                     seq = 0L,
                     contractId = inboxContract.id.value,
                     location = "inbox",
                     oldFee = inboxContract.feeOffered,
                     newFee = feeApplied,
                     oldSalvage = inboxContract.salvage,
                     newSalvage = salvageApplied
                 )
             )
 
             state.copy(
                 contracts = state.contracts.copy(
                     inbox = state.contracts.inbox.map { if (it.id.value.toLong() == cmd.contractId) updatedDraft else it }
                 )
             )
         }
 
         boardContract != null -> {
             val oldFee = boardContract.fee
             val feeApplied = cmd.newFee ?: oldFee
             val salvageApplied = cmd.newSalvage ?: boardContract.salvage
 
             val updatedBoard = boardContract.copy(
                 fee = feeApplied,
                 salvage = salvageApplied
             )
 
             ctx.emit(
                 ContractTermsUpdated(
                     day = state.meta.dayIndex,
                     revision = state.meta.revision,
                     cmdId = cmd.cmdId,
                     seq = 0L,
                     contractId = boardContract.id.value,
                     location = "board",
                     oldFee = oldFee,
                     newFee = feeApplied,
                     oldSalvage = boardContract.salvage,
                     newSalvage = salvageApplied
                 )
             )
 
             state.copy(
                 contracts = state.contracts.copy(
                     board = state.contracts.board.map { if (it.id.value.toLong() == cmd.contractId) updatedBoard else it }
                 )
             )
         }
 
         else -> state
     }
 }
 
 /**
  * Cancels a contract from inbox or board.
  *
  * @param state Current state.
  * @param cmd CancelContract command.
  * @param _rng Deterministic RNG (unused in this handler).
  * @param ctx Event emission context.
  */
 internal fun handleCancelContract(
     state: GameState,
     cmd: CancelContract,
     _rng: Rng,
     ctx: SeqContext
 ): GameState {
     val inboxContract = state.contracts.inbox.firstOrNull { it.id.value.toLong() == cmd.contractId }
     val boardContract = state.contracts.board.firstOrNull { it.id.value.toLong() == cmd.contractId }
 
     return when {
         inboxContract != null -> {
             ctx.emit(
                 ContractCancelled(
                     day = state.meta.dayIndex,
                     revision = state.meta.revision,
                     cmdId = cmd.cmdId,
                     seq = 0L,
                     contractId = inboxContract.id.value,
                     location = "inbox",
                     refundedCopper = 0
                 )
             )
 
             state.copy(contracts = state.contracts.copy(inbox = state.contracts.inbox.filter { it.id.value.toLong() != cmd.contractId }))
         }
 
         boardContract != null -> {
             // Settlement: Economy delta for cancellation
             val economyDelta = EconomySettlement.computeCancelContractDelta(boardContract.clientDeposit)
 
             ctx.emit(
                 ContractCancelled(
                     day = state.meta.dayIndex,
                     revision = state.meta.revision,
                     cmdId = cmd.cmdId,
                     seq = 0L,
                     contractId = boardContract.id.value,
                     location = "board",
                     refundedCopper = boardContract.clientDeposit
                 )
             )
 
             state.copy(
                 contracts = state.contracts.copy(board = state.contracts.board.filter { it.id.value.toLong() != cmd.contractId }),
                 economy = economyDelta.applyTo(state.economy)
             )
         }
 
         else -> state
     }
 }
 
 /**
  * Closes a completed return (manual player action).
  *
  * ## MVP: Accept/Reject Support
  * - ACCEPT: Normal closure with payment and trophy allocation per salvage policy.
  * - REJECT: Lifecycle terminated, no payment, zero trophies, escrow released.
  *
  * @param state Current state.
  * @param cmd CloseReturn command with optional decision.
  * @param _rng Deterministic RNG (unused in this handler - zero RNG draws).
  * @param ctx Event emission context.
  */
 @Suppress("ReturnCount")
 internal fun handleCloseReturn(
     state: GameState,
     cmd: CloseReturn,
     _rng: Rng,
     ctx: SeqContext
 ): GameState {
     val ret = state.contracts.returns.firstOrNull { it.activeContractId.value.toLong() == cmd.activeContractId } ?: return state
     val board = state.contracts.board.firstOrNull { it.id == ret.boardContractId }
 
     // Normalize decision: null defaults to ACCEPT only under non-STRICT policies
     val effectiveDecision = cmd.decision ?: ReturnDecision.ACCEPT
 
     // Policy: Check if closure is allowed with the given decision
     val closureCheck = ReturnClosurePolicy.canClose(
         state.guild.proofPolicy,
         effectiveDecision,
         ret.trophiesQuality,
         ret.suspectedTheft
     )
     if (!closureCheck.allowed) {
         ctx.emit(
             ReturnClosureBlocked(
                 day = state.meta.dayIndex,
                 revision = state.meta.revision,
                 cmdId = cmd.cmdId,
                 seq = 0L,
                 activeContractId = ret.activeContractId.value,
                 policy = state.guild.proofPolicy,
                 reason = closureCheck.reason ?: "unknown"
             )
         )
         return state
     }
 
     // Settlement: Hero lifecycle (same for ACCEPT and REJECT)
     val updatedRoster = HeroLifecycle.computeManualCloseUpdate(
         ret.heroIds,
         state.heroes.roster
     )
 
     // Filter out this return and mark active as CLOSED
     val updatedReturns = state.contracts.returns.filter { it.activeContractId.value.toLong() != cmd.activeContractId }
     val updatedActives = state.contracts.active.map {
         if (it.id.value.toLong() == cmd.activeContractId) it.copy(status = ActiveStatus.CLOSED) else it
     }
 
     // Settlement: Economy (differs based on decision)
     val economyDelta = if (effectiveDecision == ReturnDecision.REJECT) {
         // REJECT: no payment, no trophies, but release escrow
         val clientDeposit = board?.clientDeposit ?: 0
         EconomyDelta(
             moneyDelta = 0,          // no fee payment
             reservedDelta = -clientDeposit,  // release escrow
             trophiesDelta = 0        // no trophies awarded
         )
     } else {
         // ACCEPT: normal closure logic
         EconomySettlement.computeManualCloseDelta(
             ret.outcome,
             board,
             ret.trophiesCount,
             ret.trophiesQuality,
             ret.suspectedTheft,
             state.economy
         )
     }
 
     // Settlement: Board status -> complete and archive if eligible
     val (updatedBoard, newlyArchived) = BoardStatusModel.completeBoardAndExtract(
         boards = state.contracts.board,
         boardIdToComplete = board?.id,
         activeContracts = updatedActives
     )
 
     // Settlement: Guild progression (count completion only for ACCEPT, not REJECT)
     val shouldCountCompletion = (effectiveDecision == ReturnDecision.ACCEPT)
     val guildResult = if (shouldCountCompletion) {
         GuildProgression.computeAfterCompletion(
             state.guild.completedContractsTotal,
             state.guild.guildRank
         )
     } else {
         // REJECT: treat as FAIL-like, no completion increment
         GuildProgressionResult(
             newCompletedContractsTotal = state.guild.completedContractsTotal,
             contractsForNextRank = state.guild.contractsForNextRank,
             newGuildRank = state.guild.guildRank,
             oldRank = state.guild.guildRank,
             rankChanged = false
         )
     }
 
     if (guildResult.rankChanged) {
         ctx.emit(
             GuildRankUp(
                 day = state.meta.dayIndex,
                 revision = state.meta.revision,
                 cmdId = cmd.cmdId,
                 seq = 0L,
                 oldRank = guildResult.oldRank,
                 newRank = guildResult.newGuildRank,
                 completedContracts = guildResult.newCompletedContractsTotal
             )
         )
     }
 
     // Apply all settlements
     val newState = state.copy(
         contracts = state.contracts.copy(
             board = updatedBoard,
             archive = state.contracts.archive + newlyArchived,
             active = updatedActives,
             returns = updatedReturns
         ),
         heroes = state.heroes.copy(roster = updatedRoster),
         economy = economyDelta.applyTo(state.economy),
         guild = state.guild.copy(
             completedContractsTotal = guildResult.newCompletedContractsTotal,
             contractsForNextRank = guildResult.contractsForNextRank,
             guildRank = guildResult.newGuildRank
         )
     )
 
     // Emit appropriate event based on decision
     if (effectiveDecision == ReturnDecision.REJECT) {
         ctx.emit(
             ReturnRejected(
                 day = newState.meta.dayIndex,
                 revision = newState.meta.revision,
                 cmdId = cmd.cmdId,
                 seq = 0L,
                 activeContractId = cmd.activeContractId.toInt()
             )
         )
     } else {
         ctx.emit(
             ReturnClosed(
                 day = newState.meta.dayIndex,
                 revision = newState.meta.revision,
                 cmdId = cmd.cmdId,
                 seq = 0L,
                 activeContractId = cmd.activeContractId.toInt()
             )
         )
     }
 
     return newState
 }