feat: add initiative and combat round tracker (Closes #97) (#139)

This commit was merged in pull request #139.
This commit is contained in:
2026-04-05 04:13:22 +02:00
parent 4befffacc3
commit ecd8988dba
7 changed files with 1041 additions and 0 deletions
@@ -0,0 +1,67 @@
# Issue #97: Initiative and Combat Round Tracker
## Summary
Add an initiative and combat round tracking system to the Combat tab. This includes rolling initiative using the character's Reaction + Intuition + configurable initiative dice, tracking combat rounds, initiative passes, displaying the current initiative score, and showing action economy reminders per pass.
## Implementation Plan
### Step 1: Create CombatTracker model class
**File**: `sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/CombatTracker.kt`
Create a data class (NOT serialized, as combat state is transient/session-only) holding:
- `initiativeScore: Int` - Current initiative score (after rolling)
- `initiativeDice: Int` - Number of initiative dice (default 1, can be increased by augmentations)
- `currentRound: Int` - Current combat round (starts at 1)
- `currentPass: Int` - Current initiative pass (starts at 1)
- `isInCombat: Boolean` - Whether combat is active
- `actionsUsed: Set<ActionType>` - Actions used this pass
Create an `ActionType` enum: `FreeAction`, `SimpleAction1`, `SimpleAction2`, `ComplexAction`
- In SR5e: each pass you get 1 Free Action + either 2 Simple Actions OR 1 Complex Action
Helper functions:
- `rollInitiative(reactionPlusIntuition: Int, dice: Int): CombatTracker` - rolls initiative dice, sets score
- `nextPass(): CombatTracker` - advances to next pass (reduces initiative by 10), or to next round if score <= 0
- `nextRound(): CombatTracker` - increments round, resets pass to 1 (requires re-rolling initiative)
- `resetCombat(): CombatTracker` - resets everything
- `useAction(action: ActionType): CombatTracker` - marks action as used
- `resetActions(): CombatTracker` - clears used actions for new pass
- `hasMultiplePasses(): Boolean` - initiative > 10 means extra passes
- `passesRemaining(): Int` - how many initiative passes the character gets this round
### Step 2: Add CombatTrackerPanel UI composable
**File**: `sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CombatTrackerPanel.kt`
Create the UI panel with:
- **Initiative section**: "Roll Initiative" button, configurable initiative dice count (1-5), display current initiative score
- **Round tracker**: Display "Round X / Pass Y", increment/reset buttons
- **Action economy**: Show available actions per pass (Free, Simple x2 OR Complex), toggle buttons to mark used
- Use existing Card/Panel pattern from other panels (DamageMonitorPanel, etc.)
### Step 3: Add test tags for combat tracker
**File**: Update `TestTags.kt` with new constants for combat tracker elements.
### Step 4: Add string resources
**File**: Update `strings.xml` with all combat tracker labels.
### Step 5: Integrate CombatTrackerPanel into CombatContent
**File**: Update `CharacterSheetPage.kt` to add the CombatTrackerPanel above the existing DamageMonitorPanel in the Combat tab.
The CombatTracker state will be managed as `remember` state in the CombatContent composable (not persisted) since combat is a session-only activity.
### Step 6: Add unit tests
**File**: `sharedUI/src/commonTest/kotlin/org/shahondin1624/CombatTrackerTest.kt`
Test:
- Initiative rolling produces valid scores
- Round counter increments correctly
- Pass tracking works (initiative - 10 per pass)
- Action economy tracking
- Reset functionality
## Acceptance Criteria Checklist
1. [ ] Roll initiative button that uses character's initiative attributes + dice
2. [ ] Combat round counter with increment/reset
3. [ ] Initiative pass tracking for multiple passes
4. [ ] Current initiative score displayed during combat
5. [ ] Action economy reminder per pass
@@ -459,4 +459,31 @@
<string name="contact_notes_placeholder">Optional notes about the contact</string> <string name="contact_notes_placeholder">Optional notes about the contact</string>
<string name="contact_loyalty_display">L: %1$d</string> <string name="contact_loyalty_display">L: %1$d</string>
<string name="contact_connection_display">C: %1$d</string> <string name="contact_connection_display">C: %1$d</string>
<!-- Combat Tracker -->
<string name="combat_tracker_title">Initiative Tracker</string>
<string name="combat_not_in_combat">No active combat encounter</string>
<string name="combat_roll_initiative">Roll Initiative</string>
<string name="combat_reroll_initiative">Re-Roll Initiative</string>
<string name="combat_initiative_dice_label">Initiative Dice</string>
<string name="combat_initiative_score">Initiative: %1$d</string>
<string name="combat_round_label">Round %1$d</string>
<string name="combat_pass_label">Pass %1$d</string>
<string name="combat_round_pass">Round %1$d / Pass %2$d</string>
<string name="combat_passes_remaining">Passes remaining: %1$d</string>
<string name="combat_no_more_passes">No more passes this round</string>
<string name="combat_next_pass">Next Pass</string>
<string name="combat_next_round">Next Round</string>
<string name="combat_end_combat">End Combat</string>
<string name="combat_actions_title">Actions This Pass</string>
<string name="combat_action_free">Free Action</string>
<string name="combat_action_simple">Simple Action</string>
<string name="combat_action_complex">Complex Action</string>
<string name="combat_action_used">Used</string>
<string name="combat_action_available">Available</string>
<string name="combat_action_blocked">Blocked</string>
<string name="combat_initiative_base">Base (REA + INT): %1$d</string>
<string name="combat_start_combat">Start Combat</string>
<string name="combat_decrease_dice_content_desc">Decrease initiative dice</string>
<string name="combat_increase_dice_content_desc">Increase initiative dice</string>
</resources> </resources>
@@ -412,4 +412,23 @@ object TestTags {
const val LIFESTYLE_EDIT_COST = "lifestyle_edit_cost" const val LIFESTYLE_EDIT_COST = "lifestyle_edit_cost"
const val LIFESTYLE_EDIT_CONFIRM = "lifestyle_edit_confirm" const val LIFESTYLE_EDIT_CONFIRM = "lifestyle_edit_confirm"
const val LIFESTYLE_EDIT_DISMISS = "lifestyle_edit_dismiss" const val LIFESTYLE_EDIT_DISMISS = "lifestyle_edit_dismiss"
// --- Combat tracker panel ---
const val PANEL_COMBAT_TRACKER = "panel_combat_tracker"
const val COMBAT_ROLL_INITIATIVE = "combat_roll_initiative"
const val COMBAT_INITIATIVE_SCORE = "combat_initiative_score"
const val COMBAT_INITIATIVE_DICE = "combat_initiative_dice"
const val COMBAT_INITIATIVE_DICE_INCREASE = "combat_initiative_dice_increase"
const val COMBAT_INITIATIVE_DICE_DECREASE = "combat_initiative_dice_decrease"
const val COMBAT_ROUND_COUNTER = "combat_round_counter"
const val COMBAT_PASS_COUNTER = "combat_pass_counter"
const val COMBAT_NEXT_PASS = "combat_next_pass"
const val COMBAT_NEXT_ROUND = "combat_next_round"
const val COMBAT_RESET = "combat_reset"
const val COMBAT_END_COMBAT = "combat_end_combat"
const val COMBAT_ACTION_FREE = "combat_action_free"
const val COMBAT_ACTION_SIMPLE_1 = "combat_action_simple_1"
const val COMBAT_ACTION_SIMPLE_2 = "combat_action_simple_2"
const val COMBAT_ACTION_COMPLEX = "combat_action_complex"
const val COMBAT_PASSES_REMAINING = "combat_passes_remaining"
} }
@@ -667,6 +667,8 @@ private fun CombatContent(
spacing: Dp, spacing: Dp,
onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit
) { ) {
var combatTracker by remember { mutableStateOf(org.shahondin1624.model.charactermodel.CombatTracker()) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -674,6 +676,11 @@ private fun CombatContent(
.padding(spacing), .padding(spacing),
verticalArrangement = Arrangement.spacedBy(spacing) verticalArrangement = Arrangement.spacedBy(spacing)
) { ) {
CombatTrackerPanel(
combatTracker = combatTracker,
attributes = character.attributes,
onCombatTrackerChanged = { newTracker -> combatTracker = newTracker }
)
DamageMonitorPanel( DamageMonitorPanel(
damageMonitor = character.damageMonitor, damageMonitor = character.damageMonitor,
attributes = character.attributes, attributes = character.attributes,
@@ -0,0 +1,493 @@
package org.shahondin1624.lib.components.charactermodel
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.UiConstants
import org.shahondin1624.model.attributes.Attributes
import org.shahondin1624.model.charactermodel.ActionType
import org.shahondin1624.model.charactermodel.CombatTracker
import org.shahondin1624.theme.LocalWindowSizeClass
import org.shahondin1624.theme.WindowSizeClass
import shadowruncharsheet.sharedui.generated.resources.Res
import shadowruncharsheet.sharedui.generated.resources.*
/**
* Panel for tracking initiative, combat rounds, passes, and action economy
* during a Shadowrun 5e combat encounter.
*
* State is managed externally via [combatTracker] and [onCombatTrackerChanged],
* following the same pattern as DamageMonitorPanel.
*/
@Composable
fun CombatTrackerPanel(
combatTracker: CombatTracker,
attributes: Attributes,
onCombatTrackerChanged: (CombatTracker) -> Unit
) {
val windowSizeClass = LocalWindowSizeClass.current
val padding = UiConstants.Spacing.medium(windowSizeClass)
val spacing = UiConstants.Spacing.small(windowSizeClass)
val initiativeBase = attributes.initiative()
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.PANEL_COMBAT_TRACKER)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(padding),
verticalArrangement = Arrangement.spacedBy(spacing)
) {
// Title
Text(
text = stringResource(Res.string.combat_tracker_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (!combatTracker.isInCombat) {
// Pre-combat: show initiative dice config and roll button
PreCombatSection(
combatTracker = combatTracker,
initiativeBase = initiativeBase,
spacing = spacing,
onCombatTrackerChanged = onCombatTrackerChanged
)
} else {
// Active combat: show initiative score, round/pass, actions
ActiveCombatSection(
combatTracker = combatTracker,
initiativeBase = initiativeBase,
spacing = spacing,
windowSizeClass = windowSizeClass,
onCombatTrackerChanged = onCombatTrackerChanged
)
}
}
}
}
@Composable
private fun PreCombatSection(
combatTracker: CombatTracker,
initiativeBase: Int,
spacing: androidx.compose.ui.unit.Dp,
onCombatTrackerChanged: (CombatTracker) -> Unit
) {
Text(
text = stringResource(Res.string.combat_not_in_combat),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Initiative base display
Text(
text = stringResource(Res.string.combat_initiative_base, initiativeBase),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Initiative dice selector
InitiativeDiceSelector(
currentDice = combatTracker.initiativeDice,
onDiceChanged = { newDice ->
onCombatTrackerChanged(combatTracker.copy(initiativeDice = newDice))
}
)
// Roll Initiative button
Button(
onClick = {
onCombatTrackerChanged(
combatTracker.rollInitiative(initiativeBase, combatTracker.initiativeDice)
)
},
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.COMBAT_ROLL_INITIATIVE)
) {
Text(stringResource(Res.string.combat_roll_initiative))
}
}
@Composable
private fun ActiveCombatSection(
combatTracker: CombatTracker,
initiativeBase: Int,
spacing: androidx.compose.ui.unit.Dp,
windowSizeClass: WindowSizeClass,
onCombatTrackerChanged: (CombatTracker) -> Unit
) {
// Initiative score - prominent display
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(Res.string.combat_initiative_score, combatTracker.initiativeScore),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.testTag(TestTags.COMBAT_INITIATIVE_SCORE)
)
Text(
text = stringResource(
Res.string.combat_round_pass,
combatTracker.currentRound,
combatTracker.currentPass
),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
// Round and pass display with test tags
Text(
text = stringResource(Res.string.combat_round_label, combatTracker.currentRound),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.testTag(TestTags.COMBAT_ROUND_COUNTER)
)
Text(
text = stringResource(Res.string.combat_pass_label, combatTracker.currentPass),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.testTag(TestTags.COMBAT_PASS_COUNTER)
)
}
}
// Passes remaining info
val passesRemaining = combatTracker.passesRemaining()
if (combatTracker.hasActionsThisPass()) {
Text(
text = stringResource(Res.string.combat_passes_remaining, passesRemaining),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag(TestTags.COMBAT_PASSES_REMAINING)
)
} else {
Text(
text = stringResource(Res.string.combat_no_more_passes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Medium,
modifier = Modifier.testTag(TestTags.COMBAT_PASSES_REMAINING)
)
}
// Action economy section
if (combatTracker.hasActionsThisPass()) {
ActionEconomySection(
combatTracker = combatTracker,
windowSizeClass = windowSizeClass,
onCombatTrackerChanged = onCombatTrackerChanged
)
}
// Initiative dice selector (can still adjust for re-rolls)
InitiativeDiceSelector(
currentDice = combatTracker.initiativeDice,
onDiceChanged = { newDice ->
onCombatTrackerChanged(combatTracker.copy(initiativeDice = newDice))
}
)
// Combat control buttons
when (windowSizeClass) {
WindowSizeClass.Compact -> {
CombatControlButtonsVertical(
combatTracker = combatTracker,
initiativeBase = initiativeBase,
onCombatTrackerChanged = onCombatTrackerChanged
)
}
else -> {
CombatControlButtonsHorizontal(
combatTracker = combatTracker,
initiativeBase = initiativeBase,
onCombatTrackerChanged = onCombatTrackerChanged
)
}
}
}
@Composable
private fun CombatControlButtonsVertical(
combatTracker: CombatTracker,
initiativeBase: Int,
onCombatTrackerChanged: (CombatTracker) -> Unit
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
onCombatTrackerChanged(
combatTracker.rollInitiative(initiativeBase, combatTracker.initiativeDice)
)
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(Res.string.combat_reroll_initiative))
}
OutlinedButton(
onClick = { onCombatTrackerChanged(combatTracker.nextPass()) },
enabled = combatTracker.hasActionsThisPass(),
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.COMBAT_NEXT_PASS)
) {
Text(stringResource(Res.string.combat_next_pass))
}
OutlinedButton(
onClick = { onCombatTrackerChanged(combatTracker.nextRound()) },
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.COMBAT_NEXT_ROUND)
) {
Text(stringResource(Res.string.combat_next_round))
}
FilledTonalButton(
onClick = { onCombatTrackerChanged(combatTracker.resetCombat()) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
),
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.COMBAT_END_COMBAT)
) {
Text(stringResource(Res.string.combat_end_combat))
}
}
}
@Composable
private fun CombatControlButtonsHorizontal(
combatTracker: CombatTracker,
initiativeBase: Int,
onCombatTrackerChanged: (CombatTracker) -> Unit
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
onCombatTrackerChanged(
combatTracker.rollInitiative(initiativeBase, combatTracker.initiativeDice)
)
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(Res.string.combat_reroll_initiative))
}
OutlinedButton(
onClick = { onCombatTrackerChanged(combatTracker.nextPass()) },
enabled = combatTracker.hasActionsThisPass(),
modifier = Modifier
.weight(1f)
.testTag(TestTags.COMBAT_NEXT_PASS)
) {
Text(stringResource(Res.string.combat_next_pass))
}
OutlinedButton(
onClick = { onCombatTrackerChanged(combatTracker.nextRound()) },
modifier = Modifier
.weight(1f)
.testTag(TestTags.COMBAT_NEXT_ROUND)
) {
Text(stringResource(Res.string.combat_next_round))
}
FilledTonalButton(
onClick = { onCombatTrackerChanged(combatTracker.resetCombat()) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
),
modifier = Modifier
.weight(1f)
.testTag(TestTags.COMBAT_END_COMBAT)
) {
Text(stringResource(Res.string.combat_end_combat))
}
}
}
@Composable
private fun InitiativeDiceSelector(
currentDice: Int,
onDiceChanged: (Int) -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag(TestTags.COMBAT_INITIATIVE_DICE)
) {
Text(
text = stringResource(Res.string.combat_initiative_dice_label),
style = MaterialTheme.typography.bodyMedium
)
IconButton(
onClick = { if (currentDice > 1) onDiceChanged(currentDice - 1) },
enabled = currentDice > 1,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.COMBAT_INITIATIVE_DICE_DECREASE)
) {
Text("-", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
text = "${currentDice}d6",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(min = 40.dp)
)
IconButton(
onClick = { if (currentDice < 5) onDiceChanged(currentDice + 1) },
enabled = currentDice < 5,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.COMBAT_INITIATIVE_DICE_INCREASE)
) {
Text("+", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
}
}
@Composable
private fun ActionEconomySection(
combatTracker: CombatTracker,
windowSizeClass: WindowSizeClass,
onCombatTrackerChanged: (CombatTracker) -> Unit
) {
Text(
text = stringResource(Res.string.combat_actions_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
val actions = listOf(
Triple(ActionType.FreeAction, stringResource(Res.string.combat_action_free), TestTags.COMBAT_ACTION_FREE),
Triple(ActionType.SimpleAction1, stringResource(Res.string.combat_action_simple) + " 1", TestTags.COMBAT_ACTION_SIMPLE_1),
Triple(ActionType.SimpleAction2, stringResource(Res.string.combat_action_simple) + " 2", TestTags.COMBAT_ACTION_SIMPLE_2),
Triple(ActionType.ComplexAction, stringResource(Res.string.combat_action_complex), TestTags.COMBAT_ACTION_COMPLEX)
)
when (windowSizeClass) {
WindowSizeClass.Compact -> {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
for ((action, label, tag) in actions) {
ActionChip(
action = action,
label = label,
tag = tag,
combatTracker = combatTracker,
onCombatTrackerChanged = onCombatTrackerChanged,
modifier = Modifier.fillMaxWidth()
)
}
}
}
else -> {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
for ((action, label, tag) in actions) {
ActionChip(
action = action,
label = label,
tag = tag,
combatTracker = combatTracker,
onCombatTrackerChanged = onCombatTrackerChanged,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
@Composable
private fun ActionChip(
action: ActionType,
label: String,
tag: String,
combatTracker: CombatTracker,
onCombatTrackerChanged: (CombatTracker) -> Unit,
modifier: Modifier = Modifier
) {
val isUsed = action in combatTracker.actionsUsed
val isAvailable = combatTracker.isActionAvailable(action)
val containerColor = when {
isUsed -> MaterialTheme.colorScheme.surfaceVariant
isAvailable -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
}
val contentColor = when {
isUsed -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
isAvailable -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
}
val statusText = when {
isUsed -> stringResource(Res.string.combat_action_used)
isAvailable -> stringResource(Res.string.combat_action_available)
else -> stringResource(Res.string.combat_action_blocked)
}
FilledTonalButton(
onClick = {
if (isAvailable && !isUsed) {
onCombatTrackerChanged(combatTracker.useAction(action))
}
},
enabled = isAvailable && !isUsed,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = containerColor,
disabledContentColor = contentColor
),
modifier = modifier.testTag(tag),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium
)
Text(
text = statusText,
style = MaterialTheme.typography.labelSmall
)
}
}
}
@@ -0,0 +1,175 @@
package org.shahondin1624.model.charactermodel
import org.shahondin1624.lib.functions.rollXDice
/**
* Types of actions available during an initiative pass in Shadowrun 5e.
* Each pass allows: 1 Free Action + (2 Simple Actions OR 1 Complex Action).
*/
enum class ActionType(val displayName: String) {
FreeAction("Free Action"),
SimpleAction1("Simple Action"),
SimpleAction2("Simple Action"),
ComplexAction("Complex Action")
}
/**
* Tracks initiative and combat round state during a combat encounter.
*
* This is transient (session-only) state -- it is not serialized or persisted
* because combat encounters are short-lived sessions.
*
* In Shadowrun 5e:
* - Initiative = Reaction + Intuition + Xd6 (X = initiative dice, usually 1-4)
* - Each combat round consists of multiple initiative passes
* - After each pass, initiative score drops by 10
* - Characters act again in the next pass only if their score is still > 0
* - Each pass grants: 1 Free Action + (2 Simple Actions OR 1 Complex Action)
*/
data class CombatTracker(
val initiativeScore: Int = 0,
val initiativeDice: Int = 1,
val currentRound: Int = 0,
val currentPass: Int = 1,
val isInCombat: Boolean = false,
val actionsUsed: Set<ActionType> = emptySet()
) {
/**
* Roll initiative using the character's Reaction + Intuition base and initiative dice.
* Starts combat at round 1, pass 1.
*/
fun rollInitiative(reactionPlusIntuition: Int, dice: Int = initiativeDice): CombatTracker {
val diceResults = rollXDice(sides = 6, numberOfDice = dice)
val score = reactionPlusIntuition + diceResults.sum()
return copy(
initiativeScore = score,
initiativeDice = dice,
currentRound = if (currentRound == 0) 1 else currentRound,
currentPass = 1,
isInCombat = true,
actionsUsed = emptySet()
)
}
/**
* Roll initiative with a predetermined result (for testing).
*/
fun rollInitiativeWithResult(reactionPlusIntuition: Int, diceTotal: Int, dice: Int = initiativeDice): CombatTracker {
val score = reactionPlusIntuition + diceTotal
return copy(
initiativeScore = score,
initiativeDice = dice,
currentRound = if (currentRound == 0) 1 else currentRound,
currentPass = 1,
isInCombat = true,
actionsUsed = emptySet()
)
}
/**
* Advance to the next initiative pass.
* Initiative score drops by 10. If score drops to 0 or below,
* the character's turn for this round is over.
*/
fun nextPass(): CombatTracker {
val newScore = initiativeScore - 10
return if (newScore > 0) {
copy(
initiativeScore = newScore,
currentPass = currentPass + 1,
actionsUsed = emptySet()
)
} else {
// Character has no more passes this round
copy(
initiativeScore = 0,
currentPass = currentPass + 1,
actionsUsed = emptySet()
)
}
}
/**
* Advance to the next combat round.
* Increments round counter, resets pass to 1, and clears initiative
* (must re-roll at the start of the new round).
*/
fun nextRound(): CombatTracker {
return copy(
initiativeScore = 0,
currentRound = currentRound + 1,
currentPass = 1,
actionsUsed = emptySet()
)
}
/**
* Reset all combat state, ending the encounter.
*/
fun resetCombat(): CombatTracker {
return CombatTracker()
}
/**
* Mark an action as used during the current pass.
*/
fun useAction(action: ActionType): CombatTracker {
return copy(actionsUsed = actionsUsed + action)
}
/**
* Clear all used actions (e.g., at the start of a new pass).
*/
fun resetActions(): CombatTracker {
return copy(actionsUsed = emptySet())
}
/**
* Whether the character has multiple initiative passes this round.
* A character gets additional passes if their initiative score exceeds 10.
*/
fun hasMultiplePasses(): Boolean = initiativeScore > 10
/**
* Calculate how many initiative passes the character gets this round
* based on their current initiative score.
* Score 1-10 = 1 pass, 11-20 = 2 passes, 21-30 = 3 passes, etc.
*/
fun passesRemaining(): Int {
if (initiativeScore <= 0) return 0
return ((initiativeScore - 1) / 10) + 1
}
/**
* Total passes available from the original initiative roll this round.
* Based on the current state (may change if initiative is modified mid-round).
*/
fun totalPassesThisRound(): Int {
// Reconstruct original score: current score + (currentPass - 1) * 10
val originalScore = initiativeScore + (currentPass - 1) * 10
if (originalScore <= 0) return 0
return ((originalScore - 1) / 10) + 1
}
/**
* Check if a specific action is still available this pass.
* Rules: Free Action is always separate.
* Simple Actions: can use up to 2, but not if Complex Action was used.
* Complex Action: can use 1, but not if any Simple Action was used.
*/
fun isActionAvailable(action: ActionType): Boolean {
if (action in actionsUsed) return false
return when (action) {
ActionType.FreeAction -> true
ActionType.SimpleAction1 -> ActionType.ComplexAction !in actionsUsed
ActionType.SimpleAction2 -> ActionType.ComplexAction !in actionsUsed && ActionType.SimpleAction1 in actionsUsed
ActionType.ComplexAction -> ActionType.SimpleAction1 !in actionsUsed && ActionType.SimpleAction2 !in actionsUsed
}
}
/**
* Whether the character still has their turn in the current pass
* (initiative score > 0).
*/
fun hasActionsThisPass(): Boolean = initiativeScore > 0
}
@@ -0,0 +1,253 @@
package org.shahondin1624
import org.shahondin1624.model.charactermodel.ActionType
import org.shahondin1624.model.charactermodel.CombatTracker
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class CombatTrackerTest {
@Test
fun initialStateIsNotInCombat() {
val tracker = CombatTracker()
assertFalse(tracker.isInCombat)
assertEquals(0, tracker.initiativeScore)
assertEquals(0, tracker.currentRound)
assertEquals(1, tracker.currentPass)
assertEquals(1, tracker.initiativeDice)
assertTrue(tracker.actionsUsed.isEmpty())
}
@Test
fun rollInitiativeStartsCombat() {
val tracker = CombatTracker()
val rolled = tracker.rollInitiativeWithResult(
reactionPlusIntuition = 10,
diceTotal = 5,
dice = 1
)
assertTrue(rolled.isInCombat)
assertEquals(15, rolled.initiativeScore)
assertEquals(1, rolled.currentRound)
assertEquals(1, rolled.currentPass)
}
@Test
fun rollInitiativeWithMultipleDice() {
val tracker = CombatTracker(initiativeDice = 3)
val rolled = tracker.rollInitiativeWithResult(
reactionPlusIntuition = 8,
diceTotal = 12,
dice = 3
)
assertEquals(20, rolled.initiativeScore)
assertEquals(3, rolled.initiativeDice)
}
@Test
fun rollInitiativeProducesReasonableScores() {
// Use the real random roll and just verify range
val tracker = CombatTracker(initiativeDice = 1)
val rolled = tracker.rollInitiative(reactionPlusIntuition = 10, dice = 1)
// Min: 10 + 1 = 11, Max: 10 + 6 = 16
assertTrue(rolled.initiativeScore in 11..16)
assertTrue(rolled.isInCombat)
}
@Test
fun roundCounterIncrements() {
val tracker = CombatTracker()
.rollInitiativeWithResult(10, 5)
assertEquals(1, tracker.currentRound)
val round2 = tracker.nextRound()
assertEquals(2, round2.currentRound)
assertEquals(1, round2.currentPass)
assertEquals(0, round2.initiativeScore) // Must re-roll
val round3 = round2.nextRound()
assertEquals(3, round3.currentRound)
}
@Test
fun nextPassReducesInitiativeByTen() {
val tracker = CombatTracker()
.rollInitiativeWithResult(10, 15) // Score = 25
assertEquals(25, tracker.initiativeScore)
assertEquals(1, tracker.currentPass)
val pass2 = tracker.nextPass()
assertEquals(15, pass2.initiativeScore)
assertEquals(2, pass2.currentPass)
val pass3 = pass2.nextPass()
assertEquals(5, pass3.initiativeScore)
assertEquals(3, pass3.currentPass)
// Next pass would go to -5, clamped to 0
val pass4 = pass3.nextPass()
assertEquals(0, pass4.initiativeScore)
assertEquals(4, pass4.currentPass)
}
@Test
fun passesRemainingCalculation() {
// Score 5 = 1 pass
val tracker1 = CombatTracker().rollInitiativeWithResult(5, 0)
assertEquals(1, tracker1.passesRemaining())
// Score 10 = 1 pass
val tracker2 = CombatTracker().rollInitiativeWithResult(10, 0)
assertEquals(1, tracker2.passesRemaining())
// Score 11 = 2 passes
val tracker3 = CombatTracker().rollInitiativeWithResult(11, 0)
assertEquals(2, tracker3.passesRemaining())
// Score 25 = 3 passes
val tracker4 = CombatTracker().rollInitiativeWithResult(15, 10)
assertEquals(3, tracker4.passesRemaining())
// Score 0 = 0 passes
val tracker5 = CombatTracker(initiativeScore = 0)
assertEquals(0, tracker5.passesRemaining())
}
@Test
fun hasMultiplePassesWhenScoreAboveTen() {
val low = CombatTracker().rollInitiativeWithResult(5, 3) // 8
assertFalse(low.hasMultiplePasses())
val high = CombatTracker().rollInitiativeWithResult(8, 5) // 13
assertTrue(high.hasMultiplePasses())
}
@Test
fun totalPassesThisRoundAccountsForCurrentPass() {
val tracker = CombatTracker().rollInitiativeWithResult(10, 15) // 25
assertEquals(3, tracker.totalPassesThisRound())
val pass2 = tracker.nextPass() // score 15, pass 2
assertEquals(3, pass2.totalPassesThisRound())
val pass3 = pass2.nextPass() // score 5, pass 3
assertEquals(3, pass3.totalPassesThisRound())
}
@Test
fun resetCombatClearsEverything() {
val tracker = CombatTracker()
.rollInitiativeWithResult(10, 5)
.useAction(ActionType.FreeAction)
.nextPass()
val reset = tracker.resetCombat()
assertFalse(reset.isInCombat)
assertEquals(0, reset.initiativeScore)
assertEquals(0, reset.currentRound)
assertEquals(1, reset.currentPass)
assertTrue(reset.actionsUsed.isEmpty())
}
// --- Action Economy Tests ---
@Test
fun freeActionIsAlwaysAvailable() {
val tracker = CombatTracker().rollInitiativeWithResult(10, 5)
assertTrue(tracker.isActionAvailable(ActionType.FreeAction))
}
@Test
fun freeActionCanBeUsed() {
val tracker = CombatTracker().rollInitiativeWithResult(10, 5)
val used = tracker.useAction(ActionType.FreeAction)
assertTrue(ActionType.FreeAction in used.actionsUsed)
assertFalse(used.isActionAvailable(ActionType.FreeAction))
}
@Test
fun simpleActionsCanBeUsedSequentially() {
val tracker = CombatTracker().rollInitiativeWithResult(10, 5)
assertTrue(tracker.isActionAvailable(ActionType.SimpleAction1))
// SimpleAction2 requires SimpleAction1 to be used first
assertFalse(tracker.isActionAvailable(ActionType.SimpleAction2))
val usedFirst = tracker.useAction(ActionType.SimpleAction1)
assertTrue(usedFirst.isActionAvailable(ActionType.SimpleAction2))
val usedBoth = usedFirst.useAction(ActionType.SimpleAction2)
assertFalse(usedBoth.isActionAvailable(ActionType.SimpleAction1))
assertFalse(usedBoth.isActionAvailable(ActionType.SimpleAction2))
}
@Test
fun complexActionBlocksSimpleActions() {
val tracker = CombatTracker().rollInitiativeWithResult(10, 5)
assertTrue(tracker.isActionAvailable(ActionType.ComplexAction))
val used = tracker.useAction(ActionType.ComplexAction)
assertFalse(used.isActionAvailable(ActionType.SimpleAction1))
assertFalse(used.isActionAvailable(ActionType.SimpleAction2))
}
@Test
fun simpleActionBlocksComplexAction() {
val tracker = CombatTracker().rollInitiativeWithResult(10, 5)
val used = tracker.useAction(ActionType.SimpleAction1)
assertFalse(used.isActionAvailable(ActionType.ComplexAction))
}
@Test
fun nextPassClearsUsedActions() {
val tracker = CombatTracker()
.rollInitiativeWithResult(10, 15) // High score for multiple passes
.useAction(ActionType.FreeAction)
.useAction(ActionType.ComplexAction)
assertEquals(2, tracker.actionsUsed.size)
val nextPass = tracker.nextPass()
assertTrue(nextPass.actionsUsed.isEmpty())
}
@Test
fun hasActionsThisPassWhenScorePositive() {
val tracker = CombatTracker().rollInitiativeWithResult(5, 3) // 8
assertTrue(tracker.hasActionsThisPass())
val noScore = CombatTracker(initiativeScore = 0, isInCombat = true)
assertFalse(noScore.hasActionsThisPass())
}
@Test
fun nextRoundResetsPassAndClearsActions() {
val tracker = CombatTracker()
.rollInitiativeWithResult(10, 5)
.useAction(ActionType.FreeAction)
.nextPass()
val nextRound = tracker.nextRound()
assertEquals(1, nextRound.currentPass)
assertTrue(nextRound.actionsUsed.isEmpty())
assertEquals(0, nextRound.initiativeScore) // Must re-roll
}
@Test
fun reRollInitiativePreservesRound() {
val tracker = CombatTracker()
.rollInitiativeWithResult(10, 5)
.nextRound()
.nextRound() // Now at round 3
assertEquals(3, tracker.currentRound)
assertEquals(0, tracker.initiativeScore)
// Re-roll should keep round 3
val rerolled = tracker.rollInitiativeWithResult(10, 5)
assertEquals(3, rerolled.currentRound)
assertEquals(15, rerolled.initiativeScore)
assertEquals(1, rerolled.currentPass)
}
}