Add the alteration system to the server & link the app on it.

This commit is contained in:
Thomas Andres Gomez 2025-02-26 14:43:42 +01:00
parent 4ed11660c3
commit 29747dcb5c
83 changed files with 1797 additions and 811 deletions

View file

@ -1,9 +1,17 @@
package com.pixelized.shared.lwa
import com.pixelized.shared.lwa.model.AlteredCharacterSheetFactory
import com.pixelized.shared.lwa.model.alteration.AlterationJsonFactory
import com.pixelized.shared.lwa.model.campaign.CampaignJsonFactory
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJsonFactory
import com.pixelized.shared.lwa.parser.dice.DiceParser
import com.pixelized.shared.lwa.parser.expression.ExpressionParser
import com.pixelized.shared.lwa.parser.word.WordParser
import com.pixelized.shared.lwa.usecase.CampaignUseCase
import com.pixelized.shared.lwa.usecase.CharacterSheetUseCase
import com.pixelized.shared.lwa.usecase.ExpressionUseCase
import com.pixelized.shared.lwa.usecase.RollUseCase
import com.pixelized.shared.lwa.usecase.SkillStepUseCase
import kotlinx.serialization.json.Json
import org.koin.core.module.dsl.factoryOf
import org.koin.dsl.module
@ -12,6 +20,7 @@ val sharedModuleDependencies
get() = listOf(
toolsDependencies,
factoryDependencies,
parserDependencies,
useCaseDependencies,
)
@ -29,10 +38,23 @@ val factoryDependencies
get() = module {
factoryOf(::CharacterSheetJsonFactory)
factoryOf(::CampaignJsonFactory)
factoryOf(::AlteredCharacterSheetFactory)
factoryOf(::AlterationJsonFactory)
}
val parserDependencies
get() = module {
factoryOf(::WordParser)
factoryOf(::DiceParser)
factoryOf(::ExpressionParser)
}
val useCaseDependencies
get() = module {
factoryOf(::CharacterSheetUseCase)
factoryOf(::CampaignUseCase)
factoryOf(::SkillStepUseCase)
factoryOf(::RollUseCase)
factoryOf(::ExpressionUseCase)
factoryOf(::CharacterSheetUseCase)
}

View file

@ -43,4 +43,13 @@ fun campaignPath(
OperatingSystem.Windows -> "${storePath(os = os)}campaign\\"
OperatingSystem.Macintosh -> "${storePath(os = os)}campaign/"
}
}
fun alterationsPath(
os: OperatingSystem = OperatingSystem.current,
): String {
return when (os) {
OperatingSystem.Windows -> "${storePath(os = os)}alterations\\"
OperatingSystem.Macintosh -> "${storePath(os = os)}alterations/"
}
}

View file

