feat: add healing and recovery tracking system (Closes #102) (#112)

This commit was merged in pull request #112.
This commit is contained in:
2026-04-04 20:38:30 +02:00
parent 120e922fff
commit d55f6754fe
7 changed files with 586 additions and 19 deletions
+57
View File
@@ -0,0 +1,57 @@
# Issue #102: Healing and Recovery Tracking System
## Summary
Implement a healing and recovery system for the Shadowrun 5e character sheet. This adds healing actions to the damage monitor, implements Shadowrun 5e healing rules (natural and magical healing with different timescales for stun vs physical), healing test dice rolls, recovery state tracking, and overflow/death threshold warnings.
## Acceptance Criteria Checklist
1. [ ] Healing action button on damage monitor
2. [ ] Stun damage heals faster than physical (different intervals)
3. [ ] Healing test calculates boxes healed from successes
4. [ ] Recovery state tracked and displayed
5. [ ] Overflow damage / death threshold warnings
## Implementation Plan
### Step 1: Model - HealingSystem.kt
Create `/workspace/repo/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/HealingSystem.kt`:
- `enum class HealingType { Natural, FirstAid, Magical }`
- `enum class RecoveryState { Healthy, Injured, Critical, Stabilized, BleedingOut, Dead }`
- `data class HealingResult` - holds dice pool, successes (boxes healed), damage track healed
- `fun calculateHealingPool(healingType, body, willpower): Int` - Body+Willpower for natural healing
- `fun performHealingTest(pool): HealingResult` - uses existing DiceRoll to roll, successes = boxes healed
- `fun getHealingInterval(damageTrack, healingType): String` - returns human-readable interval (Stun: 1 hour natural, Physical: 1 day natural)
### Step 2: Model - Update DamageMonitor.kt
Add methods to DamageMonitor:
- `fun healStun(boxes: Int): DamageMonitor` - reduce stun damage by N boxes
- `fun healPhysical(boxes: Int): DamageMonitor` - reduce physical damage by N boxes
- `fun recoveryState(): RecoveryState` - compute current state from damage levels
- `fun isOverflowing(): Boolean` - true if overflow > 0
- `fun deathThresholdWarning(): Boolean` - true if overflow >= overflowMax
### Step 3: UI - HealingDialog.kt
Create a dialog composable for healing actions:
- Shows healing type selector (Natural / First Aid / Magical)
- Displays the healing interval for context
- Shows the dice pool calculation
- "Roll Healing Test" button that performs the test
- Displays result: number of boxes healed
- Applies healing on confirmation
### Step 4: UI - Update DamageMonitorPanel.kt
- Add a "Heal" button to each damage track (Stun and Physical)
- Display recovery state badge/chip (Healthy/Injured/Critical/Stabilized/BleedingOut)
- Add overflow/death threshold warning when overflow damage is present
- Wire healing dialog to the damage monitor update callback
### Step 5: Update TestTags.kt
Add test tags for new components:
- HEALING_DIALOG, HEALING_TYPE_SELECTOR, HEALING_ROLL_BUTTON, HEALING_RESULT
- RECOVERY_STATE_BADGE, DEATH_WARNING
### Step 6: Wire up in CharacterSheetPage.kt
- No changes needed - CombatContent already passes `onUpdateCharacter` to DamageMonitorPanel
- The DamageMonitorPanel's `onDamageChanged` callback handles updates
@@ -169,4 +169,18 @@ object TestTags {
// --- Navigation: Character creation ---
const val NAV_CHARACTER_CREATION = "nav_character_creation"
// --- Healing dialog ---
const val HEALING_DIALOG = "healing_dialog"
const val HEALING_TYPE_SELECTOR = "healing_type_selector"
const val HEALING_ROLL_BUTTON = "healing_roll_button"
const val HEALING_RESULT = "healing_result"
const val HEALING_APPLY_BUTTON = "healing_apply_button"
const val HEALING_DISMISS_BUTTON = "healing_dismiss_button"
fun healButton(track: String): String = "heal_button_${track.lowercase()}"
// --- Recovery state ---
const val RECOVERY_STATE_BADGE = "recovery_state_badge"
const val DEATH_WARNING = "death_warning"
const val OVERFLOW_WARNING = "overflow_warning"
}
@@ -390,6 +390,7 @@ private fun CombatContent(
) {
DamageMonitorPanel(
damageMonitor = character.damageMonitor,
attributes = character.attributes,
onDamageChanged = { newDamageMonitor ->
onUpdateCharacter { char ->
char.copy(damageMonitor = newDamageMonitor)
@@ -5,7 +5,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
@@ -15,24 +15,59 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.UiConstants
import org.shahondin1624.model.charactermodel.DamageMonitor
import org.shahondin1624.model.charactermodel.DamageTrackType
import org.shahondin1624.model.attributes.Attributes
import org.shahondin1624.model.charactermodel.*
import org.shahondin1624.theme.LocalWindowSizeClass
import org.shahondin1624.theme.WindowSizeClass
/**
* Displays the damage monitor as visual box grids for stun, physical, and overflow tracks.
* Boxes are clickable: tap empty box to mark damage, tap filled box to heal.
* Includes healing action buttons, recovery state display, and overflow/death warnings.
*/
@Composable
fun DamageMonitorPanel(
damageMonitor: DamageMonitor,
attributes: Attributes? = null,
onDamageChanged: ((DamageMonitor) -> Unit)? = null
) {
val windowSizeClass = LocalWindowSizeClass.current
val padding = UiConstants.Spacing.medium(windowSizeClass)
val spacing = UiConstants.Spacing.small(windowSizeClass)
// Healing dialog state
var healingTrack by remember { mutableStateOf<DamageTrackType?>(null) }
// Show healing dialog
healingTrack?.let { track ->
val currentDamage = when (track) {
DamageTrackType.Stun -> damageMonitor.stunCurrent()
DamageTrackType.Physical -> damageMonitor.physicalCurrent()
DamageTrackType.Overflow -> damageMonitor.overflowCurrent()
}
val body = attributes?.body() ?: 1
val willpower = attributes?.willpower() ?: 1
HealingDialog(
damageTrack = track,
currentDamage = currentDamage,
body = body,
willpower = willpower,
onHealApplied = { result ->
onDamageChanged?.let { callback ->
val healed = when (result.damageTrack) {
DamageTrackType.Stun -> damageMonitor.healStun(result.boxesHealed)
DamageTrackType.Physical -> damageMonitor.healPhysical(result.boxesHealed)
DamageTrackType.Overflow -> damageMonitor // Overflow heals via physical
}
callback(healed)
}
healingTrack = null
},
onDismiss = { healingTrack = null }
)
}
Card(
modifier = Modifier
.fillMaxWidth()
@@ -42,13 +77,22 @@ fun DamageMonitorPanel(
modifier = Modifier
.fillMaxWidth()
.padding(padding)
) {
// Title row with recovery state badge
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Damage Monitor",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = spacing)
fontWeight = FontWeight.Bold
)
RecoveryStateBadge(damageMonitor.recoveryState())
}
Spacer(Modifier.height(spacing))
// Show wound modifier if there is physical damage
val woundMod = damageMonitor.woundModifier()
@@ -64,6 +108,46 @@ fun DamageMonitorPanel(
)
}
// Overflow / death warnings
if (damageMonitor.deathThresholdReached()) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = spacing)
.testTag(TestTags.DEATH_WARNING)
) {
Text(
text = "DEATH THRESHOLD REACHED - Character is dead!",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(8.dp),
textAlign = TextAlign.Center
)
}
} else if (damageMonitor.isOverflowing()) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = spacing)
.testTag(TestTags.OVERFLOW_WARNING)
) {
Text(
text = "WARNING: Overflow damage! ${damageMonitor.overflowCurrent()} / ${damageMonitor.overflowMax()} - Character is bleeding out!",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(8.dp)
)
}
}
when (windowSizeClass) {
WindowSizeClass.Compact -> {
Column(verticalArrangement = Arrangement.spacedBy(spacing)) {
@@ -73,7 +157,10 @@ fun DamageMonitorPanel(
{ position ->
callback(damageMonitor.toggleDamageBox(DamageTrackType.Stun, position))
}
}
},
onHealClick = if (onDamageChanged != null && damageMonitor.stunCurrent() > 0) {
{ healingTrack = DamageTrackType.Stun }
} else null
)
DamageTrack(
"Physical", damageMonitor.physicalCurrent(), damageMonitor.physicalMax(),
@@ -81,7 +168,10 @@ fun DamageMonitorPanel(
{ position ->
callback(damageMonitor.toggleDamageBox(DamageTrackType.Physical, position))
}
}
},
onHealClick = if (onDamageChanged != null && damageMonitor.physicalCurrent() > 0) {
{ healingTrack = DamageTrackType.Physical }
} else null
)
DamageTrack(
"Overflow", damageMonitor.overflowCurrent(), damageMonitor.overflowMax(),
@@ -104,7 +194,10 @@ fun DamageMonitorPanel(
{ position ->
callback(damageMonitor.toggleDamageBox(DamageTrackType.Stun, position))
}
}
},
onHealClick = if (onDamageChanged != null && damageMonitor.stunCurrent() > 0) {
{ healingTrack = DamageTrackType.Stun }
} else null
)
DamageTrack(
"Physical", damageMonitor.physicalCurrent(), damageMonitor.physicalMax(), Modifier.weight(1f),
@@ -112,7 +205,10 @@ fun DamageMonitorPanel(
{ position ->
callback(damageMonitor.toggleDamageBox(DamageTrackType.Physical, position))
}
}
},
onHealClick = if (onDamageChanged != null && damageMonitor.physicalCurrent() > 0) {
{ healingTrack = DamageTrackType.Physical }
} else null
)
DamageTrack(
"Overflow", damageMonitor.overflowCurrent(), damageMonitor.overflowMax(), Modifier.weight(1f),
@@ -129,13 +225,40 @@ fun DamageMonitorPanel(
}
}
@Composable
private fun RecoveryStateBadge(state: RecoveryState) {
val (containerColor, contentColor) = when (state) {
RecoveryState.Healthy -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
RecoveryState.Injured -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer
RecoveryState.Critical -> MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
RecoveryState.Stabilized -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer
RecoveryState.BleedingOut -> MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
RecoveryState.Dead -> MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
}
Surface(
color = containerColor,
shape = MaterialTheme.shapes.small,
modifier = Modifier.testTag(TestTags.RECOVERY_STATE_BADGE)
) {
Text(
text = state.displayName,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = contentColor,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
@Composable
private fun DamageTrack(
label: String,
current: Int,
max: Int,
modifier: Modifier = Modifier,
onBoxClick: ((Int) -> Unit)? = null
onBoxClick: ((Int) -> Unit)? = null,
onHealClick: (() -> Unit)? = null
) {
val filledColor = MaterialTheme.colorScheme.error
val emptyColor = MaterialTheme.colorScheme.surfaceVariant
@@ -152,11 +275,31 @@ private fun DamageTrack(
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "$current / $max",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Heal button - only for Stun and Physical tracks when there is damage
onHealClick?.let {
TextButton(
onClick = it,
modifier = Modifier
.height(28.dp)
.testTag(TestTags.healButton(label)),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
) {
Text(
text = "Heal",
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
Spacer(Modifier.height(4.dp))
@@ -0,0 +1,184 @@
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.unit.dp
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.model.charactermodel.*
/**
* Dialog for performing healing actions on a damage track.
* Allows the user to select healing type, see the dice pool and interval,
* roll a healing test, and apply the result.
*/
@Composable
fun HealingDialog(
damageTrack: DamageTrackType,
currentDamage: Int,
body: Int,
willpower: Int,
onHealApplied: (HealingResult) -> Unit,
onDismiss: () -> Unit
) {
var selectedHealingType by remember { mutableStateOf(HealingType.Natural) }
var healingResult by remember { mutableStateOf<HealingResult?>(null) }
val dicePool = HealingSystem.calculateHealingPool(selectedHealingType, body, willpower)
val interval = HealingSystem.getHealingInterval(damageTrack, selectedHealingType)
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TestTags.HEALING_DIALOG),
title = {
Text(
text = "Heal ${damageTrack.name} Damage",
fontWeight = FontWeight.Bold
)
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Current damage info
Text(
text = "Current damage: $currentDamage boxes",
style = MaterialTheme.typography.bodyMedium
)
// Healing type selector
Text(
text = "Healing Type",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium
)
Column(
modifier = Modifier.testTag(TestTags.HEALING_TYPE_SELECTOR)
) {
HealingType.entries.forEach { healingType ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
RadioButton(
selected = selectedHealingType == healingType,
onClick = {
selectedHealingType = healingType
healingResult = null // Reset result on type change
}
)
Text(
text = healingType.displayName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
// Dice pool and interval info
HorizontalDivider()
Text(
text = "Dice Pool: $dicePool dice",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = "Interval: $interval",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Roll button
if (healingResult == null) {
Button(
onClick = {
healingResult = HealingSystem.performHealingTest(
healingType = selectedHealingType,
damageTrack = damageTrack,
dicePool = dicePool
)
},
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.HEALING_ROLL_BUTTON),
enabled = currentDamage > 0
) {
Text("Roll Healing Test")
}
}
// Show result
healingResult?.let { result ->
HorizontalDivider()
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
),
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.HEALING_RESULT)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "Healing Test Result",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Dice rolled: ${result.diceRoll.result.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall
)
Text(
text = "Successes: ${result.diceRoll.numberOfSuccesses}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
val actualHealed = minOf(result.boxesHealed, currentDamage)
Text(
text = "Boxes healed: $actualHealed",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = if (actualHealed > 0) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
}
}
}
}
},
confirmButton = {
healingResult?.let { result ->
TextButton(
onClick = { onHealApplied(result) },
modifier = Modifier.testTag(TestTags.HEALING_APPLY_BUTTON)
) {
Text("Apply Healing")
}
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
modifier = Modifier.testTag(TestTags.HEALING_DISMISS_BUTTON)
) {
Text(if (healingResult != null) "Cancel" else "Close")
}
}
)
}
@@ -133,6 +133,52 @@ data class DamageMonitor(
}
}
/**
* Heal stun damage by reducing current stun by the specified number of boxes.
* Cannot reduce below 0.
*/
fun healStun(boxes: Int): DamageMonitor {
val newStun = (stunCurrent - boxes).coerceAtLeast(0)
return copy(stunCurrent = newStun)
}
/**
* Heal physical damage by reducing current physical by the specified number of boxes.
* Cannot reduce below 0. Also clears overflow if physical damage is reduced.
*/
fun healPhysical(boxes: Int): DamageMonitor {
val newPhysical = (physicalCurrent - boxes).coerceAtLeast(0)
// If physical damage is reduced and there was overflow, clear overflow proportionally
val newOverflow = if (newPhysical == 0) 0 else overflowCurrent
return copy(physicalCurrent = newPhysical, overflowCurrent = newOverflow)
}
/**
* Compute the current recovery state based on damage levels.
*/
fun recoveryState(): RecoveryState {
return when {
overflowCurrent >= overflowMax() && overflowMax() > 0 -> RecoveryState.Dead
overflowCurrent > 0 -> RecoveryState.BleedingOut
physicalCurrent >= physicalMax() -> RecoveryState.Critical
physicalCurrent > 0 || stunCurrent > stunMax() / 2 -> RecoveryState.Injured
stunCurrent > 0 -> RecoveryState.Injured
else -> RecoveryState.Healthy
}
}
/**
* Returns true if the character has overflow damage (physical track is full
* and additional damage has spilled over).
*/
fun isOverflowing(): Boolean = overflowCurrent > 0
/**
* Returns true if overflow damage has reached or exceeded the maximum,
* meaning the character is dead.
*/
fun deathThresholdReached(): Boolean = overflowMax() > 0 && overflowCurrent >= overflowMax()
/**
* Returns a copy of this DamageMonitor with the given attributes.
* Use this instead of mutation to maintain data class immutability.
@@ -0,0 +1,122 @@
package org.shahondin1624.model.charactermodel
import org.shahondin1624.lib.functions.DiceRoll
/**
* Type of healing being performed per Shadowrun 5e rules.
*/
enum class HealingType(val displayName: String) {
Natural("Natural Healing"),
FirstAid("First Aid"),
Magical("Magical Healing")
}
/**
* Recovery state derived from current damage levels.
*/
enum class RecoveryState(val displayName: String) {
Healthy("Healthy"),
Injured("Injured"),
Critical("Critical"),
Stabilized("Stabilized"),
BleedingOut("Bleeding Out"),
Dead("Dead")
}
/**
* Result of a healing test roll.
*/
data class HealingResult(
val healingType: HealingType,
val damageTrack: DamageTrackType,
val dicePool: Int,
val diceRoll: DiceRoll,
val boxesHealed: Int
)
/**
* Healing system implementing Shadowrun 5e healing rules.
*
* Natural healing:
* - Stun: Body + Willpower, 1 hour interval
* - Physical: Body + Willpower, 1 day interval
*
* First Aid:
* - Stun/Physical: First Aid skill (default pool of 4) + Body of patient
* - One-time application
*
* Magical healing:
* - Stun/Physical: Magic rating (default pool of 6)
* - Immediate effect
*
* In all cases, each success (hit) on the healing test heals 1 box of damage.
*/
object HealingSystem {
/**
* Calculate the dice pool for a healing test.
*
* @param healingType The type of healing being performed
* @param body Character's Body attribute value
* @param willpower Character's Willpower attribute value
* @return The number of dice to roll
*/
fun calculateHealingPool(
healingType: HealingType,
body: Int,
willpower: Int
): Int {
return when (healingType) {
HealingType.Natural -> body + willpower
HealingType.FirstAid -> 4 + body // First Aid skill (default 4) + patient Body
HealingType.Magical -> 6 // Magic rating (default 6)
}
}
/**
* Perform a healing test by rolling the dice pool.
* Each success heals 1 box of damage.
*
* @param healingType The type of healing
* @param damageTrack Which damage track to heal (Stun or Physical)
* @param dicePool Number of dice to roll
* @return HealingResult with the roll outcome
*/
fun performHealingTest(
healingType: HealingType,
damageTrack: DamageTrackType,
dicePool: Int
): HealingResult {
val roll = DiceRoll(numberOfDice = dicePool).roll()
return HealingResult(
healingType = healingType,
damageTrack = damageTrack,
dicePool = dicePool,
diceRoll = roll,
boxesHealed = roll.numberOfSuccesses
)
}
/**
* Get the human-readable healing interval for a damage track and healing type.
* Per Shadowrun 5e rules:
* - Natural Stun: 1 hour per test
* - Natural Physical: 1 day per test
* - First Aid: one-time application (no interval)
* - Magical: immediate
*/
fun getHealingInterval(
damageTrack: DamageTrackType,
healingType: HealingType
): String {
return when (healingType) {
HealingType.Natural -> when (damageTrack) {
DamageTrackType.Stun -> "1 hour per test"
DamageTrackType.Physical -> "1 day per test"
DamageTrackType.Overflow -> "N/A (heal physical first)"
}
HealingType.FirstAid -> "One-time application"
HealingType.Magical -> "Immediate"
}
}
}