Merge branch 'feature/remodel' into 'main'

Feature/remodel

See merge request pixelized/LwaCharacterSheet!3
This commit is contained in:
Thomas Andres Gomez 2025-03-21 17:51:50 +00:00
commit 1db6e593d7
25 changed files with 369 additions and 472 deletions

View file

@ -48,14 +48,11 @@ class DataSyncViewModel(
.combine(campaignRepository.campaignFlow) { _, campaign: Campaign -> campaign }
.distinctUntilChanged()
.onEach { campaign ->
(campaign.characters.keys + campaign.npcs.keys).forEach { id ->
campaign.instances.keys.forEach { id ->
characterRepository.characterDetail(
characterSheetId = id.characterSheetId,
forceUpdate = true,
)
alterationRepository.updateActiveAlterations(
characterInstanceId = id,
)
}
}
.launchIn(this)

View file

@ -26,17 +26,4 @@ interface LwaClient {
suspend fun campaignRemoveNpc(characterSheetId: String, instanceId: Int)
suspend fun alterations(): List<AlterationJson>
suspend fun activeAlterations(
prefix: Char,
characterSheetId: String,
instanceId: Int,
): List<String>
suspend fun toggleActiveAlterations(
prefix: Char,
characterSheetId: String,
instanceId: Int,
alterationId: String,
)
}

View file

@ -73,24 +73,4 @@ class LwaClientImpl(
override suspend fun alterations(): List<AlterationJson> = client
.get("$root/alterations")
.body()
override suspend fun activeAlterations(
prefix: Char,
characterSheetId: String,
instanceId: Int,
): List<String> = client
.get("$root/alterations/active?characterSheetId=$characterSheetId&instanceId=$instanceId&prefix=$prefix")
.body()
override suspend fun toggleActiveAlterations(
prefix: Char,
characterSheetId: String,
instanceId: Int,
alterationId: String,
) = client
.put("$root/alterations/active/toggle?characterSheetId=$characterSheetId&instanceId=$instanceId&prefix=$prefix") {
contentType(ContentType.Application.Json)
setBody(alterationId)
}
.body<Unit>()
}

View file

@ -1,7 +1,9 @@
package com.pixelized.desktop.lwa.repository.alteration
import com.pixelized.desktop.lwa.repository.campaign.CampaignStore
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 com.pixelized.shared.lwa.model.campaign.Campaign.CharacterInstance
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -13,22 +15,20 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
// Theses typealias are there for readability only.
private typealias AlterationId = String
class AlterationRepository(
private val store: AlterationStore,
private val campaignStore: CampaignStore,
private val alterationStore: AlterationStore,
) {
private val scope = CoroutineScope(Dispatchers.IO + Job())
private val activeAlterationMapFlow: StateFlow<Map<CharacterInstance.Id, Map<String, List<FieldAlteration>>>> =
combine(
store.alterations,
store.active,
) { alterations, actives ->
actives.map { activeEntry ->
alterationStore.alterationsFlow,
campaignStore.campaignFlow,
) { alterations, campaign: Campaign ->
campaign.instances.map { activeEntry ->
activeEntry.key to transformToAlterationFieldMap(
alterations = alterations,
actives = activeEntry.value
actives = activeEntry.value.alterations,
)
}.toMap()
}.stateIn(
@ -49,32 +49,13 @@ class AlterationRepository(
return activeAlterationMapFlow.value[characterInstanceId] ?: emptyMap()
}
suspend fun updateActiveAlterations(
characterInstanceId: CharacterInstance.Id,
) {
store.updateActiveAlterations(
characterInstanceId = characterInstanceId,
)
}
suspend fun toggleActiveAlteration(
characterInstanceId: CharacterInstance.Id,
alterationId: String,
) {
// alteration was active for the character toggle it off.
store.toggleActiveAlteration(
characterInstanceId = characterInstanceId,
alterationId = 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 ->
actives.forEach { alterationId ->
alterations[alterationId]?.let { alteration ->
alteration.fields.forEach { field ->
fieldAlterations
.getOrPut(field.fieldId) { mutableListOf() }

View file

@ -1,13 +1,8 @@
package com.pixelized.desktop.lwa.repository.alteration
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.factory.CampaignJsonFactory
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
import com.pixelized.shared.lwa.protocol.websocket.ToggleActiveAlteration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -17,38 +12,20 @@ import kotlinx.coroutines.launch
class AlterationStore(
private val alterationFactory: AlterationJsonFactory,
private val campaignJsonFactory: CampaignJsonFactory,
private val network: NetworkRepository,
private val client: LwaClient,
) {
private val _alterations = MutableStateFlow<Map<String, Alteration>>(emptyMap())
val alterations: StateFlow<Map<String, Alteration>> = _alterations
private val _active = MutableStateFlow<Map<CharacterInstance.Id, List<String>>>(emptyMap())
val active: StateFlow<Map<CharacterInstance.Id, List<String>>> get() = _active
private val _alterationsFlow = MutableStateFlow<Map<String, Alteration>>(emptyMap())
val alterationsFlow: StateFlow<Map<String, Alteration>> = _alterationsFlow
init {
val scope = CoroutineScope(Dispatchers.IO + Job())
scope.launch {
updateAlterations()
}
scope.launch {
network.data.collect(::handleMessage)
}
}
private suspend fun updateAlterations() {
_alterations.value = loadAlteration()
}
suspend fun updateActiveAlterations(
characterInstanceId: CharacterInstance.Id,
) {
_active.value = _active.value.toMutableMap().also {
it[characterInstanceId] = loadActiveAlterations(
characterInstanceId = characterInstanceId,
)
}
_alterationsFlow.value = loadAlteration()
}
private suspend fun loadAlteration(): Map<String, Alteration> {
@ -57,69 +34,11 @@ class AlterationStore(
return data.associateBy { it.id }
}
private suspend fun loadActiveAlterations(
characterInstanceId: CharacterInstance.Id,
): List<String> {
val request = client.activeAlterations(
prefix = characterInstanceId.prefix,
characterSheetId = characterInstanceId.characterSheetId,
instanceId = characterInstanceId.instanceId,
)
return request
}
fun alterations(): Collection<Alteration> {
return alterations.value.values
return alterationsFlow.value.values
}
fun alteration(alterationId: String): Alteration? {
return alterations.value[alterationId]
}
suspend fun toggleActiveAlteration(
characterInstanceId: CharacterInstance.Id,
alterationId: String,
) {
client.toggleActiveAlterations(
prefix = characterInstanceId.prefix,
characterSheetId = characterInstanceId.characterSheetId,
instanceId = characterInstanceId.instanceId,
alterationId = alterationId,
)
}
private suspend fun handleMessage(message: SocketMessage) {
when (message) {
is ToggleActiveAlteration -> {
setActiveAlteration(
characterInstanceId = CharacterInstance.Id(
prefix = message.prefix,
characterSheetId = message.characterSheetId,
instanceId = message.instanceId,
),
alterationId = message.alterationId,
active = message.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)
}
return alterationsFlow.value[alterationId]
}
}

View file

@ -130,6 +130,11 @@ class CampaignStore(
characterInstanceId = instanceId,
diminished = message.diminished,
)
is CampaignMessage.ToggleActiveAlteration -> updateAlterations(
characterInstanceId = instanceId,
alterationId = message.alterationId,
)
}
}
@ -162,8 +167,8 @@ class CampaignStore(
) {
val campaign = _campaignFlow.value
when {
campaign.characters.containsKey(characterInstanceId) -> {
when (characterInstanceId.prefix) {
Campaign.CharacterInstance.Id.PLAYER -> {
val characters = campaign.characters.toMutableMap().also {
it[characterInstanceId] = useCase.updateCharacteristic(
instance = campaign.character(id = characterInstanceId),
@ -174,7 +179,7 @@ class CampaignStore(
_campaignFlow.value = _campaignFlow.value.copy(characters = characters)
}
campaign.npcs.containsKey(characterInstanceId) -> {
Campaign.CharacterInstance.Id.NPC -> {
val npcs = campaign.npcs.toMutableMap().also {
it[characterInstanceId] = useCase.updateCharacteristic(
instance = campaign.npc(id = characterInstanceId),
@ -193,8 +198,8 @@ class CampaignStore(
) {
val campaign = _campaignFlow.value
when {
campaign.characters.containsKey(characterInstanceId) -> {
when (characterInstanceId.prefix) {
Campaign.CharacterInstance.Id.PLAYER -> {
val characters = campaign.characters.toMutableMap().also {
it[characterInstanceId] = useCase.updateDiminished(
instance = campaign.character(id = characterInstanceId),
@ -204,7 +209,7 @@ class CampaignStore(
_campaignFlow.value = _campaignFlow.value.copy(characters = characters)
}
campaign.npcs.containsKey(characterInstanceId) -> {
Campaign.CharacterInstance.Id.NPC -> {
val npcs = campaign.npcs.toMutableMap().also {
it[characterInstanceId] = useCase.updateDiminished(
instance = campaign.npc(id = characterInstanceId),
@ -216,6 +221,53 @@ class CampaignStore(
}
}
suspend fun updateAlterations(
characterInstanceId: Campaign.CharacterInstance.Id,
alterationId: String,
) {
val campaign = _campaignFlow.value
when (characterInstanceId.prefix) {
Campaign.CharacterInstance.Id.PLAYER -> {
// fetch all the current campaign character
val characters = campaign.characters.toMutableMap()
// update the corresponding character alterations
characters[characterInstanceId]?.let { character ->
characters[characterInstanceId] = character.copy(
alterations = character.alterations.toMutableList().also { alterations ->
if (alterations.contains(alterationId)) {
alterations.remove(alterationId)
} else {
alterations.add(alterationId)
}
},
)
}
// update the flow.
_campaignFlow.value = _campaignFlow.value.copy(characters = characters)
}
Campaign.CharacterInstance.Id.NPC -> {
// fetch all the current campaign character
val characters = campaign.npcs.toMutableMap()
// update the corresponding character alterations
characters[characterInstanceId]?.let { character ->
characters[characterInstanceId] = character.copy(
alterations = character.alterations.toMutableList().also { alterations ->
if (alterations.contains(alterationId)) {
alterations.remove(alterationId)
} else {
alterations.add(alterationId)
}
},
)
}
// update the flow.
_campaignFlow.value = _campaignFlow.value.copy(npcs = characters)
}
}
}
// endregion
private fun MutableStateFlow<Campaign>.update(campaign: Campaign): Campaign {

View file

@ -14,7 +14,6 @@ import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent
import com.pixelized.shared.lwa.protocol.websocket.RestSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.RollMessage
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
import com.pixelized.shared.lwa.protocol.websocket.ToggleActiveAlteration
import com.pixelized.shared.lwa.protocol.websocket.UpdateSkillUsageMessage
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.chat__characteristic_change__hp_down
@ -65,6 +64,7 @@ class TextMessageFactory(
)
}
is CampaignMessage -> when (message) {
is CampaignMessage.UpdateDiminished -> {
val sheetPreview = characterSheetRepository
.characterPreview(characterId = message.characterSheetId)
@ -100,10 +100,12 @@ class TextMessageFactory(
)
}
is CampaignMessage.ToggleActiveAlteration -> null // TODO
}
is RestSynchronisation.Campaign -> null
is RestSynchronisation.CharacterSheetDelete -> null
is RestSynchronisation.CharacterSheetUpdate -> null
is ToggleActiveAlteration -> null
is UpdateSkillUsageMessage -> null
is GameMasterEvent -> null
is GameEvent.DisplayPortrait -> null

View file

@ -1,56 +1,13 @@
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.factory.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,
store: AlterationStore,
) {
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.characterInstanceIdFromJson(characterInstanceIdJson = it.key)
}
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyMap()
)
private val alterationsFlow = store.alterationsFlow()
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,
)
return alterationsFlow.value
}
}

View file

@ -1,8 +1,6 @@
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.factory.CampaignJsonFactory
import com.pixelized.shared.lwa.utils.PathProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -15,12 +13,10 @@ import java.io.File
class AlterationStore(
private val pathProvider: PathProvider,
private val campaignJsonFactory: CampaignJsonFactory,
private val json: Json,
) {
private val directory = File(pathProvider.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
@ -28,113 +24,44 @@ class AlterationStore(
// 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)
alterationsFlow.value = try {
loadAlterations()
} catch (exception: Exception) {
// TODO log exception
println(exception) // TODO proper exception handling
emptyList()
}
}
private fun loadActiveAlterations(): Map<String, List<String>> {
val mainFile = file()
val jsonExt = ".json"
@Throws(
FileReadException::class,
JsonConversionException::class,
)
private fun loadAlterations(): List<AlterationJson> {
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)
}
// Guard, if the json is blank no alteration have been save, ignore this file.
if (json.isBlank()) {
return@mapNotNull null
}
try {
val alterationIds = this.json.decodeFromString<List<String>>(json)
val characterInstanceId = file.name.dropLast(n = jsonExt.length)
characterInstanceId to alterationIds
this.json.decodeFromString<AlterationJson>(json)
} 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("${pathProvider.alterationsPath()}alterations.json")
}
private fun file(
id: String,
): File {
return File("${pathProvider.alterationsPath()}$id.json")
}
private fun MutableList<String>.toggle(alterationId: String): MutableList<String> {
if (contains(alterationId)) {
remove(alterationId)
} else {
add(alterationId)
}
return this
?: emptyList()
}
sealed class AlterationStoreException(root: Exception) : Exception(root)

View file

@ -121,6 +121,40 @@ class CampaignService(
}
}
suspend fun removeInstance(
characterSheetId: String,
): Boolean {
// fetch all the current campaign character
val characterIds = campaign.characters
.filterKeys { it.characterSheetId == characterSheetId }
.keys
val npcIds = campaign.npcs
.filterKeys { it.characterSheetId == characterSheetId }
.keys
// check if the character is in the campaign.
if (characterIds.isEmpty() && npcIds.isEmpty()) return false
// update the corresponding character
val characters = campaign.characters.toMutableMap()
val npcs = campaign.npcs.toMutableMap()
characterIds.forEach(characters::remove)
npcIds.forEach(npcs::remove)
// save the campaign to the disk + update the flow.
return try {
store.save(
campaign = campaign.copy(
characters = characters,
npcs = npcs,
)
)
true
} catch (exception: Exception) {
false
}
}
suspend fun setScene(
scene: Campaign.Scene,
): Boolean {
@ -142,8 +176,8 @@ class CampaignService(
characteristic: Campaign.CharacterInstance.Characteristic,
value: Int,
) {
when {
campaign.characters.containsKey(characterInstanceId) -> {
when (characterInstanceId.prefix) {
Campaign.CharacterInstance.Id.PLAYER -> {
// fetch all the current campaign character
val characters = campaign.characters.toMutableMap()
// update the corresponding character using the use case.
@ -158,7 +192,7 @@ class CampaignService(
)
}
campaign.npcs.containsKey(characterInstanceId) -> {
Campaign.CharacterInstance.Id.NPC -> {
// fetch all the current campaign character
val npcs = campaign.npcs.toMutableMap()
// update the corresponding character using the use case.
@ -179,8 +213,8 @@ class CampaignService(
characterInstanceId: Campaign.CharacterInstance.Id,
diminished: Int,
) {
when {
campaign.characters.containsKey(characterInstanceId) -> {
when (characterInstanceId.prefix) {
Campaign.CharacterInstance.Id.PLAYER -> {
// fetch all the current campaign character
val characters = campaign.characters.toMutableMap()
// update the corresponding character using the use case.
@ -194,7 +228,7 @@ class CampaignService(
)
}
campaign.npcs.containsKey(characterInstanceId) -> {
Campaign.CharacterInstance.Id.NPC -> {
// fetch all the current campaign character
val npcs = campaign.npcs.toMutableMap()
// update the corresponding character using the use case.
@ -210,6 +244,55 @@ class CampaignService(
}
}
suspend fun toggleAlteration(
characterInstanceId: Campaign.CharacterInstance.Id,
alterationId: String,
) {
when (characterInstanceId.prefix) {
Campaign.CharacterInstance.Id.PLAYER -> {
// fetch all the current campaign character
val characters = campaign.characters.toMutableMap()
// update the corresponding character alterations
characters[characterInstanceId]?.let { character ->
characters[characterInstanceId] = character.copy(
alterations = character.alterations.toMutableList().also { alterations ->
if (alterations.contains(alterationId)) {
alterations.remove(alterationId)
} else {
alterations.add(alterationId)
}
},
)
}
// save the campaign to the disk + update the flow.
store.save(
campaign = campaign.copy(characters = characters)
)
}
Campaign.CharacterInstance.Id.NPC -> {
// fetch all the current campaign character
val characters = campaign.npcs.toMutableMap()
// update the corresponding character alterations
characters[characterInstanceId]?.let { character ->
characters[characterInstanceId] = character.copy(
alterations = character.alterations.toMutableList().also { alterations ->
if (alterations.contains(alterationId)) {
alterations.remove(alterationId)
} else {
alterations.add(alterationId)
}
},
)
}
// save the campaign to the disk + update the flow.
store.save(
campaign = campaign.copy(npcs = characters)
)
}
}
}
suspend fun updateToggleParty() {
store.save(
campaign = campaign.copy(

View file

@ -18,7 +18,7 @@ class CampaignStore(
private val factory: CampaignJsonFactory,
private val json: Json,
) {
private val flow = MutableStateFlow(value = Campaign.EMPTY)
private val campaignFlow = MutableStateFlow(value = Campaign.EMPTY)
init {
// create the directory if needed.
@ -27,28 +27,51 @@ class CampaignStore(
val scope = CoroutineScope(Dispatchers.IO + Job())
// load the initial data
scope.launch {
update()
updateCampaignFromDisk()
}
}
fun campaignFlow(): StateFlow<Campaign> = flow
fun campaignFlow(): StateFlow<Campaign> = campaignFlow
suspend fun update() {
flow.value = load()
}
suspend fun load(): Campaign {
return try {
val json = file().readText(charset = Charsets.UTF_8)
if (json.isBlank()) error("Campaign file is empty")
val campaign = this.json.decodeFromString<CampaignJson>(json)
factory.convertFromJson(campaign)
private fun updateCampaignFromDisk() {
campaignFlow.value = try {
loadCampaign()
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
Campaign.EMPTY
}
}
suspend fun save(campaign: Campaign) {
@Throws(
FileReadException::class,
JsonConversionException::class,
)
fun loadCampaign(): Campaign {
val file = file()
val json = try {
file.readText(charset = Charsets.UTF_8)
} catch (exception: Exception) {
throw FileReadException(root = exception)
}
// Guard, if the file is empty we load a default campaign.
if (json.isBlank()) return Campaign.EMPTY
val campaign = try {
val data = this.json.decodeFromString<CampaignJson>(json)
factory.convertFromJson(data)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
return campaign
}
@Throws(
JsonConversionException::class,
FileWriteException::class,
)
fun save(campaign: Campaign) {
// convert the data to json format
val json = try {
factory.convertToJson(data = campaign).let(json::encodeToString)
@ -66,12 +89,13 @@ class CampaignStore(
throw FileWriteException(root = exception)
}
// Update the dataflow.
flow.value = campaign
campaignFlow.value = campaign
}
sealed class CampaignStoreException(root: Exception) : Exception(root)
class JsonConversionException(root: Exception) : CampaignStoreException(root)
class FileWriteException(root: Exception) : CampaignStoreException(root)
class FileReadException(root: Exception) : CampaignStoreException(root)
private fun file(): File {
return File("${pathProvider.campaignPath()}campaign.json")

View file

@ -18,7 +18,7 @@ class CharacterSheetService(
) {
private val scope = CoroutineScope(Dispatchers.IO + Job())
private val sheets get() = sheetsFlow.value
private val sheetsFlow = store.characterSheetFlow()
private val sheetsFlow = store.characterSheetsFlow()
.map { entry -> entry.associateBy { character -> character.id } }
.stateIn(
scope = scope,
@ -38,8 +38,8 @@ class CharacterSheetService(
return store.save(sheet = factory.convertFromJson(character))
}
fun deleteCharacterSheet(characterId: String): Boolean {
return store.delete(id = characterId)
fun deleteCharacterSheet(characterSheetId: String): Boolean {
return store.delete(id = characterSheetId)
}
// Data manipulation through WebSocket.

View file

@ -20,74 +20,33 @@ class CharacterSheetStore(
private val json: Json,
) {
private val directory = File(pathProvider.characterStorePath()).also { it.mkdirs() }
private val flow = MutableStateFlow<List<CharacterSheet>>(value = emptyList())
private val characterSheetsFlow = MutableStateFlow<List<CharacterSheet>>(value = emptyList())
init {
// build a coroutine scope for async calls
val scope = CoroutineScope(Dispatchers.IO + Job())
// load the initial data
scope.launch {
flow.value = load()
updateCharacterSheets()
}
}
fun characterSheetFlow(): StateFlow<List<CharacterSheet>> = flow
fun characterSheetsFlow(): StateFlow<List<CharacterSheet>> = characterSheetsFlow
@Throws(
CharacterSheetStoreException::class,
FileWriteException::class,
JsonConversionException::class,
)
fun save(sheet: CharacterSheet) {
// convert the character sheet into json format.
val json = try {
factory.convertToJson(sheet = sheet).let(json::encodeToString)
private suspend fun updateCharacterSheets() {
characterSheetsFlow.value = try {
loadCharacterSheets()
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
println(exception) // TODO proper exception handling
emptyList()
}
// write the character file.
try {
val file = characterSheetFile(id = sheet.id)
file.writeText(
text = json,
charset = Charsets.UTF_8,
)
} catch (exception: Exception) {
throw FileWriteException(root = exception)
}
// Update the dataflow.
flow.value = flow.value
.toMutableList()
.also { data ->
val index = data.indexOfFirst { it.id == sheet.id }
if (index >= 0) {
data[index] = sheet
} else {
data.add(sheet)
}
}
.sortedWith(compareBy(Collator.getInstance()) { it.name })
}
fun delete(id: String): Boolean {
val file = characterSheetFile(id = id)
val deleted = file.delete()
if (deleted) {
flow.value = flow.value.toMutableList()
.also { data ->
data.removeIf { it.id == id }
}
.sortedBy {
it.name
}
}
return deleted
}
@Throws(
CharacterSheetStoreException::class,
FileReadException::class,
JsonConversionException::class,
)
suspend fun load(): List<CharacterSheet> {
suspend fun loadCharacterSheets(): List<CharacterSheet> {
return directory
.listFiles()
?.mapNotNull { file ->
@ -111,6 +70,56 @@ class CharacterSheetStore(
?: emptyList()
}
@Throws(
FileWriteException::class,
JsonConversionException::class,
)
fun save(sheet: CharacterSheet) {
// convert the character sheet into json format.
val json = try {
factory.convertToJson(sheet = sheet).let(json::encodeToString)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
// write the character file.
try {
val file = characterSheetFile(id = sheet.id)
file.writeText(
text = json,
charset = Charsets.UTF_8,
)
} catch (exception: Exception) {
throw FileWriteException(root = exception)
}
// Update the dataflow.
characterSheetsFlow.value = characterSheetsFlow.value
.toMutableList()
.also { data ->
val index = data.indexOfFirst { it.id == sheet.id }
if (index >= 0) {
data[index] = sheet
} else {
data.add(sheet)
}
}
.sortedWith(compareBy(Collator.getInstance()) { it.name })
}
fun delete(id: String): Boolean {
val file = characterSheetFile(id = id)
val deleted = file.delete()
if (deleted) {
characterSheetsFlow.value = characterSheetsFlow.value.toMutableList()
.also { data ->
data.removeIf { it.id == id }
}
.sortedBy {
it.name
}
}
return deleted
}
private fun characterSheetFile(id: String): File {
return File("${pathProvider.characterStorePath()}${id}.json")
}

View file

@ -6,12 +6,12 @@ import com.pixelized.server.lwa.model.character.CharacterSheetService
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory
import com.pixelized.shared.lwa.protocol.websocket.CampaignMessage
import com.pixelized.shared.lwa.protocol.websocket.CampaignMessage.ToggleActiveAlteration
import com.pixelized.shared.lwa.protocol.websocket.GameEvent
import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent
import com.pixelized.shared.lwa.protocol.websocket.RestSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.RollMessage
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
import com.pixelized.shared.lwa.protocol.websocket.ToggleActiveAlteration
import com.pixelized.shared.lwa.protocol.websocket.UpdateSkillUsageMessage
import kotlinx.coroutines.flow.MutableSharedFlow
@ -45,6 +45,11 @@ class Engine(
characterInstanceId = instanceId,
diminished = message.diminished,
)
is ToggleActiveAlteration -> campaignService.toggleAlteration(
characterInstanceId = instanceId,
alterationId = message.alterationId,
)
}
}

View file

@ -1,15 +1,14 @@
package com.pixelized.server.lwa.server
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.removeCampaignCharacter
import com.pixelized.server.lwa.server.rest.alteration.getAlterations
import com.pixelized.server.lwa.server.rest.campaign.deleteCampaignNpc
import com.pixelized.server.lwa.server.rest.campaign.getCampaign
import com.pixelized.server.lwa.server.rest.campaign.putCampaignCharacter
import com.pixelized.server.lwa.server.rest.campaign.putCampaignNpc
import com.pixelized.server.lwa.server.rest.campaign.putCampaignScene
import com.pixelized.server.lwa.server.rest.campaign.putToggleAlteration
import com.pixelized.server.lwa.server.rest.campaign.removeCampaignCharacter
import com.pixelized.server.lwa.server.rest.character.deleteCharacter
import com.pixelized.server.lwa.server.rest.character.getCharacter
import com.pixelized.server.lwa.server.rest.character.getCharacters
@ -112,6 +111,10 @@ class LocalServer {
}
}
)
get(
path = "/alterations",
body = engine.getAlterations(),
)
get(
path = "/characters",
body = engine.getCharacters(),
@ -135,6 +138,10 @@ class LocalServer {
path = "",
body = engine.getCampaign(),
)
put(
path = "/toggleAlteration",
body = engine.putToggleAlteration(),
)
route(path = "/character") {
put(
path = "/update",
@ -160,20 +167,6 @@ class LocalServer {
body = engine.putCampaignScene(),
)
}
route(path = "/alterations") {
get(
path = "",
body = engine.getAlteration(),
)
get(
path = "/active",
body = engine.getActiveAlteration(),
)
put(
path = "/active/toggle",
body = engine.putActiveAlteration(),
)
}
}
}
)

View file

@ -1,25 +0,0 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterInstanceId
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
fun Engine.getActiveAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val characterInstanceId = call.queryParameters.characterInstanceId
// fetch the data from the service
val data = alterationService.active(characterInstanceId = characterInstanceId)
// respond to the client.
call.respond(data)
} catch (exception: Exception) {
call.respondText(
text = exception.localizedMessage,
status = HttpStatusCode.UnprocessableEntity,
)
}
}
}

View file

@ -3,7 +3,7 @@ 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 {
fun Engine.getAlterations(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
call.respond(alterationService.alterations())
}

View file

@ -1,13 +1,13 @@
package com.pixelized.server.lwa.server.rest.alteration
package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterInstanceId
import com.pixelized.shared.lwa.protocol.websocket.ToggleActiveAlteration
import com.pixelized.shared.lwa.protocol.websocket.CampaignMessage
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 {
fun Engine.putToggleAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
try {
// get the query parameter
@ -17,30 +17,31 @@ fun Engine.putActiveAlteration(): suspend io.ktor.server.routing.RoutingContext.
val alterationId = call.receive<String>()
// Update the alteration
val updated = alterationService.toggleActiveAlteration(
campaignService.toggleAlteration(
characterInstanceId = characterInstanceId,
alterationId = alterationId,
)
if (!updated) {
error("Unexpected error occurred when toggling the alteration (id:$alterationId) for the character (id:$characterInstanceId)")
}
// build the Http response & send it
call.respondText(
text = "$HttpStatusCode.Accepted",
status = HttpStatusCode.Accepted,
)
val isAlterationActive = campaignService.campaign()
.instances[characterInstanceId]
?.alterations
?.contains(alterationId)
?: false
// share the modification to all client through the websocket.
webSocket.emit(
ToggleActiveAlteration(
CampaignMessage.ToggleActiveAlteration(
timestamp = System.currentTimeMillis(),
prefix = characterInstanceId.prefix,
characterSheetId = characterInstanceId.characterSheetId,
instanceId = characterInstanceId.instanceId,
alterationId = alterationId,
active = alterationService.isAlterationActive(
characterInstanceId = characterInstanceId,
alterationId = alterationId
),
active = isAlterationActive,
)
)
} catch (exception: Exception) {

View file

@ -11,9 +11,10 @@ fun Engine.deleteCharacter(): suspend io.ktor.server.routing.RoutingContext.() -
val characterSheetId = call.parameters.characterSheetId
val deleted = characterService.deleteCharacterSheet(
characterId = characterSheetId
characterSheetId = characterSheetId
) && campaignService.removeInstance(
characterSheetId = characterSheetId,
)
// TODO campaign & alteration cleanup.
if (deleted) {
call.respondText(

View file

@ -6,8 +6,11 @@ data class Campaign(
val scene: Scene,
val options: Options,
) {
val instances = characters + npcs
data class CharacterInstance(
val characteristic: Map<Characteristic, Int>,
val alterations: List<String>,
val diminished: Int,
) {
data class Id(
@ -39,6 +42,7 @@ data class Campaign(
companion object {
fun empty() = CharacterInstance(
characteristic = emptyMap(),
alterations = emptyList(),
diminished = 0,
)
}

View file

@ -13,6 +13,7 @@ data class CampaignJsonV1(
@Serializable
data class CharacterInstanceJsonV1(
val characteristic: Map<CharacteristicV1, Int>,
val alterations: List<String>?,
val diminished: Int?,
) : CampaignJson.CharacterInstanceJson {
enum class CharacteristicV1 : CampaignJson.CharacterInstanceJson.CharacteristicJson {

View file

@ -79,6 +79,7 @@ class CampaignJsonFactory(
characteristic = data.characteristic
.map { char -> convertToJson(characteristic = char.key) to char.value }
.toMap(),
alterations = data.alterations,
diminished = data.diminished,
)
}

View file

@ -49,6 +49,7 @@ class CampaignJsonV1Factory {
characteristic = characterInstanceJson.characteristic
.mapKeys { convertFromV1(characteristicJson = it.key) }
.toMap(),
alterations = characterInstanceJson.alterations ?: emptyList(),
diminished = characterInstanceJson.diminished ?: 0,
)
}

View file

@ -28,4 +28,14 @@ sealed interface CampaignMessage : SocketMessage, CharacterInstanceIdMessage {
override val instanceId: Int,
val diminished: Int,
) : CampaignMessage
@Serializable
data class ToggleActiveAlteration(
override val timestamp: Long,
override val characterSheetId: String,
override val prefix: Char,
override val instanceId: Int,
val alterationId: String,
val active: Boolean,
) : CampaignMessage, CharacterInstanceIdMessage
}

View file

@ -1,13 +0,0 @@
package com.pixelized.shared.lwa.protocol.websocket
import kotlinx.serialization.Serializable
@Serializable
data class ToggleActiveAlteration(
override val timestamp: Long,
override val characterSheetId: String,
override val prefix: Char,
override val instanceId: Int,
val alterationId: String,
val active: Boolean,
) : SocketMessage, CharacterInstanceIdMessage