feat: add spells and adept powers tracking system (Closes #92) (#134)

This commit was merged in pull request #134.
This commit is contained in:
2026-04-05 02:38:36 +02:00
parent 4acb83b6ca
commit 736e033269
19 changed files with 1130 additions and 8 deletions
+72
View File
@@ -0,0 +1,72 @@
# Plan: Issue #92 - Spells and Adept Powers System
## Summary
Implement a complete spells and adept powers tracking system for Shadowrun 5e characters, including data models for spells (with type, range, duration, drain) and adept powers (with power point cost), UI panels for adding/removing/viewing both, drain value display per spell, power point budget for adepts based on Magic attribute, and full serialization support. This follows existing patterns from the Augmentation system (model + panel + edit dialog + string resources + test tags + migration).
## Acceptance Criteria Checklist
1. [ ] Data model for spells with type (Combat/Detection/Health/Illusion/Manipulation), range, duration, and drain
2. [ ] Data model for adept powers with power point cost
3. [ ] UI to add/remove/view spells and adept powers
4. [ ] Drain value displayed per spell
5. [ ] Power point budget shown for Adepts (based on Magic attribute)
6. [ ] Serialization support for spell/power data
## Implementation Steps
### Step 1: Add Magic attribute to Attributes
- Add `magic` field to `Attributes` data class (default 0, meaning mundane)
- Add `Magic` to `AttributeType` enum
- Update `getAttributeByType`, `getAllAttributes`, `withAttribute` to handle Magic
- Update `MetatypeAttributeLimits` if needed
### Step 2: Create Spell data model
- New file: `model/magic/Spell.kt`
- `SpellType` enum: Combat, Detection, Health, Illusion, Manipulation
- `SpellRange` enum: Touch, LineOfSight, Area (or similar SR5e ranges)
- `SpellDuration` enum: Instant, Sustained, Permanent
- `Spell` data class: name, type, range, duration, drain, description, sustained (boolean for tracking)
- All classes `@Serializable`
### Step 3: Create Adept Power data model
- New file: `model/magic/AdeptPower.kt`
- `AdeptPower` data class: name, powerPointCost (Float), description
- `@Serializable`
### Step 4: Add spells and adept powers to ShadowrunCharacter
- Add `spells: List<Spell> = emptyList()` field
- Add `adeptPowers: List<AdeptPower> = emptyList()` field
- Add helper functions: `totalPowerPointsUsed()`, `powerPointBudget()` (= magic attribute value)
- Add `activeSustainedSpells()` count for penalty tracking
### Step 5: Create SpellPanel UI
- New file: `lib/components/charactermodel/SpellPanel.kt`
- Follow AugmentationPanel pattern: Card with list, add/edit/remove buttons
- Show spell name, type, range, duration, drain value per spell
- SpellEditDialog for add/edit with dropdowns for type, range, duration, drain input
### Step 6: Create AdeptPowerPanel UI
- New file: `lib/components/charactermodel/AdeptPowerPanel.kt`
- Show power point budget: "X / Y PP" (used / available from Magic)
- List adept powers with name, cost, description
- AdeptPowerEditDialog for add/edit
### Step 7: Add new tab "Magic" to CharacterSheetPage
- Add `Magic` to `CharacterTab` enum
- Create `MagicContent` composable that shows SpellPanel and AdeptPowerPanel
- Wire up onUpdateCharacter callbacks
### Step 8: Add string resources
- Add all new UI strings to strings.xml following existing patterns
### Step 9: Add TestTags
- Add spell panel, adept power panel, and dialog test tags to TestTags.kt
### Step 10: Schema migration v0.2 -> v0.3
- Create `MigrationV02ToV03.kt` adding empty spells/adeptPowers lists and magic=0
- Update SchemaVersion.CURRENT to "v0.3"
- Register migration in MigrationRegistry
- Update MigrationV01ToV02 to add missing fields for forward compatibility
### Step 11: Update Defaults
- Update EXAMPLE_CHARACTER and createNewCharacter to include new fields (they get defaults from data class)
- Add magic attribute to EXAMPLE_ATTRIBUTES and DEFAULT_ATTRIBUTES
@@ -54,6 +54,7 @@
<string name="tab_overview">Overview</string> <string name="tab_overview">Overview</string>
<string name="tab_attributes">Attributes</string> <string name="tab_attributes">Attributes</string>
<string name="tab_talents">Talents</string> <string name="tab_talents">Talents</string>
<string name="tab_magic">Magic</string>
<string name="tab_combat">Combat</string> <string name="tab_combat">Combat</string>
<!-- Character header --> <!-- Character header -->
@@ -298,4 +299,37 @@
<string name="augmentation_essence_display">Essence: %1$s</string> <string name="augmentation_essence_display">Essence: %1$s</string>
<string name="augmentation_type_cyberware">Cyberware</string> <string name="augmentation_type_cyberware">Cyberware</string>
<string name="augmentation_type_bioware">Bioware</string> <string name="augmentation_type_bioware">Bioware</string>
<!-- Spells -->
<string name="spells_title">Spells</string>
<string name="spell_no_spells">No spells known</string>
<string name="spell_add_title">Add Spell</string>
<string name="spell_edit_title">Edit Spell</string>
<string name="spell_add_content_desc">Add spell</string>
<string name="spell_edit_content_desc">Edit spell</string>
<string name="spell_remove_content_desc">Remove spell</string>
<string name="spell_name_placeholder">e.g., Fireball</string>
<string name="spell_type_label">Type</string>
<string name="spell_range_label">Range</string>
<string name="spell_duration_label">Duration</string>
<string name="spell_drain_label">Drain Value</string>
<string name="spell_drain_value">Drain: %1$d</string>
<string name="spell_description_label">Description</string>
<string name="spell_description_placeholder">Optional notes</string>
<string name="spell_sustained_label">Sustained</string>
<string name="spell_sustained_penalty">%1$d sustained spell(s): %2$d dice pool penalty</string>
<!-- Adept Powers -->
<string name="adept_powers_title">Adept Powers</string>
<string name="adept_power_no_powers">No adept powers active</string>
<string name="adept_power_add_title">Add Adept Power</string>
<string name="adept_power_edit_title">Edit Adept Power</string>
<string name="adept_power_add_content_desc">Add adept power</string>
<string name="adept_power_edit_content_desc">Edit adept power</string>
<string name="adept_power_remove_content_desc">Remove adept power</string>
<string name="adept_power_name_placeholder">e.g., Improved Reflexes</string>
<string name="adept_power_cost_label">Power Point Cost</string>
<string name="adept_power_description_label">Description</string>
<string name="adept_power_description_placeholder">Optional notes</string>
<string name="adept_power_point_budget">Power Points</string>
</resources> </resources>
@@ -261,6 +261,43 @@ object TestTags {
fun augmentationEffectRow(index: Int): String = "augmentation_effect_row_$index" fun augmentationEffectRow(index: Int): String = "augmentation_effect_row_$index"
fun augmentationEffectRemoveButton(index: Int): String = "augmentation_effect_remove_button_$index" fun augmentationEffectRemoveButton(index: Int): String = "augmentation_effect_remove_button_$index"
// --- Spell panel ---
const val PANEL_SPELLS = "panel_spells"
const val SPELL_ADD_BUTTON = "spell_add_button"
const val SPELL_SUSTAINED_PENALTY = "spell_sustained_penalty"
fun spellEntry(index: Int): String = "spell_entry_$index"
fun spellEditButton(index: Int): String = "spell_edit_button_$index"
fun spellRemoveButton(index: Int): String = "spell_remove_button_$index"
fun spellDrainValue(index: Int): String = "spell_drain_value_$index"
fun spellSustainedToggle(index: Int): String = "spell_sustained_toggle_$index"
// --- Spell edit dialog ---
const val SPELL_EDIT_DIALOG = "spell_edit_dialog"
const val SPELL_EDIT_NAME = "spell_edit_name"
const val SPELL_EDIT_TYPE = "spell_edit_type"
const val SPELL_EDIT_RANGE = "spell_edit_range"
const val SPELL_EDIT_DURATION = "spell_edit_duration"
const val SPELL_EDIT_DRAIN = "spell_edit_drain"
const val SPELL_EDIT_DESCRIPTION = "spell_edit_description"
const val SPELL_EDIT_CONFIRM = "spell_edit_confirm"
const val SPELL_EDIT_DISMISS = "spell_edit_dismiss"
// --- Adept power panel ---
const val PANEL_ADEPT_POWERS = "panel_adept_powers"
const val ADEPT_POWER_ADD_BUTTON = "adept_power_add_button"
const val ADEPT_POWER_POINT_BUDGET = "adept_power_point_budget"
fun adeptPowerEntry(index: Int): String = "adept_power_entry_$index"
fun adeptPowerEditButton(index: Int): String = "adept_power_edit_button_$index"
fun adeptPowerRemoveButton(index: Int): String = "adept_power_remove_button_$index"
// --- Adept power edit dialog ---
const val ADEPT_POWER_EDIT_DIALOG = "adept_power_edit_dialog"
const val ADEPT_POWER_EDIT_NAME = "adept_power_edit_name"
const val ADEPT_POWER_EDIT_COST = "adept_power_edit_cost"
const val ADEPT_POWER_EDIT_DESCRIPTION = "adept_power_edit_description"
const val ADEPT_POWER_EDIT_CONFIRM = "adept_power_edit_confirm"
const val ADEPT_POWER_EDIT_DISMISS = "adept_power_edit_dismiss"
// --- Lifestyle edit dialog --- // --- Lifestyle edit dialog ---
const val LIFESTYLE_EDIT_DIALOG = "lifestyle_edit_dialog" const val LIFESTYLE_EDIT_DIALOG = "lifestyle_edit_dialog"
const val LIFESTYLE_EDIT_NAME = "lifestyle_edit_name" const val LIFESTYLE_EDIT_NAME = "lifestyle_edit_name"
@@ -0,0 +1,318 @@
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.magic.AdeptPower
import org.jetbrains.compose.resources.stringResource
import org.shahondin1624.theme.LocalWindowSizeClass
import shadowruncharsheet.sharedui.generated.resources.Res
import shadowruncharsheet.sharedui.generated.resources.*
/**
* Panel displaying the character's adept powers.
* Shows power point budget (used / available from Magic attribute)
* and a list of adept powers with their costs.
*/
@Composable
fun AdeptPowerPanel(
adeptPowers: List<AdeptPower>,
powerPointBudget: Float,
totalPowerPointsUsed: Float,
onAdeptPowersChanged: (List<AdeptPower>) -> Unit
) {
val windowSizeClass = LocalWindowSizeClass.current
val padding = UiConstants.Spacing.medium(windowSizeClass)
var editingIndex by remember { mutableStateOf<Int?>(null) }
var showAddDialog by remember { mutableStateOf(false) }
// Show edit dialog
editingIndex?.let { index ->
val power = adeptPowers[index]
AdeptPowerEditDialog(
adeptPower = power,
onConfirm = { updated ->
val newList = adeptPowers.toMutableList()
newList[index] = updated
onAdeptPowersChanged(newList)
editingIndex = null
},
onDismiss = { editingIndex = null }
)
}
// Show add dialog
if (showAddDialog) {
AdeptPowerEditDialog(
adeptPower = null,
onConfirm = { newPower ->
onAdeptPowersChanged(adeptPowers + newPower)
showAddDialog = false
},
onDismiss = { showAddDialog = false }
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.PANEL_ADEPT_POWERS)
) {
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 = stringResource(Res.string.adept_powers_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
IconButton(
onClick = { showAddDialog = true },
modifier = Modifier.testTag(TestTags.ADEPT_POWER_ADD_BUTTON)
) {
Icon(Icons.Default.Add, contentDescription = stringResource(Res.string.adept_power_add_content_desc))
}
}
// Power point budget display
Row(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.ADEPT_POWER_POINT_BUDGET),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(Res.string.adept_power_point_budget),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Text(
text = "%.1f / %.1f PP".format(totalPowerPointsUsed, powerPointBudget),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = if (totalPowerPointsUsed > powerPointBudget) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary
)
}
if (adeptPowers.isEmpty()) {
Text(
text = stringResource(Res.string.adept_power_no_powers),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
// Adept power entries
adeptPowers.forEachIndexed { index, power ->
AdeptPowerEntryRow(
power = power,
index = index,
onEdit = { editingIndex = index },
onRemove = {
val newList = adeptPowers.toMutableList()
newList.removeAt(index)
onAdeptPowersChanged(newList)
}
)
}
}
}
}
}
@Composable
private fun AdeptPowerEntryRow(
power: AdeptPower,
index: Int,
onEdit: () -> Unit,
onRemove: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.adeptPowerEntry(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 = power.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
if (power.description.isNotBlank()) {
Text(
text = power.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
text = "%.1f PP".format(power.powerPointCost),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp)
)
IconButton(
onClick = onEdit,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.adeptPowerEditButton(index))
) {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(Res.string.adept_power_edit_content_desc),
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = onRemove,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.adeptPowerRemoveButton(index))
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(Res.string.adept_power_remove_content_desc),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
/**
* Dialog for adding or editing an adept power entry.
*
* @param adeptPower The adept power to edit, or null when adding a new one.
*/
@Composable
fun AdeptPowerEditDialog(
adeptPower: AdeptPower?,
onConfirm: (AdeptPower) -> Unit,
onDismiss: () -> Unit
) {
var name by remember { mutableStateOf(adeptPower?.name ?: "") }
var costText by remember { mutableStateOf(adeptPower?.powerPointCost?.toString() ?: "0.5") }
var description by remember { mutableStateOf(adeptPower?.description ?: "") }
val cost = costText.toFloatOrNull()
val isValid = name.isNotBlank() && cost != null && cost >= 0f
val isAddMode = adeptPower == null
val title = if (isAddMode) stringResource(Res.string.adept_power_add_title) else stringResource(Res.string.adept_power_edit_title)
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TestTags.ADEPT_POWER_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(stringResource(Res.string.label_name)) },
placeholder = { Text(stringResource(Res.string.adept_power_name_placeholder)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.ADEPT_POWER_EDIT_NAME)
)
// Power point cost
OutlinedTextField(
value = costText,
onValueChange = { newValue ->
if (newValue.isEmpty() || newValue.matches(Regex("^\\d*\\.?\\d*$"))) {
costText = newValue
}
},
label = { Text(stringResource(Res.string.adept_power_cost_label)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
isError = cost == null && costText.isNotEmpty(),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.ADEPT_POWER_EDIT_COST)
)
// Description
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(stringResource(Res.string.adept_power_description_label)) },
placeholder = { Text(stringResource(Res.string.adept_power_description_placeholder)) },
maxLines = 3,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.ADEPT_POWER_EDIT_DESCRIPTION)
)
}
},
confirmButton = {
TextButton(
onClick = {
if (isValid) {
onConfirm(
AdeptPower(
name = name,
powerPointCost = cost!!,
description = description
)
)
}
},
enabled = isValid,
modifier = Modifier.testTag(TestTags.ADEPT_POWER_EDIT_CONFIRM)
) {
Text(stringResource(Res.string.save))
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
modifier = Modifier.testTag(TestTags.ADEPT_POWER_EDIT_DISMISS)
) {
Text(stringResource(Res.string.cancel))
}
}
)
}
@@ -38,6 +38,7 @@ private enum class CharacterTab(val titleRes: StringResource) {
Overview(Res.string.tab_overview), Overview(Res.string.tab_overview),
Attributes(Res.string.tab_attributes), Attributes(Res.string.tab_attributes),
Talents(Res.string.tab_talents), Talents(Res.string.tab_talents),
Magic(Res.string.tab_magic),
Combat(Res.string.tab_combat) Combat(Res.string.tab_combat)
} }
@@ -254,6 +255,7 @@ fun CharacterSheetPage(
CharacterTab.Overview -> ExpandedOverviewContent(character, spacing, onEditCharacterData, onUpdateCharacter) CharacterTab.Overview -> ExpandedOverviewContent(character, spacing, onEditCharacterData, onUpdateCharacter)
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent) CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
CharacterTab.Magic -> MagicContent(character, spacing, onUpdateCharacter)
CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter) CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter)
} }
} }
@@ -263,6 +265,7 @@ fun CharacterSheetPage(
CharacterTab.Overview -> OverviewContent(character, spacing, onEditCharacterData, onUpdateCharacter) CharacterTab.Overview -> OverviewContent(character, spacing, onEditCharacterData, onUpdateCharacter)
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent) CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
CharacterTab.Magic -> MagicContent(character, spacing, onUpdateCharacter)
CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter) CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter)
} }
} }
@@ -455,6 +458,36 @@ private fun TalentsContent(
} }
} }
@Composable
private fun MagicContent(
character: ShadowrunCharacter,
spacing: Dp,
onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(spacing),
verticalArrangement = Arrangement.spacedBy(spacing)
) {
SpellPanel(
spells = character.spells,
onSpellsChanged = { newSpells ->
onUpdateCharacter { char -> char.copy(spells = newSpells) }
}
)
AdeptPowerPanel(
adeptPowers = character.adeptPowers,
powerPointBudget = character.powerPointBudget(),
totalPowerPointsUsed = character.totalPowerPointsUsed(),
onAdeptPowersChanged = { newPowers ->
onUpdateCharacter { char -> char.copy(adeptPowers = newPowers) }
}
)
}
}
@Composable @Composable
private fun CombatContent( private fun CombatContent(
character: ShadowrunCharacter, character: ShadowrunCharacter,
@@ -0,0 +1,446 @@
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.magic.Spell
import org.shahondin1624.model.magic.SpellDuration
import org.shahondin1624.model.magic.SpellRange
import org.shahondin1624.model.magic.SpellType
import org.jetbrains.compose.resources.stringResource
import org.shahondin1624.theme.LocalWindowSizeClass
import shadowruncharsheet.sharedui.generated.resources.Res
import shadowruncharsheet.sharedui.generated.resources.*
/**
* Panel displaying the character's known spells.
* Shows spell details including type, range, duration, and drain value.
* Supports adding, editing, removing spells, and toggling sustained status.
*/
@Composable
fun SpellPanel(
spells: List<Spell>,
onSpellsChanged: (List<Spell>) -> Unit
) {
val windowSizeClass = LocalWindowSizeClass.current
val padding = UiConstants.Spacing.medium(windowSizeClass)
var editingIndex by remember { mutableStateOf<Int?>(null) }
var showAddDialog by remember { mutableStateOf(false) }
// Show edit dialog
editingIndex?.let { index ->
val spell = spells[index]
SpellEditDialog(
spell = spell,
onConfirm = { updated ->
val newList = spells.toMutableList()
newList[index] = updated
onSpellsChanged(newList)
editingIndex = null
},
onDismiss = { editingIndex = null }
)
}
// Show add dialog
if (showAddDialog) {
SpellEditDialog(
spell = null,
onConfirm = { newSpell ->
onSpellsChanged(spells + newSpell)
showAddDialog = false
},
onDismiss = { showAddDialog = false }
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.PANEL_SPELLS)
) {
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 = stringResource(Res.string.spells_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
IconButton(
onClick = { showAddDialog = true },
modifier = Modifier.testTag(TestTags.SPELL_ADD_BUTTON)
) {
Icon(Icons.Default.Add, contentDescription = stringResource(Res.string.spell_add_content_desc))
}
}
if (spells.isEmpty()) {
Text(
text = stringResource(Res.string.spell_no_spells),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
// Spell entries
spells.forEachIndexed { index, spell ->
SpellEntryRow(
spell = spell,
index = index,
onEdit = { editingIndex = index },
onRemove = {
val newList = spells.toMutableList()
newList.removeAt(index)
onSpellsChanged(newList)
},
onToggleSustained = {
val newList = spells.toMutableList()
newList[index] = spell.copy(sustained = !spell.sustained)
onSpellsChanged(newList)
}
)
}
// Sustained spell count
val sustainedCount = spells.count { it.sustained }
if (sustainedCount > 0) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = stringResource(Res.string.spell_sustained_penalty, sustainedCount, sustainedCount * -2),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag(TestTags.SPELL_SUSTAINED_PENALTY)
)
}
}
}
}
}
@Composable
private fun SpellEntryRow(
spell: Spell,
index: Int,
onEdit: () -> Unit,
onRemove: () -> Unit,
onToggleSustained: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.spellEntry(index)),
shape = MaterialTheme.shapes.small,
color = if (spell.sustained) MaterialTheme.colorScheme.primaryContainer
else 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 = spell.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Text(
text = "${spell.type.name} | ${spellRangeDisplayName(spell.range)} | ${spell.duration.name}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(Res.string.spell_drain_value, spell.drain),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag(TestTags.spellDrainValue(index))
)
}
// Sustained toggle for sustained-duration spells
if (spell.duration == SpellDuration.Sustained) {
FilterChip(
selected = spell.sustained,
onClick = onToggleSustained,
label = { Text(stringResource(Res.string.spell_sustained_label)) },
modifier = Modifier
.padding(horizontal = 4.dp)
.testTag(TestTags.spellSustainedToggle(index))
)
}
IconButton(
onClick = onEdit,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.spellEditButton(index))
) {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(Res.string.spell_edit_content_desc),
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = onRemove,
modifier = Modifier
.size(32.dp)
.testTag(TestTags.spellRemoveButton(index))
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(Res.string.spell_remove_content_desc),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
/**
* Converts SpellRange enum to a user-friendly display name.
*/
private fun spellRangeDisplayName(range: SpellRange): String {
return when (range) {
SpellRange.Touch -> "Touch"
SpellRange.LineOfSight -> "LOS"
SpellRange.LineOfSightArea -> "LOS (A)"
}
}
/**
* Dialog for adding or editing a spell entry.
*
* @param spell The spell to edit, or null when adding a new one.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SpellEditDialog(
spell: Spell?,
onConfirm: (Spell) -> Unit,
onDismiss: () -> Unit
) {
var name by remember { mutableStateOf(spell?.name ?: "") }
var type by remember { mutableStateOf(spell?.type ?: SpellType.Combat) }
var range by remember { mutableStateOf(spell?.range ?: SpellRange.LineOfSight) }
var duration by remember { mutableStateOf(spell?.duration ?: SpellDuration.Instant) }
var drainText by remember { mutableStateOf(spell?.drain?.toString() ?: "0") }
var description by remember { mutableStateOf(spell?.description ?: "") }
var typeExpanded by remember { mutableStateOf(false) }
var rangeExpanded by remember { mutableStateOf(false) }
var durationExpanded by remember { mutableStateOf(false) }
val drain = drainText.toIntOrNull()
val isValid = name.isNotBlank() && drain != null
val isAddMode = spell == null
val title = if (isAddMode) stringResource(Res.string.spell_add_title) else stringResource(Res.string.spell_edit_title)
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TestTags.SPELL_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(stringResource(Res.string.label_name)) },
placeholder = { Text(stringResource(Res.string.spell_name_placeholder)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.SPELL_EDIT_NAME)
)
// Type dropdown
ExposedDropdownMenuBox(
expanded = typeExpanded,
onExpandedChange = { typeExpanded = it },
modifier = Modifier.testTag(TestTags.SPELL_EDIT_TYPE)
) {
OutlinedTextField(
value = type.name,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(Res.string.spell_type_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = typeExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
expanded = typeExpanded,
onDismissRequest = { typeExpanded = false }
) {
SpellType.entries.forEach { st ->
DropdownMenuItem(
text = { Text(st.name) },
onClick = {
type = st
typeExpanded = false
}
)
}
}
}
// Range dropdown
ExposedDropdownMenuBox(
expanded = rangeExpanded,
onExpandedChange = { rangeExpanded = it },
modifier = Modifier.testTag(TestTags.SPELL_EDIT_RANGE)
) {
OutlinedTextField(
value = spellRangeDisplayName(range),
onValueChange = {},
readOnly = true,
label = { Text(stringResource(Res.string.spell_range_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = rangeExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
expanded = rangeExpanded,
onDismissRequest = { rangeExpanded = false }
) {
SpellRange.entries.forEach { sr ->
DropdownMenuItem(
text = { Text(spellRangeDisplayName(sr)) },
onClick = {
range = sr
rangeExpanded = false
}
)
}
}
}
// Duration dropdown
ExposedDropdownMenuBox(
expanded = durationExpanded,
onExpandedChange = { durationExpanded = it },
modifier = Modifier.testTag(TestTags.SPELL_EDIT_DURATION)
) {
OutlinedTextField(
value = duration.name,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(Res.string.spell_duration_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = durationExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
expanded = durationExpanded,
onDismissRequest = { durationExpanded = false }
) {
SpellDuration.entries.forEach { sd ->
DropdownMenuItem(
text = { Text(sd.name) },
onClick = {
duration = sd
durationExpanded = false
}
)
}
}
}
// Drain value
OutlinedTextField(
value = drainText,
onValueChange = { newValue ->
if (newValue.isEmpty() || newValue.matches(Regex("^-?\\d*$"))) {
drainText = newValue
}
},
label = { Text(stringResource(Res.string.spell_drain_label)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = drain == null && drainText.isNotEmpty(),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.SPELL_EDIT_DRAIN)
)
// Description
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(stringResource(Res.string.spell_description_label)) },
placeholder = { Text(stringResource(Res.string.spell_description_placeholder)) },
maxLines = 3,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.SPELL_EDIT_DESCRIPTION)
)
}
},
confirmButton = {
TextButton(
onClick = {
if (isValid) {
onConfirm(
Spell(
name = name,
type = type,
range = range,
duration = duration,
drain = drain!!,
description = description,
sustained = spell?.sustained ?: false
)
)
}
},
enabled = isValid,
modifier = Modifier.testTag(TestTags.SPELL_EDIT_CONFIRM)
) {
Text(stringResource(Res.string.save))
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
modifier = Modifier.testTag(TestTags.SPELL_EDIT_DISMISS)
) {
Text(stringResource(Res.string.cancel))
}
}
)
}
@@ -19,7 +19,8 @@ val EXAMPLE_ATTRIBUTES = Attributes(
logic = Attribute(AttributeType.Logic, 4), logic = Attribute(AttributeType.Logic, 4),
intuition = Attribute(AttributeType.Intuition, 3), intuition = Attribute(AttributeType.Intuition, 3),
charisma = Attribute(AttributeType.Charisma, 2), charisma = Attribute(AttributeType.Charisma, 2),
edge = 2 edge = 2,
magic = Attribute(AttributeType.Magic, 0)
) )
private val DEFAULT_ATTRIBUTES = Attributes( private val DEFAULT_ATTRIBUTES = Attributes(
@@ -31,7 +32,8 @@ private val DEFAULT_ATTRIBUTES = Attributes(
logic = Attribute(AttributeType.Logic, 1), logic = Attribute(AttributeType.Logic, 1),
intuition = Attribute(AttributeType.Intuition, 1), intuition = Attribute(AttributeType.Intuition, 1),
charisma = Attribute(AttributeType.Charisma, 1), charisma = Attribute(AttributeType.Charisma, 1),
edge = 1 edge = 1,
magic = Attribute(AttributeType.Magic, 0)
) )
/** /**
@@ -13,6 +13,7 @@ enum class AttributeType(@Transient val color: Color) {
Willpower(Color.Blue), Willpower(Color.Blue),
Logic(Color(147, 104, 31)), Logic(Color(147, 104, 31)),
Intuition(Color.Gray), Intuition(Color.Gray),
Charisma(Color(225, 172, 46)) Charisma(Color(225, 172, 46)),
Magic(Color(148, 103, 189))
; ;
} }
@@ -18,6 +18,7 @@ data class Attributes(
private val intuition: Attribute, private val intuition: Attribute,
private val charisma: Attribute, private val charisma: Attribute,
val edge: Int, val edge: Int,
private val magic: Attribute = Attribute(AttributeType.Magic, 0),
override val version: String = SchemaVersion.CURRENT override val version: String = SchemaVersion.CURRENT
): Versionable { ): Versionable {
@Transient @Transient
@@ -59,6 +60,10 @@ data class Attributes(
return applyModifiers(modifiers).charisma.value return applyModifiers(modifiers).charisma.value
} }
fun magic(modifiers: List<AttributeModifier> = emptyList()): Int {
return applyModifiers(modifiers).magic.value
}
fun initiative(modifiers: List<AttributeModifier> = emptyList()): Int { fun initiative(modifiers: List<AttributeModifier> = emptyList()): Int {
return applyModifiers(modifiers).reaction.value + applyModifiers(modifiers).intuition.value return applyModifiers(modifiers).reaction.value + applyModifiers(modifiers).intuition.value
} }
@@ -117,11 +122,12 @@ data class Attributes(
AttributeType.Logic -> logic AttributeType.Logic -> logic
AttributeType.Intuition -> intuition AttributeType.Intuition -> intuition
AttributeType.Charisma -> charisma AttributeType.Charisma -> charisma
AttributeType.Magic -> magic
} }
} }
fun getAllAttributes(): List<Attribute> { fun getAllAttributes(): List<Attribute> {
return listOf(body, agility, reaction, strength, willpower, logic, intuition, charisma) return listOf(body, agility, reaction, strength, willpower, logic, intuition, charisma, magic)
} }
/** /**
@@ -138,6 +144,7 @@ data class Attributes(
AttributeType.Logic -> copy(logic = attr) AttributeType.Logic -> copy(logic = attr)
AttributeType.Intuition -> copy(intuition = attr) AttributeType.Intuition -> copy(intuition = attr)
AttributeType.Charisma -> copy(charisma = attr) AttributeType.Charisma -> copy(charisma = attr)
AttributeType.Magic -> copy(magic = attr)
} }
} }
} }
@@ -61,6 +61,7 @@ object MetatypeAttributeLimits {
AttributeType.Logic -> l.logic AttributeType.Logic -> l.logic
AttributeType.Intuition -> l.intuition AttributeType.Intuition -> l.intuition
AttributeType.Charisma -> l.charisma AttributeType.Charisma -> l.charisma
AttributeType.Magic -> 6 // Magic max is 6 for all metatypes in SR5e
} }
} }
@@ -6,6 +6,8 @@ import org.shahondin1624.model.attributes.Attributes
import org.shahondin1624.model.characterdata.Augmentation import org.shahondin1624.model.characterdata.Augmentation
import org.shahondin1624.model.characterdata.CharacterData import org.shahondin1624.model.characterdata.CharacterData
import org.shahondin1624.model.characterdata.Lifestyle import org.shahondin1624.model.characterdata.Lifestyle
import org.shahondin1624.model.magic.AdeptPower
import org.shahondin1624.model.magic.Spell
import org.shahondin1624.model.migration.SchemaVersion import org.shahondin1624.model.migration.SchemaVersion
import org.shahondin1624.model.talents.Talents import org.shahondin1624.model.talents.Talents
@@ -18,6 +20,8 @@ data class ShadowrunCharacter(
val notes: String = "", val notes: String = "",
val lifestyles: List<Lifestyle> = emptyList(), val lifestyles: List<Lifestyle> = emptyList(),
val augmentations: List<Augmentation> = emptyList(), val augmentations: List<Augmentation> = emptyList(),
val spells: List<Spell> = emptyList(),
val adeptPowers: List<AdeptPower> = emptyList(),
override val version: String = SchemaVersion.CURRENT override val version: String = SchemaVersion.CURRENT
): Versionable { ): Versionable {
/** /**
@@ -30,4 +34,21 @@ data class ShadowrunCharacter(
* Cannot go below 0. * Cannot go below 0.
*/ */
fun currentEssence(): Float = (characterData.essence - totalEssenceLoss()).coerceAtLeast(0f) fun currentEssence(): Float = (characterData.essence - totalEssenceLoss()).coerceAtLeast(0f)
/**
* Power point budget for adepts, derived from the Magic attribute.
* A mundane character (Magic 0) has 0 power points.
*/
fun powerPointBudget(): Float = attributes.magic().toFloat()
/**
* Total power points currently spent on adept powers.
*/
fun totalPowerPointsUsed(): Float = adeptPowers.sumOf { it.powerPointCost.toDouble() }.toFloat()
/**
* Number of spells currently being sustained.
* Each sustained spell applies a -2 dice pool penalty.
*/
fun activeSustainedSpells(): Int = spells.count { it.sustained }
} }
@@ -24,6 +24,7 @@ fun getAttributeLimit(metatype: Metatype, attributeType: AttributeType): Attribu
AttributeType.Logic -> AttributeLimit(1, 6) AttributeType.Logic -> AttributeLimit(1, 6)
AttributeType.Intuition -> AttributeLimit(1, 6) AttributeType.Intuition -> AttributeLimit(1, 6)
AttributeType.Charisma -> AttributeLimit(1, 6) AttributeType.Charisma -> AttributeLimit(1, 6)
AttributeType.Magic -> AttributeLimit(0, 6)
} }
Metatype.Elf -> when (attributeType) { Metatype.Elf -> when (attributeType) {
AttributeType.Agility -> AttributeLimit(2, 7) AttributeType.Agility -> AttributeLimit(2, 7)
@@ -0,0 +1,20 @@
package org.shahondin1624.model.magic
import kotlinx.serialization.Serializable
/**
* Represents an adept power in Shadowrun 5e.
*
* Adepts channel magic internally and use power points (derived from their
* Magic attribute) to activate physical powers.
*
* @param name The name of the adept power (e.g., "Improved Reflexes", "Killing Hands")
* @param powerPointCost The power point cost of this power
* @param description Optional description or notes about the power
*/
@Serializable
data class AdeptPower(
val name: String,
val powerPointCost: Float,
val description: String = ""
)
@@ -0,0 +1,57 @@
package org.shahondin1624.model.magic
import kotlinx.serialization.Serializable
/**
* Category of spell in Shadowrun 5e.
*/
@Serializable
enum class SpellType {
Combat,
Detection,
Health,
Illusion,
Manipulation
}
/**
* Range category for spells.
*/
@Serializable
enum class SpellRange {
Touch,
LineOfSight,
LineOfSightArea
}
/**
* Duration category for spells.
*/
@Serializable
enum class SpellDuration {
Instant,
Sustained,
Permanent
}
/**
* Represents a single spell known by a magician character in Shadowrun 5e.
*
* @param name The name of the spell (e.g., "Fireball", "Heal", "Invisibility")
* @param type The spell category (Combat, Detection, Health, Illusion, Manipulation)
* @param range The range of the spell
* @param duration How long the spell lasts
* @param drain The drain value (DV) of the spell - damage to the caster after casting
* @param description Optional description or notes about the spell
* @param sustained Whether this spell is currently being sustained (applies -2 penalty per sustained spell)
*/
@Serializable
data class Spell(
val name: String,
val type: SpellType,
val range: SpellRange = SpellRange.LineOfSight,
val duration: SpellDuration = SpellDuration.Instant,
val drain: Int = 0,
val description: String = "",
val sustained: Boolean = false
)
@@ -11,7 +11,8 @@ import kotlinx.serialization.json.jsonPrimitive
object MigrationRegistry { object MigrationRegistry {
private val migrations: List<VersionMigration> = listOf( private val migrations: List<VersionMigration> = listOf(
MigrationV01ToV02() MigrationV01ToV02(),
MigrationV02ToV03()
) )
/** /**
@@ -0,0 +1,65 @@
package org.shahondin1624.model.migration
import kotlinx.serialization.json.*
/**
* Migration from schema v0.2 to v0.3.
*
* Changes in v0.3:
* - Adds "spells" field to ShadowrunCharacter (default: [])
* - Adds "adeptPowers" field to ShadowrunCharacter (default: [])
* - Adds "magic" attribute to Attributes (default: {"type":"Magic","value":0})
* - Updates all version fields from "v0.2" to "v0.3"
*/
class MigrationV02ToV03 : VersionMigration {
override val fromVersion: String = SchemaVersion.V0_2
override val toVersion: String = SchemaVersion.V0_3
override fun migrate(json: JsonObject): JsonObject {
val mutable = json.toMutableMap()
// Add spells list if missing
if ("spells" !in mutable) {
mutable["spells"] = JsonArray(emptyList())
}
// Add adeptPowers list if missing
if ("adeptPowers" !in mutable) {
mutable["adeptPowers"] = JsonArray(emptyList())
}
// Add magic attribute to attributes object if missing
val attributes = mutable["attributes"]
if (attributes is JsonObject) {
val attrMutable = attributes.toMutableMap()
if ("magic" !in attrMutable) {
attrMutable["magic"] = buildJsonObject {
put("type", "Magic")
put("value", 0)
}
}
attrMutable["version"] = JsonPrimitive(toVersion)
mutable["attributes"] = JsonObject(attrMutable)
}
// Update version fields at all levels
mutable["version"] = JsonPrimitive(toVersion)
mutable.updateNestedVersion("characterData")
mutable.updateNestedVersion("talents")
mutable.updateNestedVersion("damageMonitor")
return JsonObject(mutable)
}
/**
* Update the "version" field inside a nested JSON object.
*/
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.2" const val CURRENT: String = "v0.3"
/** /**
* Initial version used before the migration system was introduced. * Initial version used before the migration system was introduced.
@@ -13,4 +13,6 @@ object SchemaVersion {
const val V0_1: String = "v0.1" const val V0_1: String = "v0.1"
const val V0_2: String = "v0.2" const val V0_2: String = "v0.2"
const val V0_3: String = "v0.3"
} }
@@ -1,5 +1,6 @@
package org.shahondin1624 package org.shahondin1624
import org.shahondin1624.model.attributes.AttributeType
import org.shahondin1624.model.characterdata.Metatype import org.shahondin1624.model.characterdata.Metatype
import org.shahondin1624.model.createNewCharacter import org.shahondin1624.model.createNewCharacter
import kotlin.test.Test import kotlin.test.Test
@@ -18,7 +19,8 @@ class NewCharacterTest {
val char = createNewCharacter() val char = createNewCharacter()
val attrs = char.attributes.getAllAttributes() val attrs = char.attributes.getAllAttributes()
for (attr in attrs) { for (attr in attrs) {
assertEquals(1, attr.value, "Attribute ${attr.type.name} should be 1") val expectedValue = if (attr.type == AttributeType.Magic) 0 else 1
assertEquals(expectedValue, attr.value, "Attribute ${attr.type.name} should be $expectedValue")
} }
} }
@@ -132,9 +132,11 @@ class VersionMigrationTest {
@Test @Test
fun findMigrationChainReturnsPathFromV01() { fun findMigrationChainReturnsPathFromV01() {
val chain = MigrationRegistry.findMigrationChain("v0.1") val chain = MigrationRegistry.findMigrationChain("v0.1")
assertEquals(1, chain.size) assertEquals(2, chain.size)
assertEquals("v0.1", chain[0].fromVersion) assertEquals("v0.1", chain[0].fromVersion)
assertEquals("v0.2", chain[0].toVersion) assertEquals("v0.2", chain[0].toVersion)
assertEquals("v0.2", chain[1].fromVersion)
assertEquals("v0.3", chain[1].toVersion)
} }
@Test @Test