@ -0,0 +1,136 @@
package com.pixelized.shared.lwa.model
import com.pixelized.shared.lwa.model.alteration.FieldAlteration
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.ARMOR
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.CHA
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.CON
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.DEX
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.DMG
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.GHP
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.HEI
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.HP
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.INT
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.LB
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.LVL
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.MOV
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.PORTRAIT
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.POW
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.PP
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.STR
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.THUMBNAIL
import com.pixelized.shared.lwa.parser.expression.Expression
import com.pixelized.shared.lwa.usecase.CharacterSheetUseCase
import com.pixelized.shared.lwa.usecase.ExpressionUseCase
class AlteredCharacterSheetFactory(
private val sheetUseCase: CharacterSheetUseCase,
private val expressionUseCase: ExpressionUseCase,
) {
fun sheet(
characterSheet: CharacterSheet,
alterations: Map<String, List<FieldAlteration>>,
): AlteredCharacterSheet {
return AlteredCharacterSheet(
sheetUseCase = sheetUseCase,
expressionUseCase = expressionUseCase,
sheet = characterSheet,
alterations = alterations,
)
}
}
class AlteredCharacterSheet(
private val sheetUseCase: CharacterSheetUseCase,
private val expressionUseCase: ExpressionUseCase,
private val sheet: CharacterSheet,
private val alterations: Map<String, List<FieldAlteration>>,
) {
val id: String = sheet.id
val name: String = sheet.name
val portrait: String?
get() = alterations[PORTRAIT]
?.firstNotNullOfOrNull { (it.expression as? Expression.UrlExpression)?.url }
?: sheet.portrait
val thumbnail: String?
get() = alterations[THUMBNAIL]
?.firstNotNullOfOrNull { (it.expression as? Expression.UrlExpression)?.url }
?: sheet.thumbnail
val level: Int
get() = sheet.level + alterations[LVL].sum()
val strength: Int
get() = sheet.strength + alterations[STR].sum()
val dexterity: Int
get() = sheet.dexterity + alterations[DEX].sum()
val constitution: Int
get() = sheet.constitution + alterations[CON].sum()
val height: Int
get() = sheet.height + alterations[HEI].sum()
val intelligence: Int
get() = sheet.intelligence + alterations[INT].sum()
val power: Int
get() = sheet.power + alterations[POW].sum()
val charisma: Int
get() = sheet.charisma + alterations[CHA].sum()
val movement: Int
get() = sheetUseCase.movement() + alterations[MOV].sum()
val armor: Int
get() = sheetUseCase.armor() + alterations[ARMOR].sum()
val maxHp: Int
get() = sheetUseCase.maxHp(
constitution = constitution,
height = height,
level = level,
) + alterations[HP].sum()
val maxPp: Int
get() = sheetUseCase.maxPp(
power = power,
) + alterations[PP].sum()
val damageBonus: String
get() {
val initial = sheetUseCase.damageBonus(
strength = strength,
height = height,
)
return alterations[DMG]
?.joinToString(separator = "+") { it.expression.toString() }
?.let { "$initial+$it" }
?: initial
}
val learning: Int
get() = sheetUseCase.learning(
intelligence = intelligence
) + alterations[LB].sum()
val hpGrow: Int
get() = sheetUseCase.hpGrow(
constitution = constitution,
) + alterations[GHP].sum()
// Helper method
private fun List<FieldAlteration>?.sum() = this?.sumOf {
expressionUseCase.computeExpression(
sheet = sheet,
alterations = alterations,
expression = it.expression
)
} ?: 0
}

View file

@ -0,0 +1,19 @@
package com.pixelized.shared.lwa.model.alteration
import com.pixelized.shared.lwa.parser.expression.Expression
data class Alteration(
val id: String,
val metadata: MetaData,
val fields: List<Field>,
) {
data class MetaData(
val name: String,
val description: String,
)
data class Field(
val fieldId: String, // this id is not the id of the instance but the id of the impacted field (characteristic, skill etc.)
val expression: Expression,
)
}

View file

@ -0,0 +1,6 @@
package com.pixelized.shared.lwa.model.alteration
import kotlinx.serialization.Serializable
@Serializable
sealed interface AlterationJson

View file

@ -0,0 +1,73 @@
package com.pixelized.shared.lwa.model.alteration
import com.pixelized.shared.lwa.parser.expression.Expression
import com.pixelized.shared.lwa.parser.expression.ExpressionParser
class AlterationJsonFactory(
private val expressionParser: ExpressionParser,
) {
fun convertFromJson(
json: AlterationJson,
): Alteration {
return when (json) {
is AlterationJsonV1 -> convertFromJsonV1(json = json)
}
}
fun convertFromJsonV1(
json: AlterationJsonV1,
): Alteration {
return Alteration(
id = json.id,
metadata = convertFromJsonV1(json = json.metadata),
fields = json.fields.map { convertFromJsonV1(json = it) }
)
}
fun convertFromJsonV1(
json: AlterationJsonV1.AlterationMetadataJsonV1,
): Alteration.MetaData {
return Alteration.MetaData(
name = json.name,
description = json.description,
)
}
fun convertFromJsonV1(
json: AlterationJsonV1.FieldJsonV1,
): Alteration.Field {
return Alteration.Field(
fieldId = json.fieldId,
expression = expressionParser.parse(json.expression) ?: Expression.Flat(0),
)
}
fun convertToJson(
data: Alteration,
): AlterationJson {
return AlterationJsonV1(
id = data.id,
metadata = convertToJson(data = data.metadata),
fields = data.fields.map { convertToJson(data = it) },
)
}
fun convertToJson(
data: Alteration.MetaData,
): AlterationJsonV1.AlterationMetadataJsonV1 {
return AlterationJsonV1.AlterationMetadataJsonV1(
name = data.name,
description = data.description,
)
}
fun convertToJson(
data: Alteration.Field,
): AlterationJsonV1.FieldJsonV1 {
return AlterationJsonV1.FieldJsonV1(
fieldId = data.fieldId,
expression = data.expression.toString(),
)
}
}

