Link the player character ribbon to the server.

This commit is contained in:
Thomas Andres Gomez 2025-02-24 21:35:33 +01:00
parent ed1b27039d
commit 4ed11660c3
17 changed files with 228 additions and 72 deletions

View file

@ -20,6 +20,7 @@ import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsFactory
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsStore
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDiminishedViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.PlayerRibbonFactory
@ -105,6 +106,7 @@ val factoryDependencies
factoryOf(::SettingsFactory)
factoryOf(::CampaignJsonFactory)
factoryOf(::PlayerRibbonFactory)
factoryOf(::CharacterDetailFactory)
}
val viewModelDependencies
@ -134,4 +136,4 @@ val useCaseDependencies
factoryOf(::ExpressionUseCase)
factoryOf(::SettingsUseCase)
factoryOf(::CharacterSheetUseCase)
}
}

View file

@ -99,6 +99,10 @@ class ExpressionUseCase(
rollUseCase.roll(expression.dice)
}
is Expression.UrlExpression -> {
0 // Ignore this case.
}
is Expression.WordExpression -> when (expression.word.type) {
Word.Type.BDC -> evaluate(expressionParser.parse(sheet.damageBonus))
Word.Type.BDD -> evaluate(expressionParser.parse(sheet.damageBonus))

View file

@ -67,6 +67,14 @@ sealed interface Expression {
}
}
data class UrlExpression(
val url: String,
) : Expression {
override fun toString(): String {
return url
}
}
data class DiceExpression(
val dice: Dice,
) : Expression {

View file

@ -2,6 +2,9 @@ package com.pixelized.desktop.lwa.parser.expression
import com.pixelized.desktop.lwa.parser.dice.DiceParser
import com.pixelized.desktop.lwa.parser.word.WordParser
import org.jetbrains.skia.toIPoint
import java.net.URI
import java.net.URL
/**
* Highly inspired by the following javascript implementation:
@ -15,6 +18,12 @@ class ExpressionParser(
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) },
@ -50,8 +59,11 @@ class ExpressionParser(
* 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)
private fun isToken(
currentToken: String? = null,
): Boolean = stack.peek().let {
it != null && !tokenBreak.contains(it) ||
(tokenBreakException[it]?.any { currentToken?.contains(it) ?: false } ?: false)
}
/**
@ -69,7 +81,7 @@ class ExpressionParser(
val token = StringBuilder()
do {
stack.pull().let(token::append)
} while (isToken())
} while (isToken(token.toString()))
return token.toString()
}
@ -186,6 +198,16 @@ class ExpressionParser(
return Expression.DiceExpression(dice)
}
val url = try {
println(token)
URI.create(token).toString()
} catch (_: Exception) {
null
}
if (url != null) {
return Expression.UrlExpression(url)
}
throw Error.UnRecognizedToken(actual = token, expression = stack.input)
}
}

View file

@ -15,7 +15,7 @@ class AlterationRepository(
private val activeAlterationIdMapFlow: HashMap<CharacterId, MutableStateFlow<List<AlterationId>>> =
hashMapOf("0f2117e9-e077-4354-8d77-20150df1c462" to MutableStateFlow(listOf("7c00dafa-a67d-4351-8ea9-67d933012cde", "65e37d32-3031-4bf8-9369-d2c45d2efac0")))
fun alterations(characterId: String): Flow<Map<String, List<FieldAlteration>>> {
fun alterationsFlow(characterId: String): Flow<Map<String, List<FieldAlteration>>> {
return activeAlterationIdMapFlow
.getOrPut(characterId) { MutableStateFlow(emptyList()) }
.map { activeAlterationIds ->

View file

@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class CharacterSheetRepository(
private val store: CharacterSheetStore,
@ -17,6 +16,7 @@ class CharacterSheetRepository(
private val scope = CoroutineScope(Dispatchers.IO + Job())
val characterSheetPreviewFlow get() = store.previewFlow
val characterDetailFlow get() = store.detailFlow
fun characterPreview(characterId: String?): CharacterSheetPreview? {
return characterSheetPreviewFlow.value.firstOrNull { it.id == characterId }
@ -35,17 +35,8 @@ class CharacterSheetRepository(
fun characterDetailFlow(
characterId: String?,
forceUpdate: Boolean = false,
): StateFlow<CharacterSheet?> {
val initial = store.detailFlow.value[characterId]
if (forceUpdate || initial == null) {
scope.launch {
characterDetail(
characterId = characterId,
forceUpdate = forceUpdate,
)
}
}
return store.detailFlow
.map { sheets ->
sheets[characterId]

View file

@ -45,7 +45,7 @@ import org.jetbrains.compose.resources.painterResource
import org.koin.compose.viewmodel.koinViewModel
@Stable
data class CharacterDetailUio(
data class CharacterDetailHeaderUio(
val id: String,
val portrait: String?,
val name: String,
@ -55,7 +55,7 @@ data class CharacterDetailUio(
)
@Stable
data class CharacterDynDetailUio(
data class CharacterDetailHeaderInstanceUio(
val hp: String,
val pp: String,
)
@ -109,8 +109,8 @@ fun CharacterDetail(
@Composable
fun CharacterDetailContent(
modifier: Modifier = Modifier,
character: CharacterDetailUio,
dynDetail: State<CharacterDynDetailUio?>,
character: CharacterDetailHeaderUio,
dynDetail: State<CharacterDetailHeaderInstanceUio?>,
onDismissRequest: () -> Unit,
onDiminished: () -> Unit,
onHp: () -> Unit,
@ -139,7 +139,7 @@ fun CharacterDetailContent(
@Composable
private fun Background(
modifier: Modifier = Modifier,
character: CharacterDetailUio,
character: CharacterDetailHeaderUio,
) {
Surface(
modifier = modifier.fillMaxSize(),
@ -168,8 +168,8 @@ private fun Background(
@Composable
private fun CharacterHeader(
modifier: Modifier = Modifier,
character: CharacterDetailUio,
dynDetail: State<CharacterDynDetailUio?>,
character: CharacterDetailHeaderUio,
dynDetail: State<CharacterDetailHeaderInstanceUio?>,
onDismissRequest: () -> Unit,
onDiminished: () -> Unit,
onHp: () -> Unit,

View file

@ -0,0 +1,39 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.detail
import com.pixelized.desktop.lwa.business.ExpressionUseCase
import com.pixelized.desktop.lwa.parser.expression.Expression
import com.pixelized.desktop.lwa.repository.alteration.model.FieldAlteration
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.HP
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.PP
class CharacterDetailFactory(
private val expressionUseCase: ExpressionUseCase,
) {
fun convertToCharacterDetailHeaderUio(
sheet: CharacterSheet?,
alterations: Map<String, List<FieldAlteration>>,
): CharacterDetailHeaderUio? {
if (sheet == null) return null
fun List<FieldAlteration>?.sum(): Int {
return this?.sumOf {
expressionUseCase.computeExpression(sheet = sheet, expression = it.expression)
} ?: 0
}
return CharacterDetailHeaderUio(
id = sheet.id,
portrait = alterations[PORTRAIT]
?.firstNotNullOfOrNull { (it.expression as? Expression.UrlExpression)?.url }
?: sheet.portrait,
name = sheet.name,
hp = "${sheet.hp + alterations[HP].sum()}",
pp = "${sheet.pp + alterations[PP].sum()}",
mov = "${sheet.movement + alterations[MOV].sum()}"
)
}
}

View file

@ -13,33 +13,43 @@ import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.shared.lwa.model.campaign.damage
import com.pixelized.shared.lwa.model.campaign.power
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMap
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.flow.map
class CharacterDetailViewModel(
private val characterRepository: CharacterSheetRepository,
private val campaignRepository: CampaignRepository,
private val alterationRepository: AlterationRepository,
private val characterDetailFactory: CharacterDetailFactory,
) : ViewModel() {
private val displayedCharacterId = MutableStateFlow<String?>(null)
val detail: StateFlow<CharacterDetailUio?> = displayedCharacterId.map { id ->
val sheet = characterRepository.characterDetail(id) ?: return@map null
CharacterDetailUio(
id = sheet.id,
portrait = sheet.portrait,
name = sheet.name,
hp = "${sheet.hp}",
pp = "${sheet.pp}",
mov = "${sheet.movement}"
)
@OptIn(ExperimentalCoroutinesApi::class)
val detail: StateFlow<CharacterDetailHeaderUio?> = displayedCharacterId.flatMapLatest { id ->
if (id != null) {
combine(
characterRepository.characterDetailFlow(characterId = id),
alterationRepository.alterationsFlow(characterId = id),
) { sheet, alteration ->
characterDetailFactory.convertToCharacterDetailHeaderUio(
sheet = sheet,
alterations = alteration,
)
}
} else {
flowOf(null)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
@ -48,15 +58,15 @@ class CharacterDetailViewModel(
@Composable
@Stable
fun collectDynamicDetailAsState(id: String): State<CharacterDynDetailUio?> {
fun collectDynamicDetailAsState(id: String): State<CharacterDetailHeaderInstanceUio?> {
val scope = rememberCoroutineScope()
val flow: StateFlow<CharacterDynDetailUio?> = remember(id) {
val flow: StateFlow<CharacterDetailHeaderInstanceUio?> = remember(id) {
combine(
characterRepository.characterDetailFlow(id),
campaignRepository.characterInstanceFlow(id = id),
) { sheet, instance ->
if (sheet == null) return@combine null
CharacterDynDetailUio(
CharacterDetailHeaderInstanceUio(
hp = "${sheet.hp - instance.damage}",
pp = "${sheet.power - instance.power}",
)

View file

@ -1,24 +1,48 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon
import com.pixelized.desktop.lwa.business.ExpressionUseCase
import com.pixelized.desktop.lwa.parser.expression.Expression
import com.pixelized.desktop.lwa.repository.alteration.model.FieldAlteration
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.damage
import com.pixelized.shared.lwa.model.campaign.power
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.HP
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.PP
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.THUMBNAIL
class PlayerRibbonFactory {
class PlayerRibbonFactory(
private val expressionUseCase: ExpressionUseCase,
) {
fun convertToPlayerPortraitUio(
characterSheet: CharacterSheet?,
characterInstance: Campaign.CharacterInstance,
alterations: Map<String, List<FieldAlteration>>,
): PlayerPortraitUio? {
if (characterSheet == null) return null
fun List<FieldAlteration>?.sum(): Int {
return this?.sumOf {
expressionUseCase.computeExpression(
sheet = characterSheet,
expression = it.expression,
)
} ?: 0
}
val maxHp = characterSheet.hp + alterations[HP].sum()
val maxPp = characterSheet.pp + alterations[PP].sum()
return PlayerPortraitUio(
id = characterSheet.id,
portrait = characterSheet.thumbnail,
hp = characterSheet.hp - characterInstance.damage,
maxHp = characterSheet.hp,
pp = characterSheet.pp - characterInstance.power,
maxPp = characterSheet.pp,
portrait = alterations[THUMBNAIL]
?.firstNotNullOfOrNull { (it.expression as? Expression.UrlExpression)?.url }
?: characterSheet.thumbnail,
hp = maxHp - characterInstance.damage,
maxHp = maxHp,
pp = maxPp - characterInstance.power,
maxPp = maxPp,
)
}
}

View file

@ -8,30 +8,49 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class PlayerRibbonViewModel(
private val rollHistoryRepository: RollHistoryRepository,
characterRepository: CharacterSheetRepository,
alterationRepository: AlterationRepository,
private val ribbonFactory: PlayerRibbonFactory,
campaignRepository: CampaignRepository,
) : ViewModel() {
@OptIn(ExperimentalCoroutinesApi::class)
val characters: StateFlow<List<PlayerPortraitUio>> = campaignRepository.campaignFlow
.map { campaign ->
campaign.characters.mapNotNull { entry ->
ribbonFactory.convertToPlayerPortraitUio(
characterSheet = characterRepository.characterDetail(characterId = entry.key),
characterInstance = entry.value,
)
}
}.stateIn(
.flatMapMerge { campaign ->
combine<PlayerPortraitUio?, List<PlayerPortraitUio>>(
flows = campaign.characters.map { entry ->
combine(
characterRepository.characterDetailFlow(characterId = entry.key),
alterationRepository.alterationsFlow(characterId = entry.key),
) { sheet, alterations ->
ribbonFactory.convertToPlayerPortraitUio(
characterSheet = sheet,
characterInstance = entry.value,
alterations = alterations,
)
}
},
transform = { headers ->
headers.mapNotNull { it }.toList()
}
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
@ -39,6 +58,19 @@ class PlayerRibbonViewModel(
private val rolls = hashMapOf<String, MutableState<PlayerPortraitRollUio?>>()
init {
viewModelScope.launch {
campaignRepository.campaignFlow.collectLatest {
it.characters.keys.forEach { id ->
characterRepository.characterDetail(
characterId = id,
forceUpdate = true,
)
}
}
}
}
@Composable
@Stable
fun roll(characterId: String): State<PlayerPortraitRollUio?> {

View file

@ -46,7 +46,6 @@ class CharacterSheetFactory(
private val skillUseCase: ExpressionUseCase,
private val expressionUseCase: ExpressionUseCase,
) {
suspend fun convertToUio(
sheet: CharacterSheet?,
campaign: Campaign,

View file

@ -73,7 +73,7 @@ class CharacterSheetViewModel(
private val sheetFlow = combine(
characterRepository.characterDetailFlow(characterId = argument.id),
campaignRepository.campaignFlow,
alteration.alterations(characterId = argument.id),
alteration.alterationsFlow(characterId = argument.id),
transform = { sheet, campaign, alterations ->
factory.convertToUio(
sheet = sheet,

View file

@ -1,5 +1,6 @@
package com.pixelized.desktop.lwa.business
import com.pixelized.shared.lwa.usecase.CharacterSheetUseCase
import org.junit.Test
class DamageBonusUseCaseTest {

View file

@ -1,5 +1,6 @@
package com.pixelized.desktop.lwa.business
import com.pixelized.shared.lwa.usecase.CharacterSheetUseCase
import org.junit.Test
class SkillNormalizerUseCaseText {

View file

@ -62,6 +62,26 @@ class ExpressionParserTest {
)
}
@Test
fun textUrlExpression() {
val parser = ExpressionParser(
diceParser = DiceParser(),
wordParser = WordParser(),
)
parser.test(
expression = "https://drive.google.com/uc?export=view&id=19ME-r7LiDQSrDCq8faVlTNQ7tfX8UUOO",
expected = Expression.UrlExpression("https://drive.google.com/uc?export=view&id=19ME-r7LiDQSrDCq8faVlTNQ7tfX8UUOO"),
)
parser.test(
expression = "www.google.fr",
expected = Expression.UrlExpression("www.google.fr"),
)
parser.test(
expression = "google.fr",
expected = Expression.UrlExpression("google.fr"),
)
}
@Test
fun testFunctionExpression() {
val parser = ExpressionParser(

View file

@ -48,6 +48,26 @@ data class CharacterSheet(
val roll: String,
)
object CharacteristicId {
const val PORTRAIT = "PORTRAIT"
const val THUMBNAIL = "THUMBNAIL"
const val LVL = "LEVEL"
const val STR = "STR"
const val DEX = "DEX"
const val CON = "CON"
const val HEI = "HEI"
const val INT = "INT"
const val POW = "POW"
const val CHA = "CHA"
const val MOV = "MOV"
const val HP = "HP"
const val PP = "PP"
const val DMG = "DMG"
const val ARMOR = "ARMOR"
const val LB = "LEARNING"
const val GHP = "HP_GROW"
}
object CommonSkillId {
const val COMBAT_ID = "COMBAT"
const val DODGE_ID = "DODGE"
@ -66,21 +86,4 @@ data class CharacterSheet(
const val SLEIGHT_OF_HAND_ID = "SLEIGHT_OF_HAND"
const val AID_ID = "AID"
}
object CharacteristicId {
const val STR = "STR"
const val DEX = "DEX"
const val CON = "CON"
const val HEI = "HEI"
const val INT = "INT"
const val POW = "POW"
const val CHA = "CHA"
const val MOV = "MOV"
const val HP = "HP"
const val PP = "PP"
const val DMG = "DMG"
const val ARMOR = "ARMOR"
const val LB = "LEARNING"
const val GHP = "HP_GROW"
}
}