From 82ec8383596139b21e6719fa8eb41e160c962ff4 Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Tue, 7 Apr 2026 09:32:41 +0200 Subject: [PATCH] feat: add Technomancer Resonance attribute and Submersion tracking (Closes #149) (#153) --- .plan/issue-149-resonance-submersion.md | 51 ++ .../composeResources/values/strings.xml | 26 + .../shahondin1624/lib/components/TestTags.kt | 22 + .../charactermodel/CharacterSheetPage.kt | 11 + .../charactermodel/SubmersionPanel.kt | 446 ++++++++++++++++++ .../charactermodel/ShadowrunCharacter.kt | 21 + .../org/shahondin1624/model/magic/Echo.kt | 81 ++++ .../shahondin1624/model/magic/Submersion.kt | 45 ++ .../model/migration/MigrationRegistry.kt | 3 +- .../model/migration/MigrationV12ToV13.kt | 45 ++ .../model/migration/SchemaVersion.kt | 4 +- 11 files changed, 753 insertions(+), 2 deletions(-) create mode 100644 .plan/issue-149-resonance-submersion.md create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/SubmersionPanel.kt create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/model/magic/Echo.kt create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/model/magic/Submersion.kt create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV12ToV13.kt diff --git a/.plan/issue-149-resonance-submersion.md b/.plan/issue-149-resonance-submersion.md new file mode 100644 index 0000000..6d54d4f --- /dev/null +++ b/.plan/issue-149-resonance-submersion.md @@ -0,0 +1,51 @@ +# Issue #149: Technomancer Resonance Attribute and Submersion Tracking + +## Summary +Add Resonance as a special attribute for Technomancers (already partially tracked in Attributes) and implement Submersion tracking (the Technomancer equivalent of initiation). Submersion increases max Resonance, costs karma, and grants Echoes (special abilities). This requires a new model class, migration, UI panel, string resources, and test tags. + +## Implementation Plan + +### Step 1: Create Echo and Submersion model classes +- Create `/model/magic/Echo.kt` with: + - `PredefinedEcho` enum listing common Echoes (Overclocking, Skinlink, Living Network, Neurofilter, Resonance Link, etc.) + - `Echo` data class with name, description, isCustom fields +- Create `/model/magic/Submersion.kt` with: + - `Submersion` data class tracking grade, echoes list, and calculated karma costs + - `submersionKarmaCost(newGrade: Int): Int = 13 + 3 * newGrade` + - `maxResonance(baseMax: Int = 6): Int = baseMax + grade` + +### Step 2: Add submersion field to ShadowrunCharacter +- Add `submersion: Submersion = Submersion()` to ShadowrunCharacter +- Add helper methods: + - `maxResonance(): Int` — returns 6 + submersion grade + - `effectiveResonance(): Int` — reduced by essence loss (floor of current essence, capped at maxResonance) + +### Step 3: Create schema migration v0.12 -> v0.13 +- New `MigrationV12ToV13` adding empty submersion object with grade=0, echoes=[] +- Update `SchemaVersion` to add V0_13 and set CURRENT = "v0.13" +- Register in MigrationRegistry + +### Step 4: Add string resources +- Add submersion panel strings to strings.xml + +### Step 5: Create SubmersionPanel UI +- Panel showing current submersion grade, max resonance, echo list +- Add/remove echoes (predefined and custom) +- Submerse button showing karma cost for next grade +- Display in MagicContent section of CharacterSheetPage (for Technomancer characters) + +### Step 6: Add test tags +- Add submersion-related test tags to TestTags.kt + +### Step 7: Compile and test + +## AC Verification Checklist +1. Resonance attribute tracked as special attribute (like Magic) — already exists in Attributes.kt +2. Resonance displayed prominently for Technomancers — verify in UI +3. Submersion grade tracking with karma cost calculation — Submersion model + UI +4. Echo selection at each submersion grade — Echo model + UI +5. Predefined list of common Echoes — PredefinedEcho enum +6. Max Resonance increases with submersion grade (6 + grade) — maxResonance() method +7. Resonance loss from essence reduction — effectiveResonance() considers essence +8. UI for submersion management — SubmersionPanel +9. Serialization support — @Serializable + migration diff --git a/sharedUI/src/commonMain/composeResources/values/strings.xml b/sharedUI/src/commonMain/composeResources/values/strings.xml index d363432..2e765dc 100644 --- a/sharedUI/src/commonMain/composeResources/values/strings.xml +++ b/sharedUI/src/commonMain/composeResources/values/strings.xml @@ -629,4 +629,30 @@ Data Fault Machine + + + Submersion + Submersion Grade: %1$d + Max Resonance: %1$d (6 + %2$d) + Effective Resonance: %1$d + Resonance: %1$d + Next submersion costs %1$d karma + Submerse (Grade %1$d) + Reduce Grade + Echoes (%1$d) + No Echoes gained yet + Add Echo + Add a new Echo + Edit Echo + Remove Echo + Add Echo + Edit Echo + Custom Echo + Select Echo + Echo name + Description + Effect description + Submersion is only available for Technomancers + Essence loss reduces effective Resonance by %1$d + Karma cost formula: 13 + 3 x grade diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt index 2c38e6a..7fe8366 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt @@ -546,4 +546,26 @@ object TestTags { const val SPRITE_REGISTER_TASKS_INPUT = "sprite_register_tasks_input" const val SPRITE_REGISTER_CONFIRM = "sprite_register_confirm" const val SPRITE_REGISTER_DISMISS = "sprite_register_dismiss" + + // --- Submersion panel --- + const val PANEL_SUBMERSION = "panel_submersion" + const val SUBMERSION_RESONANCE_DISPLAY = "submersion_resonance_display" + const val SUBMERSION_EFFECTIVE_RESONANCE = "submersion_effective_resonance" + const val SUBMERSION_GRADE = "submersion_grade" + const val SUBMERSION_MAX_RESONANCE = "submersion_max_resonance" + const val SUBMERSION_SUBMERSE_BUTTON = "submersion_submerse_button" + const val SUBMERSION_REDUCE_BUTTON = "submersion_reduce_button" + const val SUBMERSION_ECHO_ADD_BUTTON = "submersion_echo_add_button" + fun submersionEchoEntry(index: Int): String = "submersion_echo_entry_$index" + fun submersionEchoEditButton(index: Int): String = "submersion_echo_edit_button_$index" + fun submersionEchoRemoveButton(index: Int): String = "submersion_echo_remove_button_$index" + + // --- Submersion Echo edit dialog --- + const val SUBMERSION_ECHO_EDIT_DIALOG = "submersion_echo_edit_dialog" + const val SUBMERSION_ECHO_NAME = "submersion_echo_name" + const val SUBMERSION_ECHO_DESCRIPTION = "submersion_echo_description" + const val SUBMERSION_ECHO_CUSTOM_TOGGLE = "submersion_echo_custom_toggle" + const val SUBMERSION_ECHO_PREDEFINED_SELECTOR = "submersion_echo_predefined_selector" + const val SUBMERSION_ECHO_CONFIRM = "submersion_echo_confirm" + const val SUBMERSION_ECHO_DISMISS = "submersion_echo_dismiss" } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt index a4d66f0..485b964 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt @@ -650,6 +650,17 @@ private fun MagicContent( .padding(spacing), verticalArrangement = Arrangement.spacedBy(spacing) ) { + SubmersionPanel( + submersion = character.submersion, + resonance = character.attributes.resonance(), + effectiveResonance = character.effectiveResonance(), + maxResonance = character.maxResonance(), + essenceReduction = kotlin.math.ceil(character.totalEssenceLoss().toDouble()).toInt(), + isTechnomancer = character.isTechnomancer(), + onSubmersionChanged = { newSubmersion -> + onUpdateCharacter { char -> char.copy(submersion = newSubmersion) } + } + ) SpellPanel( spells = character.spells, onSpellsChanged = { newSpells -> diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/SubmersionPanel.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/SubmersionPanel.kt new file mode 100644 index 0000000..1cf05b1 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/SubmersionPanel.kt @@ -0,0 +1,446 @@ +package org.shahondin1624.lib.components.charactermodel + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +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.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.shahondin1624.lib.components.TestTags +import org.shahondin1624.lib.components.UiConstants +import org.shahondin1624.model.magic.Echo +import org.shahondin1624.model.magic.PredefinedEcho +import org.shahondin1624.model.magic.Submersion +import org.shahondin1624.model.magic.createEchoFromPredefined +import org.shahondin1624.theme.LocalWindowSizeClass +import shadowruncharsheet.sharedui.generated.resources.Res +import shadowruncharsheet.sharedui.generated.resources.* + +/** + * Panel displaying the Technomancer's submersion status, Resonance info, and Echoes. + * + * Shows current submersion grade, max Resonance, effective Resonance, + * karma cost for next submersion, and a list of gained Echoes. + */ +@Composable +fun SubmersionPanel( + submersion: Submersion, + resonance: Int, + effectiveResonance: Int, + maxResonance: Int, + essenceReduction: Int, + isTechnomancer: Boolean, + onSubmersionChanged: (Submersion) -> Unit +) { + val windowSizeClass = LocalWindowSizeClass.current + val padding = UiConstants.Spacing.medium(windowSizeClass) + + var editingEchoIndex by remember { mutableStateOf(null) } + var showAddEchoDialog by remember { mutableStateOf(false) } + + // Echo edit dialog + editingEchoIndex?.let { index -> + val echo = submersion.echoes[index] + EchoEditDialog( + echo = echo, + onConfirm = { updated -> + val newEchoes = submersion.echoes.toMutableList() + newEchoes[index] = updated + onSubmersionChanged(submersion.copy(echoes = newEchoes)) + editingEchoIndex = null + }, + onDismiss = { editingEchoIndex = null } + ) + } + + // Echo add dialog + if (showAddEchoDialog) { + EchoEditDialog( + echo = null, + onConfirm = { newEcho -> + onSubmersionChanged(submersion.copy(echoes = submersion.echoes + newEcho)) + showAddEchoDialog = false + }, + onDismiss = { showAddEchoDialog = false } + ) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.PANEL_SUBMERSION) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(padding), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Title + Text( + text = stringResource(Res.string.submersion_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + if (!isTechnomancer) { + Text( + text = stringResource(Res.string.submersion_not_technomancer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + // Resonance display + Text( + text = stringResource(Res.string.submersion_resonance_display, resonance), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.testTag(TestTags.SUBMERSION_RESONANCE_DISPLAY) + ) + + // Effective Resonance (if different due to essence loss) + if (essenceReduction > 0) { + Text( + text = stringResource(Res.string.submersion_effective_resonance, effectiveResonance), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(TestTags.SUBMERSION_EFFECTIVE_RESONANCE) + ) + Text( + text = stringResource(Res.string.submersion_essence_warning, essenceReduction), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Grade display + Text( + text = stringResource(Res.string.submersion_grade_label, submersion.grade), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + modifier = Modifier.testTag(TestTags.SUBMERSION_GRADE) + ) + + // Max Resonance + Text( + text = stringResource(Res.string.submersion_max_resonance, maxResonance, submersion.grade), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(TestTags.SUBMERSION_MAX_RESONANCE) + ) + + // Karma cost info + Text( + text = stringResource(Res.string.submersion_next_cost, submersion.nextSubmersionKarmaCost()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = stringResource(Res.string.submersion_karma_formula), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Submerse / Reduce grade buttons + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(vertical = 4.dp) + ) { + Button( + onClick = { + onSubmersionChanged( + submersion.copy(grade = submersion.grade + 1) + ) + }, + modifier = Modifier.testTag(TestTags.SUBMERSION_SUBMERSE_BUTTON) + ) { + Text(stringResource(Res.string.submersion_submerse_button, submersion.grade + 1)) + } + + if (submersion.grade > 0) { + OutlinedButton( + onClick = { + onSubmersionChanged( + submersion.copy(grade = submersion.grade - 1) + ) + }, + modifier = Modifier.testTag(TestTags.SUBMERSION_REDUCE_BUTTON) + ) { + Text(stringResource(Res.string.submersion_reduce_button)) + } + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Echoes section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.submersion_echoes_title, submersion.echoes.size), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + IconButton( + onClick = { showAddEchoDialog = true }, + modifier = Modifier.testTag(TestTags.SUBMERSION_ECHO_ADD_BUTTON) + ) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(Res.string.submersion_add_echo_content_desc) + ) + } + } + + if (submersion.echoes.isEmpty()) { + Text( + text = stringResource(Res.string.submersion_no_echoes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + submersion.echoes.forEachIndexed { index, echo -> + EchoEntryRow( + echo = echo, + index = index, + onEdit = { editingEchoIndex = index }, + onRemove = { + val newEchoes = submersion.echoes.toMutableList() + newEchoes.removeAt(index) + onSubmersionChanged(submersion.copy(echoes = newEchoes)) + } + ) + } + } + } + } + } +} + +@Composable +private fun EchoEntryRow( + echo: Echo, + index: Int, + onEdit: () -> Unit, + onRemove: () -> Unit +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.submersionEchoEntry(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 = echo.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + if (echo.description.isNotBlank()) { + Text( + text = echo.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (echo.isCustom) { + Text( + text = "Custom", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary + ) + } + } + Row { + IconButton( + onClick = onEdit, + modifier = Modifier + .size(32.dp) + .testTag(TestTags.submersionEchoEditButton(index)) + ) { + Icon( + Icons.Default.Edit, + contentDescription = stringResource(Res.string.submersion_echo_edit_content_desc), + modifier = Modifier.size(18.dp) + ) + } + IconButton( + onClick = onRemove, + modifier = Modifier + .size(32.dp) + .testTag(TestTags.submersionEchoRemoveButton(index)) + ) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(Res.string.submersion_echo_remove_content_desc), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } +} + +/** + * Dialog for adding or editing an Echo entry. + * Supports both predefined and custom Echoes. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EchoEditDialog( + echo: Echo?, + onConfirm: (Echo) -> Unit, + onDismiss: () -> Unit +) { + var isCustom by remember { mutableStateOf(echo?.isCustom ?: false) } + var selectedPredefined by remember { mutableStateOf(null) } + var name by remember { mutableStateOf(echo?.name ?: "") } + var description by remember { mutableStateOf(echo?.description ?: "") } + var predefinedExpanded by remember { mutableStateOf(false) } + + val isValid = name.isNotBlank() + val isAddMode = echo == null + val title = if (isAddMode) stringResource(Res.string.submersion_echo_add_title) + else stringResource(Res.string.submersion_echo_edit_title) + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TestTags.SUBMERSION_ECHO_EDIT_DIALOG), + title = { Text(title) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Custom/Predefined toggle (only for add mode) + if (isAddMode) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(stringResource(Res.string.submersion_echo_custom_toggle)) + Switch( + checked = isCustom, + onCheckedChange = { isCustom = it }, + modifier = Modifier.testTag(TestTags.SUBMERSION_ECHO_CUSTOM_TOGGLE) + ) + } + } + + if (!isCustom && isAddMode) { + // Predefined selector + ExposedDropdownMenuBox( + expanded = predefinedExpanded, + onExpandedChange = { predefinedExpanded = it }, + modifier = Modifier.testTag(TestTags.SUBMERSION_ECHO_PREDEFINED_SELECTOR) + ) { + OutlinedTextField( + value = selectedPredefined?.echoName ?: "", + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(Res.string.submersion_echo_predefined_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = predefinedExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + ExposedDropdownMenu( + expanded = predefinedExpanded, + onDismissRequest = { predefinedExpanded = false } + ) { + PredefinedEcho.entries.forEach { pe -> + DropdownMenuItem( + text = { Text(pe.echoName) }, + onClick = { + selectedPredefined = pe + name = pe.echoName + description = pe.description + predefinedExpanded = false + } + ) + } + } + } + } + + // Name + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(Res.string.label_name)) }, + placeholder = { Text(stringResource(Res.string.submersion_echo_name_placeholder)) }, + singleLine = true, + readOnly = !isCustom && isAddMode && selectedPredefined != null, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.SUBMERSION_ECHO_NAME) + ) + + // Description + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text(stringResource(Res.string.submersion_echo_description_label)) }, + placeholder = { Text(stringResource(Res.string.submersion_echo_description_placeholder)) }, + maxLines = 3, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.SUBMERSION_ECHO_DESCRIPTION) + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (isValid) { + onConfirm( + Echo( + name = name, + description = description, + isCustom = isCustom || (echo?.isCustom ?: false) + ) + ) + } + }, + enabled = isValid, + modifier = Modifier.testTag(TestTags.SUBMERSION_ECHO_CONFIRM) + ) { + Text(stringResource(Res.string.save)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + modifier = Modifier.testTag(TestTags.SUBMERSION_ECHO_DISMISS) + ) { + Text(stringResource(Res.string.cancel)) + } + } + ) +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/ShadowrunCharacter.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/ShadowrunCharacter.kt index 60b2c3b..9ae140d 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/ShadowrunCharacter.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/ShadowrunCharacter.kt @@ -15,6 +15,7 @@ import org.shahondin1624.model.magic.AdeptPower import org.shahondin1624.model.magic.ComplexForm import org.shahondin1624.model.magic.Spell import org.shahondin1624.model.magic.Sprite +import org.shahondin1624.model.magic.Submersion import org.shahondin1624.model.matrix.MatrixDevice import org.shahondin1624.model.matrix.MatrixDeviceType import org.shahondin1624.model.migration.SchemaVersion @@ -42,6 +43,7 @@ data class ShadowrunCharacter( val complexForms: List = emptyList(), val sprites: List = emptyList(), val vehicles: List = emptyList(), + val submersion: Submersion = Submersion(), override val version: String = SchemaVersion.CURRENT ): Versionable { /** @@ -165,6 +167,25 @@ data class ShadowrunCharacter( */ fun registeredSprites(): List = sprites.filter { it.isRegistered } + /** + * Maximum Resonance value for this character based on submersion grade. + * Base maximum is 6, plus 1 per submersion grade. + */ + fun maxResonance(): Int = submersion.maxResonance() + + /** + * Effective Resonance after accounting for essence loss from cyberware. + * In Shadowrun 5e, each point of essence lost reduces the maximum Magic or + * Resonance by 1. Resonance is capped at maxResonance and reduced by + * the ceiling of essence loss. + */ + fun effectiveResonance(): Int { + val essenceLoss = totalEssenceLoss() + val reductionFromEssence = kotlin.math.ceil(essenceLoss.toDouble()).toInt() + val maxRes = maxResonance() + return (attributes.resonance()).coerceAtMost(maxRes - reductionFromEssence).coerceAtLeast(0) + } + /** * Net karma cost of all qualities. * Positive qualities cost karma (positive value), negative qualities grant karma (negative value). diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/magic/Echo.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/magic/Echo.kt new file mode 100644 index 0000000..acc1cec --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/magic/Echo.kt @@ -0,0 +1,81 @@ +package org.shahondin1624.model.magic + +import kotlinx.serialization.Serializable + +/** + * Predefined Echoes available to Technomancers upon submersion in Shadowrun 5e. + * Each submersion grade grants access to one Echo. + */ +enum class PredefinedEcho( + val echoName: String, + val description: String +) { + Overclocking( + echoName = "Overclocking", + description = "Increase one Living Persona attribute by 1" + ), + Skinlink( + echoName = "Skinlink", + description = "Connect to devices by physical touch without wireless" + ), + LivingNetwork( + echoName = "Living Network", + description = "Share Resonance with other Technomancers in a network" + ), + Neurofilter( + echoName = "Neurofilter", + description = "Gain additional resistance dice against Black IC and biofeedback damage" + ), + ResonanceLink( + echoName = "Resonance Link", + description = "Maintain a persistent connection with a registered sprite" + ), + MindOverMachine( + echoName = "Mind over Machine", + description = "Use mental attributes in place of physical for Matrix actions" + ), + PuppetMaster( + echoName = "Puppet Master", + description = "Control multiple sprites simultaneously without penalty" + ), + InfoSavant( + echoName = "Info Savant", + description = "Process data faster, gaining a bonus to Data Search tests" + ), + ResonanceRiding( + echoName = "Resonance Riding", + description = "Travel through the Matrix at enhanced speed" + ), + DigitalCamouflage( + echoName = "Digital Camouflage", + description = "Reduce your Matrix signature, making you harder to detect" + ) +} + +/** + * Represents an Echo gained through submersion by a Technomancer. + * + * Echoes are special abilities gained at each submersion grade, + * similar to metamagics for initiated mages. + * + * @param name The name of the Echo + * @param description Description of the Echo's effect + * @param isCustom Whether this is a custom (user-created) Echo + */ +@Serializable +data class Echo( + val name: String, + val description: String = "", + val isCustom: Boolean = false +) + +/** + * Creates an [Echo] from a [PredefinedEcho] template. + */ +fun createEchoFromPredefined(predefined: PredefinedEcho): Echo { + return Echo( + name = predefined.echoName, + description = predefined.description, + isCustom = false + ) +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/magic/Submersion.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/magic/Submersion.kt new file mode 100644 index 0000000..36dd9b8 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/magic/Submersion.kt @@ -0,0 +1,45 @@ +package org.shahondin1624.model.magic + +import kotlinx.serialization.Serializable + +/** + * Tracks a Technomancer's submersion progress in Shadowrun 5e. + * + * Submersion is the Technomancer equivalent of initiation for mages. + * Each submersion grade: + * - Costs karma (13 + 3 x new grade) + * - Increases maximum Resonance by 1 + * - Grants access to one Echo (special ability) + * + * @param grade The current submersion grade (0 = not submerged) + * @param echoes The list of Echoes gained through submersion + */ +@Serializable +data class Submersion( + val grade: Int = 0, + val echoes: List = emptyList() +) { + /** + * Calculate the karma cost for the next submersion grade. + * Formula: 13 + 3 x new grade + */ + fun nextSubmersionKarmaCost(): Int = submersionKarmaCost(grade + 1) + + /** + * The maximum Resonance value based on submersion grade. + * Base max is 6 (natural attribute maximum), plus 1 per submersion grade. + * + * @param baseMax The natural attribute maximum (default 6) + */ + fun maxResonance(baseMax: Int = 6): Int = baseMax + grade + + companion object { + /** + * Calculate the karma cost to achieve a specific submersion grade. + * Formula: 13 + 3 x new grade + * + * @param newGrade The grade being submersed to + */ + fun submersionKarmaCost(newGrade: Int): Int = 13 + 3 * newGrade + } +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt index d2ab7e2..089f125 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt @@ -21,7 +21,8 @@ object MigrationRegistry { MigrationV08ToV09(), MigrationV09ToV10(), MigrationV10ToV11(), - MigrationV11ToV12() + MigrationV11ToV12(), + MigrationV12ToV13() ) /** diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV12ToV13.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV12ToV13.kt new file mode 100644 index 0000000..3009e5e --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV12ToV13.kt @@ -0,0 +1,45 @@ +package org.shahondin1624.model.migration + +import kotlinx.serialization.json.* + +/** + * Migration from schema v0.12 to v0.13. + * + * Changes in v0.13: + * - Adds optional "submersion" field with default grade=0 and empty echoes list + * - Updates all version fields from "v0.12" to "v0.13" + */ +class MigrationV12ToV13 : VersionMigration { + override val fromVersion: String = SchemaVersion.V0_12 + override val toVersion: String = SchemaVersion.V0_13 + + override fun migrate(json: JsonObject): JsonObject { + val mutable = json.toMutableMap() + + // Add default submersion object + if (!mutable.containsKey("submersion")) { + mutable["submersion"] = buildJsonObject { + put("grade", 0) + put("echoes", JsonArray(emptyList())) + } + } + + // Update version fields + mutable["version"] = JsonPrimitive(toVersion) + mutable.updateNestedVersion("characterData") + mutable.updateNestedVersion("attributes") + mutable.updateNestedVersion("talents") + mutable.updateNestedVersion("damageMonitor") + + return JsonObject(mutable) + } + + private fun MutableMap.updateNestedVersion(key: String) { + val nested = this[key] + if (nested is JsonObject) { + val nestedMutable = nested.toMutableMap() + nestedMutable["version"] = JsonPrimitive(toVersion) + this[key] = JsonObject(nestedMutable) + } + } +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt index 0b6a89c..e1c4192 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt @@ -5,7 +5,7 @@ package org.shahondin1624.model.migration * as their default version value. */ object SchemaVersion { - const val CURRENT: String = "v0.12" + const val CURRENT: String = "v0.13" /** * Initial version used before the migration system was introduced. @@ -33,4 +33,6 @@ object SchemaVersion { const val V0_11: String = "v0.11" const val V0_12: String = "v0.12" + + const val V0_13: String = "v0.13" }