This commit was merged in pull request #140.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
+27
-1
@@ -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) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+579
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user