feat: add character background notes and lifestyle tracking (Closes #103) (#113)

This commit was merged in pull request #113.
This commit is contained in:
2026-04-04 20:45:16 +02:00
parent d55f6754fe
commit a9d7f961ca
8 changed files with 603 additions and 5 deletions
+48
View File
@@ -0,0 +1,48 @@
# Issue #103: Character Background Notes and Lifestyle Tracking
## Summary
Add free-text background/notes fields and lifestyle tracking (with multiple lifestyle support and monthly cost summary) to the Shadowrun character sheet. This requires new model classes, serialization support, UI panels on the Overview tab, and an edit dialog for lifestyles.
## Acceptance Criteria Checklist
1. [ ] Free-text notes/background field on character sheet
2. [ ] Lifestyle entries with name, level, and monthly cost
3. [ ] Multiple lifestyles supported
4. [ ] Total monthly cost displayed
5. [ ] Serialization support for notes and lifestyle data
## Implementation Plan
### Step 1: Create Lifestyle Model
- Create `sharedUI/src/commonMain/kotlin/org/shahondin1624/model/characterdata/Lifestyle.kt`
- `LifestyleLevel` enum: Street(0), Squatter(500), Low(2000), Middle(5000), High(10000), Luxury(100000) with monthly cost per Shadowrun 5e rules
- `Lifestyle` data class: name (String), level (LifestyleLevel), monthlyCost (Int) — all @Serializable
### Step 2: Add Notes and Lifestyles to ShadowrunCharacter
- Add `notes: String = ""` field to `ShadowrunCharacter`
- Add `lifestyles: List<Lifestyle> = emptyList()` field to `ShadowrunCharacter`
- Both with default values to maintain backward compatibility with existing serialized data
### Step 3: Update Defaults
- Update `createNewCharacter()` and `EXAMPLE_CHARACTER` in Defaults.kt to include notes and lifestyles fields (can use defaults)
### Step 4: Create UI - NotesPanel
- Create `sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/NotesPanel.kt`
- Card-based panel with a multiline text field for background/notes
- Editable in-place with auto-save behavior (via onUpdateCharacter callback)
### Step 5: Create UI - LifestylePanel
- Create `sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/LifestylePanel.kt`
- Card showing list of lifestyles with name, level, and monthly cost
- Total monthly cost displayed at bottom
- Add/remove lifestyle buttons
- Edit dialog for adding/editing a lifestyle entry (name, level dropdown, optional cost override)
### Step 6: Integrate Panels into CharacterSheetPage
- Add NotesPanel and LifestylePanel to the Overview tab in CharacterSheetPage.kt
- Add to both OverviewContent and ExpandedOverviewContent
### Step 7: Add Test Tags
- Add test tags for notes panel, lifestyle panel, and lifestyle entries to TestTags.kt
### Step 8: Update Serialization Test
- Update SerializationRoundTripTest to verify notes and lifestyles survive serialization round-trip
@@ -183,4 +183,24 @@ object TestTags {
const val RECOVERY_STATE_BADGE = "recovery_state_badge"
const val DEATH_WARNING = "death_warning"
const val OVERFLOW_WARNING = "overflow_warning"
// --- Notes panel ---
const val PANEL_NOTES = "panel_notes"
const val NOTES_TEXT_FIELD = "notes_text_field"
// --- Lifestyle panel ---
const val PANEL_LIFESTYLES = "panel_lifestyles"
const val LIFESTYLE_TOTAL_COST = "lifestyle_total_cost"
const val LIFESTYLE_ADD_BUTTON = "lifestyle_add_button"
fun lifestyleEntry(index: Int): String = "lifestyle_entry_$index"
fun lifestyleRemoveButton(index: Int): String = "lifestyle_remove_button_$index"
fun lifestyleEditButton(index: Int): String = "lifestyle_edit_button_$index"
// --- Lifestyle edit dialog ---
const val LIFESTYLE_EDIT_DIALOG = "lifestyle_edit_dialog"
const val LIFESTYLE_EDIT_NAME = "lifestyle_edit_name"
const val LIFESTYLE_EDIT_LEVEL = "lifestyle_edit_level"
const val LIFESTYLE_EDIT_COST = "lifestyle_edit_cost"
const val LIFESTYLE_EDIT_CONFIRM = "lifestyle_edit_confirm"
const val LIFESTYLE_EDIT_DISMISS = "lifestyle_edit_dismiss"
}
@@ -222,7 +222,7 @@ fun CharacterSheetPage(
WindowSizeClass.Expanded -> {
// Expanded: two-column layout for Overview and Combat
when (selectedTab) {
CharacterTab.Overview -> ExpandedOverviewContent(character, spacing, onEditCharacterData)
CharacterTab.Overview -> ExpandedOverviewContent(character, spacing, onEditCharacterData, onUpdateCharacter)
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter)
@@ -231,7 +231,7 @@ fun CharacterSheetPage(
else -> {
when (selectedTab) {
CharacterTab.Overview -> OverviewContent(character, spacing, onEditCharacterData)
CharacterTab.Overview -> OverviewContent(character, spacing, onEditCharacterData, onUpdateCharacter)
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter)
@@ -245,7 +245,8 @@ fun CharacterSheetPage(
private fun OverviewContent(
character: ShadowrunCharacter,
spacing: Dp,
onEditCharacterData: () -> Unit
onEditCharacterData: () -> Unit,
onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit
) {
Column(
modifier = Modifier
@@ -258,6 +259,18 @@ private fun OverviewContent(
ResourcePanel(character.characterData, character.attributes.edge)
DerivedAttributesPanel(character.attributes)
ReputationPanel(character.characterData)
LifestylePanel(
lifestyles = character.lifestyles,
onLifestylesChanged = { newLifestyles ->
onUpdateCharacter { char -> char.copy(lifestyles = newLifestyles) }
}
)
NotesPanel(
notes = character.notes,
onNotesChanged = { newNotes ->
onUpdateCharacter { char -> char.copy(notes = newNotes) }
}
)
}
}
@@ -265,7 +278,8 @@ private fun OverviewContent(
private fun ExpandedOverviewContent(
character: ShadowrunCharacter,
spacing: Dp,
onEditCharacterData: () -> Unit
onEditCharacterData: () -> Unit,
onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit
) {
Column(
modifier = Modifier
@@ -287,6 +301,27 @@ private fun ExpandedOverviewContent(
}
}
ReputationPanel(character.characterData)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing)
) {
Column(modifier = Modifier.weight(1f)) {
LifestylePanel(
lifestyles = character.lifestyles,
onLifestylesChanged = { newLifestyles ->
onUpdateCharacter { char -> char.copy(lifestyles = newLifestyles) }
}
)
}
Column(modifier = Modifier.weight(1f)) {
NotesPanel(
notes = character.notes,
onNotesChanged = { newNotes ->
onUpdateCharacter { char -> char.copy(notes = newNotes) }
}
)
}
}
}
}
@@ -0,0 +1,330 @@
package org.shahondin1624.lib.components.charactermodel
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 org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.UiConstants
import org.shahondin1624.model.characterdata.Lifestyle
import org.shahondin1624.model.characterdata.LifestyleLevel
import org.shahondin1624.theme.LocalWindowSizeClass
/**
* Panel displaying the character's lifestyles with total monthly nuyen burn rate.
* Supports adding, editing, and removing lifestyle entries.
*/
@Composable
fun LifestylePanel(
lifestyles: List<Lifestyle>,
onLifestylesChanged: (List<Lifestyle>) -> Unit
) {
val windowSizeClass = LocalWindowSizeClass.current
val padding = UiConstants.Spacing.medium(windowSizeClass)
val totalMonthlyCost = lifestyles.sumOf { it.monthlyCost }
var editingIndex by remember { mutableStateOf<Int?>(null) }
var showAddDialog by remember { mutableStateOf(false) }
// Show edit dialog
editingIndex?.let { index ->
val lifestyle = lifestyles[index]
LifestyleEditDialog(
lifestyle = lifestyle,
onConfirm = { updated ->
val newList = lifestyles.toMutableList()
newList[index] = updated
onLifestylesChanged(newList)
editingIndex = null
},
onDismiss = { editingIndex = null }
)
}
// Show add dialog
if (showAddDialog) {
LifestyleEditDialog(
lifestyle = null,
onConfirm = { newLifestyle ->
onLifestylesChanged(lifestyles + newLifestyle)
showAddDialog = false
},
onDismiss = { showAddDialog = false }
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.PANEL_LIFESTYLES)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(padding),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header row with title and add button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Lifestyles",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
IconButton(
onClick = { showAddDialog = true },
modifier = Modifier.testTag(TestTags.LIFESTYLE_ADD_BUTTON)
) {
Icon(Icons.Default.Add, contentDescription = "Add Lifestyle")
}
}
if (lifestyles.isEmpty()) {
Text(
text = "No lifestyles configured",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
// Lifestyle entries
lifestyles.forEachIndexed { index, lifestyle ->
LifestyleEntryRow(
lifestyle = lifestyle,
index = index,
onEdit = { editingIndex = index },
onRemove = {
val newList = lifestyles.toMutableList()
newList.removeAt(index)
onLifestylesChanged(newList)
}
)
}
// Total monthly cost
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.LIFESTYLE_TOTAL_COST),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Total Monthly Cost",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Text(
text = "${totalMonthlyCost}\u00A5/month",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
@Composable
private fun LifestyleEntryRow(
lifestyle: Lifestyle,
index: Int,
onEdit: () -> Unit,
onRemove: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.lifestyleEntry(index)),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = lifestyle.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Text(
text = lifestyle.level.name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = "${lifestyle.monthlyCost}\u00A5",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp)
)
IconButton(
onClick = onEdit,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.lifestyleEditButton(index))
) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit",
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = onRemove,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.lifestyleRemoveButton(index))
) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
/**
* Dialog for adding or editing a lifestyle entry.
*
* @param lifestyle The lifestyle to edit, or null when adding a new one.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LifestyleEditDialog(
lifestyle: Lifestyle?,
onConfirm: (Lifestyle) -> Unit,
onDismiss: () -> Unit
) {
var name by remember { mutableStateOf(lifestyle?.name ?: "") }
var level by remember { mutableStateOf(lifestyle?.level ?: LifestyleLevel.Low) }
var costText by remember { mutableStateOf(lifestyle?.monthlyCost?.toString() ?: level.monthlyCost.toString()) }
var levelExpanded by remember { mutableStateOf(false) }
val cost = costText.toIntOrNull()
val isValid = name.isNotBlank() && cost != null && cost >= 0
// When level changes and no custom cost was set, update cost to match level default
val isAddMode = lifestyle == null
val title = if (isAddMode) "Add Lifestyle" else "Edit Lifestyle"
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TestTags.LIFESTYLE_EDIT_DIALOG),
title = { Text(title) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Name
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
placeholder = { Text("e.g., Downtown Apartment") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.LIFESTYLE_EDIT_NAME)
)
// Level dropdown
ExposedDropdownMenuBox(
expanded = levelExpanded,
onExpandedChange = { levelExpanded = it },
modifier = Modifier.testTag(TestTags.LIFESTYLE_EDIT_LEVEL)
) {
OutlinedTextField(
value = "${level.name} (${level.monthlyCost}\u00A5/month)",
onValueChange = {},
readOnly = true,
label = { Text("Level") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = levelExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
expanded = levelExpanded,
onDismissRequest = { levelExpanded = false }
) {
LifestyleLevel.entries.forEach { ll ->
DropdownMenuItem(
text = { Text("${ll.name} (${ll.monthlyCost}\u00A5/month)") },
onClick = {
val previousLevelCost = level.monthlyCost
level = ll
// Auto-update cost if it matches the previous level's default
if (costText == previousLevelCost.toString()) {
costText = ll.monthlyCost.toString()
}
levelExpanded = false
}
)
}
}
}
// Monthly cost (editable for custom amounts)
OutlinedTextField(
value = costText,
onValueChange = { if (it.all { c -> c.isDigit() } || it.isEmpty()) costText = it },
label = { Text("Monthly Cost (\u00A5)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = cost == null && costText.isNotEmpty(),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.LIFESTYLE_EDIT_COST)
)
}
},
confirmButton = {
TextButton(
onClick = {
if (isValid) {
onConfirm(Lifestyle(name = name, level = level, monthlyCost = cost!!))
}
},
enabled = isValid,
modifier = Modifier.testTag(TestTags.LIFESTYLE_EDIT_CONFIRM)
) {
Text("Save")
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
modifier = Modifier.testTag(TestTags.LIFESTYLE_EDIT_DISMISS)
) {
Text("Cancel")
}
}
)
}
@@ -0,0 +1,57 @@
package org.shahondin1624.lib.components.charactermodel
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.UiConstants
import org.shahondin1624.theme.LocalWindowSizeClass
/**
* Panel for free-text character background notes.
* Provides an editable multiline text field for backstory,
* personality notes, contacts, and other narrative information.
*/
@Composable
fun NotesPanel(
notes: String,
onNotesChanged: (String) -> Unit
) {
val windowSizeClass = LocalWindowSizeClass.current
val padding = UiConstants.Spacing.medium(windowSizeClass)
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.PANEL_NOTES)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(padding),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Background & Notes",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
OutlinedTextField(
value = notes,
onValueChange = onNotesChanged,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp)
.testTag(TestTags.NOTES_TEXT_FIELD),
placeholder = { Text("Character backstory, personality, contacts, goals...") },
minLines = 4,
maxLines = 12
)
}
}
}
@@ -0,0 +1,32 @@
package org.shahondin1624.model.characterdata
import kotlinx.serialization.Serializable
/**
* Shadowrun 5e lifestyle levels with standard monthly costs in nuyen.
*/
@Serializable
enum class LifestyleLevel(val monthlyCost: Int) {
Street(0),
Squatter(500),
Low(2000),
Middle(5000),
High(10000),
Luxury(100000)
}
/**
* A single lifestyle entry. A character can maintain multiple lifestyles
* (e.g., a primary residence and a safehouse).
*
* @param name Descriptive name (e.g., "Downtown Apartment", "Safehouse")
* @param level The lifestyle level determining base monthly cost
* @param monthlyCost Actual monthly cost in nuyen (defaults to the level's standard cost,
* but can be overridden for customized lifestyles)
*/
@Serializable
data class Lifestyle(
val name: String,
val level: LifestyleLevel,
val monthlyCost: Int = level.monthlyCost
)
@@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable
import org.shahondin1624.model.Versionable
import org.shahondin1624.model.attributes.Attributes
import org.shahondin1624.model.characterdata.CharacterData
import org.shahondin1624.model.characterdata.Lifestyle
import org.shahondin1624.model.talents.Talents
@Serializable
@@ -12,5 +13,7 @@ data class ShadowrunCharacter(
val attributes: Attributes,
val talents: Talents,
val damageMonitor: DamageMonitor,
val notes: String = "",
val lifestyles: List<Lifestyle> = emptyList(),
override val version: String = "v0.1"
): Versionable
@@ -5,6 +5,8 @@ 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.Lifestyle
import org.shahondin1624.model.characterdata.LifestyleLevel
import org.shahondin1624.model.characterdata.Metatype
import org.shahondin1624.model.charactermodel.DamageMonitor
import org.shahondin1624.model.charactermodel.ShadowrunCharacter
@@ -71,11 +73,18 @@ class SerializationRoundTripTest {
overflowCurrent = 0
)
val lifestyles = listOf(
Lifestyle(name = "Downtown Apartment", level = LifestyleLevel.Middle),
Lifestyle(name = "Safehouse", level = LifestyleLevel.Low, monthlyCost = 2500)
)
val original = ShadowrunCharacter(
characterData = characterData,
attributes = attributes,
talents = talents,
damageMonitor = damageMonitor
damageMonitor = damageMonitor,
notes = "Street samurai background. Born in the Barrens.",
lifestyles = lifestyles
)
// Round-trip
@@ -125,6 +134,18 @@ class SerializationRoundTripTest {
assertEquals(original.damageMonitor.physicalCurrent(), restored.damageMonitor.physicalCurrent())
assertEquals(original.damageMonitor.overflowCurrent(), restored.damageMonitor.overflowCurrent())
// Notes assertions
assertEquals(original.notes, restored.notes)
// Lifestyle assertions
assertEquals(original.lifestyles.size, restored.lifestyles.size)
original.lifestyles.forEachIndexed { i, orig ->
val rest = restored.lifestyles[i]
assertEquals(orig.name, rest.name, "Lifestyle[$i].name")
assertEquals(orig.level, rest.level, "Lifestyle[$i].level")
assertEquals(orig.monthlyCost, rest.monthlyCost, "Lifestyle[$i].monthlyCost")
}
// Version
assertEquals(original.version, restored.version)
}
@@ -151,6 +172,57 @@ class SerializationRoundTripTest {
assertEquals(original.version, restored.version)
}
@Test
fun notesAndLifestylesDefaultToEmptyOnDeserialization() {
// Verify that characters without notes/lifestyles fields (backward compat) deserialize with defaults
val original = org.shahondin1624.model.EXAMPLE_CHARACTER.copy(
damageMonitor = DamageMonitor(
attributes = org.shahondin1624.model.EXAMPLE_CHARACTER.attributes,
stunCurrent = 0,
physicalCurrent = 0,
overflowCurrent = 0
)
)
val json = DataLoader.serialize(original)
val restored = DataLoader.deserialize(json)
assertEquals("", restored.notes, "Default notes should be empty string")
assertEquals(emptyList(), restored.lifestyles, "Default lifestyles should be empty list")
}
@Test
fun lifestyleRoundTripWithMultipleEntries() {
val lifestyles = listOf(
Lifestyle(name = "Primary Residence", level = LifestyleLevel.High),
Lifestyle(name = "Bolt Hole", level = LifestyleLevel.Squatter),
Lifestyle(name = "Custom Place", level = LifestyleLevel.Low, monthlyCost = 3000)
)
val original = org.shahondin1624.model.EXAMPLE_CHARACTER.copy(
damageMonitor = DamageMonitor(
attributes = org.shahondin1624.model.EXAMPLE_CHARACTER.attributes,
stunCurrent = 0,
physicalCurrent = 0,
overflowCurrent = 0
),
notes = "A long backstory\nwith multiple lines\nand special chars: !@#$%",
lifestyles = lifestyles
)
val json = DataLoader.serialize(original)
val restored = DataLoader.deserialize(json)
assertEquals(original.notes, restored.notes)
assertEquals(3, restored.lifestyles.size)
assertEquals(LifestyleLevel.High, restored.lifestyles[0].level)
assertEquals(10000, restored.lifestyles[0].monthlyCost)
assertEquals(3000, restored.lifestyles[2].monthlyCost, "Custom cost should be preserved")
assertEquals(
lifestyles.sumOf { it.monthlyCost },
restored.lifestyles.sumOf { it.monthlyCost },
"Total monthly cost should match"
)
}
@Test
fun serializedJsonContainsAllFields() {
val original = org.shahondin1624.model.EXAMPLE_CHARACTER
@@ -163,6 +235,7 @@ class SerializationRoundTripTest {
"body", "agility", "reaction", "strength",
"willpower", "logic", "intuition", "charisma",
"stunCurrent", "physicalCurrent", "overflowCurrent",
"notes", "lifestyles",
"version"
)
for (field in checks) {