diff --git a/.plan/issue-97-initiative-combat-tracker.md b/.plan/issue-97-initiative-combat-tracker.md new file mode 100644 index 0000000..2f41204 --- /dev/null +++ b/.plan/issue-97-initiative-combat-tracker.md @@ -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` - 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 diff --git a/sharedUI/src/commonMain/composeResources/values/strings.xml b/sharedUI/src/commonMain/composeResources/values/strings.xml index d028e54..e34efa0 100644 --- a/sharedUI/src/commonMain/composeResources/values/strings.xml +++ b/sharedUI/src/commonMain/composeResources/values/strings.xml @@ -459,4 +459,31 @@ Optional notes about the contact L: %1$d C: %1$d + + + Initiative Tracker + No active combat encounter + Roll Initiative + Re-Roll Initiative + Initiative Dice + Initiative: %1$d + Round %1$d + Pass %1$d + Round %1$d / Pass %2$d + Passes remaining: %1$d + No more passes this round + Next Pass + Next Round + End Combat + Actions This Pass + Free Action + Simple Action + Complex Action + Used + Available + Blocked + Base (REA + INT): %1$d + Start Combat + Decrease initiative dice + Increase initiative dice diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt index 3c3e8f9..9b6d3ae 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt @@ -412,4 +412,23 @@ object TestTags { const val LIFESTYLE_EDIT_COST = "lifestyle_edit_cost" const val LIFESTYLE_EDIT_CONFIRM = "lifestyle_edit_confirm" 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" } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt index 1958707..d4dc7be 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt @@ -667,6 +667,8 @@ private fun CombatContent( spacing: Dp, onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit ) { + var combatTracker by remember { mutableStateOf(org.shahondin1624.model.charactermodel.CombatTracker()) } + Column( modifier = Modifier .fillMaxSize() @@ -674,6 +676,11 @@ private fun CombatContent( .padding(spacing), verticalArrangement = Arrangement.spacedBy(spacing) ) { + CombatTrackerPanel( + combatTracker = combatTracker, + attributes = character.attributes, + onCombatTrackerChanged = { newTracker -> combatTracker = newTracker } + ) DamageMonitorPanel( damageMonitor = character.damageMonitor, attributes = character.attributes, diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CombatTrackerPanel.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CombatTrackerPanel.kt new file mode 100644 index 0000000..49e7bec --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CombatTrackerPanel.kt @@ -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 + ) + } + } +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/CombatTracker.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/CombatTracker.kt new file mode 100644 index 0000000..e0df4a5 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/CombatTracker.kt @@ -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 = 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 +} diff --git a/sharedUI/src/commonTest/kotlin/org/shahondin1624/CombatTrackerTest.kt b/sharedUI/src/commonTest/kotlin/org/shahondin1624/CombatTrackerTest.kt new file mode 100644 index 0000000..da230ee --- /dev/null +++ b/sharedUI/src/commonTest/kotlin/org/shahondin1624/CombatTrackerTest.kt @@ -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) + } +}