Refactor the roll mechanism to allow broader instructions set.

This commit is contained in:
Thomas Andres Gomez 2024-11-27 14:09:26 +01:00
parent d2ae180cf7
commit 8d93d46cce
10 changed files with 207 additions and 146 deletions

View file

@ -1,19 +1,7 @@
package com.pixelized.desktop.lwa.business
import com.pixelized.desktop.lwa.repository.characterSheet.model.CharacterSheet
object RollUseCase {
private val diceParser = Regex(
"""(?<sign>[+-])?\s*(?<modifier>[ade])?(?<quantity>\d+)[dD](?<face>\d+)"""
)
private val flatParser = Regex(
"""(?<sign>[+-])?\s*[^a-zA-Z](?<value>\d+)\b"""
)
private val paramParser = Regex(
"""(?<sign>[+-])?\s*(?<param>BDGT)\b"""
)
fun rollD100(): Int {
return roll(quantity = 1, faces = 100)
}
@ -22,39 +10,6 @@ object RollUseCase {
return sum(count = quantity) { (Math.random() * faces.toDouble() + 1).toInt() }
}
fun roll(characterSheet: CharacterSheet, roll: String): Int {
println(roll)
return diceParser.findAll(roll).sumOf { match ->
val (sign, modifier, quantity, faces) = match.destructured
((if (sign == "-") -1 else 1) * roll(
quantity = quantity.toInt(),
faces = faces.toInt()
)).also {
println("roll ${sign}${quantity}d${faces} -> $it")
}
} + flatParser.findAll(roll).sumOf { match ->
val (sign, value) = match.destructured
((if (sign == "-") -1 else 1) * value.toInt()).also {
println("flat: ${sign}${value} -> $it")
}
} + paramParser.findAll(roll).sumOf { match ->
val (sign, param) = match.destructured
(if (sign == "-") -1 else 1) * when (param) {
"BDGT" -> diceParser.findAll(characterSheet.damageBonus).sumOf {
val (sign, modifier, quantity, faces) = it.destructured
((if (sign == "-") -1 else 1) * roll(
quantity = quantity.toInt(),
faces = faces.toInt()
)).also {
println("param: ${sign}${param} -> $it")
}
}
else -> 0
}
}
}
private fun sum(count: Int, block: () -> Int): Int {
return if (count > 1) {
block() + sum(count - 1, block)

View file

@ -1,39 +1,110 @@
package com.pixelized.desktop.lwa.business
import com.pixelized.desktop.lwa.parser.arithmetic.ArithmeticParser
import com.pixelized.desktop.lwa.parser.arithmetic.Instruction
import com.pixelized.desktop.lwa.repository.characterSheet.model.CharacterSheet
import kotlin.math.max
class SkillValueComputationUseCase {
class SkillValueComputationUseCase(
private val arithmeticParser: ArithmeticParser,
) {
fun computeSkillValue(
sheet: CharacterSheet,
skill: CharacterSheet.Skill,
diminished: Int,
): Int {
val baseSum = skill.base.sumOf {
when (val instruction = it.instruction) {
val baseSum = arithmeticParser.parse(skill.base).sumOf { instruction ->
when (instruction) {
is Instruction.Dice -> 0
is Instruction.Flat -> instruction.value
Instruction.Word.BDC -> 0
Instruction.Word.BDD -> 0
Instruction.Word.STR -> sheet.strength
Instruction.Word.DEX -> sheet.dexterity
Instruction.Word.CON -> sheet.constitution
Instruction.Word.HEI -> sheet.height
Instruction.Word.INT -> sheet.intelligence
Instruction.Word.POW -> sheet.power
Instruction.Word.CHA -> sheet.charisma
} * it.sign
is Instruction.Flat -> {
instruction.value
}
is Instruction.Word -> {
when (instruction.type) {
Instruction.Word.Type.BDC -> 0
Instruction.Word.Type.BDD -> 0
Instruction.Word.Type.STR -> sheet.strength
Instruction.Word.Type.DEX -> sheet.dexterity
Instruction.Word.Type.CON -> sheet.constitution
Instruction.Word.Type.HEI -> sheet.height
Instruction.Word.Type.INT -> sheet.intelligence
Instruction.Word.Type.POW -> sheet.power
Instruction.Word.Type.CHA -> sheet.charisma
}
}
} * instruction.sign
}
val base = if (skill.occupation) {
max(MIN_OCCUPATION_VALUE, baseSum)
} else {
baseSum
}
return max(base + skill.bonus + skill.level - diminished, 0)
}
fun computeRoll(
sheet: CharacterSheet,
roll: String,
): Int { // TODO Roll detail instead of an simple Int.
print("Roll ->")
return arithmeticParser.parse(roll).sumOf { instruction ->
val value = when (instruction) {
is Instruction.Dice -> RollUseCase.roll(
quantity = instruction.quantity,
faces = instruction.faces
)
is Instruction.Flat -> instruction.value
is Instruction.Word -> {
when (instruction.type) {
Instruction.Word.Type.BDC -> {
val damageBonusInstructions = arithmeticParser
.parse(sheet.damageBonus)
.firstOrNull()
if (damageBonusInstructions is Instruction.Dice) {
RollUseCase.roll(
quantity = damageBonusInstructions.quantity,
faces = damageBonusInstructions.faces
)
} else {
0
}
}
Instruction.Word.Type.BDD -> {
val damageBonusInstructions = arithmeticParser
.parse(sheet.damageBonus)
.firstOrNull()
if (damageBonusInstructions is Instruction.Dice) {
RollUseCase.roll(
quantity = damageBonusInstructions.quantity,
faces = damageBonusInstructions.faces
) / 2
} else {
0
}
}
Instruction.Word.Type.STR -> sheet.strength
Instruction.Word.Type.DEX -> sheet.dexterity
Instruction.Word.Type.CON -> sheet.constitution
Instruction.Word.Type.HEI -> sheet.height
Instruction.Word.Type.INT -> sheet.intelligence
Instruction.Word.Type.POW -> sheet.power
Instruction.Word.Type.CHA -> sheet.charisma
}
}
} * instruction.sign
value.also { print(" ($instruction):$it") }
}.also { println() }
}
companion object {
private const val MIN_OCCUPATION_VALUE = 40
}

View file

@ -21,13 +21,13 @@ class ArithmeticParser {
)
@Throws(Instruction.UnknownInstruction::class)
fun parse(value: String): List<Arithmetic> {
fun parse(value: String): List<Instruction> {
return operatorParser.findAll(value).mapNotNull {
val (operator, instruction) = it.destructured
if (instruction.isNotBlank()) {
Arithmetic(
parseInstruction(
sign = parseOperator(operator),
instruction = parseInstruction(instruction),
instruction = instruction,
)
} else {
null
@ -36,10 +36,14 @@ class ArithmeticParser {
}
@Throws(Instruction.UnknownInstruction::class)
fun parseInstruction(instruction: String): Instruction {
fun parseInstruction(
sign: Int,
instruction: String
): Instruction {
diceParser.find(instruction)?.let {
val (modifier, quantity, faces) = it.destructured
Instruction.Dice(
sign = sign,
modifier = parseModifier(value = modifier),
quantity = quantity.toInt(),
faces = faces.toInt(),
@ -47,11 +51,17 @@ class ArithmeticParser {
}?.let { return it }
wordParser.find(instruction)?.let {
parseWord(value = it.value)
parseWord(
sign = sign,
value = it.value
)
}?.let { return it }
flatParser.find(instruction)?.let {
Instruction.Flat(value = it.value.toInt())
Instruction.Flat(
sign = sign,
value = it.value.toInt(),
)
}?.let { return it }
throw Instruction.UnknownInstruction(payload = instruction)
@ -74,31 +84,39 @@ class ArithmeticParser {
}
}
private fun parseWord(value: String): Word? {
private fun parseWord(
sign: Int,
value: String,
): Word? {
return try {
Word.valueOf(value)
Word(
sign = sign,
type = Word.Type.valueOf(value),
)
} catch (_: Exception) {
return null
}
}
fun convertInstructionToString(
instructions: List<Arithmetic>,
instructions: List<Instruction>,
): String {
return instructions.map {
val sign = if (it.sign > 0) "+" else "-"
val value = when (val instruction = it.instruction) {
is Instruction.Dice -> "${instruction.modifier ?: ""}${instruction.quantity}d${instruction.faces}"
is Instruction.Flat -> "${instruction.value}"
Word.BDC -> Word.BDC.name
Word.BDD -> Word.BDD.name
Word.STR -> Word.STR.name
Word.DEX -> Word.DEX.name
Word.CON -> Word.CON.name
Word.HEI -> Word.HEI.name
Word.INT -> Word.INT.name
Word.POW -> Word.POW.name
Word.CHA -> Word.CHA.name
val value = when (it) {
is Instruction.Dice -> "${it.modifier ?: ""}${it.quantity}d${it.faces}"
is Instruction.Flat -> "${it.value}"
is Word -> when (it.type) {
Word.Type.BDC -> Word.Type.BDC.name
Word.Type.BDD -> Word.Type.BDD.name
Word.Type.STR -> Word.Type.STR.name
Word.Type.DEX -> Word.Type.DEX.name
Word.Type.CON -> Word.Type.CON.name
Word.Type.HEI -> Word.Type.HEI.name
Word.Type.INT -> Word.Type.INT.name
Word.Type.POW -> Word.Type.POW.name
Word.Type.CHA -> Word.Type.CHA.name
}
}
"$sign$value"
}.joinToString(separator = " ") {
@ -107,6 +125,6 @@ class ArithmeticParser {
}
companion object {
val words: List<String> = Word.entries.map { it.name }
val words: List<String> = Word.Type.entries.map { it.name }
}
}

View file

@ -1,40 +1,64 @@
package com.pixelized.desktop.lwa.parser.arithmetic
class Arithmetic(
sealed class Instruction(
val sign: Int,
val instruction: Instruction,
)
sealed interface Instruction {
data class Dice(
) {
class Dice(
sign: Int,
val modifier: Modifier?,
val quantity: Int,
val faces: Int,
) : Instruction {
) : Instruction(
sign = sign,
) {
enum class Modifier {
ADVANTAGE,
DISADVANTAGE,
EMPHASIS,
}
override fun toString(): String {
return "${sign.sign}${quantity}d${faces}"
}
}
data class Flat(
class Flat(
sign: Int,
val value: Int,
) : Instruction
) : Instruction(
sign = sign,
) {
override fun toString(): String {
return "${sign.sign}${value}"
}
}
enum class Word : Instruction {
BDC, // Damages bonus for melee
BDD, // Damages bonus for range
STR, // Strength
DEX, // Dexterity
CON, // Constitution
HEI, // Height
INT, // Intelligence
POW, // Power
CHA, // Charisma
class Word(
sign: Int,
val type: Type,
) : Instruction(
sign = sign
) {
enum class Type {
BDC, // Damages bonus for melee
BDD, // Damages bonus for range
STR, // Strength
DEX, // Dexterity
CON, // Constitution
HEI, // Height
INT, // Intelligence
POW, // Power
CHA, // Charisma
}
override fun toString(): String {
return "${sign.sign}${type}"
}
}
class UnknownInstruction(payload: String) : RuntimeException(
"Unknown instruction exception. Unable to parse the following payload:\"$payload\" into an instruction"
)
val Int.sign: String get() = if (this > 0) "+" else "-"
}

View file

@ -36,7 +36,7 @@ class CharacterSheetJsonFactory(
CharacterSheetJsonV1.Skill(
id = it.id,
label = it.label,
base = arithmeticParser.convertInstructionToString(it.base),
base = it.base,
bonus = it.bonus,
level = it.level,
occupation = it.occupation,
@ -47,7 +47,7 @@ class CharacterSheetJsonFactory(
CharacterSheetJsonV1.Skill(
id = it.id,
label = it.label,
base = arithmeticParser.convertInstructionToString(it.base),
base = it.base,
bonus = it.bonus,
level = it.level,
occupation = it.occupation,
@ -58,7 +58,7 @@ class CharacterSheetJsonFactory(
CharacterSheetJsonV1.Skill(
id = it.id,
label = it.label,
base = arithmeticParser.convertInstructionToString(it.base),
base = it.base,
bonus = it.bonus,
level = it.level,
occupation = it.occupation,
@ -69,7 +69,7 @@ class CharacterSheetJsonFactory(
CharacterSheetJsonV1.Roll(
id = it.id,
label = it.label,
roll = arithmeticParser.convertInstructionToString(it.roll),
roll = it.roll,
)
},
)
@ -117,7 +117,7 @@ class CharacterSheetJsonFactory(
CharacterSheet.Skill(
id = it.id,
label = it.label,
base = arithmeticParser.parse(it.base),
base = it.base,
bonus = it.bonus,
level = it.level,
occupation = it.occupation,
@ -128,7 +128,7 @@ class CharacterSheetJsonFactory(
CharacterSheet.Skill(
id = it.id,
label = it.label,
base = arithmeticParser.parse(it.base),
base = it.base,
bonus = it.bonus,
level = it.level,
occupation = it.occupation,
@ -139,7 +139,7 @@ class CharacterSheetJsonFactory(
CharacterSheet.Skill(
id = it.id,
label = it.label,
base = arithmeticParser.parse(it.base),
base = it.base,
bonus = it.bonus,
level = it.level,
occupation = it.occupation,
@ -150,7 +150,7 @@ class CharacterSheetJsonFactory(
CharacterSheet.Roll(
id = it.id,
label = it.label,
roll = arithmeticParser.parse(it.roll),
roll = it.roll,
)
},
)

View file

@ -1,7 +1,5 @@
package com.pixelized.desktop.lwa.repository.characterSheet.model
import com.pixelized.desktop.lwa.parser.arithmetic.Arithmetic
data class CharacterSheet(
val id: String,
val name: String,
@ -36,7 +34,7 @@ data class CharacterSheet(
data class Skill(
val id: String,
val label: String,
val base: List<Arithmetic>,
val base: String,
val bonus: Int,
val level: Int,
val occupation: Boolean,
@ -46,7 +44,7 @@ data class CharacterSheet(
data class Roll(
val id: String,
val label: String,
val roll: List<Arithmetic>,
val roll: String,
)
object CommonSkillId {

View file

@ -2,6 +2,7 @@ package com.pixelized.desktop.lwa.screen.characterSheet.detail
import com.pixelized.desktop.lwa.business.SkillValueComputationUseCase
import com.pixelized.desktop.lwa.composable.tooltip.TooltipUio
import com.pixelized.desktop.lwa.parser.arithmetic.ArithmeticParser
import com.pixelized.desktop.lwa.repository.characterSheet.model.CharacterSheet
import com.pixelized.desktop.lwa.repository.characterSheet.model.CharacterSheet.CommonSkillId
import com.pixelized.desktop.lwa.screen.characterSheet.detail.CharacterSheetPageUio.Characteristic
@ -51,6 +52,7 @@ import org.jetbrains.compose.resources.getString
class CharacterSheetFactory(
private val skillUseCase: SkillValueComputationUseCase,
private val arithmeticParser: ArithmeticParser,
) {
companion object {
@ -251,7 +253,7 @@ class CharacterSheetFactory(
if (it.roll.isNotEmpty()) {
CharacterSheetPageUio.Roll(
label = it.label,
value = "TODO",//it.roll,
value = it.roll,
)
} else {
null

View file

@ -4,9 +4,7 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import com.pixelized.desktop.lwa.business.DamageBonusUseCase
import com.pixelized.desktop.lwa.business.SkillNormalizerUseCase.normalize
import com.pixelized.desktop.lwa.parser.arithmetic.Arithmetic
import com.pixelized.desktop.lwa.parser.arithmetic.ArithmeticParser
import com.pixelized.desktop.lwa.parser.arithmetic.Instruction
import com.pixelized.desktop.lwa.repository.characterSheet.model.CharacterSheet
import com.pixelized.desktop.lwa.screen.characterSheet.common.SkillFieldFactory
import com.pixelized.desktop.lwa.screen.characterSheet.common.occupation
@ -94,12 +92,7 @@ class CharacterSheetEditFactory(
CharacterSheet.Skill(
id = editedSkill.id,
label = editedSkill.label,
base = listOf(
Arithmetic(
sign = 1,
instruction = Instruction.Flat(editedSkill.base.value)
)
),
base = "${editedSkill.base}",
bonus = editedSkill.bonus.value.value.toIntOrNull() ?: 0,
level = editedSkill.level.value.value.toIntOrNull() ?: 0,
occupation = editedSkill.option.checked.value,
@ -113,7 +106,7 @@ class CharacterSheetEditFactory(
CharacterSheet.Skill(
id = editedSkill.id,
label = editedSkill.label.value.value,
base = parser.parse(editedSkill.base.value.value),
base = editedSkill.base.value.value,
bonus = editedSkill.bonus.value.value.toIntOrNull() ?: 0,
level = editedSkill.level.value.value.toIntOrNull() ?: 0,
occupation = editedSkill.options.occupation,
@ -127,7 +120,7 @@ class CharacterSheetEditFactory(
CharacterSheet.Skill(
id = editedSkill.id,
label = editedSkill.label.value.value,
base = parser.parse(editedSkill.base.value.value),
base = editedSkill.base.value.value,
bonus = editedSkill.bonus.value.value.toIntOrNull() ?: 0,
level = editedSkill.level.value.value.toIntOrNull() ?: 0,
occupation = editedSkill.options.occupation,
@ -138,7 +131,7 @@ class CharacterSheetEditFactory(
CharacterSheet.Roll(
id = "", // TODO
label = it.label.value,
roll = parser.parse(value = it.unpack()),
roll = it.unpack(),
)
},
)
@ -345,7 +338,7 @@ class CharacterSheetEditFactory(
id = skill.id,
label = specialSkillsLabel,
labelValue = skill.label,
baseValue = parser.convertInstructionToString(instructions = skill.base),
baseValue = skill.base,
bonusValue = skill.bonus.takeIf { it > 0 }?.toString() ?: "",
levelValue = skill.level.takeIf { it > 0 }?.toString() ?: "",
options = run {
@ -362,7 +355,7 @@ class CharacterSheetEditFactory(
id = skill.id,
label = magicSkillsLabel,
labelValue = skill.label,
baseValue = parser.convertInstructionToString(instructions = skill.base),
baseValue = skill.base,
bonusValue = skill.bonus.takeIf { it > 0 }?.toString() ?: "",
levelValue = skill.level.takeIf { it > 0 }?.toString() ?: "",
options = run {

View file

@ -3,14 +3,13 @@ package com.pixelized.desktop.lwa.screen.roll
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.pixelized.desktop.lwa.business.RollUseCase
import com.pixelized.desktop.lwa.business.SkillStepUseCase
import com.pixelized.desktop.lwa.repository.characterSheet.model.CharacterSheet
import com.pixelized.desktop.lwa.business.SkillValueComputationUseCase
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.characterSheet.model.CharacterSheet
import com.pixelized.desktop.lwa.repository.roll.RollHistoryRepository
import com.pixelized.desktop.lwa.screen.characterSheet.detail.CharacterSheetPageUio
import com.pixelized.desktop.lwa.screen.roll.DifficultyUio.Difficulty
@ -34,6 +33,7 @@ import org.jetbrains.compose.resources.getString
class RollViewModel(
private val characterSheetRepository: CharacterSheetRepository,
private val rollHistoryRepository: RollHistoryRepository,
private val skillComputation: SkillValueComputationUseCase,
) : ViewModel() {
private lateinit var sheet: CharacterSheet
@ -168,11 +168,12 @@ class RollViewModel(
}
)
}
val roll = if (rollAction == "1d100") {
RollUseCase.rollD100()
} else {
RollUseCase.roll(characterSheet = sheet, roll = rollAction)
}
val roll = skillComputation.computeRoll(
sheet = sheet,
roll = rollAction,
)
val success = rollStep?.let {
when (roll) {
in it.criticalSuccess -> getString(resource = Res.string.roll_page__critical_success)

View file

@ -1,12 +1,11 @@
package com.pixelized.desktop.lwa.parser
import com.pixelized.desktop.lwa.parser.arithmetic.Arithmetic
import com.pixelized.desktop.lwa.parser.arithmetic.ArithmeticParser
import com.pixelized.desktop.lwa.parser.arithmetic.Instruction
import com.pixelized.desktop.lwa.parser.arithmetic.ArithmeticParser
import kotlin.test.Test
import kotlin.test.assertFails
class ArithmeticParserTest {
class InstructionParserTest {
@Test
fun testDiceInstructionParse() {
@ -14,16 +13,16 @@ class ArithmeticParserTest {
fun test(
instruction: String,
expectedModifier: Instruction.Dice.Modifier?,
expectedModifier: Instruction.Dice.Modifier.Dice.Modifier?,
expectedQuantity: Int,
expectedFaces: Int,
) {
val dice = parser.parseInstruction(instruction = instruction)
assert(dice is Instruction.Dice) {
assert(dice is Instruction.Dice.Dice) {
"Instruction should be ArithmeticInstruction.Dice but was: ${dice::class.java.simpleName}"
}
(dice as? Instruction.Dice)?.let {
(dice as? Instruction.Dice.Dice)?.let {
assert(dice.modifier == expectedModifier) {
"$instruction modifier should be:\"$expectedModifier\", but was: ${dice.modifier}"
}
@ -69,10 +68,10 @@ class ArithmeticParserTest {
ArithmeticParser.words.map { instruction ->
val word = parser.parseInstruction(instruction = instruction)
assert(word is Instruction.Word) {
assert(word is Instruction.Word.Word) {
"Instruction should be ArithmeticInstruction.Word but was: ${word::class.java.simpleName}"
}
(word as? Instruction.Word)?.let {
(word as? Instruction.Word.Word)?.let {
assert(it.name == instruction) {
"Instruction should be $instruction, but was ${it.name}"
}
@ -87,10 +86,10 @@ class ArithmeticParserTest {
"100".let { instruction ->
val flat = parser.parseInstruction(instruction = instruction)
assert(flat is Instruction.Flat) {
assert(flat is Instruction.Flat.Flat) {
"Instruction should be ArithmeticInstruction.Flat but was: ${flat::class.java.simpleName}"
}
(flat as? Instruction.Flat)?.let {
(flat as? Instruction.Flat.Flat)?.let {
assert("${it.value}" == instruction) {
"Instruction should be $instruction, but was ${it.value}"
}
@ -113,7 +112,7 @@ class ArithmeticParserTest {
val parser = ArithmeticParser()
fun test(
arithmetics: Arithmetic,
arithmetics: Instruction,
expectedSign: Int,
expectedInstruction: Instruction,
) {