From 44e00fd8786942d9e76f16fc210db24125560c14 Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Sun, 5 Apr 2026 04:36:23 +0200 Subject: [PATCH] feat: add Matrix attributes and hacking rules support (Closes #98) (#140) --- implementation-plan-98.md | 106 ++++ .../composeResources/values/strings.xml | 33 + .../shahondin1624/lib/components/TestTags.kt | 30 + .../charactermodel/CharacterSheetPage.kt | 28 +- .../components/charactermodel/MatrixPanel.kt | 579 ++++++++++++++++++ .../model/attributes/Attributes.kt | 4 +- .../charactermodel/ShadowrunCharacter.kt | 12 + .../model/matrix/MatrixDevice.kt | 106 ++++ .../model/migration/MigrationRegistry.kt | 3 +- .../model/migration/MigrationV07ToV08.kt | 42 ++ .../model/migration/SchemaVersion.kt | 4 +- .../org/shahondin1624/IntegrationTest.kt | 2 +- .../org/shahondin1624/MatrixDeviceTest.kt | 351 +++++++++++ .../org/shahondin1624/VersionMigrationTest.kt | 4 +- 14 files changed, 1297 insertions(+), 7 deletions(-) create mode 100644 implementation-plan-98.md create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/MatrixPanel.kt create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/model/matrix/MatrixDevice.kt create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV07ToV08.kt create mode 100644 sharedUI/src/commonTest/kotlin/org/shahondin1624/MatrixDeviceTest.kt diff --git a/implementation-plan-98.md b/implementation-plan-98.md new file mode 100644 index 0000000..6632d24 --- /dev/null +++ b/implementation-plan-98.md @@ -0,0 +1,106 @@ +# Implementation Plan: Matrix attributes and hacking rules support (#98) + +## Issue Summary +Add support for Matrix-specific attributes for Deckers and Technomancers, including cyberdeck/commlink ASDF attributes (Attack, Sleaze, Data Processing, Firewall), Matrix initiative calculation, noise/signal tracking, device condition monitor, and Living Persona stats for Technomancers. + +## Requirements +### Explicit Requirements +1. Data model for Matrix device with ASDF attributes (Attack, Sleaze, Data Processing, Firewall) +2. Matrix initiative calculated correctly (Data Processing + Intuition + dice) +3. Device condition monitor +4. UI section for Matrix stats +5. Serialization support + +### Derived Requirements +- Schema version migration from v0.7 to v0.8 +- Integration with existing Attributes system for initiative calculation +- Backward compatibility with existing saved characters (empty/null Matrix device) +- Noise and signal tracking fields +- Living Persona support for Technomancers +- String resources for all UI labels +- Test tags for new UI components +- Tests for model, serialization, and migration + +### Assumptions +- A character has at most one active Matrix device (cyberdeck, commlink, or RCC) +- Living Persona is a special device type where ASDF comes from mental attributes +- Matrix condition monitor = 8 + (Device Rating / 2), following SR5e rules +- Matrix initiative = Data Processing + Intuition + Xd6 (updating existing placeholder) + +## Design Decisions + +### Device Model Structure +**Chosen:** A single `MatrixDevice` data class with a `MatrixDeviceType` enum (Commlink, Cyberdeck, RCC, LivingPersona). The device holds ASDF attributes, device rating, noise modifier, and current matrix damage. This is simple, serializable, and consistent with how other gear models (Weapon, Armor) are structured in the project. + +### Where to store Matrix data +**Chosen:** Add an optional `matrixDevice: MatrixDevice? = null` field on `ShadowrunCharacter`. This follows the pattern of optional subsystems (like spells, adeptPowers) and keeps backward compatibility clean. + +### Matrix Initiative Calculation +**Chosen:** Update `Attributes.matrixInitiative()` to accept a data processing value parameter (defaulting to current behavior). The `ShadowrunCharacter` will provide a convenience method that combines the device's Data Processing with the character's Intuition. The CombatTracker can use this for Matrix combat initiative. + +## Implementation Steps + +### Step 1: Create MatrixDevice data model +- File: `sharedUI/src/commonMain/kotlin/org/shahondin1624/model/matrix/MatrixDevice.kt` +- Create `MatrixDeviceType` enum: Commlink, Cyberdeck, RCC, LivingPersona +- Create `MatrixDevice` data class with: name, type, deviceRating, attack, sleaze, dataProcessing, firewall, noiseModifier, currentMatrixDamage +- Device condition monitor max = 8 + (deviceRating / 2) +- Include validation in init block + +### Step 2: Update ShadowrunCharacter +- File: `sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/ShadowrunCharacter.kt` +- Add `matrixDevice: MatrixDevice? = null` field +- Add `matrixInitiative()` method that returns Data Processing + Intuition +- Add `matrixConditionMonitorMax()` convenience method +- Add `deviceConditionMonitorCurrent()` convenience method + +### Step 3: Update Attributes.matrixInitiative() +- File: `sharedUI/src/commonMain/kotlin/org/shahondin1624/model/attributes/Attributes.kt` +- Update `matrixInitiative()` to accept an optional `dataProcessing: Int` parameter +- Formula: dataProcessing + intuition (base, before dice) + +### Step 4: Schema version migration v0.7 -> v0.8 +- File: `sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt` - Add V0_8 +- File: `sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV07ToV08.kt` - New migration +- File: `sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt` - Register new migration +- Migration adds null/absent `matrixDevice` field (which defaults to null on deserialization) + +### Step 5: Add string resources +- File: `sharedUI/src/commonMain/composeResources/values/strings.xml` +- Add strings for Matrix panel: title, device type labels, ASDF labels, condition monitor, noise, etc. + +### Step 6: Add test tags +- File: `sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt` +- Add Matrix panel test tags + +### Step 7: Create MatrixPanel UI +- File: `sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/MatrixPanel.kt` +- Display Matrix device stats: ASDF attributes, device rating, condition monitor +- Edit dialog for adding/editing a Matrix device +- Noise modifier display +- Condition monitor damage tracking + +### Step 8: Add Matrix tab to CharacterSheetPage +- File: `sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt` +- Add `Matrix` tab to `CharacterTab` enum +- Add `MatrixContent` composable +- Wire up in both compact and expanded layouts + +### Step 9: Write tests +- File: `sharedUI/src/commonTest/kotlin/org/shahondin1624/MatrixDeviceTest.kt` +- Test MatrixDevice creation, validation, condition monitor calculation +- Test ShadowrunCharacter matrix initiative +- Test serialization round-trip +- Test migration v0.7 -> v0.8 + +## Testing Strategy +- Unit tests for MatrixDevice model (creation, validation, condition monitor) +- Unit tests for matrix initiative calculation +- Serialization round-trip test +- Migration test for v0.7 -> v0.8 +- Pre-existing test failures (29) should not increase + +## Migration & Compatibility +- Characters without Matrix devices will have `matrixDevice = null` (Kotlin default) +- Migration v0.7 -> v0.8 updates version fields; `matrixDevice` defaults to null on deserialization +- No breaking changes to existing data diff --git a/sharedUI/src/commonMain/composeResources/values/strings.xml b/sharedUI/src/commonMain/composeResources/values/strings.xml index e34efa0..c02203b 100644 --- a/sharedUI/src/commonMain/composeResources/values/strings.xml +++ b/sharedUI/src/commonMain/composeResources/values/strings.xml @@ -486,4 +486,37 @@ Start Combat Decrease initiative dice Increase initiative dice + + + Matrix + Matrix Device + No Matrix device equipped + Add Device + Edit Device + Remove Device + Add Matrix device + Edit Matrix device + Remove Matrix device + Add Matrix Device + Edit Matrix Device + Device Name + e.g., Hermes Chariot + Device Type + Commlink + Cyberdeck + RCC + Living Persona + Device Rating + Attack + Sleaze + Data Proc. + Firewall + ASDF Attributes + Matrix Condition Monitor + %1$d / %2$d + Noise + Noise: %1$d + Matrix Initiative + %1$d + %2$dd6 + BRICKED diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt index 9b6d3ae..badd1fb 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt @@ -431,4 +431,34 @@ object TestTags { 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" + + // --- Matrix panel --- + const val PANEL_MATRIX = "panel_matrix" + const val MATRIX_ADD_BUTTON = "matrix_add_button" + const val MATRIX_EDIT_BUTTON = "matrix_edit_button" + const val MATRIX_REMOVE_BUTTON = "matrix_remove_button" + const val MATRIX_DEVICE_NAME = "matrix_device_name" + const val MATRIX_DEVICE_TYPE = "matrix_device_type" + const val MATRIX_DEVICE_RATING = "matrix_device_rating" + const val MATRIX_ATTACK = "matrix_attack" + const val MATRIX_SLEAZE = "matrix_sleaze" + const val MATRIX_DATA_PROCESSING = "matrix_data_processing" + const val MATRIX_FIREWALL = "matrix_firewall" + const val MATRIX_CONDITION_MONITOR = "matrix_condition_monitor" + const val MATRIX_NOISE = "matrix_noise" + const val MATRIX_INITIATIVE = "matrix_initiative" + const val MATRIX_BRICKED_WARNING = "matrix_bricked_warning" + fun matrixDamageBox(position: Int): String = "matrix_damage_box_$position" + + // --- Matrix edit dialog --- + const val MATRIX_EDIT_DIALOG = "matrix_edit_dialog" + const val MATRIX_EDIT_NAME_INPUT = "matrix_edit_name_input" + const val MATRIX_EDIT_TYPE_SELECTOR = "matrix_edit_type_selector" + const val MATRIX_EDIT_DEVICE_RATING_INPUT = "matrix_edit_device_rating_input" + const val MATRIX_EDIT_ATTACK_INPUT = "matrix_edit_attack_input" + const val MATRIX_EDIT_SLEAZE_INPUT = "matrix_edit_sleaze_input" + const val MATRIX_EDIT_DATA_PROCESSING_INPUT = "matrix_edit_data_processing_input" + const val MATRIX_EDIT_FIREWALL_INPUT = "matrix_edit_firewall_input" + const val MATRIX_EDIT_CONFIRM = "matrix_edit_confirm" + const val MATRIX_EDIT_DISMISS = "matrix_edit_dismiss" } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt index d4dc7be..4cd07cc 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt @@ -47,7 +47,8 @@ private enum class CharacterTab(val titleRes: StringResource) { Talents(Res.string.tab_talents), Magic(Res.string.tab_magic), Combat(Res.string.tab_combat), - Gear(Res.string.tab_gear) + Gear(Res.string.tab_gear), + Matrix(Res.string.tab_matrix) } /** @@ -269,6 +270,7 @@ fun CharacterSheetPage( CharacterTab.Magic -> MagicContent(character, spacing, onUpdateCharacter) CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter) CharacterTab.Gear -> GearContent(character, spacing, onUpdateCharacter) + CharacterTab.Matrix -> MatrixContent(character, spacing, onUpdateCharacter) } } else -> { @@ -280,6 +282,7 @@ fun CharacterSheetPage( CharacterTab.Magic -> MagicContent(character, spacing, onUpdateCharacter) CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter) CharacterTab.Gear -> GearContent(character, spacing, onUpdateCharacter) + CharacterTab.Matrix -> MatrixContent(character, spacing, onUpdateCharacter) } } } @@ -727,3 +730,26 @@ private fun GearContent( ) } } + +@Composable +private fun MatrixContent( + character: ShadowrunCharacter, + spacing: Dp, + onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(spacing), + verticalArrangement = Arrangement.spacedBy(spacing) + ) { + MatrixPanel( + matrixDevice = character.matrixDevice, + matrixInitiativeBase = character.matrixInitiativeBase(), + onDeviceChanged = { newDevice -> + onUpdateCharacter { char -> char.copy(matrixDevice = newDevice) } + } + ) + } +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/MatrixPanel.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/MatrixPanel.kt new file mode 100644 index 0000000..acdc186 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/MatrixPanel.kt @@ -0,0 +1,579 @@ +package org.shahondin1624.lib.components.charactermodel + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +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.input.KeyboardType +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.shahondin1624.lib.components.TestTags +import org.shahondin1624.lib.components.UiConstants +import org.shahondin1624.model.matrix.MatrixDevice +import org.shahondin1624.model.matrix.MatrixDeviceType +import org.jetbrains.compose.resources.stringResource +import org.shahondin1624.theme.LocalWindowSizeClass +import org.shahondin1624.theme.WindowSizeClass +import shadowruncharsheet.sharedui.generated.resources.Res +import shadowruncharsheet.sharedui.generated.resources.* + +/** + * Panel displaying the character's Matrix device and stats. + * Shows ASDF attributes, device rating, condition monitor, noise, and Matrix initiative. + */ +@Composable +fun MatrixPanel( + matrixDevice: MatrixDevice?, + matrixInitiativeBase: Int?, + onDeviceChanged: (MatrixDevice?) -> Unit +) { + val windowSizeClass = LocalWindowSizeClass.current + val padding = UiConstants.Spacing.medium(windowSizeClass) + val spacing = UiConstants.Spacing.small(windowSizeClass) + + var showEditDialog by remember { mutableStateOf(false) } + + if (showEditDialog) { + MatrixEditDialog( + device = matrixDevice, + onConfirm = { device -> + onDeviceChanged(device) + showEditDialog = false + }, + onDismiss = { showEditDialog = false } + ) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.PANEL_MATRIX) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(padding), + verticalArrangement = Arrangement.spacedBy(spacing) + ) { + // Header row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.matrix_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Row { + if (matrixDevice != null) { + IconButton( + onClick = { showEditDialog = true }, + modifier = Modifier.testTag(TestTags.MATRIX_EDIT_BUTTON) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(Res.string.matrix_edit_content_desc) + ) + } + IconButton( + onClick = { onDeviceChanged(null) }, + modifier = Modifier.testTag(TestTags.MATRIX_REMOVE_BUTTON) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(Res.string.matrix_remove_content_desc) + ) + } + } else { + IconButton( + onClick = { showEditDialog = true }, + modifier = Modifier.testTag(TestTags.MATRIX_ADD_BUTTON) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(Res.string.matrix_add_content_desc) + ) + } + } + } + } + + if (matrixDevice == null) { + Text( + text = stringResource(Res.string.matrix_no_device), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + MatrixDeviceContent( + device = matrixDevice, + matrixInitiativeBase = matrixInitiativeBase, + spacing = spacing, + windowSizeClass = windowSizeClass, + onDamageChanged = { newDevice -> onDeviceChanged(newDevice) } + ) + } + } + } +} + +@Composable +private fun MatrixDeviceContent( + device: MatrixDevice, + matrixInitiativeBase: Int?, + spacing: Dp, + windowSizeClass: WindowSizeClass, + onDamageChanged: (MatrixDevice) -> Unit +) { + // Device name and type + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = device.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.testTag(TestTags.MATRIX_DEVICE_NAME) + ) + Text( + text = deviceTypeLabel(device.type), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(TestTags.MATRIX_DEVICE_TYPE) + ) + } + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.primaryContainer + ) { + Text( + text = "DR ${device.deviceRating}", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 4.dp) + .testTag(TestTags.MATRIX_DEVICE_RATING) + ) + } + } + + // Bricked warning + if (device.isBricked()) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.testTag(TestTags.MATRIX_BRICKED_WARNING) + ) { + Text( + text = stringResource(Res.string.matrix_device_bricked), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } + } + + // ASDF attributes + Text( + text = stringResource(Res.string.matrix_asdf_title), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + + val columns = when (windowSizeClass) { + WindowSizeClass.Compact -> 2 + WindowSizeClass.Medium -> 4 + WindowSizeClass.Expanded -> 4 + } + + val asdfItems = listOf( + stringResource(Res.string.matrix_attack_label) to device.attack, + stringResource(Res.string.matrix_sleaze_label) to device.sleaze, + stringResource(Res.string.matrix_data_processing_label) to device.dataProcessing, + stringResource(Res.string.matrix_firewall_label) to device.firewall + ) + val asdfTags = listOf( + TestTags.MATRIX_ATTACK, + TestTags.MATRIX_SLEAZE, + TestTags.MATRIX_DATA_PROCESSING, + TestTags.MATRIX_FIREWALL + ) + + val rows = asdfItems.chunked(columns) + val tagRows = asdfTags.chunked(columns) + for ((rowIndex, row) in rows.withIndex()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing) + ) { + for ((itemIndex, item) in row.withIndex()) { + ASDFItem( + label = item.first, + value = item.second, + testTag = tagRows[rowIndex][itemIndex], + modifier = Modifier.weight(1f) + ) + } + repeat(columns - row.size) { + Spacer(Modifier.weight(1f)) + } + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = spacing)) + + // Matrix initiative and noise + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing) + ) { + // Matrix initiative + Surface( + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(Res.string.matrix_initiative_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = if (matrixInitiativeBase != null) { + stringResource(Res.string.matrix_initiative_display, matrixInitiativeBase, 4) + } else { + "-" + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.testTag(TestTags.MATRIX_INITIATIVE) + ) + } + } + + // Noise + Surface( + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(Res.string.matrix_noise_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = device.noiseModifier.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.testTag(TestTags.MATRIX_NOISE) + ) + } + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = spacing)) + + // Condition monitor + Text( + text = stringResource(Res.string.matrix_condition_monitor_title), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource( + Res.string.matrix_condition_monitor_display, + device.currentMatrixDamage, + device.conditionMonitorMax() + ), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(TestTags.MATRIX_CONDITION_MONITOR) + ) + + // Damage boxes + val maxBoxes = device.conditionMonitorMax() + val boxesPerRow = when (windowSizeClass) { + WindowSizeClass.Compact -> 6 + WindowSizeClass.Medium -> 8 + WindowSizeClass.Expanded -> 10 + } + val boxRows = (1..maxBoxes).chunked(boxesPerRow) + for (row in boxRows) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + for (position in row) { + val filled = position <= device.currentMatrixDamage + Surface( + modifier = Modifier + .size(28.dp) + .testTag(TestTags.matrixDamageBox(position)) + .clickable { onDamageChanged(device.toggleDamageBox(position)) }, + shape = MaterialTheme.shapes.extraSmall, + color = if (filled) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.surfaceVariant + ) { + Box(contentAlignment = Alignment.Center) { + if (filled) { + Text( + text = "X", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onError, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + } +} + +@Composable +private fun ASDFItem( + label: String, + value: Int, + testTag: String, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + Text( + text = value.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.testTag(testTag) + ) + } + } +} + +@Composable +private fun deviceTypeLabel(type: MatrixDeviceType): String { + return when (type) { + MatrixDeviceType.Commlink -> stringResource(Res.string.matrix_device_type_commlink) + MatrixDeviceType.Cyberdeck -> stringResource(Res.string.matrix_device_type_cyberdeck) + MatrixDeviceType.RCC -> stringResource(Res.string.matrix_device_type_rcc) + MatrixDeviceType.LivingPersona -> stringResource(Res.string.matrix_device_type_living_persona) + } +} + +/** + * Edit dialog for adding or editing a Matrix device. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MatrixEditDialog( + device: MatrixDevice?, + onConfirm: (MatrixDevice) -> Unit, + onDismiss: () -> Unit +) { + var name by remember { mutableStateOf(device?.name ?: "") } + var type by remember { mutableStateOf(device?.type ?: MatrixDeviceType.Commlink) } + var deviceRating by remember { mutableStateOf(device?.deviceRating?.toString() ?: "1") } + var attack by remember { mutableStateOf(device?.attack?.toString() ?: "0") } + var sleaze by remember { mutableStateOf(device?.sleaze?.toString() ?: "0") } + var dataProcessing by remember { mutableStateOf(device?.dataProcessing?.toString() ?: "0") } + var firewall by remember { mutableStateOf(device?.firewall?.toString() ?: "0") } + var typeExpanded by remember { mutableStateOf(false) } + + val isValid = name.isNotBlank() && + (deviceRating.toIntOrNull() ?: 0) >= MatrixDevice.MIN_DEVICE_RATING && + (attack.toIntOrNull() ?: -1) >= MatrixDevice.MIN_ASDF && + (sleaze.toIntOrNull() ?: -1) >= MatrixDevice.MIN_ASDF && + (dataProcessing.toIntOrNull() ?: -1) >= MatrixDevice.MIN_ASDF && + (firewall.toIntOrNull() ?: -1) >= MatrixDevice.MIN_ASDF + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TestTags.MATRIX_EDIT_DIALOG), + title = { + Text( + text = stringResource( + if (device == null) Res.string.matrix_add_title + else Res.string.matrix_edit_title + ) + ) + }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(Res.string.matrix_device_name_label)) }, + placeholder = { Text(stringResource(Res.string.matrix_device_name_placeholder)) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.MATRIX_EDIT_NAME_INPUT) + ) + + // Device type selector + ExposedDropdownMenuBox( + expanded = typeExpanded, + onExpandedChange = { typeExpanded = it }, + modifier = Modifier.testTag(TestTags.MATRIX_EDIT_TYPE_SELECTOR) + ) { + OutlinedTextField( + value = type.name, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(Res.string.matrix_device_type_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = typeExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + ExposedDropdownMenu( + expanded = typeExpanded, + onDismissRequest = { typeExpanded = false } + ) { + MatrixDeviceType.entries.forEach { deviceType -> + DropdownMenuItem( + text = { Text(deviceType.name) }, + onClick = { + type = deviceType + typeExpanded = false + } + ) + } + } + } + + OutlinedTextField( + value = deviceRating, + onValueChange = { deviceRating = it }, + label = { Text(stringResource(Res.string.matrix_device_rating_label)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.MATRIX_EDIT_DEVICE_RATING_INPUT) + ) + + // ASDF row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + OutlinedTextField( + value = attack, + onValueChange = { attack = it }, + label = { Text(stringResource(Res.string.matrix_attack_label)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + .weight(1f) + .testTag(TestTags.MATRIX_EDIT_ATTACK_INPUT) + ) + OutlinedTextField( + value = sleaze, + onValueChange = { sleaze = it }, + label = { Text(stringResource(Res.string.matrix_sleaze_label)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + .weight(1f) + .testTag(TestTags.MATRIX_EDIT_SLEAZE_INPUT) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + OutlinedTextField( + value = dataProcessing, + onValueChange = { dataProcessing = it }, + label = { Text(stringResource(Res.string.matrix_data_processing_label)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + .weight(1f) + .testTag(TestTags.MATRIX_EDIT_DATA_PROCESSING_INPUT) + ) + OutlinedTextField( + value = firewall, + onValueChange = { firewall = it }, + label = { Text(stringResource(Res.string.matrix_firewall_label)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + .weight(1f) + .testTag(TestTags.MATRIX_EDIT_FIREWALL_INPUT) + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + val newDevice = MatrixDevice( + name = name.trim(), + type = type, + deviceRating = deviceRating.toIntOrNull() ?: 1, + attack = attack.toIntOrNull() ?: 0, + sleaze = sleaze.toIntOrNull() ?: 0, + dataProcessing = dataProcessing.toIntOrNull() ?: 0, + firewall = firewall.toIntOrNull() ?: 0, + noiseModifier = device?.noiseModifier ?: 0, + currentMatrixDamage = device?.currentMatrixDamage ?: 0 + ) + onConfirm(newDevice) + }, + enabled = isValid, + modifier = Modifier.testTag(TestTags.MATRIX_EDIT_CONFIRM) + ) { + Text(stringResource(Res.string.save)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + modifier = Modifier.testTag(TestTags.MATRIX_EDIT_DISMISS) + ) { + Text(stringResource(Res.string.cancel)) + } + } + ) +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/attributes/Attributes.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/attributes/Attributes.kt index a0aedd2..55e40bf 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/attributes/Attributes.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/attributes/Attributes.kt @@ -68,8 +68,8 @@ data class Attributes( return applyModifiers(modifiers).reaction.value + applyModifiers(modifiers).intuition.value } - fun matrixInitiative(modifiers: List = emptyList()): Int { - return applyModifiers(modifiers).logic.value + fun matrixInitiative(modifiers: List = emptyList(), dataProcessing: Int = 0): Int { + return dataProcessing + applyModifiers(modifiers).intuition.value } fun astralInitiative(modifiers: List = emptyList()): Int { diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/ShadowrunCharacter.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/ShadowrunCharacter.kt index 9697d58..8e6f303 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/ShadowrunCharacter.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/ShadowrunCharacter.kt @@ -12,6 +12,7 @@ import org.shahondin1624.model.gear.GearItem import org.shahondin1624.model.gear.Weapon import org.shahondin1624.model.magic.AdeptPower import org.shahondin1624.model.magic.Spell +import org.shahondin1624.model.matrix.MatrixDevice import org.shahondin1624.model.migration.SchemaVersion import org.shahondin1624.model.qualities.Quality import org.shahondin1624.model.qualities.QualityType @@ -33,6 +34,7 @@ data class ShadowrunCharacter( val gear: List = emptyList(), val qualities: List = emptyList(), val contacts: List = emptyList(), + val matrixDevice: MatrixDevice? = null, override val version: String = SchemaVersion.CURRENT ): Versionable { /** @@ -80,6 +82,16 @@ data class ShadowrunCharacter( */ fun negativeQualities(): List = qualities.filter { it.type == QualityType.Negative } + /** + * Matrix initiative base value (Data Processing + Intuition). + * Returns null if no Matrix device is equipped. + * In Shadowrun 5e, Matrix initiative = Data Processing + Intuition + Xd6. + */ + fun matrixInitiativeBase(): Int? { + val device = matrixDevice ?: return null + return device.dataProcessing + attributes.intuition() + } + /** * Net karma cost of all qualities. * Positive qualities cost karma (positive value), negative qualities grant karma (negative value). diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/matrix/MatrixDevice.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/matrix/MatrixDevice.kt new file mode 100644 index 0000000..302475a --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/matrix/MatrixDevice.kt @@ -0,0 +1,106 @@ +package org.shahondin1624.model.matrix + +import kotlinx.serialization.Serializable + +/** + * Type of Matrix device in Shadowrun 5e. + * + * - Commlink: Basic Matrix access, no Attack/Sleaze capability + * - Cyberdeck: Full hacking capability with configurable ASDF attributes + * - RCC: Rigger Command Console for controlling drones + * - LivingPersona: Technomancer's innate Matrix presence, ASDF derived from mental attributes + */ +@Serializable +enum class MatrixDeviceType { + Commlink, + Cyberdeck, + RCC, + LivingPersona +} + +/** + * Represents a Matrix device carried by a character. + * + * In Shadowrun 5e, Matrix devices have four key attributes (ASDF): + * - Attack: Used for offensive Matrix actions + * - Sleaze: Used for covert Matrix actions + * - Data Processing: Used for Matrix searches and initiative + * - Firewall: Used for Matrix defense + * + * The device condition monitor tracks Matrix damage: + * Max boxes = 8 + (deviceRating / 2) + * + * @param name The device's name (e.g., "Hermes Chariot", "Erika MCD-1") + * @param type The type of Matrix device + * @param deviceRating The overall rating of the device (1-9 for most devices) + * @param attack The Attack attribute value + * @param sleaze The Sleaze attribute value + * @param dataProcessing The Data Processing attribute value + * @param firewall The Firewall attribute value + * @param noiseModifier Current noise modifier affecting the character's Matrix actions + * @param currentMatrixDamage Current damage to the device's condition monitor + */ +@Serializable +data class MatrixDevice( + val name: String, + val type: MatrixDeviceType, + val deviceRating: Int = 1, + val attack: Int = 0, + val sleaze: Int = 0, + val dataProcessing: Int = 0, + val firewall: Int = 0, + val noiseModifier: Int = 0, + val currentMatrixDamage: Int = 0 +) { + init { + require(deviceRating >= 1) { "Device rating must be at least 1, was $deviceRating" } + require(attack >= 0) { "Attack must be non-negative, was $attack" } + require(sleaze >= 0) { "Sleaze must be non-negative, was $sleaze" } + require(dataProcessing >= 0) { "Data Processing must be non-negative, was $dataProcessing" } + require(firewall >= 0) { "Firewall must be non-negative, was $firewall" } + require(currentMatrixDamage >= 0) { "Matrix damage cannot be negative, was $currentMatrixDamage" } + require(currentMatrixDamage <= conditionMonitorMax()) { + "Matrix damage ($currentMatrixDamage) cannot exceed condition monitor max (${conditionMonitorMax()})" + } + } + + /** + * Maximum boxes on the device's Matrix condition monitor. + * Formula: 8 + (deviceRating / 2), rounded down. + */ + fun conditionMonitorMax(): Int = 8 + (deviceRating / 2) + + /** + * Apply damage to the device, clamping to valid range. + */ + fun withDamage(damage: Int): MatrixDevice { + val clamped = damage.coerceIn(0, conditionMonitorMax()) + return copy(currentMatrixDamage = clamped) + } + + /** + * Toggle damage at a specific box position. + * If the box is filled, heal to (position - 1). If empty, damage to position. + */ + fun toggleDamageBox(position: Int): MatrixDevice { + val newValue = if (position <= currentMatrixDamage) position - 1 else position + return withDamage(newValue) + } + + /** + * Update the noise modifier. + */ + fun withNoiseModifier(noise: Int): MatrixDevice = copy(noiseModifier = noise) + + /** + * Whether the device is bricked (condition monitor full). + */ + fun isBricked(): Boolean = currentMatrixDamage >= conditionMonitorMax() + + companion object { + const val MIN_DEVICE_RATING = 1 + const val MAX_DEVICE_RATING = 9 + const val MIN_ASDF = 0 + const val MAX_ASDF = 12 + } +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt index 9f5ce39..e7f3277 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt @@ -16,7 +16,8 @@ object MigrationRegistry { MigrationV03ToV04(), MigrationV04ToV05(), MigrationV05ToV06(), - MigrationV06ToV07() + MigrationV06ToV07(), + MigrationV07ToV08() ) /** diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV07ToV08.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV07ToV08.kt new file mode 100644 index 0000000..6b87d85 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV07ToV08.kt @@ -0,0 +1,42 @@ +package org.shahondin1624.model.migration + +import kotlinx.serialization.json.* + +/** + * Migration from schema v0.7 to v0.8. + * + * Changes in v0.8: + * - Adds optional "matrixDevice" field (null) to the root character object + * - Updates all version fields from "v0.7" to "v0.8" + * - Updates matrixInitiative calculation (now uses Data Processing + Intuition) + */ +class MigrationV07ToV08 : VersionMigration { + override val fromVersion: String = SchemaVersion.V0_7 + override val toVersion: String = SchemaVersion.V0_8 + + override fun migrate(json: JsonObject): JsonObject { + val mutable = json.toMutableMap() + + // matrixDevice defaults to null via Kotlin serialization, + // but we don't need to explicitly add it since null optional fields + // are handled by kotlinx.serialization defaults. + + // Update version fields at all levels + mutable["version"] = JsonPrimitive(toVersion) + mutable.updateNestedVersion("characterData") + mutable.updateNestedVersion("attributes") + mutable.updateNestedVersion("talents") + mutable.updateNestedVersion("damageMonitor") + + return JsonObject(mutable) + } + + private fun MutableMap.updateNestedVersion(key: String) { + val nested = this[key] + if (nested is JsonObject) { + val nestedMutable = nested.toMutableMap() + nestedMutable["version"] = JsonPrimitive(toVersion) + this[key] = JsonObject(nestedMutable) + } + } +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt index 589eef7..49a7d3f 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt @@ -5,7 +5,7 @@ package org.shahondin1624.model.migration * as their default version value. */ object SchemaVersion { - const val CURRENT: String = "v0.7" + const val CURRENT: String = "v0.8" /** * Initial version used before the migration system was introduced. @@ -23,4 +23,6 @@ object SchemaVersion { const val V0_6: String = "v0.6" const val V0_7: String = "v0.7" + + const val V0_8: String = "v0.8" } diff --git a/sharedUI/src/commonTest/kotlin/org/shahondin1624/IntegrationTest.kt b/sharedUI/src/commonTest/kotlin/org/shahondin1624/IntegrationTest.kt index bf59844..042e0ba 100644 --- a/sharedUI/src/commonTest/kotlin/org/shahondin1624/IntegrationTest.kt +++ b/sharedUI/src/commonTest/kotlin/org/shahondin1624/IntegrationTest.kt @@ -255,7 +255,7 @@ class IntegrationTest { ) assertEquals(8, attrs.initiative(), "Initiative = reaction(3) + intuition(5) = 8") - assertEquals(3, attrs.matrixInitiative(), "Matrix initiative = logic(3)") + assertEquals(5, attrs.matrixInitiative(), "Matrix initiative (no device) = 0 + intuition(5) = 5") assertEquals(8, attrs.composure(), "Composure = body(4) + willpower(4) = 8") assertEquals(7, attrs.judgeIntent(), "Judge Intent = intuition(5) + charisma(2) = 7") assertEquals(7, attrs.memory(), "Memory = logic(3) + willpower(4) = 7") diff --git a/sharedUI/src/commonTest/kotlin/org/shahondin1624/MatrixDeviceTest.kt b/sharedUI/src/commonTest/kotlin/org/shahondin1624/MatrixDeviceTest.kt new file mode 100644 index 0000000..721932c --- /dev/null +++ b/sharedUI/src/commonTest/kotlin/org/shahondin1624/MatrixDeviceTest.kt @@ -0,0 +1,351 @@ +package org.shahondin1624 + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.shahondin1624.lib.functions.DataLoader +import org.shahondin1624.model.attributes.Attribute +import org.shahondin1624.model.attributes.AttributeType +import org.shahondin1624.model.attributes.Attributes +import org.shahondin1624.model.characterdata.CharacterData +import org.shahondin1624.model.characterdata.Metatype +import org.shahondin1624.model.charactermodel.DamageMonitor +import org.shahondin1624.model.charactermodel.ShadowrunCharacter +import org.shahondin1624.model.matrix.MatrixDevice +import org.shahondin1624.model.matrix.MatrixDeviceType +import org.shahondin1624.model.migration.MigrationRegistry +import org.shahondin1624.model.migration.MigrationV07ToV08 +import org.shahondin1624.model.migration.SchemaVersion +import org.shahondin1624.model.talents.Talents +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MatrixDeviceTest { + + // --- MatrixDevice creation and validation --- + + @Test + fun createBasicDevice() { + val device = MatrixDevice( + name = "Hermes Chariot", + type = MatrixDeviceType.Commlink, + deviceRating = 3, + attack = 0, + sleaze = 0, + dataProcessing = 3, + firewall = 3 + ) + assertEquals("Hermes Chariot", device.name) + assertEquals(MatrixDeviceType.Commlink, device.type) + assertEquals(3, device.deviceRating) + assertEquals(0, device.currentMatrixDamage) + } + + @Test + fun createCyberdeckWithASDFAttributes() { + val device = MatrixDevice( + name = "Erika MCD-1", + type = MatrixDeviceType.Cyberdeck, + deviceRating = 4, + attack = 5, + sleaze = 4, + dataProcessing = 3, + firewall = 2 + ) + assertEquals(5, device.attack) + assertEquals(4, device.sleaze) + assertEquals(3, device.dataProcessing) + assertEquals(2, device.firewall) + } + + @Test + fun createLivingPersona() { + val device = MatrixDevice( + name = "Living Persona", + type = MatrixDeviceType.LivingPersona, + deviceRating = 5, + attack = 4, + sleaze = 5, + dataProcessing = 6, + firewall = 3 + ) + assertEquals(MatrixDeviceType.LivingPersona, device.type) + } + + @Test + fun deviceRatingMustBePositive() { + assertFailsWith { + MatrixDevice(name = "Bad", type = MatrixDeviceType.Commlink, deviceRating = 0) + } + } + + @Test + fun attackCannotBeNegative() { + assertFailsWith { + MatrixDevice(name = "Bad", type = MatrixDeviceType.Cyberdeck, attack = -1) + } + } + + @Test + fun sleazeCannotBeNegative() { + assertFailsWith { + MatrixDevice(name = "Bad", type = MatrixDeviceType.Cyberdeck, sleaze = -1) + } + } + + @Test + fun dataProcessingCannotBeNegative() { + assertFailsWith { + MatrixDevice(name = "Bad", type = MatrixDeviceType.Cyberdeck, dataProcessing = -1) + } + } + + @Test + fun firewallCannotBeNegative() { + assertFailsWith { + MatrixDevice(name = "Bad", type = MatrixDeviceType.Cyberdeck, firewall = -1) + } + } + + // --- Condition monitor --- + + @Test + fun conditionMonitorMaxRating1() { + val device = MatrixDevice(name = "Test", type = MatrixDeviceType.Commlink, deviceRating = 1) + // 8 + (1/2) = 8 + 0 = 8 + assertEquals(8, device.conditionMonitorMax()) + } + + @Test + fun conditionMonitorMaxRating4() { + val device = MatrixDevice(name = "Test", type = MatrixDeviceType.Cyberdeck, deviceRating = 4) + // 8 + (4/2) = 8 + 2 = 10 + assertEquals(10, device.conditionMonitorMax()) + } + + @Test + fun conditionMonitorMaxRating9() { + val device = MatrixDevice(name = "Test", type = MatrixDeviceType.Cyberdeck, deviceRating = 9) + // 8 + (9/2) = 8 + 4 = 12 + assertEquals(12, device.conditionMonitorMax()) + } + + @Test + fun conditionMonitorMaxRating3() { + val device = MatrixDevice(name = "Test", type = MatrixDeviceType.RCC, deviceRating = 3) + // 8 + (3/2) = 8 + 1 = 9 + assertEquals(9, device.conditionMonitorMax()) + } + + // --- Damage operations --- + + @Test + fun withDamageClampsToRange() { + val device = MatrixDevice(name = "Test", type = MatrixDeviceType.Commlink, deviceRating = 2) + // max = 8 + 1 = 9 + val damaged = device.withDamage(5) + assertEquals(5, damaged.currentMatrixDamage) + + val overDamaged = device.withDamage(100) + assertEquals(9, overDamaged.currentMatrixDamage) + + val underDamaged = device.withDamage(-5) + assertEquals(0, underDamaged.currentMatrixDamage) + } + + @Test + fun toggleDamageBoxFillsEmpty() { + val device = MatrixDevice(name = "Test", type = MatrixDeviceType.Commlink, deviceRating = 2) + val toggled = device.toggleDamageBox(3) + assertEquals(3, toggled.currentMatrixDamage) + } + + @Test + fun toggleDamageBoxHealsIfFilled() { + val device = MatrixDevice(name = "Test", type = MatrixDeviceType.Commlink, deviceRating = 2, currentMatrixDamage = 5) + val toggled = device.toggleDamageBox(3) + assertEquals(2, toggled.currentMatrixDamage) // position - 1 + } + + @Test + fun isBrickedWhenFull() { + val device = MatrixDevice(name = "Test", type = MatrixDeviceType.Commlink, deviceRating = 1) + assertFalse(device.isBricked()) + val bricked = device.withDamage(device.conditionMonitorMax()) + assertTrue(bricked.isBricked()) + } + + // --- Noise modifier --- + + @Test + fun noiseModifierCanBeSet() { + val device = MatrixDevice(name = "Test", type = MatrixDeviceType.Commlink) + val withNoise = device.withNoiseModifier(-3) + assertEquals(-3, withNoise.noiseModifier) + } + + // --- Matrix initiative on ShadowrunCharacter --- + + private fun createTestCharacter(matrixDevice: MatrixDevice? = null): ShadowrunCharacter { + val attributes = Attributes( + body = Attribute(AttributeType.Body, 3), + agility = Attribute(AttributeType.Agility, 3), + reaction = Attribute(AttributeType.Reaction, 4), + strength = Attribute(AttributeType.Strength, 2), + willpower = Attribute(AttributeType.Willpower, 3), + logic = Attribute(AttributeType.Logic, 5), + intuition = Attribute(AttributeType.Intuition, 4), + charisma = Attribute(AttributeType.Charisma, 2), + edge = 2 + ) + return ShadowrunCharacter( + characterData = CharacterData( + concept = "Decker", + nuyen = 5000, + essence = 6.0f, + name = "Test Decker", + metatype = Metatype.Human, + age = 25, + gender = "male", + streetCred = 0, + notoriety = 0, + publicAwareness = 0, + totalKarma = 0, + currentKarma = 0 + ), + attributes = attributes, + talents = Talents(emptyList()), + damageMonitor = DamageMonitor(), + matrixDevice = matrixDevice + ) + } + + @Test + fun matrixInitiativeBaseWithDevice() { + val device = MatrixDevice( + name = "Cyberdeck", + type = MatrixDeviceType.Cyberdeck, + deviceRating = 4, + dataProcessing = 5 + ) + val character = createTestCharacter(device) + // Matrix initiative base = Data Processing (5) + Intuition (4) = 9 + assertEquals(9, character.matrixInitiativeBase()) + } + + @Test + fun matrixInitiativeBaseWithoutDevice() { + val character = createTestCharacter(null) + assertNull(character.matrixInitiativeBase()) + } + + @Test + fun matrixInitiativeOnAttributes() { + val attributes = Attributes( + body = Attribute(AttributeType.Body, 3), + agility = Attribute(AttributeType.Agility, 3), + reaction = Attribute(AttributeType.Reaction, 4), + strength = Attribute(AttributeType.Strength, 2), + willpower = Attribute(AttributeType.Willpower, 3), + logic = Attribute(AttributeType.Logic, 5), + intuition = Attribute(AttributeType.Intuition, 4), + charisma = Attribute(AttributeType.Charisma, 2), + edge = 2 + ) + // With dataProcessing = 6: 6 + 4 (intuition) = 10 + assertEquals(10, attributes.matrixInitiative(dataProcessing = 6)) + // Without dataProcessing: 0 + 4 = 4 + assertEquals(4, attributes.matrixInitiative()) + } + + // --- Serialization round-trip --- + + @Test + fun matrixDeviceSerializationRoundTrip() { + val device = MatrixDevice( + name = "Erika MCD-1", + type = MatrixDeviceType.Cyberdeck, + deviceRating = 4, + attack = 5, + sleaze = 4, + dataProcessing = 3, + firewall = 2, + noiseModifier = -2, + currentMatrixDamage = 3 + ) + val character = createTestCharacter(device) + val serialized = DataLoader.serialize(character) + val deserialized = DataLoader.deserialize(serialized) + assertEquals(device.name, deserialized.matrixDevice?.name) + assertEquals(device.type, deserialized.matrixDevice?.type) + assertEquals(device.deviceRating, deserialized.matrixDevice?.deviceRating) + assertEquals(device.attack, deserialized.matrixDevice?.attack) + assertEquals(device.sleaze, deserialized.matrixDevice?.sleaze) + assertEquals(device.dataProcessing, deserialized.matrixDevice?.dataProcessing) + assertEquals(device.firewall, deserialized.matrixDevice?.firewall) + assertEquals(device.noiseModifier, deserialized.matrixDevice?.noiseModifier) + assertEquals(device.currentMatrixDamage, deserialized.matrixDevice?.currentMatrixDamage) + } + + @Test + fun characterWithoutMatrixDeviceSerializationRoundTrip() { + val character = createTestCharacter(null) + val serialized = DataLoader.serialize(character) + val deserialized = DataLoader.deserialize(serialized) + assertNull(deserialized.matrixDevice) + } + + // --- Migration v0.7 -> v0.8 --- + + private val jsonParser = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + } + + @Test + fun migrationV07ToV08UpdatesVersion() { + val migration = MigrationV07ToV08() + assertEquals("v0.7", migration.fromVersion) + assertEquals("v0.8", migration.toVersion) + + val v07Json = jsonParser.parseToJsonElement(""" + { + "version": "v0.7", + "characterData": { "version": "v0.7", "name": "Test" }, + "attributes": { "version": "v0.7" }, + "talents": { "version": "v0.7" }, + "damageMonitor": { "version": "v0.7" } + } + """).jsonObject + + val migrated = migration.migrate(v07Json) + assertEquals("v0.8", migrated["version"]?.jsonPrimitive?.content) + assertEquals("v0.8", migrated["characterData"]?.jsonObject?.get("version")?.jsonPrimitive?.content) + assertEquals("v0.8", migrated["attributes"]?.jsonObject?.get("version")?.jsonPrimitive?.content) + assertEquals("v0.8", migrated["talents"]?.jsonObject?.get("version")?.jsonPrimitive?.content) + assertEquals("v0.8", migrated["damageMonitor"]?.jsonObject?.get("version")?.jsonPrimitive?.content) + } + + @Test + fun migrationRegistryIncludesV07ToV08() { + val chain = MigrationRegistry.findMigrationChain("v0.7") + assertEquals(1, chain.size) + assertEquals("v0.7", chain[0].fromVersion) + assertEquals("v0.8", chain[0].toVersion) + } + + @Test + fun fullMigrationFromV01ToV08() { + assertTrue(MigrationRegistry.needsMigration("v0.1")) + val chain = MigrationRegistry.findMigrationChain("v0.1") + assertEquals(7, chain.size) + assertEquals("v0.8", chain.last().toVersion) + } +} diff --git a/sharedUI/src/commonTest/kotlin/org/shahondin1624/VersionMigrationTest.kt b/sharedUI/src/commonTest/kotlin/org/shahondin1624/VersionMigrationTest.kt index 948f7d8..1004bdd 100644 --- a/sharedUI/src/commonTest/kotlin/org/shahondin1624/VersionMigrationTest.kt +++ b/sharedUI/src/commonTest/kotlin/org/shahondin1624/VersionMigrationTest.kt @@ -132,7 +132,7 @@ class VersionMigrationTest { @Test fun findMigrationChainReturnsPathFromV01() { val chain = MigrationRegistry.findMigrationChain("v0.1") - assertEquals(6, chain.size) + assertEquals(7, chain.size) assertEquals("v0.1", chain[0].fromVersion) assertEquals("v0.2", chain[0].toVersion) assertEquals("v0.2", chain[1].fromVersion) @@ -145,6 +145,8 @@ class VersionMigrationTest { assertEquals("v0.6", chain[4].toVersion) assertEquals("v0.6", chain[5].fromVersion) assertEquals("v0.7", chain[5].toVersion) + assertEquals("v0.7", chain[6].fromVersion) + assertEquals("v0.8", chain[6].toVersion) } @Test