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_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"
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -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,
|
||||||
|
|||||||
+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