feat: add skill specializations and knowledge/language skill categories (Closes #94) (#136)

This commit was merged in pull request #136.
This commit is contained in:
2026-04-05 03:25:05 +02:00
parent 73c5a1190f
commit 5628ce3917
17 changed files with 764 additions and 29 deletions
@@ -137,6 +137,38 @@
<string name="talent_min_rating_error">Minimum rating is 0</string>
<string name="talent_max_rating_error">Maximum rating is %1$d</string>
<!-- Skill categories -->
<string name="skill_category_active">Active Skills</string>
<string name="skill_category_knowledge">Knowledge Skills</string>
<string name="skill_category_language">Language Skills</string>
<string name="skill_specialization_label">Specialization (optional)</string>
<string name="skill_specialization_placeholder">e.g., Pistols</string>
<string name="skill_roll_with_spec">Roll with specialization (+2)</string>
<string name="skill_roll_without_spec">Roll without specialization</string>
<string name="skill_untrained_default">Untrained (default)</string>
<string name="skill_add_knowledge">Add Knowledge Skill</string>
<string name="skill_add_language">Add Language Skill</string>
<string name="skill_knowledge_category_label">Category</string>
<string name="skill_language_proficiency_label">Proficiency</string>
<string name="skill_add_knowledge_title">Add Knowledge Skill</string>
<string name="skill_add_language_title">Add Language Skill</string>
<string name="skill_name_label">Skill Name</string>
<string name="skill_name_placeholder">e.g., Corporate Politics</string>
<string name="skill_language_name_placeholder">e.g., Japanese</string>
<string name="skill_remove_content_desc">Remove skill</string>
<!-- Knowledge categories -->
<string name="knowledge_academic">Academic</string>
<string name="knowledge_interest">Interest</string>
<string name="knowledge_professional">Professional</string>
<string name="knowledge_street">Street</string>
<!-- Language proficiencies -->
<string name="language_basic">Basic</string>
<string name="language_specialist">Specialist</string>
<string name="language_expert">Expert</string>
<string name="language_native">Native</string>
<!-- Dice roll content descriptions -->
<string name="roll_dice_content_desc">Roll dice</string>
@@ -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"
@@ -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))
}
}
}
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
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)
}
)
}
}
}
}
@@ -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)
) {
@@ -13,7 +13,8 @@ object MigrationRegistry {
private val migrations: List<VersionMigration> = listOf(
MigrationV01ToV02(),
MigrationV02ToV03(),
MigrationV03ToV04()
MigrationV03ToV04(),
MigrationV04ToV05()
)
/**
@@ -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<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.
*/
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"
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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<SRModifier<*>>, attributes: Attributes): DiceRoll {
override fun test(
modifiers: List<SRModifier<*>>,
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,7 +58,8 @@ 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 {
@@ -35,3 +67,29 @@ fun createAllProvidedTalents() = ProvidedTalentName.entries.map {
}
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
)
@@ -12,5 +12,25 @@ sealed interface TalentDefinition {
val attribute: AttributeType
val value: Int
val custom: Boolean
fun test(modifiers: List<SRModifier<*>>, attributes: Attributes): DiceRoll
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<SRModifier<*>>,
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
}
@@ -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<TalentDefinition> = talents.filter { it.category == SkillCategory.ACTIVE }
/** All knowledge skills. */
fun knowledgeSkills(): List<TalentDefinition> = talents.filter { it.category == SkillCategory.KNOWLEDGE }
/** All language skills. */
fun languageSkills(): List<TalentDefinition> = talents.filter { it.category == SkillCategory.LANGUAGE }
}
@@ -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