diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/Module.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/Module.kt index 7fca837..21d005d 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/Module.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/Module.kt @@ -4,8 +4,7 @@ import com.pixelized.desktop.lwa.business.CharacterSheetUseCase import com.pixelized.desktop.lwa.business.RollUseCase import com.pixelized.desktop.lwa.business.SettingsUseCase import com.pixelized.desktop.lwa.business.SkillStepUseCase -import com.pixelized.desktop.lwa.business.SkillValueComputationUseCase -import com.pixelized.desktop.lwa.parser.arithmetic.ArithmeticParser +import com.pixelized.desktop.lwa.business.ExpressionUseCase import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetJsonFactory import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetStore @@ -25,6 +24,9 @@ import com.pixelized.desktop.lwa.screen.network.NetworkFactory import com.pixelized.desktop.lwa.screen.network.NetworkViewModel import com.pixelized.desktop.lwa.screen.roll.RollViewModel import com.pixelized.desktop.lwa.screen.rollhistory.RollHistoryViewModel +import com.pixelized.desktop.lwa.parser.dice.DiceParser +import com.pixelized.desktop.lwa.parser.word.WordParser +import com.pixelized.desktop.lwa.parser.expression.ExpressionParser import kotlinx.serialization.json.Json import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf @@ -84,14 +86,16 @@ val viewModelDependencies val parserDependencies get() = module { - factoryOf(::ArithmeticParser) + factoryOf(::WordParser) + factoryOf(::DiceParser) + factoryOf(::ExpressionParser) } val useCaseDependencies get() = module { factoryOf(::SkillStepUseCase) factoryOf(::RollUseCase) - factoryOf(::SkillValueComputationUseCase) + factoryOf(::ExpressionUseCase) factoryOf(::CharacterSheetUseCase) factoryOf(::SettingsUseCase) } diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/business/ExpressionUseCase.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/business/ExpressionUseCase.kt new file mode 100644 index 0000000..0015978 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/business/ExpressionUseCase.kt @@ -0,0 +1,119 @@ +package com.pixelized.desktop.lwa.business + + +import com.pixelized.desktop.lwa.parser.expression.Expression +import com.pixelized.desktop.lwa.parser.expression.ExpressionParser +import com.pixelized.desktop.lwa.parser.word.Word +import com.pixelized.desktop.lwa.repository.characterSheet.model.CharacterSheet +import kotlin.math.max +import kotlin.math.min + +class ExpressionUseCase( + private val expressionParser: ExpressionParser, + private val rollUseCase: RollUseCase, +) { + fun computeSkillValue( + sheet: CharacterSheet, + skill: CharacterSheet.Skill, + diminished: Int, + ): Int { + val context = Context( + sheet = sheet, + skill = skill, + ) + val base: Int = context.evaluate( + expression = skill.base.let(expressionParser::parse), + ).let { + when (skill.occupation) { + true -> max(MIN_OCCUPATION_VALUE, it) + else -> it + } + } + val bonus = context.evaluate( + expression = skill.bonus?.let(expressionParser::parse), + ) + val level = context.evaluate( + expression = skill.level?.let(expressionParser::parse), + ) + + return max(base + bonus + level - diminished, 0) + } + + fun computeRoll( + sheet: CharacterSheet, + expression: String, + ): Int { + val context = Context( + sheet = sheet, + skill = null, + ) + print("Evaluate:\"$expression\"") + return context.evaluate( + expression = expressionParser.parse(input = expression), + ).also { println(" > $it") } + } + + private fun Context.evaluate(expression: Expression?): Int { + + return when (expression) { + is Expression.Add -> { + evaluate(expression.first) + evaluate(expression.second) + } + + is Expression.Minus -> { + evaluate(expression.first) - evaluate(expression.second) + } + + is Expression.Div -> { + evaluate(expression.first) / evaluate(expression.second) + } + + is Expression.Prod -> { + evaluate(expression.first) * evaluate(expression.second) + } + + is Expression.Inversion -> { + -evaluate(expression) + } + + is Expression.Maximum -> { + min(evaluate(expression.first), evaluate(expression.second)) + } + + is Expression.Minimum -> { + max(evaluate(expression.first), evaluate(expression.second)) + } + + is Expression.Flat -> { + expression.value + } + + is Expression.DiceExpression -> { + rollUseCase.roll(expression.dice) + } + + is Expression.WordExpression -> when (expression.word.type) { + Word.Type.BDC -> evaluate(expressionParser.parse(sheet.damageBonus)) + Word.Type.BDD -> evaluate(expressionParser.parse(sheet.damageBonus)) + Word.Type.STR -> sheet.strength + Word.Type.DEX -> sheet.dexterity + Word.Type.CON -> sheet.constitution + Word.Type.HEI -> sheet.height + Word.Type.INT -> sheet.intelligence + Word.Type.POW -> sheet.power + Word.Type.CHA -> sheet.charisma + } + + null -> 0 + } + } + + data class Context( + val sheet: CharacterSheet, + val skill: CharacterSheet.Skill?, + ) + + companion object { + private const val MIN_OCCUPATION_VALUE = 40 + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/business/RollUseCase.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/business/RollUseCase.kt index 007e2d2..bf2ed48 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/business/RollUseCase.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/business/RollUseCase.kt @@ -1,13 +1,14 @@ package com.pixelized.desktop.lwa.business -import com.pixelized.desktop.lwa.parser.arithmetic.Instruction + +import com.pixelized.desktop.lwa.parser.dice.Dice import kotlin.math.abs import kotlin.math.max import kotlin.math.min class RollUseCase { - fun roll(dice: Instruction.Dice): Int { + fun roll(dice: Dice): Int { return roll( modifier = dice.modifier, quantity = dice.quantity, @@ -16,28 +17,28 @@ class RollUseCase { } fun roll( - modifier: Instruction.Dice.Modifier? = null, + modifier: Dice.Modifier? = null, quantity: Int = 1, faces: Int, ): Int { print("{") return sum(count = quantity) { left -> when (modifier) { - Instruction.Dice.Modifier.ADVANTAGE -> { + Dice.Modifier.ADVANTAGE -> { val roll1 = roll(faces = faces) val roll2 = roll(faces = faces) print("[$roll1,$roll2]") max(roll1, roll2) } - Instruction.Dice.Modifier.DISADVANTAGE -> { + Dice.Modifier.DISADVANTAGE -> { val roll1 = roll(faces = faces) val roll2 = roll(faces = faces) print("[$roll1,$roll2]") min(roll1, roll2) } - Instruction.Dice.Modifier.EMPHASIS -> { + Dice.Modifier.EMPHASIS -> { val roll1 = roll(faces = faces) val roll2 = roll(faces = faces) print("[$roll1,$roll2]") @@ -58,7 +59,7 @@ class RollUseCase { if (quantity > 1 && left != 1) print(",") } }.also { - print("}:") + print("}") } } diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/business/SkillValueComputationUseCase.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/business/SkillValueComputationUseCase.kt deleted file mode 100644 index b7a3bc0..0000000 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/business/SkillValueComputationUseCase.kt +++ /dev/null @@ -1,96 +0,0 @@ -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( - private val arithmeticParser: ArithmeticParser, - private val rollUseCase: RollUseCase, -) { - fun computeSkillValue( - sheet: CharacterSheet, - skill: CharacterSheet.Skill, - diminished: Int, - ): Int { - val base: Int = arithmeticParser.parse(skill.base).compute(sheet = sheet).let { - when (skill.occupation) { - true -> max(MIN_OCCUPATION_VALUE, it) - else -> it - } - } - val bonus = skill.bonus?.let(arithmeticParser::parse)?.compute(sheet = sheet) ?: 0 - val level = skill.level?.let(arithmeticParser::parse)?.compute(sheet = sheet) ?: 0 - - return max(base + bonus + level - diminished, 0) - } - - fun computeRoll( - sheet: CharacterSheet, - roll: String, - ): Int { - return arithmeticParser.parse(value = roll).compute(sheet = sheet) - } - - // TODO return RollDetail instance instead of an simple Int. - private fun List.compute( - sheet: CharacterSheet, - ): Int { - print("Roll ->") - return sumOf { instruction -> - print(" ($instruction):") - val value = when (instruction) { - is Instruction.Dice -> rollUseCase.roll(dice = instruction) - - 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(dice = damageBonusInstructions) - } else { - 0 - } - } - - Instruction.Word.Type.BDD -> { - val damageBonusInstructions = arithmeticParser - .parse(sheet.damageBonus) - .firstOrNull() - if (damageBonusInstructions is Instruction.Dice) { - rollUseCase.roll( - modifier = damageBonusInstructions.modifier, - 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 - } - } - } - - (value * instruction.sign).also { print("$it") } - }.also { - println() - } - } - - companion object { - private const val MIN_OCCUPATION_VALUE = 40 - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/arithmetic/ArithmeticParser.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/arithmetic/ArithmeticParser.kt deleted file mode 100644 index af8e397..0000000 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/arithmetic/ArithmeticParser.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.pixelized.desktop.lwa.parser.arithmetic - -import com.pixelized.desktop.lwa.parser.arithmetic.Instruction.Dice.Modifier -import com.pixelized.desktop.lwa.parser.arithmetic.Instruction.Word - -// https://medium.com/@gustavoinzunza/how-to-solve-balanced-parenthesis-problem-with-regex-in-ruby-113eff30a79a -// https://stackoverflow.com/questions/546433/regular-expression-to-match-balanced-parentheses -class ArithmeticParser { - - private val operatorParser = Regex( - """\s*(?[-+])?\s*(?[^-+]*)""" - ) - private val diceParser = Regex( - """^(?[ade])?(?\d+)[dD](?\d+)""" - ) - private val wordParser = Regex( - """^(?${words.joinToString(separator = "|") { it }})""" - ) - private val flatParser = Regex( - """(?\d+)""" - ) - - @Throws(Instruction.UnknownInstruction::class) - fun parse(value: String): List { - return operatorParser.findAll(value).mapNotNull { - val (operator, instruction) = it.destructured - if (instruction.isNotBlank()) { - parseInstruction( - sign = parseOperator(operator), - instruction = instruction, - ) - } else { - null - } - }.toList() - } - - @Throws(Instruction.UnknownInstruction::class) - 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(), - ) - }?.let { return it } - - wordParser.find(instruction)?.let { - parseWord( - sign = sign, - value = it.value - ) - }?.let { return it } - - flatParser.find(instruction)?.let { - Instruction.Flat( - sign = sign, - value = it.value.toInt(), - ) - }?.let { return it } - - throw Instruction.UnknownInstruction(payload = instruction) - } - - private fun parseOperator(operator: String): Int { - return when (operator) { - "-" -> -1 - "+" -> 1 - else -> 1 - } - } - - private fun parseModifier(value: String): Modifier? { - return when (value) { - "a" -> Modifier.ADVANTAGE - "d" -> Modifier.DISADVANTAGE - "e" -> Modifier.EMPHASIS - else -> null - } - } - - private fun parseWord( - sign: Int, - value: String, - ): Word? { - return try { - Word( - sign = sign, - type = Word.Type.valueOf(value), - ) - } catch (_: Exception) { - return null - } - } - - fun convertInstructionToString( - instructions: List, - ): String { - return instructions.map { - val sign = if (it.sign > 0) "+" else "-" - 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 = " ") { - it - } - } - - companion object { - val words: List = Word.Type.entries.map { it.name } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/arithmetic/Instruction.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/arithmetic/Instruction.kt deleted file mode 100644 index daba7ab..0000000 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/arithmetic/Instruction.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.pixelized.desktop.lwa.parser.arithmetic - -sealed class Instruction { - abstract val sign: Int - - data class Dice( - override val sign: Int, - val modifier: Modifier?, - val quantity: Int, - val faces: Int, - ) : Instruction() { - - enum class Modifier { - ADVANTAGE, - DISADVANTAGE, - EMPHASIS, - } - - override fun toString(): String { - return "${sign.sign}${ - when (modifier) { - Modifier.ADVANTAGE -> "a" - Modifier.DISADVANTAGE -> "d" - Modifier.EMPHASIS -> "e" - null -> "" - } - }${quantity}d${faces}" - } - } - - data class Flat( - override val sign: Int, - val value: Int, - ) : Instruction() { - override fun toString(): String { - return "${sign.sign}${value}" - } - } - - data class Word( - override val sign: Int, - val type: Type, - ) : Instruction() { - - 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 "-" -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/dice/Dice.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/dice/Dice.kt new file mode 100644 index 0000000..d5a71aa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/dice/Dice.kt @@ -0,0 +1,23 @@ +package com.pixelized.desktop.lwa.parser.dice + +data class Dice( + val modifier: Modifier?, + val quantity: Int, + val faces: Int, +) { + enum class Modifier { + ADVANTAGE, + DISADVANTAGE, + EMPHASIS, + } + + override fun toString(): String { + val modifier = when (modifier) { + Modifier.ADVANTAGE -> "a" + Modifier.DISADVANTAGE -> "d" + Modifier.EMPHASIS -> "e" + null -> "" + } + return "${modifier}${quantity}d${faces}" + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/dice/DiceParser.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/dice/DiceParser.kt new file mode 100644 index 0000000..708c69e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/dice/DiceParser.kt @@ -0,0 +1,27 @@ +package com.pixelized.desktop.lwa.parser.dice + +class DiceParser { + private val diceParser = Regex( + """^(?[ade])?(?\d+)[dD](?\d+)""" + ) + + fun parse(expression: String): Dice? { + return diceParser.find(expression)?.let { + val (modifier, quantity, faces) = it.destructured + Dice( + modifier = parseModifier(value = modifier), + quantity = quantity.toInt(), + faces = faces.toInt(), + ) + } + } + + private fun parseModifier(value: String): Dice.Modifier? { + return when (value) { + "a" -> Dice.Modifier.ADVANTAGE + "d" -> Dice.Modifier.DISADVANTAGE + "e" -> Dice.Modifier.EMPHASIS + else -> null + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/expression/Expression.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/expression/Expression.kt new file mode 100644 index 0000000..e26a082 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/expression/Expression.kt @@ -0,0 +1,93 @@ +package com.pixelized.desktop.lwa.parser.expression + +import com.pixelized.desktop.lwa.parser.dice.Dice +import com.pixelized.desktop.lwa.parser.word.Word + +sealed interface Expression { + + data class Add( + val first: Expression?, + val second: Expression?, + ) : Expression { + override fun toString(): String { + return "$first+$second" + } + } + + data class Minus( + val first: Expression?, + val second: Expression?, + ) : Expression { + override fun toString(): String { + return "$first-$second" + } + } + + data class Div( + val first: Expression?, + val second: Expression?, + ) : Expression { + override fun toString(): String { + return "$first/$second" + } + } + + data class Prod( + val first: Expression?, + val second: Expression?, + ) : Expression { + override fun toString(): String { + return "$first*$second" + } + } + + data class Minimum( + val first: Expression?, + val second: Expression?, + ) : Expression { + override fun toString(): String { + return "minimum($first,$second)" + } + } + + data class Maximum( + val first: Expression?, + val second: Expression?, + ) : Expression { + override fun toString(): String { + return "maximum($first,$second)" + } + } + + data class Inversion( + val expression: Expression, + ) : Expression { + override fun toString(): String { + return "-($expression)" + } + } + + data class DiceExpression( + val dice: Dice, + ) : Expression { + override fun toString(): String { + return dice.toString() + } + } + + data class WordExpression( + val word: Word, + ) : Expression { + override fun toString(): String { + return word.toString() + } + } + + data class Flat( + val value: Int, + ) : Expression { + override fun toString(): String { + return "$value" + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/expression/ExpressionParser.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/expression/ExpressionParser.kt new file mode 100644 index 0000000..79c21ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/expression/ExpressionParser.kt @@ -0,0 +1,291 @@ +package com.pixelized.desktop.lwa.parser.expression + +import com.pixelized.desktop.lwa.parser.dice.DiceParser +import com.pixelized.desktop.lwa.parser.word.WordParser + +/** + * Highly inspired by the following javascript implementation: + * https://tiarkrompf.github.io/notes/?/just-write-the-parser/ + */ +class ExpressionParser( + private val diceParser: DiceParser, + private val wordParser: WordParser, +) { + companion object { + private val tokenBreak = arrayOf( + '+', '-', '/', '*', '(', ')', ',' + ) + private val operators = mapOf( + '+' to Operator( + evaluations = { first, second -> Expression.Add(first, second) }, + priority = 100, + association = 1, + ), + '-' to Operator( + evaluations = { first, second -> Expression.Minus(first, second) }, + priority = 100, + association = 1, + ), + '*' to Operator( + evaluations = { first, second -> Expression.Prod(first, second) }, + priority = 200, + association = 1, + ), + '/' to Operator( + evaluations = { first, second -> Expression.Div(first, second) }, + priority = 200, + association = 1, + ), + ) + + private inline fun guard(condition: Boolean, error: () -> Error) { + return if (condition.not()) throw error() else Unit + } + } + + private val stack = Stack() + + /** + * Helper method to determined when a token should end. + * Every characters that are not un the [Companion.tokenBreak] list can be part of a token. + * @see Companion.tokenBreak + */ + private fun isToken(): Boolean = stack.peek().let { + it != null && !tokenBreak.contains(it) + } + + /** + * Helper method to pull from the stack until we have a complete token. + * Use the isToken() method to determined when a token should end. + * @see isToken + */ + private fun token(): String { + guard(isToken()) { + Error.ExpectedTokenCharacter( + actual = stack.peek(), + expression = stack.input, + ) + } + val token = StringBuilder() + do { + stack.pull().let(token::append) + } while (isToken()) + return token.toString() + } + + /** + * Helper method to construct an expression from a token. + * This method handle bracket recursion, functions [min(), max(), etc.] + * and token expression creation (Word, Dice, Flat, etc.). + * @see token + */ + private fun factor(): Expression? { + when (stack.peek()) { + '(' -> { + stack.moveCursor() + val result = evaluate() + guard(stack.peek() == ')') { + Error.ExpectedOperator( + expected = ')', + actual = stack.peek(), + expression = stack.input + ) + } + stack.moveCursor() + return result + } + + '-' -> { // this is considered as a sign function for the following expression. + stack.moveCursor() + val result = evaluate() + return result?.let(Expression::Inversion) + } + + '+' -> { // this is considered as a sign function for the following expression. + stack.moveCursor() + val result = evaluate() + return result + } + + else -> when (val token = token()) { + "min" -> { + // consume the '(' character + stack.moveCursor() + // evaluate the content of the first parameter. + val first = evaluate() + // check that the expression is well formed, need a ,. + guard(stack.peek() == ',') { + Error.ExpectedOperator( + expected = ',', + actual = stack.peek(), + expression = stack.input + ) + } + // consume the ',' character of the second parameter. + stack.moveCursor() + // evaluate the content of the second parameter. + val second = evaluate() + // check that the expression is well formed, need a ). + guard(stack.peek() == ')') { + Error.ExpectedOperator( + expected = ')', + actual = stack.peek(), + expression = stack.input + ) + } + // consume the ')' character + stack.moveCursor() + // build the final function expression + return Expression.Minimum(first, second) + } + + "max" -> { + // consume the '(' character + stack.moveCursor() + // evaluate the content of the first parameter. + val first = evaluate() + // check that the expression is well formed, need a ,. + guard(stack.peek() == ',') { + Error.ExpectedOperator( + expected = ',', + actual = stack.peek(), + expression = stack.input + ) + } + // consume the ',' character of the second parameter. + stack.moveCursor() + // evaluate the content of the second parameter. + val second = evaluate() + // check that the expression is well formed, need a ). + guard(stack.peek() == ')') { + Error.ExpectedOperator( + expected = ')', + actual = stack.peek(), + expression = stack.input + ) + } + // consume the ')' character + stack.moveCursor() + // build the final function expression + return Expression.Maximum(first, second) + } + + else -> { + val value = token.toIntOrNull() + if (value != null) { + return Expression.Flat(value) + } + + val word = wordParser.parse(token) + if (word != null) { + return Expression.WordExpression(word) + } + + val dice = diceParser.parse(token) + if (dice != null) { + return Expression.DiceExpression(dice) + } + + throw Error.UnRecognizedToken(actual = token, expression = stack.input) + } + } + } + } + + /** + * Helper method to handle operator priority and associativity. + * build with the example https://tiarkrompf.github.io/notes/?/just-write-the-parser/ in mind. + * @param minPriority the minimum priority to check. + * @return a nullable [Expression] + */ + private fun evaluate(minPriority: Int): Expression? { + var res = factor() + while ( + operators.contains(stack.peek()) && + (operators[stack.peek()]?.priority ?: 0) >= minPriority + ) { + val nextMin = operators[stack.peek()]?.let { it.priority + it.association } ?: 0 + res = operators[stack.pull()]?.evaluations?.invoke( + res, + evaluate(nextMin) + ) + } + return res + } + + /** + * Helper method to evaluate a expression that have been initialized into the stack. + * This is typically call just after the stack have been initialized but also when a recursion is needed. + * @return a nullable [Expression] + */ + private fun evaluate(): Expression? { + return evaluate(0) + } + + /** + * Public parsing method for an expression. + * @param input the input expression [String] + * @return a nullable [Expression] + */ + fun parse(input: String?): Expression? { + return input.takeIf { it?.isNotBlank() == true }?.let { + stack.init(expression = it) + evaluate() + } + } + + sealed class Error( + message: String, + expression: String, + ) : Exception("Expession:$expression - $message") { + + class ExpectedTokenCharacter( + val actual: Char?, + val expression: String, + ) : Error( + "Expected a token character (either a letter or a digit), but was: $actual", expression + ) + + class ExpectedOperator( + val expected: Char, + val actual: Char?, + val expression: String, + ) : Error("Expected operator: $expected, but was $actual", expression) + + class UnRecognizedToken( + val actual: String, + val expression: String, + ) : Error( + "Expected a specific token 'word', 'digit' or 'function', but was: $actual", + expression + ) + } + + private data class Operator( + val evaluations: (Expression?, Expression?) -> Expression?, + val priority: Int, // operator priority, higher is priority + val association: Int, // 0 : left associativity, 1: right + ) + + private data class Stack( + var input: String = "", + private var cursor: Int = 0, + private var peek: Char? = null, + ) { + fun init(expression: String) { + input = expression + cursor = 0 + peek = null + moveCursor() + } + + fun peek(): Char? = peek + + fun pull(): Char? = peek().also { moveCursor() } + + fun moveCursor() { + peek = input.getOrNull(cursor) + cursor = (cursor + 1).takeIf { it < input.length } ?: -1 + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/word/Word.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/word/Word.kt new file mode 100644 index 0000000..b207c3f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/word/Word.kt @@ -0,0 +1,21 @@ +package com.pixelized.desktop.lwa.parser.word + +data class Word( + val type: Type, +) { + 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 "$type" + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/word/WordParser.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/word/WordParser.kt new file mode 100644 index 0000000..bd1f749 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/parser/word/WordParser.kt @@ -0,0 +1,14 @@ +package com.pixelized.desktop.lwa.parser.word + +class WordParser { + + fun parse( + value: String, + ): Word? { + return try { + Word(type = Word.Type.valueOf(value)) + } catch (_: Exception) { + return null + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/characterSheet/detail/CharacterSheetFactory.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/characterSheet/detail/CharacterSheetFactory.kt index ef9142d..73e5d6e 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/characterSheet/detail/CharacterSheetFactory.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/characterSheet/detail/CharacterSheetFactory.kt @@ -1,6 +1,6 @@ package com.pixelized.desktop.lwa.screen.characterSheet.detail -import com.pixelized.desktop.lwa.business.SkillValueComputationUseCase +import com.pixelized.desktop.lwa.business.ExpressionUseCase import com.pixelized.desktop.lwa.composable.tooltip.TooltipUio import com.pixelized.desktop.lwa.repository.characterSheet.SkillDescriptionFactory import com.pixelized.desktop.lwa.repository.characterSheet.model.CharacterSheet @@ -39,7 +39,7 @@ import lwacharactersheet.composeapp.generated.resources.tooltip__sub_characteris import org.jetbrains.compose.resources.getString class CharacterSheetFactory( - private val skillUseCase: SkillValueComputationUseCase, + private val skillUseCase: ExpressionUseCase, private val skillDescriptionFactory: SkillDescriptionFactory, ) { diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/roll/RollViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/roll/RollViewModel.kt index 32f48b7..0a1f210 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/roll/RollViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/roll/RollViewModel.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.pixelized.desktop.lwa.business.SkillStepUseCase -import com.pixelized.desktop.lwa.business.SkillValueComputationUseCase +import com.pixelized.desktop.lwa.business.ExpressionUseCase import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository import com.pixelized.desktop.lwa.repository.characterSheet.model.CharacterSheet import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository @@ -33,7 +33,7 @@ import org.jetbrains.compose.resources.getString class RollViewModel( private val characterSheetRepository: CharacterSheetRepository, private val rollHistoryRepository: RollHistoryRepository, - private val skillComputation: SkillValueComputationUseCase, + private val skillComputation: ExpressionUseCase, private val skillStepUseCase: SkillStepUseCase, ) : ViewModel() { @@ -172,7 +172,7 @@ class RollViewModel( val roll = skillComputation.computeRoll( sheet = sheet, - roll = rollAction, + expression = rollAction, ) val success = rollStep?.let { diff --git a/composeApp/src/commonTest/kotlin/com/pixelized/desktop/lwa/parser/InstructionParserTest.kt b/composeApp/src/commonTest/kotlin/com/pixelized/desktop/lwa/parser/InstructionParserTest.kt deleted file mode 100644 index 54aa7ae..0000000 --- a/composeApp/src/commonTest/kotlin/com/pixelized/desktop/lwa/parser/InstructionParserTest.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.pixelized.desktop.lwa.parser - -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 InstructionParserTest { - - @Test - fun testDiceInstructionParse() { - val parser = ArithmeticParser() - - fun test( - instruction: String, - expectedModifier: Instruction.Dice.Modifier?, - expectedQuantity: Int, - expectedFaces: Int, - ) { - val dice = parser.parse(value = instruction).first() - - assert(dice is Instruction.Dice) { - "Instruction should be ArithmeticInstruction.Dice but was: ${dice::class.java.simpleName}" - } - (dice as? Instruction.Dice)?.let { - assert(dice.modifier == expectedModifier) { - "$instruction modifier should be:\"$expectedModifier\", but was: ${dice.modifier}" - } - assert(dice.quantity == expectedQuantity) { - "$instruction quantity should be \"$expectedQuantity\" but was ${dice.quantity}" - } - assert(dice.faces == expectedFaces) { - "$instruction faces should be \"$expectedFaces\" but was ${dice.faces}" - } - } - } - - test( - instruction = "1d100", - expectedModifier = null, - expectedQuantity = 1, - expectedFaces = 100, - ) - test( - instruction = "a2d6", - expectedModifier = Instruction.Dice.Modifier.ADVANTAGE, - expectedQuantity = 2, - expectedFaces = 6, - ) - test( - instruction = "d1d2", - expectedModifier = Instruction.Dice.Modifier.DISADVANTAGE, - expectedQuantity = 1, - expectedFaces = 2, - ) - test( - instruction = "e6d6", - expectedModifier = Instruction.Dice.Modifier.EMPHASIS, - expectedQuantity = 6, - expectedFaces = 6, - ) - } - - @Test - fun testWordInstructionParse() { - val parser = ArithmeticParser() - - ArithmeticParser.words.map { instruction -> - val word = parser.parse(value = instruction).first() - - assert(word is Instruction.Word) { - "Instruction should be ArithmeticInstruction.Word but was: ${word::class.java.simpleName}" - } - (word as? Instruction.Word)?.let { - assert(it.type.name == instruction) { - "Instruction should be $instruction, but was ${it.type.name}" - } - } - } - } - - @Test - fun testFlatInstructionParse() { - val parser = ArithmeticParser() - - "100".let { instruction -> - val flat = parser.parse(value = instruction).first() - - assert(flat is Instruction.Flat) { - "Instruction should be ArithmeticInstruction.Flat but was: ${flat::class.java.simpleName}" - } - (flat as? Instruction.Flat)?.let { - assert("${it.value}" == instruction) { - "Instruction should be $instruction, but was ${it.value}" - } - } - } - } - - @Test - fun testRollParse() { - val parser = ArithmeticParser() - - fun test( - instruction: Instruction, - expectedInstruction: Instruction, - ) { - assert(instruction == expectedInstruction) { - "Arithmetic instruction should be $expectedInstruction but was: $instruction" - } - } - - val instructions = parser.parse( - value = "1+1d6+2-BDC+BDD", - ) - - test( - instruction = instructions[0], - expectedInstruction = Instruction.Flat(sign = 1, value = 1), - ) - test( - instruction = instructions[1], - expectedInstruction = Instruction.Dice(sign = 1, modifier = null, quantity = 1, faces = 6), - ) - test( - instruction = instructions[2], - expectedInstruction = Instruction.Flat(sign = 1, value = 2), - ) - test( - instruction = instructions[3], - expectedInstruction = Instruction.Word(sign = -1, type = Instruction.Word.Type.BDC), - ) - test( - instruction = instructions[4], - expectedInstruction = Instruction.Word(sign = 1, type = Instruction.Word.Type.BDD), - ) - } -} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/com/pixelized/desktop/lwa/parser/dice/DiceParserTest.kt b/composeApp/src/commonTest/kotlin/com/pixelized/desktop/lwa/parser/dice/DiceParserTest.kt new file mode 100644 index 0000000..f7c5622 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/pixelized/desktop/lwa/parser/dice/DiceParserTest.kt @@ -0,0 +1,68 @@ +package com.pixelized.desktop.lwa.parser.dice + +import org.junit.Test + +class DiceParserTest { + + @Test + fun testInvalidExpression() { + val parser = DiceParser() + parser.test(expression = "", expected = null) + parser.test(expression = " ", expected = null) + parser.test(expression = "1", expected = null) + parser.test(expression = "d6", expected = null) + parser.test(expression = "ad6", expected = null) + parser.test(expression = "dd6", expected = null) + parser.test(expression = "ed6", expected = null) + } + + @Test + fun testFaces() { + val parser = DiceParser() + parser.test(expression = "1d4", expected = Dice(modifier = null, quantity = 1, faces = 4)) + parser.test(expression = "1d6", expected = Dice(modifier = null, quantity = 1, faces = 6)) + parser.test(expression = "1d8", expected = Dice(modifier = null, quantity = 1, faces = 8)) + } + + @Test + fun testQuantity() { + val parser = DiceParser() + parser.test(expression = "2d6", expected = Dice(modifier = null, quantity = 2, faces = 6)) + parser.test(expression = "3d6", expected = Dice(modifier = null, quantity = 3, faces = 6)) + parser.test(expression = "4d6", expected = Dice(modifier = null, quantity = 4, faces = 6)) + } + + @Test + fun testWhitespace() { + val parser = DiceParser() + parser.test(expression = " 2d6", expected = Dice(modifier = null, quantity = 2, faces = 6)) + parser.test(expression = "2d6 ", expected = Dice(modifier = null, quantity = 2, faces = 6)) + } + + @Test + fun testModifier() { + val parser = DiceParser() + parser.test( + expression = "a1d6", + expected = Dice(modifier = Dice.Modifier.ADVANTAGE, quantity = 1, faces = 6) + ) + parser.test( + expression = "d1d6", + expected = Dice(modifier = Dice.Modifier.DISADVANTAGE, quantity = 1, faces = 6) + ) + parser.test( + expression = "e1d6", + expected = Dice(modifier = Dice.Modifier.EMPHASIS, quantity = 1, faces = 6) + ) + } + + private fun DiceParser.test( + expression: String, + expected: Dice?, + ) { + val result = parse(expression = expression) + assert(result == expected) { + "DiceParser.parse(expression=$expression) is expected to return:$expected, but was:$result" + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/com/pixelized/desktop/lwa/parser/expression/ExpressionParserTest.kt b/composeApp/src/commonTest/kotlin/com/pixelized/desktop/lwa/parser/expression/ExpressionParserTest.kt new file mode 100644 index 0000000..ed22ecd --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/pixelized/desktop/lwa/parser/expression/ExpressionParserTest.kt @@ -0,0 +1,112 @@ +package com.pixelized.desktop.lwa.parser.expression + +import com.pixelized.desktop.lwa.parser.dice.DiceParser +import com.pixelized.desktop.lwa.parser.expression.ExpressionParser.Error +import com.pixelized.desktop.lwa.parser.word.WordParser +import org.junit.Test +import kotlin.test.assertFailsWith + +class ExpressionParserTest { + + @Test + fun testInvalidExpression() { + val parser = ExpressionParser( + diceParser = DiceParser(), + wordParser = WordParser(), + ) + parser.test("", null) + parser.test(" ", null) + assertFailsWith(Error.UnRecognizedToken::class) { + parser.test("pouet", null) + } + assertFailsWith(Error.ExpectedTokenCharacter::class) { + parser.test("1+", null) + } + assertFailsWith(Error.ExpectedOperator::class) { + parser.test("(153", null) + } + assertFailsWith(Error.ExpectedOperator::class) { + parser.test("min(1+1)", null) + } + } + + @Test + fun testArithmeticExpression() { + val parser = ExpressionParser( + diceParser = DiceParser(), + wordParser = WordParser(), + ) + parser.test( + expression = "-1", + expected = Expression.Inversion(Expression.Flat(1)), + ) + parser.test( + expression = "+1", + expected = Expression.Flat(1), + ) + parser.test( + expression = "1+2", + expected = Expression.Add(Expression.Flat(1), Expression.Flat(2)) + ) + parser.test( + expression = "1-2", + expected = Expression.Minus(Expression.Flat(1), Expression.Flat(2)) + ) + parser.test( + expression = "1*2", + expected = Expression.Prod(Expression.Flat(1), Expression.Flat(2)) + ) + parser.test( + expression = "1/2", + expected = Expression.Div(Expression.Flat(1), Expression.Flat(2)) + ) + } + + @Test + fun testFunctionExpression() { + val parser = ExpressionParser( + diceParser = DiceParser(), + wordParser = WordParser(), + ) + parser.test( + expression = "min(1,2)", + expected = Expression.Minimum(Expression.Flat(1), Expression.Flat(2)) + ) + parser.test( + expression = "max(1,2)", + expected = Expression.Maximum(Expression.Flat(1), Expression.Flat(2)) + ) + } + + @Test + fun testArithmeticPriorityExpression() { + val parser = ExpressionParser( + diceParser = DiceParser(), + wordParser = WordParser(), + ) + parser.test( + expression = "(1+2)*3", + expected = Expression.Prod( + Expression.Add(Expression.Flat(1), Expression.Flat(2)), + Expression.Flat(3), + ) + ) + parser.test( + expression = "1+2*3", + expected = Expression.Add( + Expression.Flat(1), + Expression.Prod(Expression.Flat(2), Expression.Flat(3)), + ) + ) + } + + private fun ExpressionParser.test( + expression: String, + expected: Expression?, + ) { + val result = parse(input = expression) + assert(result == expected) { + "ExpressionParser.parse(input=$expression) is expected to return:$expected, but was:$result" + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/com/pixelized/desktop/lwa/parser/word/WordParserTest.kt b/composeApp/src/commonTest/kotlin/com/pixelized/desktop/lwa/parser/word/WordParserTest.kt new file mode 100644 index 0000000..baeb9ed --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/pixelized/desktop/lwa/parser/word/WordParserTest.kt @@ -0,0 +1,34 @@ +package com.pixelized.desktop.lwa.parser.word + +import org.junit.Test + +class WordParserTest { + + @Test + fun testInvalidExpression() { + val parser = WordParser() + parser.test(expression = "", expected = null) + parser.test(expression = " ", expected = null) + parser.test(expression = "1", expected = null) + parser.test(expression = "1d6", expected = null) + parser.test(expression = "pouet", expected = null) + } + + @Test + fun testValidExpression() { + val parser = WordParser() + Word.Type.entries.forEach { type -> + parser.test(expression = type.name, expected = Word(type)) + } + } + + private fun WordParser.test( + expression: String, + expected: Word?, + ) { + val result = parse(value = expression) + assert(result == expected) { + "WordParser.parse(value=$expression) is expected to return:$expected, but was:$result" + } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/server/Server.kt b/server/src/main/kotlin/com/pixelized/server/lwa/server/Server.kt index 5c3bd34..ff1bc62 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/server/Server.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/server/Server.kt @@ -28,7 +28,7 @@ typealias Server = EmbeddedServer() fun create(): LocalServer {