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
}