diff --git a/sharedUI/src/commonMain/composeResources/values/strings.xml b/sharedUI/src/commonMain/composeResources/values/strings.xml index 983e751..83bdff0 100644 --- a/sharedUI/src/commonMain/composeResources/values/strings.xml +++ b/sharedUI/src/commonMain/composeResources/values/strings.xml @@ -137,6 +137,38 @@ Minimum rating is 0 Maximum rating is %1$d + + Active Skills + Knowledge Skills + Language Skills + Specialization (optional) + e.g., Pistols + Roll with specialization (+2) + Roll without specialization + Untrained (default) + Add Knowledge Skill + Add Language Skill + Category + Proficiency + Add Knowledge Skill + Add Language Skill + Skill Name + e.g., Corporate Politics + e.g., Japanese + Remove skill + + + Academic + Interest + Professional + Street + + + Basic + Specialist + Expert + Native + Roll dice diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt index 6f7337c..4d0e554 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt @@ -64,6 +64,24 @@ object TestTags { const val TALENT_EDIT_CONFIRM = "talent_edit_confirm" const val TALENT_EDIT_DISMISS = "talent_edit_dismiss" const val TALENT_EDIT_ERROR = "talent_edit_error" + const val TALENT_EDIT_SPECIALIZATION = "talent_edit_specialization" + + // --- Knowledge/Language skill dialogs --- + const val KNOWLEDGE_SKILL_ADD_DIALOG = "knowledge_skill_add_dialog" + const val KNOWLEDGE_SKILL_NAME_INPUT = "knowledge_skill_name_input" + const val KNOWLEDGE_SKILL_CATEGORY = "knowledge_skill_category" + const val KNOWLEDGE_SKILL_CONFIRM = "knowledge_skill_confirm" + const val KNOWLEDGE_SKILL_DISMISS = "knowledge_skill_dismiss" + const val KNOWLEDGE_SKILL_ADD_BUTTON = "knowledge_skill_add_button" + + const val LANGUAGE_SKILL_ADD_DIALOG = "language_skill_add_dialog" + const val LANGUAGE_SKILL_NAME_INPUT = "language_skill_name_input" + const val LANGUAGE_SKILL_PROFICIENCY = "language_skill_proficiency" + const val LANGUAGE_SKILL_CONFIRM = "language_skill_confirm" + const val LANGUAGE_SKILL_DISMISS = "language_skill_dismiss" + const val LANGUAGE_SKILL_ADD_BUTTON = "language_skill_add_button" + + fun skillRemoveButton(name: String): String = "skill_remove_${name.lowercase().replace(" ", "_")}" // --- Dice roll result dialog --- const val DICE_ROLL_DIALOG = "dice_roll_dialog" diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt index 5c28bb2..930b428 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt @@ -7,14 +7,20 @@ 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.unit.Dp import androidx.compose.ui.unit.dp +import org.shahondin1624.lib.components.TestTags import org.shahondin1624.lib.components.UiConstants import org.shahondin1624.lib.components.charactermodel.attributespage.Attribute import org.shahondin1624.lib.components.charactermodel.attributespage.AttributeEditDialog import org.shahondin1624.lib.components.charactermodel.attributespage.Talent import org.shahondin1624.lib.components.charactermodel.attributespage.TalentEditDialog import org.shahondin1624.model.talents.TalentDefinition +import org.shahondin1624.model.talents.createKnowledgeSkill +import org.shahondin1624.model.talents.createLanguageSkill +import org.shahondin1624.lib.components.charactermodel.attributespage.KnowledgeSkillDialog +import org.shahondin1624.lib.components.charactermodel.attributespage.LanguageSkillDialog import org.shahondin1624.lib.functions.DiceRoll import org.shahondin1624.lib.functions.performExtendedTest import org.shahondin1624.lib.functions.performOpposedTest @@ -200,9 +206,11 @@ fun CharacterSheetPage( editingTalent?.let { talent -> TalentEditDialog( talent = talent, - onConfirm = { newValue -> + onConfirm = { newValue, specialization -> onUpdateCharacter { char -> - val newTalents = char.talents.withTalentRating(talent.name, newValue) + val newTalents = char.talents.withTalentRatingAndSpecialization( + talent.name, newValue, specialization + ) char.copy(talents = newTalents) } editingTalent = null @@ -257,7 +265,7 @@ fun CharacterSheetPage( when (selectedTab) { CharacterTab.Overview -> ExpandedOverviewContent(character, spacing, onEditCharacterData, onUpdateCharacter) CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) - CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent) + CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent, onUpdateCharacter) CharacterTab.Magic -> MagicContent(character, spacing, onUpdateCharacter) CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter) CharacterTab.Gear -> GearContent(character, spacing, onUpdateCharacter) @@ -268,7 +276,7 @@ fun CharacterSheetPage( when (selectedTab) { CharacterTab.Overview -> OverviewContent(character, spacing, onEditCharacterData, onUpdateCharacter) CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) - CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent) + CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent, onUpdateCharacter) CharacterTab.Magic -> MagicContent(character, spacing, onUpdateCharacter) CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter) CharacterTab.Gear -> GearContent(character, spacing, onUpdateCharacter) @@ -424,13 +432,43 @@ private fun TalentsContent( character: ShadowrunCharacter, spacing: Dp, onDiceRoll: (DiceRoll, String) -> Unit, - onEditTalent: (TalentDefinition) -> Unit + onEditTalent: (TalentDefinition) -> Unit, + onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit = {} ) { val windowSizeClass = LocalWindowSizeClass.current val totalCols = UiConstants.Grid.totalColumns(windowSizeClass) val talentSpan = UiConstants.Grid.talentSpan(windowSizeClass) val talentsPerRow = totalCols / talentSpan + var showAddKnowledgeDialog by remember { mutableStateOf(false) } + var showAddLanguageDialog by remember { mutableStateOf(false) } + + if (showAddKnowledgeDialog) { + KnowledgeSkillDialog( + onConfirm = { name, category -> + onUpdateCharacter { char -> + val newTalent = createKnowledgeSkill(name, category) + char.copy(talents = char.talents.withAddedTalent(newTalent)) + } + showAddKnowledgeDialog = false + }, + onDismiss = { showAddKnowledgeDialog = false } + ) + } + + if (showAddLanguageDialog) { + LanguageSkillDialog( + onConfirm = { name, proficiency -> + onUpdateCharacter { char -> + val newTalent = createLanguageSkill(name, proficiency) + char.copy(talents = char.talents.withAddedTalent(newTalent)) + } + showAddLanguageDialog = false + }, + onDismiss = { showAddLanguageDialog = false } + ) + } + Column( modifier = Modifier .fillMaxSize() @@ -438,9 +476,15 @@ private fun TalentsContent( .padding(spacing), verticalArrangement = Arrangement.spacedBy(spacing) ) { - val talents = character.talents.talents - val rows = talents.chunked(talentsPerRow) - for (row in rows) { + // Active Skills section + Text( + text = stringResource(Res.string.skill_category_active), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 4.dp) + ) + val activeSkills = character.talents.activeSkills() + val activeRows = activeSkills.chunked(talentsPerRow) + for (row in activeRows) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(spacing) @@ -460,6 +504,106 @@ private fun TalentsContent( } } } + + HorizontalDivider(modifier = Modifier.padding(vertical = spacing)) + + // Knowledge Skills section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.skill_category_knowledge), + style = MaterialTheme.typography.titleMedium + ) + TextButton( + onClick = { showAddKnowledgeDialog = true }, + modifier = Modifier.testTag(TestTags.KNOWLEDGE_SKILL_ADD_BUTTON) + ) { + Text(stringResource(Res.string.skill_add_knowledge)) + } + } + val knowledgeSkills = character.talents.knowledgeSkills() + if (knowledgeSkills.isEmpty()) { + Text( + text = "No knowledge skills", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + val knowledgeRows = knowledgeSkills.chunked(talentsPerRow) + for (row in knowledgeRows) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing) + ) { + for (talent in row) { + Box(modifier = Modifier.weight(1f)) { + Talent( + talent = talent, + attributes = character.attributes, + onRoll = { roll -> onDiceRoll(roll, talent.name) }, + onEdit = { onEditTalent(talent) } + ) + } + } + repeat(talentsPerRow - row.size) { + Spacer(Modifier.weight(1f)) + } + } + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = spacing)) + + // Language Skills section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.skill_category_language), + style = MaterialTheme.typography.titleMedium + ) + TextButton( + onClick = { showAddLanguageDialog = true }, + modifier = Modifier.testTag(TestTags.LANGUAGE_SKILL_ADD_BUTTON) + ) { + Text(stringResource(Res.string.skill_add_language)) + } + } + val languageSkills = character.talents.languageSkills() + if (languageSkills.isEmpty()) { + Text( + text = "No language skills", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + val languageRows = languageSkills.chunked(talentsPerRow) + for (row in languageRows) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing) + ) { + for (talent in row) { + Box(modifier = Modifier.weight(1f)) { + Talent( + talent = talent, + attributes = character.attributes, + onRoll = { roll -> onDiceRoll(roll, talent.name) }, + onEdit = { onEditTalent(talent) } + ) + } + } + repeat(talentsPerRow - row.size) { + Spacer(Modifier.weight(1f)) + } + } + } + } } } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/KnowledgeSkillDialog.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/KnowledgeSkillDialog.kt new file mode 100644 index 0000000..5e5fa42 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/KnowledgeSkillDialog.kt @@ -0,0 +1,109 @@ +package org.shahondin1624.lib.components.charactermodel.attributespage + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.shahondin1624.lib.components.TestTags +import org.shahondin1624.model.talents.KnowledgeCategory +import shadowruncharsheet.sharedui.generated.resources.Res +import shadowruncharsheet.sharedui.generated.resources.* + +/** + * Dialog for adding a new knowledge skill. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KnowledgeSkillDialog( + onConfirm: (name: String, category: KnowledgeCategory) -> Unit, + onDismiss: () -> Unit +) { + var name by remember { mutableStateOf("") } + var selectedCategory by remember { mutableStateOf(KnowledgeCategory.ACADEMIC) } + var categoryExpanded by remember { mutableStateOf(false) } + + val isValid = name.isNotBlank() + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TestTags.KNOWLEDGE_SKILL_ADD_DIALOG), + title = { + Text(text = stringResource(Res.string.skill_add_knowledge_title)) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(Res.string.skill_name_label)) }, + placeholder = { Text(stringResource(Res.string.skill_name_placeholder)) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.KNOWLEDGE_SKILL_NAME_INPUT) + ) + + ExposedDropdownMenuBox( + expanded = categoryExpanded, + onExpandedChange = { categoryExpanded = it }, + modifier = Modifier.testTag(TestTags.KNOWLEDGE_SKILL_CATEGORY) + ) { + OutlinedTextField( + value = knowledgeCategoryLabel(selectedCategory), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(Res.string.skill_knowledge_category_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = categoryExpanded, + onDismissRequest = { categoryExpanded = false } + ) { + KnowledgeCategory.entries.forEach { cat -> + DropdownMenuItem( + text = { Text(knowledgeCategoryLabel(cat)) }, + onClick = { + selectedCategory = cat + categoryExpanded = false + } + ) + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { if (isValid) onConfirm(name.trim(), selectedCategory) }, + enabled = isValid, + modifier = Modifier.testTag(TestTags.KNOWLEDGE_SKILL_CONFIRM) + ) { + Text(stringResource(Res.string.create)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + modifier = Modifier.testTag(TestTags.KNOWLEDGE_SKILL_DISMISS) + ) { + Text(stringResource(Res.string.cancel)) + } + } + ) +} + +@Composable +fun knowledgeCategoryLabel(category: KnowledgeCategory): String { + return when (category) { + KnowledgeCategory.ACADEMIC -> stringResource(Res.string.knowledge_academic) + KnowledgeCategory.INTEREST -> stringResource(Res.string.knowledge_interest) + KnowledgeCategory.PROFESSIONAL -> stringResource(Res.string.knowledge_professional) + KnowledgeCategory.STREET -> stringResource(Res.string.knowledge_street) + } +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/LanguageSkillDialog.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/LanguageSkillDialog.kt new file mode 100644 index 0000000..3486e7b --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/LanguageSkillDialog.kt @@ -0,0 +1,109 @@ +package org.shahondin1624.lib.components.charactermodel.attributespage + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.shahondin1624.lib.components.TestTags +import org.shahondin1624.model.talents.LanguageProficiency +import shadowruncharsheet.sharedui.generated.resources.Res +import shadowruncharsheet.sharedui.generated.resources.* + +/** + * Dialog for adding a new language skill. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LanguageSkillDialog( + onConfirm: (name: String, proficiency: LanguageProficiency) -> Unit, + onDismiss: () -> Unit +) { + var name by remember { mutableStateOf("") } + var selectedProficiency by remember { mutableStateOf(LanguageProficiency.BASIC) } + var proficiencyExpanded by remember { mutableStateOf(false) } + + val isValid = name.isNotBlank() + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TestTags.LANGUAGE_SKILL_ADD_DIALOG), + title = { + Text(text = stringResource(Res.string.skill_add_language_title)) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(Res.string.skill_name_label)) }, + placeholder = { Text(stringResource(Res.string.skill_language_name_placeholder)) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.LANGUAGE_SKILL_NAME_INPUT) + ) + + ExposedDropdownMenuBox( + expanded = proficiencyExpanded, + onExpandedChange = { proficiencyExpanded = it }, + modifier = Modifier.testTag(TestTags.LANGUAGE_SKILL_PROFICIENCY) + ) { + OutlinedTextField( + value = languageProficiencyLabel(selectedProficiency), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(Res.string.skill_language_proficiency_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = proficiencyExpanded) }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = proficiencyExpanded, + onDismissRequest = { proficiencyExpanded = false } + ) { + LanguageProficiency.entries.forEach { prof -> + DropdownMenuItem( + text = { Text(languageProficiencyLabel(prof)) }, + onClick = { + selectedProficiency = prof + proficiencyExpanded = false + } + ) + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { if (isValid) onConfirm(name.trim(), selectedProficiency) }, + enabled = isValid, + modifier = Modifier.testTag(TestTags.LANGUAGE_SKILL_CONFIRM) + ) { + Text(stringResource(Res.string.create)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + modifier = Modifier.testTag(TestTags.LANGUAGE_SKILL_DISMISS) + ) { + Text(stringResource(Res.string.cancel)) + } + } + ) +} + +@Composable +fun languageProficiencyLabel(proficiency: LanguageProficiency): String { + return when (proficiency) { + LanguageProficiency.BASIC -> stringResource(Res.string.language_basic) + LanguageProficiency.SPECIALIST -> stringResource(Res.string.language_specialist) + LanguageProficiency.EXPERT -> stringResource(Res.string.language_expert) + LanguageProficiency.NATIVE -> stringResource(Res.string.language_native) + } +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Talent.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Talent.kt index 7255a00..712b98a 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Talent.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Talent.kt @@ -11,10 +11,16 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -30,6 +36,7 @@ import org.shahondin1624.lib.components.TestTags import org.shahondin1624.lib.components.UiConstants.SMALL_PADDING import org.shahondin1624.lib.functions.DiceRoll import org.shahondin1624.model.attributes.Attributes +import org.shahondin1624.model.talents.Talent as TalentModel import org.shahondin1624.model.talents.TalentDefinition import org.shahondin1624.theme.contrastTextColor import shadowruncharsheet.sharedui.generated.resources.Res @@ -76,6 +83,9 @@ private fun TalentCardContent( textColor: Color, onRoll: (DiceRoll) -> Unit ) { + var showSpecMenu by remember { mutableStateOf(false) } + val hasSpec = talent.specialization != null + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, @@ -94,7 +104,7 @@ private fun TalentCardContent( verticalAlignment = Alignment.CenterVertically ) { Text( - text = talent.name, + text = if (talent is TalentModel) talent.displayName() else talent.name, modifier = Modifier .weight(1f) .padding(start = SMALL_PADDING), @@ -114,7 +124,11 @@ private fun TalentCardContent( overflow = TextOverflow.Ellipsis ) Text( - text = talent.value.toString(), + text = if (talent.value == 0) { + stringResource(Res.string.skill_untrained_default) + } else { + talent.value.toString() + }, modifier = Modifier .widthIn(min = 24.dp) .padding(end = SMALL_PADDING), @@ -126,8 +140,16 @@ private fun TalentCardContent( } IconButton( onClick = { - val result = talent.test(modifiers = emptyList(), attributes = attributes) - onRoll(result) + if (hasSpec) { + showSpecMenu = true + } else { + val result = talent.test( + modifiers = emptyList(), + attributes = attributes, + withSpecialization = false + ) + onRoll(result) + } }, modifier = Modifier .padding(end = SMALL_PADDING) @@ -141,6 +163,35 @@ private fun TalentCardContent( ), modifier = Modifier.size(24.dp) ) + DropdownMenu( + expanded = showSpecMenu, + onDismissRequest = { showSpecMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.skill_roll_without_spec)) }, + onClick = { + showSpecMenu = false + val result = talent.test( + modifiers = emptyList(), + attributes = attributes, + withSpecialization = false + ) + onRoll(result) + } + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.skill_roll_with_spec)) }, + onClick = { + showSpecMenu = false + val result = talent.test( + modifiers = emptyList(), + attributes = attributes, + withSpecialization = true + ) + onRoll(result) + } + ) + } } } } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/TalentEditDialog.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/TalentEditDialog.kt index ee89629..c969d35 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/TalentEditDialog.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/TalentEditDialog.kt @@ -15,17 +15,18 @@ import shadowruncharsheet.sharedui.generated.resources.Res import shadowruncharsheet.sharedui.generated.resources.* /** - * Dialog for editing a talent's rating. + * Dialog for editing a talent's rating and optional specialization. * Rating range is 0-12; 0 means untrained. */ @Composable fun TalentEditDialog( talent: TalentDefinition, maxValue: Int = 12, - onConfirm: (Int) -> Unit, + onConfirm: (Int, String?) -> Unit, onDismiss: () -> Unit ) { var textValue by remember { mutableStateOf(talent.value.toString()) } + var specValue by remember { mutableStateOf(talent.specialization ?: "") } val parsedValue = textValue.toIntOrNull() val isValid = parsedValue != null && parsedValue in 0..maxValue @@ -68,11 +69,27 @@ fun TalentEditDialog( modifier = Modifier.testTag(TestTags.TALENT_EDIT_ERROR) ) } + + OutlinedTextField( + value = specValue, + onValueChange = { specValue = it }, + label = { Text(stringResource(Res.string.skill_specialization_label)) }, + placeholder = { Text(stringResource(Res.string.skill_specialization_placeholder)) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.TALENT_EDIT_SPECIALIZATION) + ) } }, confirmButton = { TextButton( - onClick = { if (isValid) onConfirm(parsedValue!!) }, + onClick = { + if (isValid) { + val spec = specValue.takeIf { it.isNotBlank() } + onConfirm(parsedValue!!, spec) + } + }, enabled = isValid, modifier = Modifier.testTag(TestTags.TALENT_EDIT_CONFIRM) ) { diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt index 3c13590..d124317 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationRegistry.kt @@ -13,7 +13,8 @@ object MigrationRegistry { private val migrations: List = listOf( MigrationV01ToV02(), MigrationV02ToV03(), - MigrationV03ToV04() + MigrationV03ToV04(), + MigrationV04ToV05() ) /** diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV04ToV05.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV04ToV05.kt new file mode 100644 index 0000000..ceab68c --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/MigrationV04ToV05.kt @@ -0,0 +1,77 @@ +package org.shahondin1624.model.migration + +import kotlinx.serialization.json.* + +/** + * Migration from schema v0.4 to v0.5. + * + * Changes in v0.5: + * - Adds "specialization" field (null) to all talent entries + * - Adds "category" field ("ACTIVE") to all talent entries + * - Adds "knowledgeCategory" field (null) to all talent entries + * - Adds "languageProficiency" field (null) to all talent entries + * - Updates all version fields from "v0.4" to "v0.5" + */ +class MigrationV04ToV05 : VersionMigration { + override val fromVersion: String = SchemaVersion.V0_4 + override val toVersion: String = SchemaVersion.V0_5 + + override fun migrate(json: JsonObject): JsonObject { + val mutable = json.toMutableMap() + + // Migrate talent entries to add new fields + val talents = mutable["talents"] + if (talents is JsonObject) { + val talentsMutable = talents.toMutableMap() + val talentList = talentsMutable["talents"] + if (talentList is JsonArray) { + val migratedTalents = talentList.map { talentElement -> + if (talentElement is JsonObject) { + migrateTalent(talentElement) + } else { + talentElement + } + } + talentsMutable["talents"] = JsonArray(migratedTalents) + } + talentsMutable["version"] = JsonPrimitive(toVersion) + mutable["talents"] = JsonObject(talentsMutable) + } + + // Update version fields at all levels + mutable["version"] = JsonPrimitive(toVersion) + mutable.updateNestedVersion("characterData") + mutable.updateNestedVersion("attributes") + mutable.updateNestedVersion("damageMonitor") + + return JsonObject(mutable) + } + + private fun migrateTalent(talent: JsonObject): JsonObject { + val mutable = talent.toMutableMap() + + if ("specialization" !in mutable) { + mutable["specialization"] = JsonNull + } + if ("category" !in mutable) { + mutable["category"] = JsonPrimitive("ACTIVE") + } + if ("knowledgeCategory" !in mutable) { + mutable["knowledgeCategory"] = JsonNull + } + if ("languageProficiency" !in mutable) { + mutable["languageProficiency"] = JsonNull + } + + return JsonObject(mutable) + } + + private fun MutableMap.updateNestedVersion(key: String) { + val nested = this[key] + if (nested is JsonObject) { + val nestedMutable = nested.toMutableMap() + nestedMutable["version"] = JsonPrimitive(toVersion) + this[key] = JsonObject(nestedMutable) + } + } +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt index 843ec4e..a092148 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/migration/SchemaVersion.kt @@ -5,7 +5,7 @@ package org.shahondin1624.model.migration * as their default version value. */ object SchemaVersion { - const val CURRENT: String = "v0.4" + const val CURRENT: String = "v0.5" /** * Initial version used before the migration system was introduced. @@ -17,4 +17,6 @@ object SchemaVersion { const val V0_3: String = "v0.3" const val V0_4: String = "v0.4" + + const val V0_5: String = "v0.5" } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/KnowledgeCategory.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/KnowledgeCategory.kt new file mode 100644 index 0000000..0a0df00 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/KnowledgeCategory.kt @@ -0,0 +1,14 @@ +package org.shahondin1624.model.talents + +import kotlinx.serialization.Serializable + +/** + * Sub-categories for knowledge skills in Shadowrun 5e. + */ +@Serializable +enum class KnowledgeCategory { + ACADEMIC, + INTEREST, + PROFESSIONAL, + STREET +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/LanguageProficiency.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/LanguageProficiency.kt new file mode 100644 index 0000000..3706db5 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/LanguageProficiency.kt @@ -0,0 +1,14 @@ +package org.shahondin1624.model.talents + +import kotlinx.serialization.Serializable + +/** + * Proficiency levels for language skills in Shadowrun 5e. + */ +@Serializable +enum class LanguageProficiency { + BASIC, + SPECIALIST, + EXPERT, + NATIVE +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/SkillCategory.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/SkillCategory.kt new file mode 100644 index 0000000..a8e6a29 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/SkillCategory.kt @@ -0,0 +1,16 @@ +package org.shahondin1624.model.talents + +import kotlinx.serialization.Serializable + +/** + * Categorizes skills according to Shadowrun 5e rules. + * - ACTIVE: Combat, social, technical, vehicle, and magical skills + * - KNOWLEDGE: Academic, interest, professional, and street knowledge + * - LANGUAGE: Language skills with proficiency levels + */ +@Serializable +enum class SkillCategory { + ACTIVE, + KNOWLEDGE, + LANGUAGE +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talent.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talent.kt index 71ae872..b487353 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talent.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talent.kt @@ -15,11 +15,42 @@ data class Talent( override val attribute: AttributeType, override val value: Int, override val custom: Boolean, + override val specialization: String? = null, + override val category: SkillCategory = SkillCategory.ACTIVE, + override val knowledgeCategory: KnowledgeCategory? = null, + override val languageProficiency: LanguageProficiency? = null, ): TalentDefinition { - override fun test(modifiers: List>, attributes: Attributes): DiceRoll { + override fun test( + modifiers: List>, + attributes: Attributes, + withSpecialization: Boolean + ): DiceRoll { val attributeValue = attributes.getAttributeByType(attribute).value - val combined = value + attributeValue - return modifiers.accumulateModifiers(DiceRoll(numberOfDice = combined)).roll() + val specBonus = if (withSpecialization && specialization != null) 2 else 0 + val pool = if (value == 0) { + defaultPool(attributes) + } else { + value + attributeValue + specBonus + } + return modifiers.accumulateModifiers(DiceRoll(numberOfDice = pool.coerceAtLeast(0))).roll() + } + + override fun defaultPool(attributes: Attributes): Int { + return when (category) { + SkillCategory.ACTIVE -> { + (attributes.getAttributeByType(attribute).value - 1).coerceAtLeast(0) + } + SkillCategory.KNOWLEDGE, SkillCategory.LANGUAGE -> { + (attributes.getAttributeByType(AttributeType.Intuition).value - 1).coerceAtLeast(0) + } + } + } + + /** + * Display name including specialization, e.g., "Firearms (Pistols)" + */ + fun displayName(): String { + return if (specialization != null) "$name ($specialization)" else name } } @@ -27,11 +58,38 @@ private fun createProvidedTalent(provided: ProvidedTalentName, value: Int = 0) = name = provided.name.replace("_", " "), attribute = provided.attribute, value = value, - custom = false + custom = false, + category = SkillCategory.ACTIVE ) fun createAllProvidedTalents() = ProvidedTalentName.entries.map { createProvidedTalent(it) } -fun createCustomTalent(name: String, attribute: AttributeType, value: Int = 0) = Talent(name, attribute, value, true) \ No newline at end of file +fun createCustomTalent(name: String, attribute: AttributeType, value: Int = 0) = Talent(name, attribute, value, true) + +fun createKnowledgeSkill( + name: String, + knowledgeCategory: KnowledgeCategory, + value: Int = 0 +) = Talent( + name = name, + attribute = AttributeType.Intuition, + value = value, + custom = true, + category = SkillCategory.KNOWLEDGE, + knowledgeCategory = knowledgeCategory +) + +fun createLanguageSkill( + name: String, + proficiency: LanguageProficiency, + value: Int = 0 +) = Talent( + name = name, + attribute = AttributeType.Intuition, + value = value, + custom = true, + category = SkillCategory.LANGUAGE, + languageProficiency = proficiency +) diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/TalentDefinition.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/TalentDefinition.kt index 8772d2c..780c443 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/TalentDefinition.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/TalentDefinition.kt @@ -12,5 +12,25 @@ sealed interface TalentDefinition { val attribute: AttributeType val value: Int val custom: Boolean - fun test(modifiers: List>, attributes: Attributes): DiceRoll -} \ No newline at end of file + val specialization: String? + val category: SkillCategory + val knowledgeCategory: KnowledgeCategory? + val languageProficiency: LanguageProficiency? + + /** + * Roll a skill test. When [withSpecialization] is true and a specialization + * is set, adds +2 dice to the pool per Shadowrun 5e rules. + */ + fun test( + modifiers: List>, + attributes: Attributes, + withSpecialization: Boolean = false + ): DiceRoll + + /** + * Calculate the default dice pool for an untrained test (skill rating 0). + * Active skills: linked attribute - 1 (minimum 0). + * Knowledge/Language skills: Intuition - 1 (minimum 0). + */ + fun defaultPool(attributes: Attributes): Int +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talents.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talents.kt index 1c2597a..10d71d8 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talents.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/talents/Talents.kt @@ -16,11 +16,39 @@ data class Talents( return copy( talents = talents.map { talent -> if (talent.name == talentName) { - Talent( - name = talent.name, - attribute = talent.attribute, + (talent as Talent).copy(value = newValue) + } else { + talent + } + } + ) + } + + /** + * Returns a copy with the specified talent's specialization updated. + */ + fun withTalentSpecialization(talentName: String, specialization: String?): Talents { + return copy( + talents = talents.map { talent -> + if (talent.name == talentName) { + (talent as Talent).copy(specialization = specialization?.takeIf { it.isNotBlank() }) + } else { + talent + } + } + ) + } + + /** + * Returns a copy with the specified talent's rating and specialization updated together. + */ + fun withTalentRatingAndSpecialization(talentName: String, newValue: Int, specialization: String?): Talents { + return copy( + talents = talents.map { talent -> + if (talent.name == talentName) { + (talent as Talent).copy( value = newValue, - custom = talent.custom + specialization = specialization?.takeIf { it.isNotBlank() } ) } else { talent @@ -28,4 +56,27 @@ data class Talents( } ) } + + /** + * Adds a new talent (knowledge or language skill) to the list. + */ + fun withAddedTalent(talent: TalentDefinition): Talents { + return copy(talents = talents + talent) + } + + /** + * Removes a talent by name. Only custom talents can be removed. + */ + fun withRemovedTalent(talentName: String): Talents { + return copy(talents = talents.filter { it.name != talentName || !it.custom }) + } + + /** All active skills (the default Shadowrun skill list). */ + fun activeSkills(): List = talents.filter { it.category == SkillCategory.ACTIVE } + + /** All knowledge skills. */ + fun knowledgeSkills(): List = talents.filter { it.category == SkillCategory.KNOWLEDGE } + + /** All language skills. */ + fun languageSkills(): List = talents.filter { it.category == SkillCategory.LANGUAGE } } diff --git a/sharedUI/src/commonTest/kotlin/org/shahondin1624/VersionMigrationTest.kt b/sharedUI/src/commonTest/kotlin/org/shahondin1624/VersionMigrationTest.kt index 7e56cb6..7255cee 100644 --- a/sharedUI/src/commonTest/kotlin/org/shahondin1624/VersionMigrationTest.kt +++ b/sharedUI/src/commonTest/kotlin/org/shahondin1624/VersionMigrationTest.kt @@ -132,13 +132,15 @@ class VersionMigrationTest { @Test fun findMigrationChainReturnsPathFromV01() { val chain = MigrationRegistry.findMigrationChain("v0.1") - assertEquals(3, chain.size) + assertEquals(4, chain.size) assertEquals("v0.1", chain[0].fromVersion) assertEquals("v0.2", chain[0].toVersion) assertEquals("v0.2", chain[1].fromVersion) assertEquals("v0.3", chain[1].toVersion) assertEquals("v0.3", chain[2].fromVersion) assertEquals("v0.4", chain[2].toVersion) + assertEquals("v0.4", chain[3].fromVersion) + assertEquals("v0.5", chain[3].toVersion) } @Test