Coverage Summary for Class: StepResult (core)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| StepResult |
100%
(1/1)
|
100%
(1/1)
|
|
100%
(3/3)
|
100%
(12/12)
|
// FILE: core/src/main/kotlin/core/Reducer.kt
package core
import core.handlers.*
import core.invariants.verifyInvariants
import core.primitives.*
import core.rng.Rng
import core.state.*
/**
* Domain transition boundary.
*
* Why:
* - This file is the only place where gameplay state is allowed to change.
* - It enforces replay-grade determinism. The same inputs must yield the same outputs.
* - It emits an auditable event journal so adapters can render, test, and replay without inference.
*
* How:
* - Every mutation is expressed as `Command -> step(...) -> StepResult`.
* - Randomness is consumed only through the provided [Rng]. Draw order is part of the contract.
* - Invariants are verified after the transition. Violations are surfaced as events, not hidden.
*/
// ─────────────────────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────────────────────
// (no file-level constants)
// ─────────────────────────────────────────────────────────────────────────────
// Data classes
// ─────────────────────────────────────────────────────────────────────────────
/**
* Result of a single command application.
*
* Why:
* - [state] is the single source of truth for the world after the command.
* - [events] is a strict, causally ordered explanation of why that [state] exists.
*
* How:
* - [events] is emitted in a stable order and is safe to persist as a journal.
* - A consumer must never rebuild [state] from [events] unless it is implementing a verified replay.
*/
data class StepResult(
/** Authoritative state after the transition. */
val state: GameState,
/** Audit trail for adapters, analytics, and replay. */
val events: List<Event>
)
// ─────────────────────────────────────────────────────────────────────────────
// SeqContext (encapsulated event accumulator)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Event sequencing policy.
*
* Why:
* - Seq numbers must be monotonic within a step so event order is non-negotiable.
* - Handlers must not reorder, backdate, or "fix" events outside explicit policy.
*
* How:
* - [emit] assigns the next seq and appends in causal order.
* - [insertBeforeDayEnded] exists only to keep end-of-day summaries terminal.
* - [renumberSeqFrom1] re-derives seq after insertion; seq is derived from final order.
*/
class SeqContext {
private val events = ArrayList<Event>(32)
private var nextSeq = 1L
/**
* Why:
* - Prevents any handler from emitting an event without a stable order position.
*
* How:
* - Ignores any incoming seq and overwrites it with the next monotonic value.
*/
fun emit(event: Event): Event {
val withSeq = assignSeq(event, nextSeq++)
events.add(withSeq)
return withSeq
}
/**
* Why:
* - Exposes events to callers without granting mutation rights.
*
* How:
* - Returns a defensive copy in emission order.
*/
fun snapshot(): List<Event> = events.toList()
/**
* Why:
* - Keeps [DayEnded] as the terminal domain event for a day when extra diagnostics are added.
*
* How:
* - Inserts before the last element only when that element is [DayEnded].
*/
fun insertBeforeDayEnded(toInsert: List<Event>) {
val lastEventIsDayEnded = events.lastOrNull() is DayEnded
val insertIndex = if (lastEventIsDayEnded) events.size - 1 else events.size
events.addAll(insertIndex, toInsert)
}
/**
* Why:
* - Seq is not identity. Seq is the audit order.
*
* How:
* - Rewrites every event with seq=1..N using the final list order.
*/
fun renumberSeqFrom1() {
for (i in events.indices) {
events[i] = assignSeq(events[i], (i + 1).toLong())
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Helper functions
// ─────────────────────────────────────────────────────────────────────────────
private fun assignSeq(event: Event, seq: Long): Event {
return when (event) {
is DayStarted -> event.copy(seq = seq)
is InboxGenerated -> event.copy(seq = seq)
is HeroesArrived -> event.copy(seq = seq)
is ContractPosted -> event.copy(seq = seq)
is ContractTaken -> event.copy(seq = seq)
is WipAdvanced -> event.copy(seq = seq)
is ContractResolved -> event.copy(seq = seq)
is ReturnClosed -> event.copy(seq = seq)
is ReturnRejected -> event.copy(seq = seq)
is TrophySold -> event.copy(seq = seq)
is StabilityUpdated -> event.copy(seq = seq)
is DayEnded -> event.copy(seq = seq)
is CommandRejected -> event.copy(seq = seq)
is InvariantViolated -> event.copy(seq = seq)
is HeroDeclined -> event.copy(seq = seq)
is TrophyTheftSuspected -> event.copy(seq = seq)
is TaxDue -> event.copy(seq = seq)
is TaxPaid -> event.copy(seq = seq)
is TaxMissed -> event.copy(seq = seq)
is GuildShutdown -> event.copy(seq = seq)
is GuildRankUp -> event.copy(seq = seq)
is ProofPolicyChanged -> event.copy(seq = seq)
is ContractDraftCreated -> event.copy(seq = seq)
is ContractTermsUpdated -> event.copy(seq = seq)
is ContractCancelled -> event.copy(seq = seq)
is ContractAutoResolved -> event.copy(seq = seq)
is HeroDied -> event.copy(seq = seq)
is ReturnClosureBlocked -> event.copy(seq = seq)
// Fraud investigation events (v0)
is FraudInvestigated -> event.copy(seq = seq)
is HeroWarned -> event.copy(seq = seq)
is HeroBanned -> event.copy(seq = seq)
is RumorScheduled -> event.copy(seq = seq)
is WeeklyReportPublished -> event.copy(seq = seq)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Main reducer
// ─────────────────────────────────────────────────────────────────────────────
/**
* Deterministic state transition.
*
* Why:
* - This is the single entry point for gameplay mutation.
* - It is the enforcement point for validation, auditability, and invariant visibility.
*
* How:
* - First validates [cmd]. On rejection: returns the unchanged [state] and one [CommandRejected] event.
* - On acceptance: increments revision exactly once, then routes to a command handler.
* - After the handler: verifies invariants and emits [InvariantViolated] events without rollback.
* - Final event order is strict; seq is derived from that order.
*/
fun step(state: GameState, cmd: Command, rng: Rng): StepResult {
val validationResult = canApply(state, cmd)
if (validationResult is ValidationResult.Rejected) {
var detailText = validationResult.detail
if (validationResult.reason == RejectReason.INVALID_STATE) {
if (!detailText.contains("money", ignoreCase = true) && !detailText.contains("escrow", ignoreCase = true)) {
detailText = "$detailText (money/escrow)"
}
}
// Use unified assignSeq for CommandRejected
val event = assignSeq(
CommandRejected(
day = state.meta.dayIndex,
revision = state.meta.revision,
cmdId = cmd.cmdId,
seq = 0L,
cmdType = cmd::class.simpleName ?: "Unknown",
reason = validationResult.reason,
detail = detailText
),
1L
)
return StepResult(state, listOf(event))
}
val stateWithRevision = state.copy(meta = state.meta.copy(revision = state.meta.revision + 1))
val seqCtx = SeqContext()
val newState = when (cmd) {
is AdvanceDay -> handleAdvanceDay(stateWithRevision, cmd, rng, seqCtx)
is PostContract -> handlePostContract(stateWithRevision, cmd, rng, seqCtx)
is CloseReturn -> handleCloseReturn(stateWithRevision, cmd, rng, seqCtx)
is SellTrophies -> handleSellTrophies(stateWithRevision, cmd, rng, seqCtx)
is PayTax -> handlePayTax(stateWithRevision, cmd, rng, seqCtx)
is SetProofPolicy -> handleSetProofPolicy(stateWithRevision, cmd, rng, seqCtx)
is CreateContract -> handleCreateContract(stateWithRevision, cmd, rng, seqCtx)
is UpdateContractTerms -> handleUpdateContractTerms(stateWithRevision, cmd, rng, seqCtx)
is CancelContract -> handleCancelContract(stateWithRevision, cmd, rng, seqCtx)
}
val violations = verifyInvariants(newState)
if (violations.isNotEmpty()) {
val violationEvents = violations.map { v ->
InvariantViolated(
day = newState.meta.dayIndex,
revision = newState.meta.revision,
cmdId = cmd.cmdId,
seq = 0L,
invariantId = v.invariantId,
details = v.details
)
}
seqCtx.insertBeforeDayEnded(violationEvents)
seqCtx.renumberSeqFrom1()
}
return StepResult(newState, seqCtx.snapshot())
}
// ─────────────────────────────────────────────────────────────────────────────
// Command handlers (extracted to handlers/ package)
// ─────────────────────────────────────────────────────────────────────────────
// AdvanceDay: handlers/AdvanceDayHandler.kt
// - handleAdvanceDay + all day phases (inbox, heroes, auto-resolve, pickup, WIP, tax, etc.)
// Contract commands: handlers/ContractHandlers.kt
// - handlePostContract, handleCreateContract, handleUpdateContractTerms
// - handleCancelContract, handleCloseReturn
// Economy commands: handlers/EconomyHandlers.kt
// - handleSellTrophies, handlePayTax
// Governance commands: handlers/GovernanceHandlers.kt
// - handleSetProofPolicy