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,14 +1,7 @@
package com.pixelized.desktop.lwa
import com.pixelized.desktop.lwa.business.ExpressionUseCase
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.network.LwaClient
import com.pixelized.desktop.lwa.network.LwaClientImpl
import com.pixelized.desktop.lwa.parser.dice.DiceParser
import com.pixelized.desktop.lwa.parser.expression.ExpressionParser
import com.pixelized.desktop.lwa.parser.word.WordParser
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
import com.pixelized.desktop.lwa.repository.alteration.AlterationStore
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
@ -20,6 +13,8 @@ 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.composable.character.characteristic.CharacterDetailCharacteristicDialogViewModel
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterSheetCharacteristicDialogFactory
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
@ -35,8 +30,8 @@ import com.pixelized.desktop.lwa.ui.screen.network.NetworkFactory
import com.pixelized.desktop.lwa.ui.screen.network.NetworkViewModel
import com.pixelized.desktop.lwa.ui.screen.roll.RollViewModel
import com.pixelized.desktop.lwa.ui.screen.rollhistory.RollHistoryViewModel
import com.pixelized.desktop.lwa.usecase.SettingsUseCase
import com.pixelized.shared.lwa.model.campaign.CampaignJsonFactory
import com.pixelized.shared.lwa.usecase.CharacterSheetUseCase
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.okhttp.OkHttp
@ -50,7 +45,6 @@ import org.koin.dsl.module
val appModuleDependencies
get() = listOf(
parserDependencies,
factoryDependencies,
useCaseDependencies,
storeDependencies,
@ -107,6 +101,7 @@ val factoryDependencies
factoryOf(::CampaignJsonFactory)
factoryOf(::PlayerRibbonFactory)
factoryOf(::CharacterDetailFactory)
factoryOf(::CharacterSheetCharacteristicDialogFactory)
}
val viewModelDependencies
@ -120,20 +115,10 @@ val viewModelDependencies
viewModelOf(::PlayerRibbonViewModel)
viewModelOf(::CharacterDetailViewModel)
viewModelOf(::CharacterDiminishedViewModel)
}
val parserDependencies
get() = module {
factoryOf(::WordParser)
factoryOf(::DiceParser)
factoryOf(::ExpressionParser)
viewModelOf(::CharacterDetailCharacteristicDialogViewModel)
}
val useCaseDependencies
get() = module {
factoryOf(::SkillStepUseCase)
factoryOf(::RollUseCase)
factoryOf(::ExpressionUseCase)
factoryOf(::SettingsUseCase)
factoryOf(::CharacterSheetUseCase)
}

View file

