feat: add Technomancer Resonance attribute and Submersion tracking (Closes #149) (#153)

This commit was merged in pull request #153.
This commit is contained in:
2026-04-07 09:32:41 +02:00
parent 73c4ceac4b
commit 82ec838359
11 changed files with 753 additions and 2 deletions
+51
View File
@@ -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
@@ -629,4 +629,30 @@
<string name="sprite_type_data">Data</string> <string name="sprite_type_data">Data</string>
<string name="sprite_type_fault">Fault</string> <string name="sprite_type_fault">Fault</string>
<string name="sprite_type_machine">Machine</string> <string name="sprite_type_machine">Machine</string>
<!-- Submersion panel -->
<string name="submersion_title">Submersion</string>
<string name="submersion_grade_label">Submersion Grade: %1$d</string>
<string name="submersion_max_resonance">Max Resonance: %1$d (6 + %2$d)</string>
<string name="submersion_effective_resonance">Effective Resonance: %1$d</string>
<string name="submersion_resonance_display">Resonance: %1$d</string>
<string name="submersion_next_cost">Next submersion costs %1$d karma</string>
<string name="submersion_submerse_button">Submerse (Grade %1$d)</string>
<string name="submersion_reduce_button">Reduce Grade</string>
<string name="submersion_echoes_title">Echoes (%1$d)</string>
<string name="submersion_no_echoes">No Echoes gained yet</string>
<string name="submersion_add_echo">Add Echo</string>
<string name="submersion_add_echo_content_desc">Add a new Echo</string>
<string name="submersion_echo_edit_content_desc">Edit Echo</string>
<string name="submersion_echo_remove_content_desc">Remove Echo</string>
<string name="submersion_echo_add_title">Add Echo</string>
<string name="submersion_echo_edit_title">Edit Echo</string>
<string name="submersion_echo_custom_toggle">Custom Echo</string>
<string name="submersion_echo_predefined_label">Select Echo</string>
<string name="submersion_echo_name_placeholder">Echo name</string>
<string name="submersion_echo_description_label">Description</string>
<string name="submersion_echo_description_placeholder">Effect description</string>
<string name="submersion_not_technomancer">Submersion is only available for Technomancers</string>
<string name="submersion_essence_warning">Essence loss reduces effective Resonance by %1$d</string>
<string name="submersion_karma_formula">Karma cost formula: 13 + 3 x grade</string>
</resources> </resources>
@@ -546,4 +546,26 @@ object TestTags {
const val SPRITE_REGISTER_TASKS_INPUT = "sprite_register_tasks_input" const val SPRITE_REGISTER_TASKS_INPUT = "sprite_register_tasks_input"
const val SPRITE_REGISTER_CONFIRM = "sprite_register_confirm" const val SPRITE_REGISTER_CONFIRM = "sprite_register_confirm"
const val SPRITE_REGISTER_DISMISS = "sprite_register_dismiss" 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"
} }
@@ -650,6 +650,17 @@ private fun MagicContent(
.padding(spacing), .padding(spacing),
verticalArrangement = Arrangement.spacedBy(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( SpellPanel(
spells = character.spells, spells = character.spells,
onSpellsChanged = { newSpells -> onSpellsChanged = { newSpells ->
@@ -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<Int?>(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<PredefinedEcho?>(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))
}
}
)
}
@@ -15,6 +15,7 @@ import org.shahondin1624.model.magic.AdeptPower
import org.shahondin1624.model.magic.ComplexForm import org.shahondin1624.model.magic.ComplexForm
import org.shahondin1624.model.magic.Spell import org.shahondin1624.model.magic.Spell
import org.shahondin1624.model.magic.Sprite import org.shahondin1624.model.magic.Sprite
import org.shahondin1624.model.magic.Submersion
import org.shahondin1624.model.matrix.MatrixDevice import org.shahondin1624.model.matrix.MatrixDevice
import org.shahondin1624.model.matrix.MatrixDeviceType import org.shahondin1624.model.matrix.MatrixDeviceType
import org.shahondin1624.model.migration.SchemaVersion import org.shahondin1624.model.migration.SchemaVersion
@@ -42,6 +43,7 @@ data class ShadowrunCharacter(
val complexForms: List<ComplexForm> = emptyList(), val complexForms: List<ComplexForm> = emptyList(),
val sprites: List<Sprite> = emptyList(), val sprites: List<Sprite> = emptyList(),
val vehicles: List<Vehicle> = emptyList(), val vehicles: List<Vehicle> = emptyList(),
val submersion: Submersion = Submersion(),
override val version: String = SchemaVersion.CURRENT override val version: String = SchemaVersion.CURRENT
): Versionable { ): Versionable {
/** /**
@@ -165,6 +167,25 @@ data class ShadowrunCharacter(
*/ */
fun registeredSprites(): List<Sprite> = sprites.filter { it.isRegistered } fun registeredSprites(): List<Sprite> = 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. * Net karma cost of all qualities.
* Positive qualities cost karma (positive value), negative qualities grant karma (negative value). * Positive qualities cost karma (positive value), negative qualities grant karma (negative value).
@@ -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
)
}
@@ -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<Echo> = 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
}
}
@@ -21,7 +21,8 @@ object MigrationRegistry {
MigrationV08ToV09(), MigrationV08ToV09(),
MigrationV09ToV10(), MigrationV09ToV10(),
MigrationV10ToV11(), MigrationV10ToV11(),
MigrationV11ToV12() MigrationV11ToV12(),
MigrationV12ToV13()
) )
/** /**
@@ -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<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. * as their default version value.
*/ */
object SchemaVersion { object SchemaVersion {
const val CURRENT: String = "v0.12" const val CURRENT: String = "v0.13"
/** /**
* Initial version used before the migration system was introduced. * 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_11: String = "v0.11"
const val V0_12: String = "v0.12" const val V0_12: String = "v0.12"
const val V0_13: String = "v0.13"
} }