View file

@ -0,0 +1,23 @@
package com.pixelized.shared.lwa.model.alteration
import kotlinx.serialization.Serializable
@Serializable
data class AlterationJsonV1(
val id: String,
val metadata: AlterationMetadataJsonV1,
val fields: List<FieldJsonV1>,
) : AlterationJson {
@Serializable
data class FieldJsonV1(
val fieldId: String, // this id is not the id of the instance but the id of the impacted field (characteristic, skill etc.)
val expression: String,
)
@Serializable
data class AlterationMetadataJsonV1(
val name: String,
val description: String,
)
}

View file

@ -0,0 +1,9 @@
package com.pixelized.shared.lwa.model.alteration
import com.pixelized.shared.lwa.parser.expression.Expression
data class FieldAlteration(
val alterationId: String,
val metadata: Alteration.MetaData,
val expression: Expression,
)

View file

@ -1,12 +1,17 @@
package com.pixelized.shared.lwa.model.campaign
data class Campaign(
val characters: Map<String, CharacterInstance>,
val npcs: Map<String, CharacterInstance>,
val characters: Map<CharacterInstance.Id, CharacterInstance>,
val npcs: Map<CharacterInstance.Id, CharacterInstance>,
) {
data class CharacterInstance(
val characteristic: Map<Characteristic, Int>,
) {
data class Id(
val characterSheetId: String,
val instanceId: Int,
)
enum class Characteristic {
Damage,
Power,
@ -21,12 +26,18 @@ data class Campaign(
}
}
fun Campaign.character(id: String): Campaign.CharacterInstance {
fun Campaign.character(id: Campaign.CharacterInstance.Id): Campaign.CharacterInstance {
return characters[id] ?: Campaign.CharacterInstance(
characteristic = emptyMap(),
)
}
fun Campaign.npc(id: Campaign.CharacterInstance.Id): Campaign.CharacterInstance {
return npcs[id] ?: Campaign.CharacterInstance(
characteristic = emptyMap(),
)
}
val Campaign.CharacterInstance.level
get() = characteristic[Campaign.CharacterInstance.Characteristic.Damage] ?: 1

View file

@ -6,51 +6,94 @@ class CampaignJsonFactory {
json: CampaignJson,
): Campaign {
return when (json) {
is CampaignJsonV1 -> convertFromV1(json = json)
is CampaignJsonV1 -> convertFromV1(campaignJson = json)
}
}
private fun convertFromV1(
json: CampaignJsonV1,
campaignJson: CampaignJsonV1,
): Campaign {
return Campaign(
characters = json.characters.mapValues { convertFromV1(json = it.value) },
npcs = json.npcs.mapValues { convertFromV1(json = it.value) },
characters = campaignJson.characters
.map {
convertFromV1(characterInstanceIdJson = it.key) to convertFromV1(
characterInstanceJson = it.value
)
}
.toMap(),
npcs = campaignJson.npcs
.map {
convertFromV1(characterInstanceIdJson = it.key) to convertFromV1(
characterInstanceJson = it.value
)
}
.toMap(),
)
}
private fun convertFromV1(
json: CampaignJsonV1.CharacterInstanceJson,
fun convertFromV1(
characterInstanceIdJson: String,
): Campaign.CharacterInstance.Id {
return Campaign.CharacterInstance.Id(
characterSheetId = characterInstanceIdJson.drop(4), // drop first 3 number then the -
instanceId = characterInstanceIdJson.take(3).toIntOrNull() ?: 0,
)
}
fun convertFromV1(
characterInstanceJson: CampaignJsonV1.CharacterInstanceJson,
): Campaign.CharacterInstance {
return Campaign.CharacterInstance(
characteristic = json.characteristic.map { char ->
when (char.key) {
CampaignJsonV1.CharacterInstanceJson.Characteristic.Damage -> Campaign.CharacterInstance.Characteristic.Damage
CampaignJsonV1.CharacterInstanceJson.Characteristic.Power -> Campaign.CharacterInstance.Characteristic.Power
} to char.value
}.toMap(),
characteristic = characterInstanceJson.characteristic
.map { char -> convertFromV1(characteristicJson = char.key) to char.value }
.toMap(),
)
}
fun convertFromV1(
characteristicJson: CampaignJsonV1.CharacterInstanceJson.Characteristic,
): Campaign.CharacterInstance.Characteristic {
return when (characteristicJson) {
CampaignJsonV1.CharacterInstanceJson.Characteristic.Damage -> Campaign.CharacterInstance.Characteristic.Damage
CampaignJsonV1.CharacterInstanceJson.Characteristic.Power -> Campaign.CharacterInstance.Characteristic.Power
}
}
fun convertToJson(
data: Campaign,
): CampaignJson {
return CampaignJsonV1(
characters = data.characters.mapValues { convertToJson(data = it.value) },
npcs = data.npcs.mapValues { convertToJson(data = it.value) },
characters = data.characters
.map { convertToJson(id = it.key) to convertToJson(data = it.value) }
.toMap(),
npcs = data.npcs
.map { convertToJson(id = it.key) to convertToJson(data = it.value) }
.toMap(),
)
}
private fun convertToJson(
fun convertToJson(
id: Campaign.CharacterInstance.Id,
): String {
return "${String.format("%03d", id.instanceId)}-${id.characterSheetId}"
}
fun convertToJson(
data: Campaign.CharacterInstance,
): CampaignJsonV1.CharacterInstanceJson {
return CampaignJsonV1.CharacterInstanceJson(
characteristic = data.characteristic.map { char ->
when (char.key) {
Campaign.CharacterInstance.Characteristic.Damage -> CampaignJsonV1.CharacterInstanceJson.Characteristic.Damage
Campaign.CharacterInstance.Characteristic.Power -> CampaignJsonV1.CharacterInstanceJson.Characteristic.Power
} to char.value
}.toMap(),
characteristic = data.characteristic
.map { char -> convertToJson(characteristic = char.key) to char.value }
.toMap(),
)
}
fun convertToJson(
characteristic: Campaign.CharacterInstance.Characteristic,
): CampaignJsonV1.CharacterInstanceJson.Characteristic {
return when (characteristic) {
Campaign.CharacterInstance.Characteristic.Damage -> CampaignJsonV1.CharacterInstanceJson.Characteristic.Damage
Campaign.CharacterInstance.Characteristic.Power -> CampaignJsonV1.CharacterInstanceJson.Characteristic.Power
}
}
}

View file

@ -16,12 +16,7 @@ data class CharacterSheet(
val charisma: Int,
// sub characteristics
val movement: Int,
val hp: Int,
val pp: Int,
val damageBonus: String,
val armor: Int,
val learning: Int,
val hpGrow: Int,
// skills
val commonSkills: List<Skill>,
val specialSkills: List<Skill>,

View file

@ -31,26 +31,8 @@ class CharacterSheetJsonFactory(
intelligence = json.intelligence,
power = json.power,
charisma = json.charisma,
movement = defaultMovement(),
hp = defaultMaxHp(
constitution = json.constitution,
height = json.height,
level = json.level
),
pp = defaultMaxPower(
power = json.power,
),
damageBonus = defaultDamageBonus(
strength = json.strength,
height = json.height,
),
armor = defaultArmor(),
learning = defaultLearning(
intelligence = json.intelligence,
),
hpGrow = defaultHpGrow(
constitution = json.constitution,
),
movement = movement(),
armor = armor(),
commonSkills = json.skills.map {
CharacterSheet.Skill(
id = it.id,

View file

@ -0,0 +1,23 @@
package com.pixelized.shared.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}"
}
}

View file

@ -0,0 +1,27 @@
package com.pixelized.shared.lwa.parser.dice
class DiceParser {
private val diceParser = Regex(
"""^(?<modifier>[ade])?(?<quantity>\d+)[dD](?<faces>\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
}
}
}

View file

@ -0,0 +1,101 @@
package com.pixelized.shared.lwa.parser.expression
import com.pixelized.shared.lwa.parser.dice.Dice
import com.pixelized.shared.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 "min($first,$second)"
}
}
data class Maximum(
val first: Expression?,
val second: Expression?,
) : Expression {
override fun toString(): String {
return "max($first,$second)"
}
}
data class Inversion(
val expression: Expression,
) : Expression {
override fun toString(): String {
return "-($expression)"
}
}
data class UrlExpression(
val url: String,
) : Expression {
override fun toString(): String {
return url
}
}
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"
}
}
}

View file

@ -0,0 +1,310 @@
package com.pixelized.shared.lwa.parser.expression
import com.pixelized.shared.lwa.parser.dice.DiceParser
import com.pixelized.shared.lwa.parser.word.WordParser
import java.net.URI
/**
* 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 tokenBreakException = mapOf(
'/' to listOf("http:", "https:"),
'.' to listOf("http:", "https:"),
'+' to listOf("http:", "https:"),
'-' to listOf("http:", "https:"),
)
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(
currentToken: String? = null,
): Boolean = stack.peek().let {
it != null && !tokenBreak.contains(it) ||
(tokenBreakException[it]?.any { currentToken?.contains(it) ?: false } ?: false)
}
/**
* 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(token.toString()))
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)
}
val url = try {
URI.create(token).toString()
} catch (_: Exception) {
null
}
if (url != null) {
return Expression.UrlExpression(url)
}
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
}
}
}

View file

@ -0,0 +1,21 @@
package com.pixelized.shared.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"
}
}

View file

@ -0,0 +1,14 @@
package com.pixelized.shared.lwa.parser.word
class WordParser {
fun parse(
value: String,
): Word? {
return try {
Word(type = Word.Type.valueOf(value))
} catch (_: Exception) {
return null
}
}
}

View file

@ -15,6 +15,13 @@ sealed class RestSynchronisation : MessagePayload {
val characterId: String,
) : RestSynchronisation()
@Serializable
data class ToggleActiveAlteration(
val characterId: String,
val alterationId: String,
val active: Boolean,
) : RestSynchronisation()
@Serializable
data object Campaign : RestSynchronisation()
}

View file

@ -1,11 +1,11 @@
package com.pixelized.shared.lwa.protocol.websocket.payload
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.CampaignJsonV1
import kotlinx.serialization.Serializable
@Serializable
data class UpdatePlayerCharacteristicMessage(
val characterId: String,
val characteristic: Campaign.CharacterInstance.Characteristic,
val characterInstanceId: String,
val characteristic: CampaignJsonV1.CharacterInstanceJson.Characteristic,
val value: Int,
) : MessagePayload

View file

@ -10,31 +10,31 @@ class CharacterSheetUseCase {
return value - value % 5 // (truncate(value.toFloat() / 5f) * 5f).toInt()
}
fun defaultMovement(): Int = 10
fun movement(): Int = 10
fun defaultMaxHp(
fun maxHp(
constitution: Int,
height: Int,
level: Int,
): Int {
val add = max(defaultHpGrow(constitution = constitution) * (level - 1), 0)
val add = max(hpGrow(constitution = constitution) * (level - 1), 0)
return (ceil((constitution + height) / 2f).toInt()) + add
}
fun defaultMaxPower(
fun maxPp(
power: Int,
): Int {
return power
}
fun defaultDamageBonus(
fun damageBonus(
strength: Int,
height: Int,
): String {
return defaultDamageBonus(sum = strength + height)
return damageBonus(sum = strength + height)
}
fun defaultDamageBonus(
fun damageBonus(
sum: Int,
): String {
return when {
@ -47,13 +47,13 @@ class CharacterSheetUseCase {
}
}
fun defaultArmor(): Int = 0
fun armor(): Int = 0
fun defaultLearning(intelligence: Int): Int {
fun learning(intelligence: Int): Int {
return max(0, (intelligence - 10) * 2)
}
fun defaultHpGrow(constitution: Int): Int {
fun hpGrow(constitution: Int): Int {
return (constitution / 3)
}

View file

@ -0,0 +1,172 @@
package com.pixelized.shared.lwa.usecase
import com.pixelized.shared.lwa.model.alteration.FieldAlteration
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.CHA
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.CON
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.DEX
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.HEI
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.INT
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.POW
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.STR
import com.pixelized.shared.lwa.parser.expression.Expression
import com.pixelized.shared.lwa.parser.expression.ExpressionParser
import com.pixelized.shared.lwa.parser.word.Word
import kotlin.math.max
import kotlin.math.min
class ExpressionUseCase(
private val expressionParser: ExpressionParser,
private val characterSheetUseCase: CharacterSheetUseCase,
private val rollUseCase: RollUseCase,
) {
fun computeSkillValue(
sheet: CharacterSheet,
alterations: Map<String, List<FieldAlteration>>,
skill: CharacterSheet.Skill,
): Int {
val context = Context(
sheet = sheet,
skill = skill,
alterations = alterations,
)
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 = max((skill.level - 1) * 5, 0)
val alteration = alterations[skill.id]?.sumOf {
context.evaluate(it.expression)
} ?: 0
return max(base + bonus + level + alteration, 0)
}
fun computeRoll(
sheet: CharacterSheet,
alterations: Map<String, List<FieldAlteration>>,
expression: String,
): Int {
return expressionParser.parse(input = expression)?.let {
computeExpression(
sheet = sheet,
alterations = alterations,
expression = it,
)
} ?: 0
}
fun computeExpression(
sheet: CharacterSheet,
alterations: Map<String, List<FieldAlteration>>,
expression: Expression,
): Int {
val context = Context(
sheet = sheet,
skill = null,
alterations = alterations,
)
return context.evaluate(
expression = expression,
)
}
private fun Context.evaluate(expression: Expression?): Int {
fun List<FieldAlteration>?.sum() = this?.sumOf { evaluate(it.expression) } ?: 0
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.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.UrlExpression -> {
0 // Ignore this case.
}
is Expression.WordExpression -> when (expression.word.type) {
Word.Type.BDC -> evaluate(
expression = expressionParser.parse(
characterSheetUseCase.damageBonus(
strength = sheet.strength + alterations[STR].sum(),
height = sheet.height + alterations[HEI].sum(),
)
)
)
Word.Type.BDD -> evaluate(
expression = expressionParser.parse(
characterSheetUseCase.damageBonus(
strength = sheet.strength + alterations[STR].sum(),
height = sheet.height + alterations[HEI].sum(),
)
)
)
Word.Type.STR -> sheet.strength + alterations[STR].sum()
Word.Type.DEX -> sheet.dexterity + alterations[DEX].sum()
Word.Type.CON -> sheet.constitution + alterations[CON].sum()
Word.Type.HEI -> sheet.height + alterations[HEI].sum()
Word.Type.INT -> sheet.intelligence + alterations[INT].sum()
Word.Type.POW -> sheet.power + alterations[POW].sum()
Word.Type.CHA -> sheet.charisma + alterations[CHA].sum()
}
null -> 0
}
}
data class Context(
val sheet: CharacterSheet,
val skill: CharacterSheet.Skill?,
val alterations: Map<String, List<FieldAlteration>>,
)
companion object {
private const val MIN_OCCUPATION_VALUE = 40
}
}

View file

@ -0,0 +1,77 @@
package com.pixelized.shared.lwa.usecase
import com.pixelized.shared.lwa.parser.dice.Dice
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
class RollUseCase {
fun roll(dice: Dice): Int {
return roll(
modifier = dice.modifier,
quantity = dice.quantity,
faces = dice.faces,
)
}
fun roll(
modifier: Dice.Modifier? = null,
quantity: Int = 1,
faces: Int,
): Int {
print("{")
return sum(count = quantity) { left ->
when (modifier) {
Dice.Modifier.ADVANTAGE -> {
val roll1 = roll(faces = faces)
val roll2 = roll(faces = faces)
print("[$roll1,$roll2]")
max(roll1, roll2)
}
Dice.Modifier.DISADVANTAGE -> {
val roll1 = roll(faces = faces)
val roll2 = roll(faces = faces)
print("[$roll1,$roll2]")
min(roll1, roll2)
}
Dice.Modifier.EMPHASIS -> {
val roll1 = roll(faces = faces)
val roll2 = roll(faces = faces)
print("[$roll1,$roll2]")
val half = faces / 2
val roll1Abs = abs(half - roll1)
val roll2Abs = abs(half - roll2)
when {
roll1Abs == roll2Abs -> max(roll1, roll2)
roll1Abs < roll2Abs -> roll2
else -> roll1
}
}
null -> {
roll(faces = faces).also { print("$it") }
}
}.also {
if (quantity > 1 && left != 1) print(",")
}
}.also {
print("}")
}
}
private fun roll(faces: Int): Int {
return (Math.random() * faces.toDouble() + 1.0).toInt()
}
private fun sum(count: Int, block: (Int) -> Int): Int {
return if (count > 1) {
block(count) + sum(count - 1, block)
} else {
block(count)
}
}
}

View file

@ -0,0 +1,71 @@
package com.pixelized.shared.lwa.usecase
import kotlin.math.max
import kotlin.math.min
import kotlin.math.round
import kotlin.math.roundToInt
class SkillStepUseCase {
data class SkillStep(
val criticalSuccess: IntRange,
val specialSuccess: IntRange,
val success: IntRange,
val failure: IntRange,
val criticalFailure: IntRange,
)
/**
* Helper method to compute the range in which a roll is a either critical, special, success or failure.
*/
fun computeSkillStep(skill: Int): SkillStep {
val criticalSuccess = 1..min(roundToInt { skill * 0.05f }, 99)
val specialSuccess =
(roundToInt { skill * 0.05f } + 1)..min(roundToInt { skill * 0.2f }, 99)
val success = (roundToInt { skill * 0.2f } + 1)..min(skill, 99)
val criticalFailure = 100 - max(4 - criticalSuccess.last, 0)..100
val failure = (success.last + 1) until criticalFailure.first
return SkillStep(
criticalSuccess = criticalSuccess.takeIf { it.first <= it.last } ?: NONE,
specialSuccess = specialSuccess.takeIf { it.first <= it.last } ?: NONE,
success = success.takeIf { it.first <= it.last } ?: NONE,
failure = failure.takeIf { it.first <= it.last } ?: NONE,
criticalFailure = criticalFailure.takeIf { it.first <= it.last } ?: NONE,
)
}
private inline fun roundToInt(block: () -> Float): Int = round(block()).roundToInt()
fun exportWiki() {
fun print(range: IntRange): String = when {
range == NONE -> "-"
range.first == range.last -> "${range.first}"
else -> "${range.first} - ${range.last}"
}
repeat(100) { skill ->
val step = computeSkillStep(skill + 1)
println(
"|!${skill + 1} " +
"|${print(step.criticalSuccess)} " +
"|${print(step.specialSuccess)} " +
"|${print(step.success)} " +
"|${print(step.failure)} " +
"|${print(step.criticalFailure)} " +
"|"
)
}
}
fun exportTest() {
println("val expected = hashMapOf(")
(1..500).forEach {
println(" $it to ${computeSkillStep(it)},")
}
println(")")
}
companion object {
private val NONE: IntRange = -1..-1
}
}