feat: add Matrix attributes and hacking rules support (Closes #98) (#140)

This commit was merged in pull request #140.
This commit is contained in:
2026-04-05 04:36:23 +02:00
parent ecd8988dba
commit 44e00fd878
14 changed files with 1297 additions and 7 deletions
+106
View File
@@ -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
@@ -486,4 +486,37 @@
<string name="combat_start_combat">Start Combat</string>
<string name="combat_decrease_dice_content_desc">Decrease initiative dice</string>
<string name="combat_increase_dice_content_desc">Increase initiative dice</string>
<!-- Matrix tab and panel -->
<string name="tab_matrix">Matrix</string>
<string name="matrix_title">Matrix Device</string>
<string name="matrix_no_device">No Matrix device equipped</string>
<string name="matrix_add_device">Add Device</string>
<string name="matrix_edit_device">Edit Device</string>
<string name="matrix_remove_device">Remove Device</string>
<string name="matrix_add_content_desc">Add Matrix device</string>
<string name="matrix_edit_content_desc">Edit Matrix device</string>
<string name="matrix_remove_content_desc">Remove Matrix device</string>
<string name="matrix_add_title">Add Matrix Device</string>
<string name="matrix_edit_title">Edit Matrix Device</string>
<string name="matrix_device_name_label">Device Name</string>
<string name="matrix_device_name_placeholder">e.g., Hermes Chariot</string>
<string name="matrix_device_type_label">Device Type</string>
<string name="matrix_device_type_commlink">Commlink</string>
<string name="matrix_device_type_cyberdeck">Cyberdeck</string>
<string name="matrix_device_type_rcc">RCC</string>
<string name="matrix_device_type_living_persona">Living Persona</string>
<string name="matrix_device_rating_label">Device Rating</string>
<string name="matrix_attack_label">Attack</string>
<string name="matrix_sleaze_label">Sleaze</string>
<string name="matrix_data_processing_label">Data Proc.</string>
<string name="matrix_firewall_label">Firewall</string>
<string name="matrix_asdf_title">ASDF Attributes</string>
<string name="matrix_condition_monitor_title">Matrix Condition Monitor</string>
<string name="matrix_condition_monitor_display">%1$d / %2$d</string>
<string name="matrix_noise_label">Noise</string>
<string name="matrix_noise_display">Noise: %1$d</string>
<string name="matrix_initiative_label">Matrix Initiative</string>
<string name="matrix_initiative_display">%1$d + %2$dd6</string>
<string name="matrix_device_bricked">BRICKED</string>
</resources>
@@ -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"
}
@@ -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) }
}
)
}
}
@@ -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))
}
}
)
}
@@ -68,8 +68,8 @@ data class Attributes(
return applyModifiers(modifiers).reaction.value + applyModifiers(modifiers).intuition.value
}
fun matrixInitiative(modifiers: List<AttributeModifier> = emptyList()): Int {
return applyModifiers(modifiers).logic.value
fun matrixInitiative(modifiers: List<AttributeModifier> = emptyList(), dataProcessing: Int = 0): Int {
return dataProcessing + applyModifiers(modifiers).intuition.value
}
fun astralInitiative(modifiers: List<AttributeModifier> = emptyList()): Int {
@@ -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<GearItem> = emptyList(),
val qualities: List<Quality> = emptyList(),
val contacts: List<Contact> = emptyList(),
val matrixDevice: MatrixDevice? = null,
override val version: String = SchemaVersion.CURRENT
): Versionable {
/**
@@ -80,6 +82,16 @@ data class ShadowrunCharacter(
*/
fun negativeQualities(): List<Quality> = 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).
@@ -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
}
}
@@ -16,7 +16,8 @@ object MigrationRegistry {
MigrationV03ToV04(),
MigrationV04ToV05(),
MigrationV05ToV06(),
MigrationV06ToV07()
MigrationV06ToV07(),
MigrationV07ToV08()
)
/**
@@ -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<String, JsonElement>.updateNestedVersion(key: String) {
val nested = this[key]
if (nested is JsonObject) {
val nestedMutable = nested.toMutableMap()
nestedMutable["version"] = JsonPrimitive(toVersion)
this[key] = JsonObject(nestedMutable)
}
}
}
@@ -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"
}
@@ -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")
@@ -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<IllegalArgumentException> {
MatrixDevice(name = "Bad", type = MatrixDeviceType.Commlink, deviceRating = 0)
}
}
@Test
fun attackCannotBeNegative() {
assertFailsWith<IllegalArgumentException> {
MatrixDevice(name = "Bad", type = MatrixDeviceType.Cyberdeck, attack = -1)
}
}
@Test
fun sleazeCannotBeNegative() {
assertFailsWith<IllegalArgumentException> {
MatrixDevice(name = "Bad", type = MatrixDeviceType.Cyberdeck, sleaze = -1)
}
}
@Test
fun dataProcessingCannotBeNegative() {
assertFailsWith<IllegalArgumentException> {
MatrixDevice(name = "Bad", type = MatrixDeviceType.Cyberdeck, dataProcessing = -1)
}
}
@Test
fun firewallCannotBeNegative() {
assertFailsWith<IllegalArgumentException> {
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)
}
}
@@ -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