feat: validate attribute values against Shadowrun 5e racial maximums (Closes #86) (#128)

This commit was merged in pull request #128.
This commit is contained in:
2026-04-04 23:49:09 +02:00
parent f28b98463e
commit 32ec4d58ad
5 changed files with 222 additions and 6 deletions
@@ -37,6 +37,8 @@ object TestTags {
const val ATTRIBUTE_EDIT_CONFIRM = "attribute_edit_confirm"
const val ATTRIBUTE_EDIT_DISMISS = "attribute_edit_dismiss"
const val ATTRIBUTE_EDIT_ERROR = "attribute_edit_error"
const val ATTRIBUTE_EDIT_AUGMENTED_TOGGLE = "attribute_edit_augmented_toggle"
const val ATTRIBUTE_EDIT_AUGMENTED_INDICATOR = "attribute_edit_augmented_indicator"
// --- Damage monitor interaction ---
const val DAMAGE_WOUND_MODIFIER = "damage_wound_modifier"
@@ -19,6 +19,7 @@ import org.shahondin1624.lib.functions.performExtendedTest
import org.shahondin1624.lib.functions.performOpposedTest
import org.shahondin1624.lib.functions.performSimpleTest
import org.shahondin1624.model.attributes.AttributeType
import org.shahondin1624.model.attributes.MetatypeAttributeLimits
import org.shahondin1624.model.charactermodel.ShadowrunCharacter
import org.shahondin1624.model.dice.ExtendedTestResult
import org.shahondin1624.model.dice.OpposedTestResult
@@ -164,8 +165,10 @@ fun CharacterSheetPage(
// Show attribute edit dialog
editingAttributeType?.let { attrType ->
val attr = character.attributes.getAttributeByType(attrType)
val racialMax = MetatypeAttributeLimits.getMaximum(character.characterData.metatype, attrType)
AttributeEditDialog(
attribute = attr,
maxValue = racialMax,
onConfirm = { newValue ->
onUpdateCharacter { char ->
val newAttributes = char.attributes.withAttribute(attrType, newValue)
@@ -11,21 +11,28 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.model.attributes.Attribute
import org.shahondin1624.model.attributes.MetatypeAttributeLimits
/**
* Dialog for editing an attribute value.
* Provides a number input with validation for minimum 1 and configurable maximum.
* Provides a number input with validation against Shadowrun 5e racial maximums.
* Supports augmented mode where values can exceed the racial maximum by up to +4.
*/
@Composable
fun AttributeEditDialog(
attribute: Attribute,
maxValue: Int = 10,
maxValue: Int = 6,
onConfirm: (Int) -> Unit,
onDismiss: () -> Unit
) {
var textValue by remember { mutableStateOf(attribute.value.toString()) }
var isAugmented by remember { mutableStateOf(false) }
val augmentedMaxValue = maxValue + MetatypeAttributeLimits.AUGMENTATION_BONUS
val effectiveMax = if (isAugmented) augmentedMaxValue else maxValue
val parsedValue = textValue.toIntOrNull()
val isValid = parsedValue != null && parsedValue in 1..maxValue
val isValid = parsedValue != null && parsedValue in 1..effectiveMax
val isAboveRacialMax = parsedValue != null && parsedValue > maxValue && parsedValue <= augmentedMaxValue
AlertDialog(
onDismissRequest = onDismiss,
@@ -45,7 +52,7 @@ fun AttributeEditDialog(
textValue = newValue
}
},
label = { Text("Value (1-$maxValue)") },
label = { Text("Value (1-$effectiveMax)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = !isValid && textValue.isNotEmpty(),
singleLine = true,
@@ -53,12 +60,43 @@ fun AttributeEditDialog(
.fillMaxWidth()
.testTag(TestTags.ATTRIBUTE_EDIT_INPUT)
)
// Augmented mode toggle
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.testTag(TestTags.ATTRIBUTE_EDIT_AUGMENTED_TOGGLE)
) {
Checkbox(
checked = isAugmented,
onCheckedChange = { isAugmented = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Augmented (max $augmentedMaxValue)",
style = MaterialTheme.typography.bodySmall
)
}
// Visual indicator when value exceeds racial max but is within augmented max
if (isAugmented && isAboveRacialMax && isValid) {
Text(
text = "Augmented: exceeds racial maximum of $maxValue",
color = MaterialTheme.colorScheme.tertiary,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag(TestTags.ATTRIBUTE_EDIT_AUGMENTED_INDICATOR)
)
}
if (!isValid && textValue.isNotEmpty()) {
Text(
text = if (parsedValue != null && parsedValue < 1) {
"Minimum value is 1"
} else if (parsedValue != null && parsedValue > maxValue) {
"Maximum value is $maxValue"
} else if (parsedValue != null && parsedValue > effectiveMax) {
if (isAugmented) {
"Maximum augmented value is $augmentedMaxValue"
} else {
"Maximum value is $maxValue (enable augmented for higher)"
}
} else {
"Enter a valid number"
},
@@ -0,0 +1,80 @@
package org.shahondin1624.model.attributes
import org.shahondin1624.model.characterdata.Metatype
/**
* Shadowrun 5e racial attribute maximums per metatype.
* Unaugmented attributes cannot exceed these values.
* Augmented attributes may exceed racial max by up to +4.
*/
object MetatypeAttributeLimits {
/** Maximum additional points augmentation can add above the racial max. */
const val AUGMENTATION_BONUS = 4
private data class AttributeLimits(
val body: Int,
val agility: Int,
val reaction: Int,
val strength: Int,
val willpower: Int,
val logic: Int,
val intuition: Int,
val charisma: Int,
val edge: Int
)
private val limits: Map<Metatype, AttributeLimits> = mapOf(
Metatype.Human to AttributeLimits(
body = 6, agility = 6, reaction = 6, strength = 6,
willpower = 6, logic = 6, intuition = 6, charisma = 6, edge = 7
),
Metatype.Elf to AttributeLimits(
body = 6, agility = 7, reaction = 6, strength = 6,
willpower = 6, logic = 6, intuition = 6, charisma = 8, edge = 6
),
Metatype.Dwarf to AttributeLimits(
body = 8, agility = 6, reaction = 5, strength = 8,
willpower = 7, logic = 6, intuition = 6, charisma = 6, edge = 6
),
Metatype.Ork to AttributeLimits(
body = 9, agility = 6, reaction = 6, strength = 8,
willpower = 6, logic = 5, intuition = 6, charisma = 5, edge = 6
),
Metatype.Troll to AttributeLimits(
body = 10, agility = 5, reaction = 6, strength = 10,
willpower = 6, logic = 5, intuition = 5, charisma = 4, edge = 6
)
)
/**
* Returns the unaugmented racial maximum for the given metatype and attribute type.
*/
fun getMaximum(metatype: Metatype, attributeType: AttributeType): Int {
val l = limits[metatype] ?: return 6 // fallback to human-like default
return when (attributeType) {
AttributeType.Body -> l.body
AttributeType.Agility -> l.agility
AttributeType.Reaction -> l.reaction
AttributeType.Strength -> l.strength
AttributeType.Willpower -> l.willpower
AttributeType.Logic -> l.logic
AttributeType.Intuition -> l.intuition
AttributeType.Charisma -> l.charisma
}
}
/**
* Returns the maximum Edge value for the given metatype.
*/
fun getEdgeMaximum(metatype: Metatype): Int {
return limits[metatype]?.edge ?: 7
}
/**
* Returns the augmented maximum (racial max + augmentation bonus) for the given metatype and attribute.
*/
fun getAugmentedMaximum(metatype: Metatype, attributeType: AttributeType): Int {
return getMaximum(metatype, attributeType) + AUGMENTATION_BONUS
}
}
@@ -0,0 +1,93 @@
package org.shahondin1624
import org.shahondin1624.model.attributes.AttributeType
import org.shahondin1624.model.attributes.MetatypeAttributeLimits
import org.shahondin1624.model.characterdata.Metatype
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Unit tests for MetatypeAttributeLimits ensuring correct Shadowrun 5e racial maximums.
*/
class MetatypeAttributeLimitsTest {
@Test
fun humanAttributeMaximumsAreCorrect() {
assertEquals(6, MetatypeAttributeLimits.getMaximum(Metatype.Human, AttributeType.Body))
assertEquals(6, MetatypeAttributeLimits.getMaximum(Metatype.Human, AttributeType.Agility))
assertEquals(6, MetatypeAttributeLimits.getMaximum(Metatype.Human, AttributeType.Reaction))
assertEquals(6, MetatypeAttributeLimits.getMaximum(Metatype.Human, AttributeType.Strength))
assertEquals(6, MetatypeAttributeLimits.getMaximum(Metatype.Human, AttributeType.Willpower))
assertEquals(6, MetatypeAttributeLimits.getMaximum(Metatype.Human, AttributeType.Logic))
assertEquals(6, MetatypeAttributeLimits.getMaximum(Metatype.Human, AttributeType.Intuition))
assertEquals(6, MetatypeAttributeLimits.getMaximum(Metatype.Human, AttributeType.Charisma))
}
@Test
fun humanEdgeMaximumIs7() {
assertEquals(7, MetatypeAttributeLimits.getEdgeMaximum(Metatype.Human))
}
@Test
fun elfHasHigherAgilityAndCharisma() {
assertEquals(7, MetatypeAttributeLimits.getMaximum(Metatype.Elf, AttributeType.Agility))
assertEquals(8, MetatypeAttributeLimits.getMaximum(Metatype.Elf, AttributeType.Charisma))
assertEquals(6, MetatypeAttributeLimits.getMaximum(Metatype.Elf, AttributeType.Body))
}
@Test
fun dwarfHasHigherBodyStrengthWillpower() {
assertEquals(8, MetatypeAttributeLimits.getMaximum(Metatype.Dwarf, AttributeType.Body))
assertEquals(8, MetatypeAttributeLimits.getMaximum(Metatype.Dwarf, AttributeType.Strength))
assertEquals(7, MetatypeAttributeLimits.getMaximum(Metatype.Dwarf, AttributeType.Willpower))
assertEquals(5, MetatypeAttributeLimits.getMaximum(Metatype.Dwarf, AttributeType.Reaction))
}
@Test
fun orkHasHigherBodyAndStrength() {
assertEquals(9, MetatypeAttributeLimits.getMaximum(Metatype.Ork, AttributeType.Body))
assertEquals(8, MetatypeAttributeLimits.getMaximum(Metatype.Ork, AttributeType.Strength))
assertEquals(5, MetatypeAttributeLimits.getMaximum(Metatype.Ork, AttributeType.Logic))
assertEquals(5, MetatypeAttributeLimits.getMaximum(Metatype.Ork, AttributeType.Charisma))
}
@Test
fun trollHasHighestBodyAndStrength() {
assertEquals(10, MetatypeAttributeLimits.getMaximum(Metatype.Troll, AttributeType.Body))
assertEquals(10, MetatypeAttributeLimits.getMaximum(Metatype.Troll, AttributeType.Strength))
assertEquals(5, MetatypeAttributeLimits.getMaximum(Metatype.Troll, AttributeType.Agility))
assertEquals(5, MetatypeAttributeLimits.getMaximum(Metatype.Troll, AttributeType.Logic))
assertEquals(5, MetatypeAttributeLimits.getMaximum(Metatype.Troll, AttributeType.Intuition))
assertEquals(4, MetatypeAttributeLimits.getMaximum(Metatype.Troll, AttributeType.Charisma))
}
@Test
fun trollEdgeMaximumIs6() {
assertEquals(6, MetatypeAttributeLimits.getEdgeMaximum(Metatype.Troll))
}
@Test
fun augmentedMaximumAdds4ToRacialMax() {
assertEquals(10, MetatypeAttributeLimits.getAugmentedMaximum(Metatype.Human, AttributeType.Body))
assertEquals(14, MetatypeAttributeLimits.getAugmentedMaximum(Metatype.Troll, AttributeType.Body))
assertEquals(12, MetatypeAttributeLimits.getAugmentedMaximum(Metatype.Elf, AttributeType.Charisma))
}
@Test
fun allMetatypesHaveEdgeMaximums() {
for (metatype in Metatype.entries) {
val edgeMax = MetatypeAttributeLimits.getEdgeMaximum(metatype)
assert(edgeMax in 1..10) { "Edge max for $metatype should be reasonable, got $edgeMax" }
}
}
@Test
fun allMetatypesHaveAllAttributeMaximums() {
for (metatype in Metatype.entries) {
for (attrType in AttributeType.entries) {
val max = MetatypeAttributeLimits.getMaximum(metatype, attrType)
assert(max in 1..15) { "Max for $metatype/$attrType should be reasonable, got $max" }
}
}
}
}