This commit was merged in pull request #112.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
+1
@@ -390,6 +390,7 @@ private fun CombatContent(
|
||||
) {
|
||||
DamageMonitorPanel(
|
||||
damageMonitor = character.damageMonitor,
|
||||
attributes = character.attributes,
|
||||
onDamageChanged = { newDamageMonitor ->
|
||||
onUpdateCharacter { char ->
|
||||
char.copy(damageMonitor = newDamageMonitor)
|
||||
|
||||
+162
-19
@@ -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()
|
||||
@@ -43,12 +78,21 @@ fun DamageMonitorPanel(
|
||||
.fillMaxWidth()
|
||||
.padding(padding)
|
||||
) {
|
||||
Text(
|
||||
text = "Damage Monitor",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = spacing)
|
||||
)
|
||||
// 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
|
||||
)
|
||||
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
|
||||
)
|
||||
Text(
|
||||
text = "$current / $max",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
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))
|
||||
|
||||
+184
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+46
@@ -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.
|
||||
|
||||
+122
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user