This commit was merged in pull request #134.
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
+318
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
+33
@@ -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,
|
||||||
|
|||||||
+446
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
@@ -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
|
||||||
|
)
|
||||||
+2
-1
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user