This commit was merged in pull request #139.
This commit is contained in:
@@ -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_loyalty_display">L: %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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+493
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user