@ -1,130 +0,0 @@
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.shared.lwa.model.characterSheet.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,
alterations: 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 = max((skill.level - 1) * 5, 0)
return max(base + bonus + level + alterations, 0)
}
fun computeRoll(
sheet: CharacterSheet,
expression: String,
): Int {
return expressionParser.parse(input = expression)?.let {
computeExpression(sheet = sheet, expression = it)
} ?: 0
}
fun computeExpression(
sheet: CharacterSheet,
expression: Expression,
): Int {
val context = Context(
sheet = sheet,
skill = null,
)
print("Evaluate:\"$expression\"")
return context.evaluate(
expression = 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.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(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
}
}

View file

@ -1,5 +1,6 @@
package com.pixelized.desktop.lwa.network
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.campaign.CampaignJson
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson
@ -16,11 +17,21 @@ interface LwaClient {
suspend fun campaign(): CampaignJson
suspend fun campaignAddCharacter(id: String)
suspend fun campaignAddCharacter(characterSheetId: String, instanceId: Int)
suspend fun campaignDeleteCharacter(id: String)
suspend fun campaignDeleteCharacter(characterSheetId: String, instanceId: Int)
suspend fun campaignAddNpc(id: String)
suspend fun campaignAddNpc(characterSheetId: String, instanceId: Int)
suspend fun campaignDeleteNpc(id: String)
suspend fun campaignDeleteNpc(characterSheetId: String, instanceId: Int)
suspend fun alterations(): List<AlterationJson>
suspend fun activeAlterations(characterSheetId: String, instanceId: Int): List<String>
suspend fun toggleActiveAlterations(
characterSheetId: String,
instanceId: Int,
alterationId: String,
)
}

View file

@ -1,6 +1,7 @@
package com.pixelized.desktop.lwa.network
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.campaign.CampaignJson
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson
@ -46,19 +47,55 @@ class LwaClientImpl(
.get("$root/campaign")
.body()
override suspend fun campaignAddCharacter(id: String) = client
.put("$root/campaign/character/update?id=$id")
override suspend fun campaignAddCharacter(
characterSheetId: String,
instanceId: Int,
) = client
.put("$root/campaign/character/update?characterSheetId=$characterSheetId&instanceId=$instanceId")
.body<Unit>()
override suspend fun campaignDeleteCharacter(id: String) = client
.delete("$root/campaign/character/delete?id=$id")
override suspend fun campaignDeleteCharacter(
characterSheetId: String,
instanceId: Int,
) = client
.delete("$root/campaign/character/delete?characterSheetId=$characterSheetId&instanceId=$instanceId")
.body<Unit>()
override suspend fun campaignAddNpc(id: String) = client
.put("$root/campaign/npc/update?id=$id")
override suspend fun campaignAddNpc(
characterSheetId: String,
instanceId: Int,
) = client
.put("$root/campaign/npc/update?characterSheetId=$characterSheetId&instanceId=$instanceId")
.body<Unit>()
override suspend fun campaignDeleteNpc(id: String) = client
.delete("$root/campaign/npc/delete?id=$id")
override suspend fun campaignDeleteNpc(
characterSheetId: String,
instanceId: Int,
) = client
.delete("$root/campaign/npc/delete?characterSheetId=$characterSheetId&instanceId=$instanceId")
.body<Unit>()
override suspend fun alterations(): List<AlterationJson> = client
.get("$root/alterations")
.body()
override suspend fun activeAlterations(
characterSheetId: String,
instanceId: Int,
): List<String> = client
.get("$root/alterations/active?characterSheetId=$characterSheetId&instanceId=$instanceId")
.body()
override suspend fun toggleActiveAlterations(
characterSheetId: String,
instanceId: Int,
alterationId: String,
) = client
.put("$root/alterations/active/toggle?characterSheetId=$characterSheetId&instanceId=$instanceId") {
url {
contentType(ContentType.Application.Json)
setBody(alterationId)
}
}
.body<Unit>()
}

View file

@ -1,63 +1,85 @@
package com.pixelized.desktop.lwa.repository.alteration
import com.pixelized.desktop.lwa.repository.alteration.model.FieldAlteration
import com.pixelized.shared.lwa.model.alteration.Alteration
import com.pixelized.shared.lwa.model.alteration.FieldAlteration
import com.pixelized.shared.lwa.model.campaign.Campaign
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
// Theses typealias are there for readability only.
private typealias CharacterId = String
private typealias AlterationId = String
class AlterationRepository(
private val store: AlterationStore,
) {
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")))
private val scope = CoroutineScope(Dispatchers.IO + Job())
private val activeAlterationMapFlow: StateFlow<Map<Campaign.CharacterInstance.Id, Map<String, List<FieldAlteration>>>> =
combine(
store.alterations,
store.active,
) { alterations, actives ->
actives.map { activeEntry ->
activeEntry.key to transformToAlterationFieldMap(
alterations = alterations,
actives = activeEntry.value
)
}.toMap()
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyMap(),
)
fun alterationsFlow(characterId: String): Flow<Map<String, List<FieldAlteration>>> {
return activeAlterationIdMapFlow
.getOrPut(characterId) { MutableStateFlow(emptyList()) }
.map { activeAlterationIds ->
val fieldAlterations = hashMapOf<String, MutableList<FieldAlteration>>()
activeAlterationIds.forEach { id: AlterationId ->
store.alteration(alterationId = id)?.let { alteration ->
alteration.fields.forEach { field ->
fieldAlterations.getOrPut(field.fieldId) { mutableListOf() }
.add(
FieldAlteration(
alterationId = alteration.id,
metadata = alteration.metadata,
expression = field.expression,
)
)
}
}
}
fieldAlterations
}
fun alterationsFlow(
characterId: Campaign.CharacterInstance.Id,
): Flow<Map<String, List<FieldAlteration>>> {
return activeAlterationMapFlow.map { it[characterId] ?: emptyMap() }
}
fun toggle(characterId: String, alterationId: String) {
fun alterations(
characterInstanceId: Campaign.CharacterInstance.Id,
): Map<String, List<FieldAlteration>> {
return activeAlterationMapFlow.value[characterInstanceId] ?: emptyMap()
}
// check if the alteration is currently active of inactive.
val active = activeAlterationIdMapFlow[characterId]
?.value
?.contains(alterationId)
?: false
// alteration was active for the character toggle it off.
activeAlterationIdMapFlow[characterId]?.value = activeAlterationIdMapFlow[characterId]
?.value
?.toMutableList()
?.also { list ->
when (active) {
true -> list.remove(alterationId)
else -> list.add(alterationId)
private fun transformToAlterationFieldMap(
alterations: Map<String, Alteration>,
actives: List<String>,
): Map<String, List<FieldAlteration>> {
val fieldAlterations = hashMapOf<String, MutableList<FieldAlteration>>()
actives.forEach { id: AlterationId ->
alterations[id]?.let { alteration ->
alteration.fields.forEach { field ->
fieldAlterations
.getOrPut(field.fieldId) { mutableListOf() }
.add(
FieldAlteration(
alterationId = alteration.id,
metadata = alteration.metadata,
expression = field.expression,
)
)
}
}
?: emptyList()
}
return fieldAlterations
}
suspend fun toggleActiveAlteration(
characterInstanceId: Campaign.CharacterInstance.Id,
alterationId: String,
) {
// alteration was active for the character toggle it off.
store.toggleActiveAlteration(
characterInstance = characterInstanceId,
alterationId = alterationId,
)
}
}

View file

@ -1,81 +1,114 @@
package com.pixelized.desktop.lwa.repository.alteration
import com.pixelized.desktop.lwa.parser.expression.Expression
import com.pixelized.desktop.lwa.parser.expression.ExpressionParser
import com.pixelized.desktop.lwa.repository.alteration.model.Alteration
import com.pixelized.desktop.lwa.repository.alteration.model.AlterationMetadata
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.ARMOR
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.MOV
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CharacteristicId.STR
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.ACROBATICS_ID
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.AID_ID
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.ATHLETICS_ID
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.BARGAIN_ID
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.COMBAT_ID
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.DISCRETION_ID
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.INTIMIDATION_ID
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.PERCEPTION_ID
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.PERSUASION_ID
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.SLEIGHT_OF_HAND_ID
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.SPIEL_ID
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.THROW_ID
import com.pixelized.desktop.lwa.network.LwaClient
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.shared.lwa.model.alteration.Alteration
import com.pixelized.shared.lwa.model.alteration.AlterationJsonFactory
import com.pixelized.shared.lwa.model.campaign.Campaign.CharacterInstance
import com.pixelized.shared.lwa.model.campaign.CampaignJsonFactory
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.RestSynchronisation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class AlterationStore(
private val expressionParser: ExpressionParser,
private val alterationFactory: AlterationJsonFactory,
private val campaignJsonFactory: CampaignJsonFactory,
private val network: NetworkRepository,
private val client: LwaClient,
) {
private val alterations = mapOf(
"7c00dafa-a67d-4351-8ea9-67d933012cde" to Alteration(
id = "7c00dafa-a67d-4351-8ea9-67d933012cde",
metadata = AlterationMetadata(
name = "Tatouage Mak",
description = "Tatouage des Mak permettant la transformation en loup.",
),
fields = listOf(
Alteration.Field(fieldId = CharacteristicId.PP, expression = "-2".parse()),
)
),
"65e37d32-3031-4bf8-9369-d2c45d2efac0" to Alteration(
id = "65e37d32-3031-4bf8-9369-d2c45d2efac0",
metadata = AlterationMetadata(
name = "Forme de loup",
description = "Capacité spécial des maks de la tribue Palok.",
),
fields = listOf(
Alteration.Field(fieldId = STR, expression = "+1".parse()),
Alteration.Field(fieldId = DEX, expression = "-1".parse()),
Alteration.Field(fieldId = HEI, expression = "-1".parse()),
Alteration.Field(fieldId = MOV, expression = "+5".parse()),
Alteration.Field(fieldId = ARMOR, expression = "+1".parse()),
private val _alterations = MutableStateFlow<Map<String, Alteration>>(emptyMap())
val alterations: StateFlow<Map<String, Alteration>> = _alterations
Alteration.Field(fieldId = COMBAT_ID, expression = "+10".parse()),
Alteration.Field(fieldId = THROW_ID, expression = "-100".parse()),
Alteration.Field(fieldId = ATHLETICS_ID, expression = "+20".parse()),
Alteration.Field(fieldId = ACROBATICS_ID, expression = "-10".parse()),
Alteration.Field(fieldId = PERCEPTION_ID, expression = "+20".parse()),
Alteration.Field(fieldId = PERSUASION_ID, expression = "-20".parse()),
Alteration.Field(fieldId = INTIMIDATION_ID, expression = "+20".parse()),
Alteration.Field(fieldId = SPIEL_ID, expression = "-20".parse()),
Alteration.Field(fieldId = BARGAIN_ID, expression = "-20".parse()),
Alteration.Field(fieldId = DISCRETION_ID, expression = "+20".parse()),
Alteration.Field(fieldId = SLEIGHT_OF_HAND_ID, expression = "-100".parse()),
Alteration.Field(fieldId = AID_ID, expression = "-100".parse()),
private val _active = MutableStateFlow<Map<CharacterInstance.Id, List<String>>>(emptyMap())
val active: StateFlow<Map<CharacterInstance.Id, List<String>>> get() = _active
Alteration.Field(
fieldId = "40a4dcca-7010-4522-9d58-0cfac0a586e8", // Pistage
expression = "+20".parse()
),
)
init {
val scope = CoroutineScope(Dispatchers.IO + Job())
scope.launch {
updateAlterations()
}
scope.launch {
network.data.collect(::handleMessage)
}
}
private suspend fun updateAlterations() {
_alterations.value = loadAlteration()
}
private suspend fun loadAlteration(): Map<String, Alteration> {
val request = client.alterations()
val data = request.map { alterationFactory.convertFromJson(json = it) }
return data.associateBy { it.id }
}
private suspend fun loadActiveAlterations(
characterInstanceId: CharacterInstance.Id,
): List<String> {
val request = client.activeAlterations(
characterSheetId = characterInstanceId.characterSheetId,
instanceId = characterInstanceId.instanceId,
)
)
_active.value = _active.value.toMutableMap().also {
it[characterInstanceId] = request
}
return request
}
fun alterations(): Collection<Alteration> = alterations.values
fun alterations(): Collection<Alteration> {
return alterations.value.values
}
fun alteration(alterationId: String): Alteration? = alterations[alterationId]
fun alteration(alterationId: String): Alteration? {
return alterations.value[alterationId]
}
private fun String.parse(): Expression {
return expressionParser.parse(this)!!
suspend fun toggleActiveAlteration(
characterInstance: CharacterInstance.Id,
alterationId: String,
) {
client.toggleActiveAlterations(
characterSheetId = characterInstance.characterSheetId,
instanceId = characterInstance.instanceId,
alterationId = alterationId,
)
}
private suspend fun handleMessage(message: Message) {
when (val payload = message.value) {
is RestSynchronisation.ToggleActiveAlteration -> {
setActiveAlteration(
characterInstanceId = campaignJsonFactory.convertFromV1(
characterInstanceIdJson = payload.characterId,
),
alterationId = payload.alterationId,
active = payload.active,
)
}
else -> Unit
}
}
private suspend fun setActiveAlteration(
characterInstanceId: CharacterInstance.Id,
alterationId: String,
active: Boolean,
) {
_active.value = _active.value.toMutableMap().also { map ->
map[characterInstanceId] = map[characterInstanceId]?.toMutableList()
?.also {
when {
it.contains(alterationId) && !active -> it.remove(alterationId)
!it.contains(alterationId) && active -> it.add(alterationId)
}
}
?: listOfNotNull(if (active) alterationId else null)
}
}
}

View file

@ -1,14 +0,0 @@
package com.pixelized.desktop.lwa.repository.alteration.model
import com.pixelized.desktop.lwa.parser.expression.Expression
data class Alteration(
val id: String,
val metadata: AlterationMetadata,
val fields: List<Field>,
) {
data class Field(
val fieldId: String, // this id is not the id of the instance but the id of the impacted characteristic in the character sheet.
val expression: Expression,
)
}

View file

@ -1,6 +0,0 @@
package com.pixelized.desktop.lwa.repository.alteration.model
data class AlterationMetadata(
val name: String,
val description: String,
)

View file

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

View file

@ -17,7 +17,9 @@ class CampaignRepository(
val campaignFlow get() = store.campaignFlow
fun characterInstanceFlow(id: String): StateFlow<Campaign.CharacterInstance> {
fun characterInstanceFlow(
id: Campaign.CharacterInstance.Id,
): StateFlow<Campaign.CharacterInstance> {
return campaignFlow
.mapNotNull {
it.characters[id]
@ -28,4 +30,10 @@ class CampaignRepository(
initialValue = campaignFlow.value.character(id = id),
)
}
fun characterInstance(
chracterInstanceId: Campaign.CharacterInstance.Id,
): Campaign.CharacterInstance {
return campaignFlow.value.character(chracterInstanceId)
}
}

View file

@ -47,7 +47,7 @@ class CampaignStore(
}
private fun updateCharacteristic(
characterId: String,
characterId: Campaign.CharacterInstance.Id,
characteristic: Campaign.CharacterInstance.Characteristic,
value: Int,
) {
@ -69,9 +69,11 @@ class CampaignStore(
}
is UpdatePlayerCharacteristicMessage -> {
val id = factory.convertFromV1(characterInstanceIdJson = payload.characterInstanceId)
val characteristic = factory.convertFromV1(characteristicJson = payload.characteristic)
updateCharacteristic(
characterId = payload.characterId,
characteristic = payload.characteristic,
characterId = id,
characteristic = characteristic,
value = payload.value,
)
}

View file

@ -23,11 +23,11 @@ class CharacterSheetRepository(
}
suspend fun characterDetail(
characterId: String?,
characterSheetId: String?,
forceUpdate: Boolean = false,
): CharacterSheet? {
return try {
characterId?.let { store.characterDetail(characterId = it, forceUpdate = forceUpdate) }
characterSheetId?.let { store.characterDetail(characterId = it, forceUpdate = forceUpdate) }
} catch (exception: Exception) {
null
}

View file

@ -1,6 +1,6 @@
package com.pixelized.desktop.lwa.repository.settings
import com.pixelized.desktop.lwa.business.SettingsUseCase
import com.pixelized.desktop.lwa.usecase.SettingsUseCase
import com.pixelized.desktop.lwa.repository.settings.model.Settings
import com.pixelized.desktop.lwa.repository.settings.model.SettingsJson
import com.pixelized.desktop.lwa.repository.settings.model.SettingsJsonV1

View file

@ -1,6 +1,6 @@
package com.pixelized.desktop.lwa.repository.settings
import com.pixelized.desktop.lwa.business.SettingsUseCase
import com.pixelized.desktop.lwa.usecase.SettingsUseCase
import com.pixelized.desktop.lwa.repository.settings.model.Settings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers

View file

@ -1,6 +1,6 @@
package com.pixelized.desktop.lwa.repository.settings
import com.pixelized.desktop.lwa.business.SettingsUseCase
import com.pixelized.desktop.lwa.usecase.SettingsUseCase
import com.pixelized.desktop.lwa.repository.settings.model.Settings
import com.pixelized.desktop.lwa.repository.settings.model.SettingsJson
import com.pixelized.shared.lwa.storePath
@ -10,7 +10,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File

View file

@ -0,0 +1,100 @@
package com.pixelized.desktop.lwa.ui.composable.character.characteristic
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
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.network.NetworkRepository
import com.pixelized.shared.lwa.model.AlteredCharacterSheetFactory
import com.pixelized.shared.lwa.model.alteration.FieldAlteration
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.Campaign.CharacterInstance.Characteristic
import com.pixelized.shared.lwa.model.campaign.CampaignJsonFactory
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet
import com.pixelized.shared.lwa.protocol.websocket.payload.UpdatePlayerCharacteristicMessage
class CharacterDetailCharacteristicDialogViewModel(
private val characterSheetRepository: CharacterSheetRepository,
private val campaignRepository: CampaignRepository,
private val alterationRepository: AlterationRepository,
private val campaignJsonFactory: CampaignJsonFactory,
private val alteredCharacterSheetFactory: AlteredCharacterSheetFactory,
private val factory: CharacterSheetCharacteristicDialogFactory,
private val network: NetworkRepository,
) : ViewModel() {
private val _statChangeDialog = mutableStateOf<CharacterSheetCharacteristicDialogUio?>(null)
val statChangeDialog: State<CharacterSheetCharacteristicDialogUio?> get() = _statChangeDialog
fun hideSubCharacteristicDialog() {
_statChangeDialog.value = null
}
suspend fun showSubCharacteristicDialog(
characterInstanceId: Campaign.CharacterInstance.Id,
characteristic: Characteristic,
) {
val sheet: CharacterSheet? = characterSheetRepository.characterDetail(
characterSheetId = characterInstanceId.characterSheetId,
)
val characterInstance: Campaign.CharacterInstance = campaignRepository.characterInstance(
chracterInstanceId = characterInstanceId,
)
val alterations: Map<String, List<FieldAlteration>> = alterationRepository.alterations(
characterInstanceId = characterInstanceId,
)
_statChangeDialog.value = factory.convertToDialogUio(
characterInstanceId = characterInstanceId,
characteristic = characteristic,
characterSheet = sheet,
characterInstance = characterInstance,
alterations = alterations,
)
}
suspend fun changeSubCharacteristic(
characterInstanceId: Campaign.CharacterInstance.Id,
characteristic: Characteristic,
value: Int,
) {
// fetch the linked character sheet
val sheet = characterSheetRepository.characterDetail(
characterSheetId = characterInstanceId.characterSheetId,
)
val alterations = alterationRepository.alterations(
characterInstanceId = characterInstanceId,
)
// we need the maximum HP / Power that the character sheet have.
if (sheet != null) {
val alteredSheet = alteredCharacterSheetFactory.sheet(
characterSheet = sheet,
alterations = alterations,
)
// convert the data to json format
val characterInstanceIdJson = campaignJsonFactory.convertToJson(
id = characterInstanceId,
)
val characteristicJson = campaignJsonFactory.convertToJson(
characteristic = characteristic,
)
// share the data through the websocket.
network.share(
payload = UpdatePlayerCharacteristicMessage(
characterInstanceId = characterInstanceIdJson,
characteristic = characteristicJson,
value = when (characteristic) {
Characteristic.Damage -> {
alteredSheet.maxHp - value
}
Characteristic.Power -> {
alteredSheet.maxPp - value
}
},
),
)
}
}
}

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog
package com.pixelized.desktop.lwa.ui.composable.character.characteristic
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
@ -49,8 +49,9 @@ import lwacharactersheet.composeapp.generated.resources.dialog__confirm_action
import org.jetbrains.compose.resources.stringResource
@Stable
data class StatChangeDialogUio(
val id: Campaign.CharacterInstance.Characteristic,
data class CharacterSheetCharacteristicDialogUio(
val characterInstanceId: Campaign.CharacterInstance.Id,
val characteristic: Campaign.CharacterInstance.Characteristic,
val label: String,
val value: () -> TextFieldValue,
val onValueChange: (TextFieldValue) -> Unit,
@ -58,9 +59,9 @@ data class StatChangeDialogUio(
)
@Composable
fun CharacterSheetStatDialog(
dialog: State<StatChangeDialogUio?>,
onConfirm: (StatChangeDialogUio) -> Unit,
fun CharacterSheetCharacteristicDialog(
dialog: State<CharacterSheetCharacteristicDialogUio?>,
onConfirm: (CharacterSheetCharacteristicDialogUio) -> Unit,
onDismissRequest: () -> Unit,
) {
AnimatedContent(
@ -92,8 +93,8 @@ fun CharacterSheetStatDialog(
@Composable
private fun Dialog(
dialog: StatChangeDialogUio,
onConfirm: (StatChangeDialogUio) -> Unit,
dialog: CharacterSheetCharacteristicDialogUio,
onConfirm: (CharacterSheetCharacteristicDialogUio) -> Unit,
onDismissRequest: () -> Unit,
) {
val typography = MaterialTheme.typography

View file

@ -0,0 +1,70 @@
package com.pixelized.desktop.lwa.ui.composable.character.characteristic
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.pixelized.shared.lwa.model.AlteredCharacterSheetFactory
import com.pixelized.shared.lwa.model.alteration.FieldAlteration
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.Campaign.CharacterInstance.Characteristic
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 lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__sub_characteristics__hit_point
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__sub_characteristics__power_point
import org.jetbrains.compose.resources.getString
class CharacterSheetCharacteristicDialogFactory(
private val alteredCharacterSheetFactory: AlteredCharacterSheetFactory,
) {
suspend fun convertToDialogUio(
characterInstanceId: Campaign.CharacterInstance.Id,
characteristic: Characteristic,
characterSheet: CharacterSheet?,
characterInstance: Campaign.CharacterInstance,
alterations: Map<String, List<FieldAlteration>>,
): CharacterSheetCharacteristicDialogUio? {
if (characterSheet == null) return null
val alteredCharacterSheet = alteredCharacterSheetFactory.sheet(
characterSheet = characterSheet,
alterations = alterations,
)
return when (characteristic) {
Characteristic.Damage -> {
val value = mutableStateOf(
"${alteredCharacterSheet.maxHp - characterInstance.damage}".let {
TextFieldValue(text = it, selection = TextRange(it.length))
}
)
CharacterSheetCharacteristicDialogUio(
characterInstanceId = characterInstanceId,
characteristic = characteristic,
label = getString(resource = Res.string.character_sheet_edit__sub_characteristics__hit_point),
value = { value.value },
onValueChange = { value.value = it },
maxValue = "${alteredCharacterSheet.maxHp}",
)
}
Characteristic.Power -> {
val value = mutableStateOf(
"${alteredCharacterSheet.maxPp - characterInstance.power}".let {
TextFieldValue(text = it, selection = TextRange(it.length))
}
)
CharacterSheetCharacteristicDialogUio(
characterInstanceId = characterInstanceId,
characteristic = characteristic,
label = getString(resource = Res.string.character_sheet_edit__sub_characteristics__power_point),
value = { value.value },
onValueChange = { value.value = it },
maxValue = "${alteredCharacterSheet.maxPp}",
)
}
}
}
}

View file

@ -3,30 +3,43 @@ package com.pixelized.desktop.lwa.ui.navigation.screen.destination
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.CharacterSheetPage
import com.pixelized.desktop.lwa.utils.extention.ARG
import com.pixelized.shared.lwa.model.campaign.Campaign
object CharacterSheetDestination {
private const val ROUTE = "character.sheet"
private const val CHARACTER_ID = "id"
private const val CHARACTER_SHEET_ID = "sheetId"
private const val CHARACTER_INSTANCE_ID = "instanceId"
fun baseRoute() = "$ROUTE?${CHARACTER_ID.ARG}"
fun baseRoute() = "$ROUTE?${CHARACTER_SHEET_ID.ARG}&${CHARACTER_INSTANCE_ID.ARG}"
fun navigationRoute(id: String) = "$ROUTE?$CHARACTER_ID=$id"
fun navigationRoute(id: Campaign.CharacterInstance.Id) = ROUTE +
"?$CHARACTER_SHEET_ID=${id.characterSheetId}" +
"&$CHARACTER_INSTANCE_ID=${id.instanceId}"
fun arguments() = listOf(
navArgument(CHARACTER_ID) {
nullable = true
}
navArgument(CHARACTER_SHEET_ID) {
nullable = false
type = NavType.StringType
},
navArgument(CHARACTER_INSTANCE_ID) {
nullable = false
type = NavType.IntType
},
)
data class Argument(
val id: String,
val characterInstanceId: Campaign.CharacterInstance.Id,
) {
constructor(savedStateHandle: SavedStateHandle) : this(
id = savedStateHandle.get<String>(CHARACTER_ID) ?: error("missing character id")
characterInstanceId = Campaign.CharacterInstance.Id(
savedStateHandle.get<String>(CHARACTER_SHEET_ID) ?: error("missing character id"),
savedStateHandle.get<Int>(CHARACTER_INSTANCE_ID) ?: error("missing character id"),
),
)
}
}
@ -41,7 +54,7 @@ fun NavGraphBuilder.composableCharacterSheetPage() {
}
fun NavHostController.navigateToCharacterSheet(
id: String,
id: Campaign.CharacterInstance.Id,
) {
val route = CharacterSheetDestination.navigationRoute(id = id)
navigate(route = route)

View file

@ -4,10 +4,11 @@ import androidx.compose.runtime.Stable
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.navigation.window.WindowController
import com.pixelized.shared.lwa.model.campaign.Campaign
@Stable
class CharacterSheetWindow(
val characterId: String,
val characterId: Campaign.CharacterInstance.Id,
title: String,
size: DpSize,
) : Window(
@ -16,7 +17,7 @@ class CharacterSheetWindow(
)
fun WindowController.navigateToCharacterSheet(
characterId: String,
characterId: Campaign.CharacterInstance.Id,
title: String,
) {
showWindow(

View file

@ -94,7 +94,7 @@ fun CampaignScreen(
modifier = Modifier
.padding(all = 8.dp)
.fillMaxHeight(),
viewModel = characterDetailViewModel,
detailViewModel = characterDetailViewModel,
dismissedViewModel = dismissedViewModel,
)
},

View file

@ -1,6 +1,8 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.detail
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
@ -24,8 +26,8 @@ import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -33,7 +35,11 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.composable.blur.BlurContentController
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterDetailCharacteristicDialogViewModel
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterSheetCharacteristicDialog
import com.pixelized.desktop.lwa.ui.theme.lwa
import com.pixelized.shared.lwa.model.campaign.Campaign
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.ic_close_24dp
@ -46,34 +52,36 @@ import org.koin.compose.viewmodel.koinViewModel
@Stable
data class CharacterDetailHeaderUio(
val id: String,
val id: Campaign.CharacterInstance.Id,
val portrait: String?,
val name: String,
val hp: String,
val maxHp: String,
val pp: String,
val maxPp: String,
val mov: String,
)
@Stable
data class CharacterDetailHeaderInstanceUio(
val hp: String,
val pp: String,
)
@Composable
fun CharacterDetail(
modifier: Modifier = Modifier,
dismissedViewModel: CharacterDiminishedViewModel,
viewModel: CharacterDetailViewModel = koinViewModel(),
detailViewModel: CharacterDetailViewModel = koinViewModel(),
characteristicDialogViewModel: CharacterDetailCharacteristicDialogViewModel = koinViewModel(),
) {
val blurController = remember { BlurContentController() }
val scope = rememberCoroutineScope()
val detail = viewModel.detail.collectAsState()
val detail = detailViewModel.detail.collectAsState()
AnimatedContent(
modifier = modifier,
targetState = detail.value,
transitionSpec = {
(fadeIn() + slideInHorizontally { it / 2 }).togetherWith(fadeOut())
if (initialState?.id != targetState?.id) {
(fadeIn() + slideInHorizontally { it / 2 }).togetherWith(fadeOut())
} else {
EnterTransition.None togetherWith ExitTransition.None
}
}
) {
when (it) {
@ -82,25 +90,58 @@ fun CharacterDetail(
)
else -> {
val dynDetail = viewModel.collectDynamicDetailAsState(id = it.id)
CharacterDetailContent(
Box(
modifier = Modifier
.fillMaxHeight()
.width(width = 128.dp * 4),
character = it,
dynDetail = dynDetail,
onDismissRequest = {
viewModel.hideCharacter()
},
onDiminished = {
scope.launch {
dismissedViewModel.showDiminishedDialog(id = it.id)
) {
CharacterDetailContent(
modifier = Modifier.matchParentSize(),
character = it,
onDismissRequest = {
detailViewModel.hideCharacter()
},
onDiminished = {
scope.launch {
dismissedViewModel.showDiminishedDialog(id = it.id)
}
},
onHp = {
scope.launch {
characteristicDialogViewModel.showSubCharacteristicDialog(
characterInstanceId = it.id,
characteristic = Campaign.CharacterInstance.Characteristic.Damage,
)
}
},
onPp = {
scope.launch {
characteristicDialogViewModel.showSubCharacteristicDialog(
characterInstanceId = it.id,
characteristic = Campaign.CharacterInstance.Characteristic.Power,
)
}
},
)
CharacterSheetCharacteristicDialog(
dialog = characteristicDialogViewModel.statChangeDialog,
onConfirm = { dialog ->
scope.launch {
characteristicDialogViewModel.changeSubCharacteristic(
characterInstanceId = dialog.characterInstanceId,
characteristic = dialog.characteristic,
value = dialog.value().text.toIntOrNull() ?: 0,
)
characteristicDialogViewModel.hideSubCharacteristicDialog()
blurController.hide()
}
},
onDismissRequest = {
characteristicDialogViewModel.hideSubCharacteristicDialog()
blurController.hide()
}
},
onHp = { },
onPp = { },
)
)
}
}
}
}
@ -110,7 +151,6 @@ fun CharacterDetail(
fun CharacterDetailContent(
modifier: Modifier = Modifier,
character: CharacterDetailHeaderUio,
dynDetail: State<CharacterDetailHeaderInstanceUio?>,
onDismissRequest: () -> Unit,
onDiminished: () -> Unit,
onHp: () -> Unit,
@ -127,7 +167,6 @@ fun CharacterDetailContent(
modifier = Modifier.padding(start = 16.dp).fillMaxWidth(),
character = character,
onDismissRequest = onDismissRequest,
dynDetail = dynDetail,
onDiminished = onDiminished,
onHp = onHp,
onPp = onPp,
@ -169,7 +208,6 @@ private fun Background(
private fun CharacterHeader(
modifier: Modifier = Modifier,
character: CharacterDetailHeaderUio,
dynDetail: State<CharacterDetailHeaderInstanceUio?>,
onDismissRequest: () -> Unit,
onDiminished: () -> Unit,
onHp: () -> Unit,
@ -224,13 +262,13 @@ private fun CharacterHeader(
style = MaterialTheme.typography.h6,
color = MaterialTheme.lwa.colorScheme.base.primary,
fontWeight = FontWeight.Bold,
text = dynDetail.value?.hp ?: character.hp,
text = character.hp,
)
Text(
modifier = Modifier.alignByBaseline(),
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Thin,
text = "/${character.hp}",
text = "/${character.maxHp}",
)
}
Row(
@ -247,13 +285,13 @@ private fun CharacterHeader(
style = MaterialTheme.typography.h6,
color = MaterialTheme.lwa.colorScheme.base.primary,
fontWeight = FontWeight.Bold,
text = dynDetail.value?.pp ?: character.pp,
text = character.pp,
)
Text(
modifier = Modifier.alignByBaseline(),
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Thin,
text = "/${character.pp}",
text = "/${character.maxPp}",
)
}
Row(

View file

@ -1,39 +1,41 @@
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.AlteredCharacterSheetFactory
import com.pixelized.shared.lwa.model.alteration.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.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,
private val alteredCharacterSheetFactory: AlteredCharacterSheetFactory,
) {
fun convertToCharacterDetailHeaderUio(
sheet: CharacterSheet?,
characterInstanceId: Campaign.CharacterInstance.Id,
characterSheet: CharacterSheet?,
characterInstance: Campaign.CharacterInstance,
alterations: Map<String, List<FieldAlteration>>,
): CharacterDetailHeaderUio? {
if (sheet == null) return null
if (characterSheet == null) return null
fun List<FieldAlteration>?.sum(): Int {
return this?.sumOf {
expressionUseCase.computeExpression(sheet = sheet, expression = it.expression)
} ?: 0
}
val alteredCharacterSheet = alteredCharacterSheetFactory.sheet(
characterSheet = characterSheet,
alterations = alterations,
)
val maxHp = alteredCharacterSheet.maxHp
val maxPp = alteredCharacterSheet.maxPp
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()}"
id = characterInstanceId,
portrait = alteredCharacterSheet.portrait,
name = alteredCharacterSheet.name,
hp = "${maxHp - characterInstance.damage}",
maxHp = "$maxHp",
pp = "${maxPp - characterInstance.power}",
maxPp = "$maxPp",
mov = "${alteredCharacterSheet.movement}"
)
}
}

View file

@ -1,85 +1,58 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.detail
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.shared.lwa.model.campaign.damage
import com.pixelized.shared.lwa.model.campaign.power
import com.pixelized.shared.lwa.model.campaign.Campaign
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.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
class CharacterDetailViewModel(
private val characterRepository: CharacterSheetRepository,
private val characterSheetRepository: CharacterSheetRepository,
private val campaignRepository: CampaignRepository,
private val alterationRepository: AlterationRepository,
private val characterDetailFactory: CharacterDetailFactory,
) : ViewModel() {
private val displayedCharacterId = MutableStateFlow<String?>(null)
private val displayedCharacterId = MutableStateFlow<Campaign.CharacterInstance.Id?>(null)
@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,
)
val detail: StateFlow<CharacterDetailHeaderUio?> = displayedCharacterId
.flatMapLatest { characterInstanceId ->
if (characterInstanceId != null) {
campaignRepository
.characterInstanceFlow(id = characterInstanceId)
.flatMapLatest { characterInstance ->
combine(
characterSheetRepository.characterDetailFlow(characterId = characterInstanceId.characterSheetId),
alterationRepository.alterationsFlow(characterId = characterInstanceId),
) { characterSheet, alterations ->
characterDetailFactory.convertToCharacterDetailHeaderUio(
characterInstanceId = characterInstanceId,
characterSheet = characterSheet,
characterInstance = characterInstance,
alterations = alterations,
)
}
}
} else {
flowOf(null)
}
} else {
flowOf(null)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
@Composable
@Stable
fun collectDynamicDetailAsState(id: String): State<CharacterDetailHeaderInstanceUio?> {
val scope = rememberCoroutineScope()
val flow: StateFlow<CharacterDetailHeaderInstanceUio?> = remember(id) {
combine(
characterRepository.characterDetailFlow(id),
campaignRepository.characterInstanceFlow(id = id),
) { sheet, instance ->
if (sheet == null) return@combine null
CharacterDetailHeaderInstanceUio(
hp = "${sheet.hp - instance.damage}",
pp = "${sheet.power - instance.power}",
)
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = null,
)
}
return flow.collectAsState()
}
fun showCharacter(id: String) {
fun showCharacter(id: Campaign.CharacterInstance.Id) {
displayedCharacterId.value = id
}

View file

@ -7,18 +7,21 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.DiminishedStatDialogUio
import com.pixelized.shared.lwa.model.campaign.Campaign
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.character_sheet__diminished__label
import org.jetbrains.compose.resources.getString
class CharacterDiminishedViewModel(
private val repository: CharacterSheetRepository,
private val characterSheetRepository: CharacterSheetRepository,
) : ViewModel() {
private val _diminishedDialog = mutableStateOf<DiminishedStatDialogUio?>(null)
val diminishedDialog: State<DiminishedStatDialogUio?> get() = _diminishedDialog
suspend fun showDiminishedDialog(id: String) {
suspend fun showDiminishedDialog(
id: Campaign.CharacterInstance.Id,
) {
val diminished = 0 // TODO repository.characterDiminishedFlow(id = id).value
val textFieldValue = mutableStateOf(
TextFieldValue("$diminished", selection = TextRange(index = 0))
@ -48,4 +51,6 @@ class CharacterDiminishedViewModel(
// diminished = value,
// )
}
}

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.pixelized.desktop.lwa.ui.composable.decoratedBox.DecoratedBox
import com.pixelized.desktop.lwa.ui.theme.lwa
import com.pixelized.shared.lwa.model.campaign.Campaign
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.ic_heart_24dp
import lwacharactersheet.composeapp.generated.resources.ic_water_drop_24dp
@ -33,8 +34,9 @@ import org.jetbrains.compose.resources.painterResource
@Stable
data class PlayerPortraitUio(
val id: String,
val id: Campaign.CharacterInstance.Id,
val portrait: String?,
val name: String,
val hp: Int,
val maxHp: Int,
val pp: Int,
@ -46,7 +48,7 @@ fun PlayerPortrait(
modifier: Modifier = Modifier,
size: DpSize,
character: PlayerPortraitUio,
onCharacter: (id: String) -> Unit,
onCharacter: (id: Campaign.CharacterInstance.Id) -> Unit,
) {
val colorScheme = MaterialTheme.lwa.colorScheme

View file

@ -1,7 +1,6 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.lazy.LazyColumn
@ -11,6 +10,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.pixelized.shared.lwa.model.campaign.Campaign
import org.koin.compose.viewmodel.koinViewModel
object PlayerRibbon {
@ -24,7 +24,7 @@ fun PlayerRibbon(
modifier: Modifier = Modifier,
playerRibbonViewModel: PlayerRibbonViewModel = koinViewModel(),
padding: PaddingValues = PaddingValues(all = 8.dp),
onCharacter: (id: String) -> Unit,
onCharacter: (id: Campaign.CharacterInstance.Id) -> Unit,
) {
val characters = playerRibbonViewModel.characters.collectAsState()
@ -45,7 +45,7 @@ fun PlayerRibbon(
)
PlayerPortraitRoll(
size = PlayerRibbon.Default.size,
value = playerRibbonViewModel.roll(characterId = it.id).value,
value = playerRibbonViewModel.roll(characterSheetId = it.id.characterSheetId).value,
onRightClick = {
playerRibbonViewModel.onPortraitRollRightClick(characterId = it.characterId)
},

View file

@ -1,48 +1,36 @@
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.AlteredCharacterSheetFactory
import com.pixelized.shared.lwa.model.alteration.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(
private val expressionUseCase: ExpressionUseCase,
private val alteredCharacterSheetFactory: AlteredCharacterSheetFactory,
) {
fun convertToPlayerPortraitUio(
characterSheet: CharacterSheet?,
characterInstanceId: Campaign.CharacterInstance.Id,
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()
val alteredCharacterSheet = alteredCharacterSheetFactory.sheet(
characterSheet = characterSheet,
alterations = alterations,
)
return PlayerPortraitUio(
id = characterSheet.id,
portrait = alterations[THUMBNAIL]
?.firstNotNullOfOrNull { (it.expression as? Expression.UrlExpression)?.url }
?: characterSheet.thumbnail,
hp = maxHp - characterInstance.damage,
maxHp = maxHp,
pp = maxPp - characterInstance.power,
maxPp = maxPp,
id = characterInstanceId,
portrait = alteredCharacterSheet.thumbnail,
name = alteredCharacterSheet.name,
hp = alteredCharacterSheet.maxHp - characterInstance.damage,
maxHp = alteredCharacterSheet.maxHp,
pp = alteredCharacterSheet.maxPp - characterInstance.power,
maxPp = alteredCharacterSheet.maxPp,
)
}
}

View file

@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.text.Collator
class PlayerRibbonViewModel(
private val rollHistoryRepository: RollHistoryRepository,
@ -35,18 +36,21 @@ class PlayerRibbonViewModel(
combine<PlayerPortraitUio?, List<PlayerPortraitUio>>(
flows = campaign.characters.map { entry ->
combine(
characterRepository.characterDetailFlow(characterId = entry.key),
characterRepository.characterDetailFlow(characterId = entry.key.characterSheetId),
alterationRepository.alterationsFlow(characterId = entry.key),
) { sheet, alterations ->
ribbonFactory.convertToPlayerPortraitUio(
characterSheet = sheet,
characterInstanceId = entry.key,
characterInstance = entry.value,
alterations = alterations,
)
}
},
transform = { headers ->
headers.mapNotNull { it }.toList()
headers.mapNotNull { it }
.sortedWith(compareBy(Collator.getInstance()) { it.name })
.toList()
}
)
}
@ -63,7 +67,7 @@ class PlayerRibbonViewModel(
campaignRepository.campaignFlow.collectLatest {
it.characters.keys.forEach { id ->
characterRepository.characterDetail(
characterId = id,
characterSheetId = id.characterSheetId,
forceUpdate = true,
)
}
@ -73,13 +77,13 @@ class PlayerRibbonViewModel(
@Composable
@Stable
fun roll(characterId: String): State<PlayerPortraitRollUio?> {
val state = rolls.getOrPut(characterId) { mutableStateOf(null) }
LaunchedEffect(characterId) {
fun roll(characterSheetId: String): State<PlayerPortraitRollUio?> {
val state = rolls.getOrPut(characterSheetId) { mutableStateOf(null) }
LaunchedEffect(characterSheetId) {
rollHistoryRepository.rolls.collect { roll ->
if (roll.characterId == characterId) {
if (roll.characterId == characterSheetId) {
state.value = PlayerPortraitRollUio(
characterId = characterId,
characterId = characterSheetId,
value = roll.rollValue,
label = roll.resultLabel?.split(" ")?.joinToString(separator = "\n") { it }
)

View file

@ -1,16 +1,17 @@
package com.pixelized.desktop.lwa.ui.screen.characterSheet.detail
import com.pixelized.desktop.lwa.business.ExpressionUseCase
import com.pixelized.desktop.lwa.repository.alteration.model.FieldAlteration
import com.pixelized.desktop.lwa.ui.composable.tooltip.TooltipUio
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.CharacterSheetPageUio.Characteristic
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.CharacterSheetPageUio.Node
import com.pixelized.shared.lwa.model.AlteredCharacterSheetFactory
import com.pixelized.shared.lwa.model.alteration.FieldAlteration
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.character
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
import com.pixelized.shared.lwa.usecase.ExpressionUseCase
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.character_sheet__characteristics__cha
import lwacharactersheet.composeapp.generated.resources.character_sheet__characteristics__con
@ -43,34 +44,32 @@ import lwacharactersheet.composeapp.generated.resources.tooltip__sub_characteris
import org.jetbrains.compose.resources.getString
class CharacterSheetFactory(
private val alteredCharacterSheetFactory: AlteredCharacterSheetFactory,
private val skillUseCase: ExpressionUseCase,
private val expressionUseCase: ExpressionUseCase,
) {
suspend fun convertToUio(
sheet: CharacterSheet?,
characterSheet: CharacterSheet?,
instanceId: Campaign.CharacterInstance.Id,
campaign: Campaign,
alterations: Map<String, List<FieldAlteration>>,
): CharacterSheetPageUio? {
if (sheet == null) return null
if (characterSheet == null) return null
fun List<FieldAlteration>?.sum(): Int {
return this?.sumOf {
expressionUseCase.computeExpression(sheet = sheet, expression = it.expression)
} ?: 0
}
val alteredSheet = alteredCharacterSheetFactory.sheet(
characterSheet = characterSheet,
alterations = alterations,
)
val maxHp = sheet.hp + alterations[CharacteristicId.HP].sum()
val maxPp = sheet.pp + alterations[CharacteristicId.PP].sum()
val instance = campaign.character(sheet.id)
val instance = campaign.character(id = instanceId)
return CharacterSheetPageUio(
id = sheet.id,
name = sheet.name,
id = alteredSheet.id,
name = alteredSheet.name,
characteristics = listOf(
Characteristic(
id = CharacteristicId.STR,
label = getString(Res.string.character_sheet__characteristics__str),
value = "${sheet.strength + alterations[CharacteristicId.STR].sum()}",
value = "${alteredSheet.strength}",
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__characteristics__str),
description = getString(Res.string.tooltip__characteristics__strength),
@ -80,7 +79,7 @@ class CharacterSheetFactory(
Characteristic(
id = CharacteristicId.DEX,
label = getString(Res.string.character_sheet__characteristics__dex),
value = "${sheet.dexterity + alterations[CharacteristicId.DEX].sum()}",
value = "${alteredSheet.dexterity}",
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__characteristics__dex),
description = getString(Res.string.tooltip__characteristics__dexterity),
@ -90,7 +89,7 @@ class CharacterSheetFactory(
Characteristic(
id = CharacteristicId.CON,
label = getString(Res.string.character_sheet__characteristics__con),
value = "${sheet.constitution + alterations[CharacteristicId.CON].sum()}",
value = "${alteredSheet.constitution}",
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__characteristics__con),
description = getString(Res.string.tooltip__characteristics__constitution),
@ -100,7 +99,7 @@ class CharacterSheetFactory(
Characteristic(
id = CharacteristicId.HEI,
label = getString(Res.string.character_sheet__characteristics__hei),
value = "${sheet.height + alterations[CharacteristicId.HEI].sum()}",
value = "${alteredSheet.height}",
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__characteristics__hei),
description = getString(Res.string.tooltip__characteristics__height),
@ -110,7 +109,7 @@ class CharacterSheetFactory(
Characteristic(
id = CharacteristicId.INT,
label = getString(Res.string.character_sheet__characteristics__int),
value = "${sheet.intelligence + alterations[CharacteristicId.INT].sum()}",
value = "${alteredSheet.intelligence}",
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__characteristics__int),
description = getString(Res.string.tooltip__characteristics__intelligence),
@ -120,7 +119,7 @@ class CharacterSheetFactory(
Characteristic(
id = CharacteristicId.POW,
label = getString(Res.string.character_sheet__characteristics__pow),
value = "${sheet.power + alterations[CharacteristicId.POW].sum()}",
value = "${alteredSheet.power}",
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__characteristics__pow),
description = getString(Res.string.tooltip__characteristics__power),
@ -130,7 +129,7 @@ class CharacterSheetFactory(
Characteristic(
id = CharacteristicId.CHA,
label = getString(Res.string.character_sheet__characteristics__cha),
value = "${sheet.charisma + alterations[CharacteristicId.CHA].sum()}",
value = "${alteredSheet.charisma}",
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__characteristics__cha),
description = getString(Res.string.tooltip__characteristics__charisma),
@ -142,7 +141,7 @@ class CharacterSheetFactory(
Characteristic(
id = CharacteristicId.MOV,
label = getString(Res.string.character_sheet__sub_characteristics__movement),
value = "${sheet.movement + alterations[CharacteristicId.MOV].sum()}",
value = "${alteredSheet.movement}",
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__sub_characteristics__movement),
description = getString(Res.string.tooltip__sub_characteristics__movement),
@ -152,7 +151,7 @@ class CharacterSheetFactory(
Characteristic(
id = CharacteristicId.HP,
label = getString(Res.string.character_sheet__sub_characteristics__hit_point),
value = "${maxHp - instance.damage}/${maxHp}",
value = alteredSheet.maxHp.let { maxHp -> "${maxHp - instance.damage}/${maxHp}" },
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__sub_characteristics__hit_point),
description = getString(Res.string.tooltip__sub_characteristics__hit_point),
@ -162,7 +161,7 @@ class CharacterSheetFactory(
Characteristic(
id = CharacteristicId.PP,
label = getString(Res.string.character_sheet__sub_characteristics__power_point),
value = "${maxPp - instance.power}/${maxPp}",
value = alteredSheet.maxPp.let { maxPp -> "${maxPp - instance.power}/${maxPp}" },
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__sub_characteristics__power_point),
description = getString(Res.string.tooltip__sub_characteristics__power_point),
@ -172,7 +171,7 @@ class CharacterSheetFactory(
Characteristic(
id = CharacteristicId.DMG,
label = getString(Res.string.character_sheet__sub_characteristics__damage_bonus),
value = sheet.damageBonus,
value = alteredSheet.damageBonus,
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__sub_characteristics__damage_bonus),
description = getString(Res.string.tooltip__sub_characteristics__bonus_damage),
@ -182,7 +181,7 @@ class CharacterSheetFactory(
Characteristic(
id = CharacteristicId.ARMOR,
label = getString(Res.string.character_sheet__sub_characteristics__armor),
value = "${sheet.armor + alterations[CharacteristicId.ARMOR].sum()}",
value = "${alteredSheet.armor}",
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__sub_characteristics__armor),
description = getString(Res.string.tooltip__sub_characteristics__armor),
@ -192,7 +191,7 @@ class CharacterSheetFactory(
Characteristic(
id = CharacteristicId.LB,
label = getString(Res.string.character_sheet__sub_characteristics__learning),
value = "${sheet.learning + alterations[CharacteristicId.LB].sum()}",
value = "${alteredSheet.learning}",
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__sub_characteristics__learning),
description = getString(Res.string.tooltip__sub_characteristics__learning),
@ -202,7 +201,7 @@ class CharacterSheetFactory(
Characteristic(
id = CharacteristicId.GHP,
label = getString(Res.string.character_sheet__sub_characteristics__hp_grow),
value = "${sheet.hpGrow + alterations[CharacteristicId.GHP].sum()}",
value = "${alteredSheet.hpGrow}",
tooltips = TooltipUio(
title = getString(Res.string.character_sheet__sub_characteristics__hp_grow),
description = getString(Res.string.tooltip__sub_characteristics__hp_grow),
@ -210,14 +209,14 @@ class CharacterSheetFactory(
editable = false,
),
),
commonSkills = sheet.commonSkills.map { skill ->
commonSkills = characterSheet.commonSkills.map { skill ->
Node(
id = skill.id,
label = skill.label,
value = skillUseCase.computeSkillValue(
sheet = sheet,
sheet = characterSheet,
skill = skill,
alterations = alterations[skill.id].sum(),
alterations = alterations,
),
tooltips = skill.description?.let {
TooltipUio(
@ -228,7 +227,7 @@ class CharacterSheetFactory(
used = skill.used,
)
},
specialSKills = sheet.specialSkills.map { skill ->
specialSKills = characterSheet.specialSkills.map { skill ->
Node(
id = skill.id,
label = skill.label,
@ -239,14 +238,14 @@ class CharacterSheetFactory(
)
},
value = skillUseCase.computeSkillValue(
sheet = sheet,
sheet = characterSheet,
skill = skill,
alterations = alterations[skill.id].sum(),
alterations = alterations,
),
used = skill.used,
)
},
magicsSkills = sheet.magicSkills.map { skill ->
magicsSkills = characterSheet.magicSkills.map { skill ->
Node(
id = skill.id,
label = skill.label,
@ -257,14 +256,14 @@ class CharacterSheetFactory(
)
},
value = skillUseCase.computeSkillValue(
sheet = sheet,
sheet = characterSheet,
skill = skill,
alterations = alterations[skill.id].sum(),
alterations = alterations,
),
used = skill.used,
)
},
actions = sheet.actions.mapNotNull {
actions = characterSheet.actions.mapNotNull {
if (it.roll.isNotEmpty()) {
CharacterSheetPageUio.Roll(
label = it.label,

View file

@ -65,7 +65,6 @@ import com.pixelized.desktop.lwa.ui.navigation.window.LocalWindow
import com.pixelized.desktop.lwa.ui.navigation.window.destination.navigateToCharacterSheetEdit
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.CharacterSheetPageUio.Characteristic
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.CharacterSheetDeleteConfirmationDialog
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.CharacterSheetStatDialog
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.DiminishedStatDialog
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.preview.rememberCharacterSheetPreview
import com.pixelized.desktop.lwa.ui.screen.roll.RollPage
@ -147,11 +146,10 @@ fun CharacterSheetPage(
characterSheet = sheet,
diminishedValue = viewModel.diminishedValue,
onDiminished = {
// blurController.show()
// scope.launch {
// viewModel.showDiminishedDialog()
// }
viewModel.toggleWolf()
blurController.show()
scope.launch {
viewModel.showDiminishedDialog()
}
},
onEdit = {
windowController.navigateToCharacterSheetEdit(
@ -172,10 +170,10 @@ fun CharacterSheetPage(
viewModel.showRollOverlay()
},
onSubCharacteristic = {
blurController.show()
scope.launch {
viewModel.showSubCharacteristicDialog(id = it.id)
}
// blurController.show()
// scope.launch {
// viewModel.showSubCharacteristicDialog(id = it.id)
// }
},
onSkill = { node ->
blurController.show()
@ -232,22 +230,6 @@ fun CharacterSheetPage(
},
)
CharacterSheetStatDialog(
dialog = viewModel.statChangeDialog,
onConfirm = {
viewModel.changeSubCharacteristic(
characteristicId = it.id,
value = it.value().text.toIntOrNull() ?: 0,
)
viewModel.hideSubCharacteristicDialog()
blurController.hide()
},
onDismissRequest = {
viewModel.hideSubCharacteristicDialog()
blurController.hide()
}
)
DiminishedStatDialog(
dialog = viewModel.diminishedDialog,
onConfirm = {

View file

@ -5,8 +5,6 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -17,12 +15,6 @@ import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.CharacterSheetDestination
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.CharacterSheetDeleteConfirmationDialogUio
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.DiminishedStatDialogUio
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.StatChangeDialogUio
import com.pixelized.shared.lwa.model.campaign.Campaign.CharacterInstance.Characteristic
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.protocol.websocket.payload.UpdatePlayerCharacteristicMessage
import com.pixelized.shared.lwa.protocol.websocket.payload.UpdateSkillUsageMessage
import kotlinx.coroutines.flow.SharingStarted
@ -30,10 +22,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__sub_characteristics__hit_point
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__sub_characteristics__power_point
import org.jetbrains.compose.resources.getString
private typealias CSDCDialogUio = CharacterSheetDeleteConfirmationDialogUio
@ -55,9 +43,6 @@ class CharacterSheetViewModel(
private val _displayRollOverlay = mutableStateOf(false)
val displayRollOverlay: State<Boolean> get() = _displayRollOverlay
private val _statChangeDialog = mutableStateOf<StatChangeDialogUio?>(null)
val statChangeDialog: State<StatChangeDialogUio?> get() = _statChangeDialog
private val _diminishedDialog = mutableStateOf<DiminishedStatDialogUio?>(null)
val diminishedDialog: State<DiminishedStatDialogUio?> get() = _diminishedDialog
@ -71,12 +56,13 @@ class CharacterSheetViewModel(
get() = remember { mutableStateOf(null) }
private val sheetFlow = combine(
characterRepository.characterDetailFlow(characterId = argument.id),
characterRepository.characterDetailFlow(characterId = argument.characterInstanceId.characterSheetId),
campaignRepository.campaignFlow,
alteration.alterationsFlow(characterId = argument.id),
alteration.alterationsFlow(characterId = argument.characterInstanceId),
transform = { sheet, campaign, alterations ->
factory.convertToUio(
sheet = sheet,
characterSheet = sheet,
instanceId = argument.characterInstanceId,
campaign = campaign,
alterations = alterations
)
@ -90,10 +76,6 @@ class CharacterSheetViewModel(
@Composable
get() = sheetFlow.collectAsState()
fun toggleWolf() {
alteration.toggle(argument.id, "65e37d32-3031-4bf8-9369-d2c45d2efac0")
}
suspend fun deleteCharacter(id: String) {
characterRepository.deleteCharacter(characterId = id)
}
@ -102,7 +84,7 @@ class CharacterSheetViewModel(
viewModelScope.launch {
network.share(
payload = UpdateSkillUsageMessage(
characterId = argument.id,
characterId = argument.characterInstanceId.characterSheetId,
skillId = skill.id,
)
)
@ -111,7 +93,7 @@ class CharacterSheetViewModel(
fun showConfirmCharacterDeletionDialog() {
characterRepository.characterPreview(
characterId = argument.id
characterId = argument.characterInstanceId.characterSheetId
)?.let { preview ->
_displayDeleteConfirmationDialog.value = CharacterSheetDeleteConfirmationDialogUio(
id = preview.id,
@ -124,74 +106,6 @@ class CharacterSheetViewModel(
_displayDeleteConfirmationDialog.value = null
}
suspend fun showSubCharacteristicDialog(id: String) {
characterRepository.characterDetail(
characterId = argument.id,
)?.let { sheet ->
val instance = campaignRepository.characterInstanceFlow(id = argument.id).value
_statChangeDialog.value = when (id) {
CharacterSheet.CharacteristicId.HP -> {
val value = mutableStateOf(
"${sheet.hp - instance.damage}".let {
TextFieldValue(text = it, selection = TextRange(it.length))
}
)
StatChangeDialogUio(
id = Characteristic.Damage,
label = getString(resource = Res.string.character_sheet_edit__sub_characteristics__hit_point),
value = { value.value },
onValueChange = { value.value = it },
maxValue = "${sheet.hp}",
)
}
CharacterSheet.CharacteristicId.PP -> {
val value = mutableStateOf(
"${sheet.power - instance.power}".let {
TextFieldValue(text = it, selection = TextRange(it.length))
}
)
StatChangeDialogUio(
id = Characteristic.Power,
label = getString(resource = Res.string.character_sheet_edit__sub_characteristics__power_point),
value = { value.value },
onValueChange = { value.value = it },
maxValue = "${sheet.power}",
)
}
else -> null
}
}
}
fun hideSubCharacteristicDialog() {
_statChangeDialog.value = null
}
fun changeSubCharacteristic(
characteristicId: Characteristic,
value: Int,
) {
viewModelScope.launch {
characterRepository.characterDetail(
characterId = argument.id,
)?.let { sheet ->
network.share(
payload = UpdatePlayerCharacteristicMessage(
characterId = argument.id,
characteristic = characteristicId,
value = when (characteristicId) {
Characteristic.Damage -> sheet.hp - value
Characteristic.Power -> sheet.pp - value
else -> sheet.movement - value
},
),
)
}
}
}
fun showRollOverlay() {
_displayRollOverlay.value = true
}

View file

@ -42,6 +42,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.composable.decoratedBox.DecoratedBox
import com.pixelized.shared.lwa.model.campaign.Campaign
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.dialog__cancel_action
import lwacharactersheet.composeapp.generated.resources.dialog__confirm_action
@ -49,7 +50,7 @@ import org.jetbrains.compose.resources.stringResource
@Stable
data class DiminishedStatDialogUio(
val id: String,
val id: Campaign.CharacterInstance.Id,
val label: String,
val value: () -> TextFieldValue,
val onValueChange: (TextFieldValue) -> Unit,

View file

@ -90,20 +90,8 @@ class CharacterSheetEditFactory(
intelligence = intelligence,
power = power,
charisma = charisma,
hp = characterSheetUseCase.defaultMaxHp(
constitution = constitution,
height = height,
level = level
),
pp = characterSheetUseCase.defaultMaxPower(power = power),
movement = characterSheetUseCase.defaultMovement(),
damageBonus = characterSheetUseCase.defaultDamageBonus(
strength = strength,
height = height
),
armor = characterSheetUseCase.defaultArmor(),
learning = characterSheetUseCase.defaultLearning(intelligence = intelligence),
hpGrow = characterSheetUseCase.defaultHpGrow(constitution = constitution),
movement = characterSheetUseCase.movement(),
armor = characterSheetUseCase.armor(),
commonSkills = editedSheet.commonSkills.map { editedSkill ->
val currentSkill = currentSheet?.commonSkills?.firstOrNull {
it.id == editedSkill.id

View file

@ -29,7 +29,7 @@ class CharacterSheetEditViewModel(
private val _characterSheet = mutableStateOf(
runBlocking {
sheetFactory.convertToUio(
sheet = characterSheetRepository.characterDetail(characterId = argument.id),
sheet = characterSheetRepository.characterDetail(characterSheetId = argument.id),
onDeleteSkill = ::deleteSkill,
)
}
@ -108,7 +108,7 @@ class CharacterSheetEditViewModel(
suspend fun save() {
val updatedSheet = sheetFactory.updateCharacterSheet(
currentSheet = characterSheetRepository.characterDetail(characterId = _characterSheet.value.id),
currentSheet = characterSheetRepository.characterDetail(characterSheetId = _characterSheet.value.id),
editedSheet = _characterSheet.value,
)
characterSheetRepository.updateCharacter(

View file

@ -18,6 +18,8 @@ import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
@ -29,6 +31,8 @@ import com.pixelized.desktop.lwa.ui.navigation.screen.destination.navigateToNetw
import com.pixelized.desktop.lwa.ui.navigation.window.destination.navigateToCharacterSheet
import com.pixelized.desktop.lwa.ui.navigation.window.destination.navigateToCharacterSheetEdit
import com.pixelized.desktop.lwa.ui.navigation.window.destination.navigateToRollHistory
import com.pixelized.shared.lwa.model.campaign.Campaign
import kotlinx.coroutines.runBlocking
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__create__title
@ -47,7 +51,7 @@ import org.koin.compose.viewmodel.koinViewModel
@Stable
data class CharacterUio(
val id: String,
val id: Campaign.CharacterInstance.Id,
val name: String,
)
@ -57,6 +61,7 @@ fun MainPage(
) {
val window = LocalWindowController.current
val screen = LocalScreenController.current
val characters = viewModel.characters.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
@ -69,7 +74,7 @@ fun MainPage(
contentAlignment = Alignment.Center,
) {
MainPageContent(
characters = viewModel.characters,
characters = characters,
enableRollHistory = viewModel.enableRollHistory,
onCharacter = {
window.navigateToCharacterSheet(

View file

@ -3,30 +3,52 @@ package com.pixelized.desktop.lwa.ui.screen.main
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lordcodes.turtle.shellRun
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.utils.extention.collectAsState
import com.pixelized.shared.lwa.OperatingSystem
import com.pixelized.shared.lwa.storePath
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
class MainPageViewModel(
private val repository: CharacterSheetRepository,
private val characterSheetRepository: CharacterSheetRepository,
private val campaignRepository: CampaignRepository,
networkRepository: NetworkRepository,
) : ViewModel() {
val characters: State<List<CharacterUio>>
@Composable
get() = repository
.characterSheetPreviewFlow
.collectAsState { sheets ->
sheets.map { sheet ->
CharacterUio(
id = sheet.id,
name = sheet.name,
)
@OptIn(ExperimentalCoroutinesApi::class)
val characters: StateFlow<List<CharacterUio>> = campaignRepository.campaignFlow
.flatMapLatest { campaign ->
combine(
campaign.characters.map { entry ->
characterSheetRepository.characterDetailFlow(characterId = entry.key.characterSheetId)
.mapNotNull { sheet ->
sheet?.let {
CharacterUio(
id = entry.key,
name = it.name,
)
}
}
}
) {
it.asList()
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyList(),
)
private val networkStatus = networkRepository.status
val enableRollHistory: State<Boolean>

View file

@ -6,8 +6,8 @@ import androidx.compose.animation.core.spring
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.pixelized.desktop.lwa.business.ExpressionUseCase
import com.pixelized.desktop.lwa.business.SkillStepUseCase
import com.pixelized.shared.lwa.usecase.ExpressionUseCase
import com.pixelized.shared.lwa.usecase.SkillStepUseCase
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.CharacterSheetPageUio
@ -101,7 +101,7 @@ class RollViewModel(
this.sheet = runBlocking {
rollRotation.snapTo(0f)
rollScale.snapTo(1f)
characterSheetRepository.characterDetail(characterId = sheet.id)!!
characterSheetRepository.characterDetail(characterSheetId = sheet.id)!!
}
this.rollAction = rollAction
@ -172,6 +172,7 @@ class RollViewModel(
val roll = skillComputation.computeRoll(
sheet = sheet,
alterations = emptyMap(), // TODO ?
expression = rollAction,
)

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.business
package com.pixelized.desktop.lwa.usecase
import com.pixelized.desktop.lwa.repository.settings.model.Settings

View file

@ -10,42 +10,42 @@ class DamageBonusUseCaseTest {
val userCase = CharacterSheetUseCase()
(0 until 12).forEach {
val result = userCase.defaultDamageBonus(sum = it)
val result = userCase.damageBonus(sum = it)
val expected = "-1d6"
assert(result == expected) {
"Expected:'$expected' bonus damage for stat:'$it' but was:'$result'"
}
}
(12 until 18).forEach {
val result = userCase.defaultDamageBonus(sum = it)
val result = userCase.damageBonus(sum = it)
val expected = "-1d4"
assert(result == expected) {
"Expected:'$expected' bonus damage for stat:'$it' but was:'$result'"
}
}
(18 until 23).forEach {
val result = userCase.defaultDamageBonus(sum = it)
val result = userCase.damageBonus(sum = it)
val expected = "+0"
assert(result == expected) {
"Expected:'$expected' bonus damage for stat:'$it' but was:'$result'"
}
}
(23 until 30).forEach {
val result = userCase.defaultDamageBonus(sum = it)
val result = userCase.damageBonus(sum = it)
val expected = "+1d4"
assert(result == expected) {
"Expected:'$expected' bonus damage for stat:'$it' but was:'$result'"
}
}
(30 until 40).forEach {
val result = userCase.defaultDamageBonus(sum = it)
val result = userCase.damageBonus(sum = it)
val expected = "+1d6"
assert(result == expected) {
"Expected:'$expected' bonus damage for stat:'$it' but was:'$result'"
}
}
(40 until 100).forEach {
val result = userCase.defaultDamageBonus(sum = it)
val result = userCase.damageBonus(sum = it)
val expected = "+2d6"
assert(result == expected) {
"Expected:'$expected' bonus damage for stat:'$it' but was:'$result'"

View file

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

View file

@ -1,6 +1,7 @@
package com.pixelized.desktop.lwa.business
import com.pixelized.desktop.lwa.business.SkillStepUseCase.SkillStep
import com.pixelized.shared.lwa.usecase.SkillStepUseCase
import com.pixelized.shared.lwa.usecase.SkillStepUseCase.SkillStep
import org.junit.Test
class SkillStepUseCaseTest {

View file

@ -1,5 +1,7 @@
package com.pixelized.desktop.lwa.parser.dice
import com.pixelized.shared.lwa.parser.dice.Dice
import com.pixelized.shared.lwa.parser.dice.DiceParser
import org.junit.Test
class DiceParserTest {

View file

@ -1,8 +1,10 @@
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 com.pixelized.shared.lwa.parser.dice.DiceParser
import com.pixelized.shared.lwa.parser.expression.Expression
import com.pixelized.shared.lwa.parser.expression.ExpressionParser
import com.pixelized.shared.lwa.parser.expression.ExpressionParser.Error
import com.pixelized.shared.lwa.parser.word.WordParser
import org.junit.Test
import kotlin.test.assertFailsWith
@ -120,6 +122,20 @@ class ExpressionParserTest {
)
}
@Test
fun testReadWrite() {
val parser = ExpressionParser(
diceParser = DiceParser(),
wordParser = WordParser(),
)
parser.test(
expression = "((1+2)*3)",
)
parser.test(
expression = "(1+(2*3))",
)
}
private fun ExpressionParser.test(
expression: String,
expected: Expression?,
@ -129,4 +145,13 @@ class ExpressionParserTest {
"ExpressionParser.parse(input=$expression) is expected to return:$expected, but was:$result"
}
}
private fun ExpressionParser.test(
expression: String,
) {
val result = parse(parse(expression)?.toString())?.toString()
assert(result == expression) {
"ExpressionParser.parse(input=$expression) is expected to return:$expression, but was:$result"
}
}
}

View file

@ -1,5 +1,7 @@
package com.pixelized.desktop.lwa.parser.word
import com.pixelized.shared.lwa.parser.word.Word
import com.pixelized.shared.lwa.parser.word.WordParser
import org.junit.Test
class WordParserTest {

View file

@ -1,3 +1,5 @@
import com.pixelized.server.lwa.model.alteration.AlterationService
import com.pixelized.server.lwa.model.alteration.AlterationStore
import com.pixelized.server.lwa.model.campaign.CampaignService
import com.pixelized.server.lwa.model.campaign.CampaignStore
import com.pixelized.server.lwa.model.character.CharacterSheetService
@ -26,12 +28,14 @@ val storeDependencies
get() = module {
singleOf(::CharacterSheetStore)
singleOf(::CampaignStore)
singleOf(::AlterationStore)
}
val serviceDependencies
get() = module {
singleOf(::CharacterSheetService)
singleOf(::CampaignService)
singleOf(::AlterationService)
}
val factoryDependencies

View file

@ -0,0 +1,56 @@
package com.pixelized.server.lwa.model.alteration
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.CampaignJsonFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class AlterationService(
private val store: AlterationStore,
private val campaignJsonFactory: CampaignJsonFactory,
) {
private val scope = CoroutineScope(Dispatchers.IO + Job())
private val alterations = store.alterationsFlow()
private val actives = store.activeFlow()
.map { data ->
data.mapKeys { it: Map.Entry<String, List<String>> ->
campaignJsonFactory.convertFromV1(characterInstanceIdJson = it.key)
}
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyMap()
)
fun alterations(): List<AlterationJson> {
return alterations.value
}
fun active(
characterInstanceId: Campaign.CharacterInstance.Id,
): List<String> {
return actives.value[characterInstanceId] ?: emptyList()
}
fun isAlterationActive(
characterInstanceId: Campaign.CharacterInstance.Id,
alterationId: String,
): Boolean {
return actives.value[characterInstanceId]?.contains(alterationId) ?: false
}
suspend fun toggleActiveAlteration(
characterInstanceId: Campaign.CharacterInstance.Id,
alterationId: String,
): Boolean {
return store.toggleActiveAlteration(
characterInstanceId = characterInstanceId,
alterationId = alterationId,
)
}
}

View file

@ -0,0 +1,144 @@
package com.pixelized.server.lwa.model.alteration
import com.pixelized.shared.lwa.alterationsPath
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.CampaignJsonFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
class AlterationStore(
private val campaignJsonFactory: CampaignJsonFactory,
private val json: Json,
) {
private val directory = File(alterationsPath()).also { it.mkdirs() }
private val alterationsFlow = MutableStateFlow<List<AlterationJson>>(emptyList())
private val activeFlow = MutableStateFlow<Map<String, List<String>>>(emptyMap())
init {
// build a coroutine scope for async calls
val scope = CoroutineScope(Dispatchers.IO + Job())
// load the initial data
scope.launch {
updateAlterations()
updateActiveAlterations()
}
}
fun alterationsFlow(): StateFlow<List<AlterationJson>> = alterationsFlow
fun activeFlow(): StateFlow<Map<String, List<String>>> = activeFlow
private fun updateAlterations() {
alterationsFlow.value = loadAlterations()
}
private fun updateActiveAlterations() {
activeFlow.value = loadActiveAlterations()
}
private fun loadAlterations(): List<AlterationJson> {
return try {
val alterationFile = file()
val json = alterationFile.readText(charset = Charsets.UTF_8)
if (json.isBlank()) error("alterations file is empty")
this.json.decodeFromString<List<AlterationJson>>(json)
} catch (exception: Exception) {
// TODO log exception
emptyList()
}
}
private fun loadActiveAlterations(): Map<String, List<String>> {
val mainFile = file()
val jsonExt = ".json"
return directory
.listFiles()
?.filter { file ->
// guard ignore the main alteration file and non json files.
file.name != mainFile.name && file.name.contains(jsonExt)
}
?.mapNotNull { file ->
// read the alteration file.
val json = try {
file.readText(charset = Charsets.UTF_8)
} catch (exception: Exception) {
throw FileReadException(root = exception)
}
try {
val alterationIds = this.json.decodeFromString<List<String>>(json)
val characterInstanceId = file.name.dropLast(n = jsonExt.length)
characterInstanceId to alterationIds
} catch (exception: Exception) {
// TODO log exception
throw JsonConversionException(root = exception)
}
}
?.toMap()
?: emptyMap()
}
fun toggleActiveAlteration(
characterInstanceId: Campaign.CharacterInstance.Id,
alterationId: String,
): Boolean {
val id = campaignJsonFactory.convertToJson(id = characterInstanceId)
// toggle the activation state
val characterActiveAlterationIds = activeFlow.value[id]
?.toMutableList()
?.toggle(alterationId = alterationId)
?: listOf(alterationId)
// build the json string to save
val json = try {
this.json.encodeToString(characterActiveAlterationIds)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
// write the file
try {
val file = file(id = id)
file.writeText(
text = json,
charset = Charsets.UTF_8,
)
} catch (exception: Exception) {
throw FileWriteException(root = exception)
}
// Update the dataflow.
activeFlow.value = activeFlow.value.toMutableMap().also {
it[id] = characterActiveAlterationIds
}
return true
}
private fun file(): File {
return File("${alterationsPath()}alterations.json")
}
private fun file(
id: String,
): File {
return File("${alterationsPath()}$id.json")
}
private fun MutableList<String>.toggle(alterationId: String): MutableList<String> {
if (contains(alterationId)) {
remove(alterationId)
} else {
add(alterationId)
}
return this
}
sealed class AlterationStoreException(root: Exception) : Exception(root)
class JsonConversionException(root: Exception) : AlterationStoreException(root)
class FileWriteException(root: Exception) : AlterationStoreException(root)
class FileReadException(root: Exception) : AlterationStoreException(root)
}

View file

@ -4,6 +4,7 @@ import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.CampaignJson
import com.pixelized.shared.lwa.model.campaign.CampaignJsonFactory
import com.pixelized.shared.lwa.model.campaign.character
import com.pixelized.shared.lwa.model.campaign.npc
import com.pixelized.shared.lwa.usecase.CampaignUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -23,11 +24,6 @@ class CampaignService(
private val campaign: Campaign get() = campaignFlow.value
private val campaignFlow = store.campaignFlow()
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = Campaign.EMPTY,
)
private val campaignJsonFlow: StateFlow<CampaignJson> = campaignFlow
.map { factory.convertToJson(it) }
@ -41,13 +37,15 @@ class CampaignService(
return campaignJsonFlow.value
}
suspend fun addCharacter(characterId: String): Boolean {
suspend fun addCharacter(
characterInstanceId: Campaign.CharacterInstance.Id,
): Boolean {
// fetch all the current campaign character
val characters = campaign.characters.toMutableMap()
// check if the character is in the campaign.
if (characters.containsKey(characterId)) return false
if (characters.containsKey(characterInstanceId)) return false
// update the corresponding character
characters[characterId] = campaign.character(id = characterId)
characters[characterInstanceId] = campaign.character(id = characterInstanceId)
// save the campaign to the disk + update the flow.
store.save(
campaign = campaign.copy(characters = characters)
@ -55,13 +53,15 @@ class CampaignService(
return true
}
suspend fun removeCharacter(characterId: String): Boolean {
suspend fun removeCharacter(
characterInstanceId: Campaign.CharacterInstance.Id,
): Boolean {
// fetch all the current campaign character
val characters = campaign.characters.toMutableMap()
// check if the character is in the campaign.
if (characters.containsKey(characterId).not()) return false
if (characters.containsKey(characterInstanceId).not()) return false
// update the corresponding character
characters.remove(characterId)
characters.remove(characterInstanceId)
// save the campaign to the disk + update the flow.
store.save(
campaign = campaign.copy(characters = characters)
@ -69,46 +69,50 @@ class CampaignService(
return true
}
suspend fun addNpc(npcId: String): Boolean {
suspend fun addNpc(
npcInstanceId: Campaign.CharacterInstance.Id,
): Boolean {
// fetch all the current campaign character
val characters = campaign.npcs.toMutableMap()
val npcs = campaign.npcs.toMutableMap()
// check if the character is in the campaign.
if (characters.containsKey(npcId)) return false
if (npcs.containsKey(npcInstanceId)) return false
// update the corresponding character
characters[npcId] = campaign.character(id = npcId)
npcs[npcInstanceId] = campaign.npc(id = npcInstanceId)
// save the campaign to the disk + update the flow.
store.save(
campaign = campaign.copy(npcs = characters)
campaign = campaign.copy(npcs = npcs)
)
return true
}
suspend fun removeNpc(npcId: String): Boolean {
suspend fun removeNpc(
npcInstanceId: Campaign.CharacterInstance.Id,
): Boolean {
// fetch all the current campaign character
val characters = campaign.npcs.toMutableMap()
val npcs = campaign.npcs.toMutableMap()
// check if the character is in the campaign.
if (characters.containsKey(npcId).not()) return false
if (npcs.containsKey(npcInstanceId).not()) return false
// update the corresponding character
characters.remove(npcId)
npcs.remove(npcInstanceId)
// save the campaign to the disk + update the flow.
store.save(
campaign = campaign.copy(npcs = characters)
campaign = campaign.copy(npcs = npcs)
)
return true
}
// Data manipulation through WebSocket.
suspend fun updateCharacteristic(
characterId: String,
suspend fun updateCharacterCharacteristic(
characterInstanceId: Campaign.CharacterInstance.Id,
characteristic: Campaign.CharacterInstance.Characteristic,
value: Int,
) {
// fetch all the current campaign character
val characters = campaign.characters.toMutableMap()
// update the corresponding character using the use case.
characters[characterId] = useCase.updateCharacteristic(
character = campaign.character(id = characterId),
characters[characterInstanceId] = useCase.updateCharacteristic(
character = campaign.character(id = characterInstanceId),
characteristic = characteristic,
value = value,
)
@ -117,4 +121,23 @@ class CampaignService(
campaign = campaign.copy(characters = characters)
)
}
suspend fun updateNpcCharacteristic(
npcInstanceId: Campaign.CharacterInstance.Id,
characteristic: Campaign.CharacterInstance.Characteristic,
value: Int,
) {
// fetch all the current campaign character
val npcs = campaign.npcs.toMutableMap()
// update the corresponding character using the use case.
npcs[npcInstanceId] = useCase.updateCharacteristic(
character = campaign.npc(id = npcInstanceId),
characteristic = characteristic,
value = value,
)
// save the campaign to the disk + update the flow.
store.save(
campaign = campaign.copy(npcs = npcs)
)
}
}

View file

@ -18,22 +18,30 @@ class CampaignStore(
private val factory: CampaignJsonFactory,
private val json: Json,
) {
private val directory = File(campaignPath()).also { it.mkdirs() }
private val flow = MutableStateFlow(value = Campaign.EMPTY)
init {
// create the directory if needed.
File(campaignPath()).also { it.mkdirs() }
// build a coroutine scope for async calls
val scope = CoroutineScope(Dispatchers.IO + Job())
// load the initial data
scope.launch {
flow.value = load()
update()
}
}
fun campaignFlow(): StateFlow<Campaign> = flow
suspend fun update() {
flow.value = load()
}
suspend fun load(): Campaign {
return try {
val json = file().readText(charset = Charsets.UTF_8)
if (json.isBlank()) Campaign.EMPTY
if (json.isBlank()) error("Campaign file is empty")
val campaign = this.json.decodeFromString<CampaignJson>(json)
factory.convertFromJson(campaign)
} catch (exception: Exception) {

View file

@ -1,7 +1,9 @@
package com.pixelized.server.lwa.server
import com.pixelized.server.lwa.model.alteration.AlterationService
import com.pixelized.server.lwa.model.campaign.CampaignService
import com.pixelized.server.lwa.model.character.CharacterSheetService
import com.pixelized.shared.lwa.model.campaign.CampaignJsonFactory
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.RestSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.payload.RollMessage
@ -12,6 +14,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
class Engine(
val characterService: CharacterSheetService,
val campaignService: CampaignService,
val alterationService: AlterationService,
val campaignJsonFactory: CampaignJsonFactory,
) {
val webSocket = MutableSharedFlow<Message>()
@ -20,11 +24,13 @@ class Engine(
is RollMessage -> Unit // Nothing to do here.
is UpdatePlayerCharacteristicMessage -> campaignService.updateCharacteristic(
characterId = data.characterId,
characteristic = data.characteristic,
value = data.value,
)
is UpdatePlayerCharacteristicMessage -> {
campaignService.updateCharacterCharacteristic(
characterInstanceId = campaignJsonFactory.convertFromV1(characterInstanceIdJson = data.characterInstanceId),
characteristic = campaignJsonFactory.convertFromV1(characteristicJson = data.characteristic),
value = data.value,
)
}
is UpdateSkillUsageMessage -> characterService.updateCharacterSkillUsage(
characterId = data.characterId,
@ -36,6 +42,8 @@ class Engine(
is RestSynchronisation.CharacterUpdate -> Unit // Handle in the Rest
is RestSynchronisation.CharacterDelete -> Unit // Handle in the Rest
is RestSynchronisation.ToggleActiveAlteration -> Unit // Handle in the Rest
}
}
}

View file

@ -3,6 +3,9 @@ package com.pixelized.server.lwa.server
import com.pixelized.server.lwa.extention.decodeFromFrame
import com.pixelized.server.lwa.extention.encodeToFrame
import com.pixelized.server.lwa.server.rest.alteration.getActiveAlteration
import com.pixelized.server.lwa.server.rest.alteration.getAlteration
import com.pixelized.server.lwa.server.rest.alteration.putActiveAlteration
import com.pixelized.server.lwa.server.rest.campaign.deleteCampaignCharacter
import com.pixelized.server.lwa.server.rest.campaign.deleteCampaignNpc
import com.pixelized.server.lwa.server.rest.campaign.getCampaign
@ -143,6 +146,20 @@ class LocalServer {
)
}
}
route(path = "/alterations") {
get(
path = "",
body = engine.getAlteration(),
)
get(
path = "/active",
body = engine.getActiveAlteration(),
)
put(
path = "/active/toggle",
body = engine.putActiveAlteration(),
)
}
}
}
)

View file

@ -0,0 +1,26 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.model.campaign.Campaign
import io.ktor.server.response.respond
fun Engine.getActiveAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
// get the query parameter
val characterSheetId = call.queryParameters["characterSheetId"]
val instanceId = call.queryParameters["instanceId"]?.toIntOrNull()
// build the character instance id.
val id = if (characterSheetId != null && instanceId != null) {
Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId
)
} else {
null
}
// fetch the data from the service
val data = id?.let { alterationService.active(it) } ?: emptyList()
// respond to the client.
call.respond(data)
}
}

View file

@ -0,0 +1,10 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import io.ktor.server.response.respond
fun Engine.getAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
call.respond(alterationService.alterations())
}
}

View file

@ -0,0 +1,63 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.RestSynchronisation
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respondText
fun Engine.putActiveAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
// fetch the query parameters
val characterSheetId = call.queryParameters["characterSheetId"]
val instanceId = call.queryParameters["instanceId"]?.toIntOrNull()
val alterationId = call.receive<String>()
// build the characterInstanceId from the parameters
val characterInstanceId = if (characterSheetId != null && instanceId != null) {
Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId
)
} else {
null
}
// Update the alteration
val updated = characterInstanceId?.let {
alterationService.toggleActiveAlteration(
characterInstanceId = it,
alterationId = alterationId,
)
} ?: false
// build the Http response & send it
val code = when (updated) {
true -> HttpStatusCode.Accepted
else -> HttpStatusCode.UnprocessableEntity
}
call.respondText(
text = "$code",
status = code,
)
// share the modification to all client through the websocket.
characterInstanceId?.let {
webSocket.emit(
Message(
from = "Server",
value = RestSynchronisation.ToggleActiveAlteration(
characterId = campaignJsonFactory.convertToJson(id = it),
alterationId = alterationId,
active = alterationService.isAlterationActive(
characterInstanceId = it,
alterationId = alterationId
),
),
)
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.RestSynchronisation
import io.ktor.http.HttpStatusCode
@ -8,7 +9,16 @@ import io.ktor.server.response.respondText
fun Engine.deleteCampaignCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
val id = call.queryParameters["id"]
val characterSheetId = call.queryParameters["characterSheetId"]
val instanceId = call.queryParameters["instanceId"]?.toIntOrNull()
val id = if (characterSheetId != null && instanceId != null) {
Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId
)
} else {
null
}
val updated = id?.let { campaignService.removeCharacter(it) } ?: false
val code = when (updated) {
true -> HttpStatusCode.Accepted

View file

@ -1,6 +1,7 @@
package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.RestSynchronisation
import io.ktor.http.HttpStatusCode
@ -8,7 +9,16 @@ import io.ktor.server.response.respondText
fun Engine.deleteCampaignNpc(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
val id = call.queryParameters["id"]
val characterSheetId = call.queryParameters["characterSheetId"]
val instanceId = call.queryParameters["instanceId"]?.toIntOrNull()
val id = if (characterSheetId != null && instanceId != null) {
Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId
)
} else {
null
}
val updated = id?.let { campaignService.removeNpc(it) } ?: false
val code = when (updated) {
true -> HttpStatusCode.Accepted

View file

@ -1,6 +1,7 @@
package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.RestSynchronisation
import io.ktor.http.HttpStatusCode
@ -8,7 +9,16 @@ import io.ktor.server.response.respondText
fun Engine.putCampaignCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
val id = call.queryParameters["id"]
val characterSheetId = call.queryParameters["characterSheetId"]
val instanceId = call.queryParameters["instanceId"]?.toIntOrNull()
val id = if (characterSheetId != null && instanceId != null) {
Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId
)
} else {
null
}
val updated = id?.let { campaignService.addCharacter(it) } ?: false
val code = when (updated) {
true -> HttpStatusCode.Accepted

View file

@ -1,6 +1,7 @@
package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.RestSynchronisation
import io.ktor.http.HttpStatusCode
@ -8,16 +9,30 @@ import io.ktor.server.response.respondText
fun Engine.putCampaignNpc(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
val id = call.queryParameters["id"]
val characterSheetId = call.queryParameters["characterSheetId"]
val instanceId = call.queryParameters["instanceId"]?.toIntOrNull()
val id = if (characterSheetId != null && instanceId != null) {
Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId
)
} else {
null
}
val updated = id?.let { campaignService.addNpc(it) } ?: false
val code = when (updated) {
true -> HttpStatusCode.Accepted
else -> HttpStatusCode.UnprocessableEntity
}
call.respondText(
text = "$code",
status = code,
)
webSocket.emit(
Message(
from = "Server",

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

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.parser.dice
package com.pixelized.shared.lwa.parser.dice
data class Dice(
val modifier: Modifier?,

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.parser.dice
package com.pixelized.shared.lwa.parser.dice
class DiceParser {
private val diceParser = Regex(

View file

@ -1,7 +1,7 @@
package com.pixelized.desktop.lwa.parser.expression
package com.pixelized.shared.lwa.parser.expression
import com.pixelized.desktop.lwa.parser.dice.Dice
import com.pixelized.desktop.lwa.parser.word.Word
import com.pixelized.shared.lwa.parser.dice.Dice
import com.pixelized.shared.lwa.parser.word.Word
sealed interface Expression {
@ -10,7 +10,7 @@ sealed interface Expression {
val second: Expression?,
) : Expression {
override fun toString(): String {
return "$first+$second"
return "($first+$second)"
}
}
@ -19,7 +19,7 @@ sealed interface Expression {
val second: Expression?,
) : Expression {
override fun toString(): String {
return "$first-$second"
return "($first-$second)"
}
}
@ -28,7 +28,7 @@ sealed interface Expression {
val second: Expression?,
) : Expression {
override fun toString(): String {
return "$first/$second"
return "($first/$second)"
}
}
@ -37,7 +37,7 @@ sealed interface Expression {
val second: Expression?,
) : Expression {
override fun toString(): String {
return "$first*$second"
return "($first*$second)"
}
}
@ -46,7 +46,7 @@ sealed interface Expression {
val second: Expression?,
) : Expression {
override fun toString(): String {
return "minimum($first,$second)"
return "min($first,$second)"
}
}
@ -55,7 +55,7 @@ sealed interface Expression {
val second: Expression?,
) : Expression {
override fun toString(): String {
return "maximum($first,$second)"
return "max($first,$second)"
}
}

View file

@ -1,10 +1,8 @@
package com.pixelized.desktop.lwa.parser.expression
package com.pixelized.shared.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 com.pixelized.shared.lwa.parser.dice.DiceParser
import com.pixelized.shared.lwa.parser.word.WordParser
import java.net.URI
import java.net.URL
/**
* Highly inspired by the following javascript implementation:
@ -199,7 +197,6 @@ class ExpressionParser(
}
val url = try {
println(token)
URI.create(token).toString()
} catch (_: Exception) {
null

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.parser.word
package com.pixelized.shared.lwa.parser.word
data class Word(
val type: Type,

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.parser.word
package com.pixelized.shared.lwa.parser.word
class WordParser {

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

@ -1,7 +1,7 @@
package com.pixelized.desktop.lwa.business
package com.pixelized.shared.lwa.usecase
import com.pixelized.desktop.lwa.parser.dice.Dice
import com.pixelized.shared.lwa.parser.dice.Dice
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min

View file

@ -1,9 +1,9 @@
package com.pixelized.desktop.lwa.business
package com.pixelized.shared.lwa.usecase
import androidx.compose.ui.util.fastRoundToInt
import kotlin.math.max
import kotlin.math.min
import kotlin.math.round
import kotlin.math.roundToInt
class SkillStepUseCase {
@ -35,7 +35,7 @@ class SkillStepUseCase {
)
}
private inline fun roundToInt(block: () -> Float): Int = round(block()).fastRoundToInt()
private inline fun roundToInt(block: () -> Float): Int = round(block()).roundToInt()
fun exportWiki() {
fun print(range: IntRange): String = when {