Coverage Summary for Class: ValidationResult (core)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| ValidationResult$Rejected |
100%
(1/1)
|
|
100%
(3/3)
|
100%
(12/12)
|
| ValidationResult$Valid |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(2/2)
|
| Total |
100%
(2/2)
|
|
100%
(4/4)
|
100%
(14/14)
|
// FILE: core/src/main/kotlin/core/CommandValidation.kt
package core
import core.primitives.ActiveStatus
import core.primitives.BoardStatus
import core.primitives.ProofPolicy
import core.state.GameState
/**
* Machine-readable error codes for command validation failures.
*/
enum class RejectReason {
NOT_FOUND,
INVALID_ARG,
INVALID_STATE
}
/**
* Sealed result type representing validation outcomes.
*/
sealed interface ValidationResult {
/**
* Command passed all validation checks.
*/
data object Valid : ValidationResult
/**
* Command failed validation.
*
* @property reason Stable machine-readable error code.
* @property detail Human-readable diagnostic message (non-authoritative).
*/
data class Rejected(
val reason: RejectReason,
val detail: String
) : ValidationResult
}
/**
* Validates whether a command can be applied to the given game state.
*
* Complexity: O(n) where n is the size of the relevant collection being searched.
*
* @param state Current game state (immutable, not modified).
* @param cmd Command to validate.
* @return ValidationResult.Valid if command is valid, ValidationResult.Rejected otherwise.
*
* Side effects: None (pure function).
*/
fun canApply(state: GameState, cmd: Command): ValidationResult {
// Global check: cmdId must be positive
if (cmd.cmdId <= 0L) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_ARG,
detail = "cmdId=${cmd.cmdId} must be positive"
)
}
return when (cmd) {
is PostContract -> validatePostContract(state, cmd)
is CloseReturn -> validateCloseReturn(state, cmd)
is SellTrophies -> validateSellTrophies(state, cmd)
is PayTax -> validatePayTax(state, cmd)
is SetProofPolicy -> ValidationResult.Valid
is AdvanceDay -> ValidationResult.Valid
is CreateContract -> validateCreateContract(state, cmd)
is UpdateContractTerms -> validateUpdateContractTerms(state, cmd)
is CancelContract -> validateCancelContract(state, cmd)
}
}
/**
* Validates PostContract command.
*
* Complexity: O(n) where n = state.contracts.inbox.size.
*
* Input invariants:
* - cmd.inboxId must reference an existing inbox contract.
* - cmd.fee must be non-negative.
*
* Output contract:
* - Returns Valid if inboxId exists and fee >= 0.
* - Returns Rejected(NOT_FOUND) if inboxId not found.
* - Returns Rejected(INVALID_ARG) if fee < 0.
*
* Side effects: None.
*/
@Suppress("ReturnCount")
private fun validatePostContract(state: GameState, cmd: PostContract): ValidationResult {
val draft = state.contracts.inbox.firstOrNull { it.id.value.toLong() == cmd.inboxId }
if (draft == null) {
return ValidationResult.Rejected(
reason = RejectReason.NOT_FOUND,
detail = "inboxId=${cmd.inboxId} not found"
)
}
if (cmd.fee < 0) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_ARG,
detail = "fee=${cmd.fee} must be >= 0"
)
}
// Player only needs to cover: max(0, fee - clientDeposit)
// clientDeposit is the client's contribution which can cover part or all of the fee
val requiredFromGuild = (cmd.fee - draft.clientDeposit).coerceAtLeast(0)
val availableCopper = state.economy.moneyCopper - state.economy.reservedCopper
if (availableCopper < requiredFromGuild) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_STATE,
detail = "Insufficient available funds: need $requiredFromGuild (fee=${cmd.fee} - clientDeposit=${draft.clientDeposit}), available $availableCopper"
)
}
return ValidationResult.Valid
}
/**
* Validates CloseReturn command.
*
* Complexity: O(m + n) where m = state.contracts.active.size, n = state.contracts.returns.size.
*
* Input invariants:
* - cmd.activeContractId must reference an existing active contract.
* - Referenced contract must have status RETURN_READY.
* - A return record must exist with requiresPlayerClose=true.
* - Under STRICT policy, decision must be explicit (not null).
* - For ACCEPT decision, guild must have sufficient funds to pay fee.
* - For REJECT decision, no money check required.
*
* Output contract:
* - Returns Valid if all checks pass.
* - Returns Rejected(NOT_FOUND) if contract or return not found.
* - Returns Rejected(INVALID_ARG) if STRICT policy and decision is null.
* - Returns Rejected(INVALID_STATE) if ACCEPT and insufficient funds.
*
* Side effects: None.
*/
@Suppress("ReturnCount")
private fun validateCloseReturn(state: GameState, cmd: CloseReturn): ValidationResult {
val returnRequiresClose = state.contracts.returns.any {
it.activeContractId.value.toLong() == cmd.activeContractId && it.requiresPlayerClose
}
if (!returnRequiresClose) {
return ValidationResult.Rejected(
reason = RejectReason.NOT_FOUND,
detail = "return for activeContractId=${cmd.activeContractId} not found or does not require close"
)
}
val ret = state.contracts.returns.firstOrNull { it.activeContractId.value.toLong() == cmd.activeContractId }
?: return ValidationResult.Rejected(
reason = RejectReason.NOT_FOUND,
detail = "return for activeContractId=${cmd.activeContractId} not found"
)
// Under STRICT policy, decision must be explicit
if (state.guild.proofPolicy == ProofPolicy.STRICT && cmd.decision == null) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_ARG,
detail = "STRICT policy requires explicit decision (accept or reject)"
)
}
// For REJECT, no money check needed (no payment will be made)
val effectiveDecision = cmd.decision ?: ReturnDecision.ACCEPT
if (effectiveDecision == ReturnDecision.REJECT) {
return ValidationResult.Valid
}
// For ACCEPT, check if guild has enough money to pay the fee
val board = state.contracts.board.firstOrNull { it.id == ret.boardContractId }
val fee = board?.fee ?: 0
// Guild needs enough money to pay hero the fee
// (reserved holds clientDeposit, which is separate from fee payment)
if (state.economy.moneyCopper < fee) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_STATE,
detail = "Insufficient funds for payout: fee=${fee}, money=${state.economy.moneyCopper}"
)
}
return ValidationResult.Valid
}
/**
* Validates SellTrophies command.
*/
@Suppress("ReturnCount")
private fun validateSellTrophies(state: GameState, cmd: SellTrophies): ValidationResult {
if (cmd.amount <= 0) {
return ValidationResult.Valid
}
if (state.economy.trophiesStock < cmd.amount) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_STATE,
detail = "Insufficient trophies: need ${cmd.amount}, have ${state.economy.trophiesStock}"
)
}
return ValidationResult.Valid
}
/**
* Validates PayTax command.
*/
@Suppress("ReturnCount")
private fun validatePayTax(state: GameState, cmd: PayTax): ValidationResult {
if (cmd.amount <= 0) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_ARG,
detail = "amount=${cmd.amount} must be > 0"
)
}
if (state.economy.moneyCopper < cmd.amount) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_STATE,
detail = "Insufficient funds: need ${cmd.amount}, have ${state.economy.moneyCopper}"
)
}
return ValidationResult.Valid
}
/**
* Validates CreateContract command.
*/
@Suppress("ReturnCount")
private fun validateCreateContract(state: GameState, cmd: CreateContract): ValidationResult {
if (cmd.title.isBlank()) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_ARG,
detail = "title cannot be blank"
)
}
if (cmd.difficulty < 0 || cmd.difficulty > 100) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_ARG,
detail = "difficulty=${cmd.difficulty} must be in [0, 100]"
)
}
if (cmd.reward < 0) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_ARG,
detail = "reward=${cmd.reward} must be >= 0"
)
}
return ValidationResult.Valid
}
/**
* Validates UpdateContractTerms command.
*/
@Suppress("ReturnCount")
private fun validateUpdateContractTerms(state: GameState, cmd: UpdateContractTerms): ValidationResult {
// No debug prints — keep validation pure
// Check if contract exists in inbox or board
val inboxContract = state.contracts.inbox.firstOrNull { it.id.value.toLong() == cmd.contractId }
val boardContract = state.contracts.board.firstOrNull { it.id.value.toLong() == cmd.contractId }
if (inboxContract == null && boardContract == null) {
return ValidationResult.Rejected(
reason = RejectReason.NOT_FOUND,
detail = "contractId=${cmd.contractId} not found in inbox or board"
)
}
// Cannot update locked or completed contracts
if (boardContract != null && boardContract.status != BoardStatus.OPEN) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_STATE,
detail = "Cannot update contract with status=${boardContract.status}, only OPEN contracts can be updated"
)
}
// Validate new fee
if (cmd.newFee != null && cmd.newFee < 0) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_ARG,
detail = "newFee=${cmd.newFee} must be >= 0"
)
}
// If updating board contract fee, check escrow delta
if (boardContract != null && cmd.newFee != null) {
val currentFee = boardContract.fee
val feeDelta = cmd.newFee - currentFee
if (feeDelta > 0) {
val availableCopper = state.economy.moneyCopper - state.economy.reservedCopper
if (availableCopper < feeDelta) {
val detailStr = "Insufficient money/escrow for fee increase: need ${feeDelta}, available ${availableCopper}"
return ValidationResult.Rejected(
reason = RejectReason.INVALID_STATE,
detail = detailStr
)
}
}
}
return ValidationResult.Valid
}
/**
* Validates CancelContract command.
*/
@Suppress("ReturnCount")
private fun validateCancelContract(state: GameState, cmd: CancelContract): ValidationResult {
// Check if contract exists in inbox or board
val inboxContract = state.contracts.inbox.firstOrNull { it.id.value.toLong() == cmd.contractId }
val boardContract = state.contracts.board.firstOrNull { it.id.value.toLong() == cmd.contractId }
if (inboxContract == null && boardContract == null) {
return ValidationResult.Rejected(
reason = RejectReason.NOT_FOUND,
detail = "contractId=${cmd.contractId} not found in inbox or board"
)
}
// Cannot cancel locked or completed contracts
if (boardContract != null && boardContract.status != BoardStatus.OPEN) {
return ValidationResult.Rejected(
reason = RejectReason.INVALID_STATE,
detail = "Cannot cancel contract with status=${boardContract.status}, only OPEN contracts can be cancelled"
)
}
return ValidationResult.Valid
}