Add server synchronization feature.

This commit is contained in:
Thomas Andres Gomez 2025-05-03 19:31:22 +02:00
parent 53c6aede27
commit fa49d8ed22
15 changed files with 154 additions and 48 deletions

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M160,800L160,720L270,720L254,706Q202,660 181,601Q160,542 160,482Q160,371 226.5,284.5Q293,198 400,170L400,254Q328,280 284,342.5Q240,405 240,482Q240,527 257,569.5Q274,612 310,648L320,658L320,560L400,560L400,800L160,800ZM560,790L560,706Q632,680 676,617.5Q720,555 720,478Q720,433 703,390.5Q686,348 650,312L640,302L640,400L560,400L560,160L800,160L800,240L690,240L706,254Q755,303 777.5,360.5Q800,418 800,478Q800,589 733.5,675.5Q667,762 560,790Z" />
</vector>

View file

@ -9,11 +9,13 @@ import com.pixelized.desktop.lwa.repository.item.ItemRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.desktop.lwa.repository.tag.TagRepository import com.pixelized.desktop.lwa.repository.tag.TagRepository
import com.pixelized.shared.lwa.protocol.websocket.GameAdminEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -45,24 +47,27 @@ class DataSyncViewModel(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
suspend fun synchronise() = coroutineScope { suspend fun synchronise() = coroutineScope {
networkRepository.data.onEach { println(it) }.launchIn(this) networkRepository.data
.onEach { println(it) }
.launchIn(this)
networkRepository.status
.filter { status -> status == NetworkRepository.Status.CONNECTED }
.flatMapLatest { networkRepository.data }
.filterIsInstance(GameAdminEvent.ServerSynchronization::class)
.flowOn(context = Dispatchers.IO)
.onEach {
updateRepository()
updateCharacterInstance(
instances = campaignRepository.campaignFlow().value.instances
)
}
.launchIn(this)
networkRepository.status networkRepository.status
.filter { status -> status == NetworkRepository.Status.CONNECTED } .filter { status -> status == NetworkRepository.Status.CONNECTED }
.flowOn(context = Dispatchers.IO) .flowOn(context = Dispatchers.IO)
.onEach { .onEach { updateRepository() }
try {
tagRepository.updateAlterationTags()
alterationRepository.updateAlterationFlow()
tagRepository.updateCharacterTags()
characterRepository.updateCharacterPreviews()
campaignRepository.updateCampaign()
tagRepository.updateItemTags()
itemRepository.updateItemFlow()
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
}
}
.launchIn(this) .launchIn(this)
networkRepository.status networkRepository.status
@ -70,20 +75,38 @@ class DataSyncViewModel(
.flowOn(context = Dispatchers.IO) .flowOn(context = Dispatchers.IO)
.flatMapLatest { campaignRepository.campaignFlow().map { it.instances } } .flatMapLatest { campaignRepository.campaignFlow().map { it.instances } }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { instances -> .onEach { updateCharacterInstance(instances = it) }
instances.forEach { characterSheetId ->
try {
characterRepository.updateCharacterSheet(
characterSheetId = characterSheetId,
)
inventoryRepository.updateInventoryFlow(
characterSheetId = characterSheetId,
)
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
}
}
}
.launchIn(this) .launchIn(this)
} }
private suspend fun updateRepository() {
try {
tagRepository.updateAlterationTags()
alterationRepository.updateAlterationFlow()
tagRepository.updateCharacterTags()
characterRepository.updateCharacterPreviews()
campaignRepository.updateCampaign()
tagRepository.updateItemTags()
itemRepository.updateItemFlow()
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
}
}
private suspend fun updateCharacterInstance(
instances: Set<String>,
) {
instances.forEach { characterSheetId ->
try {
characterRepository.updateCharacterSheet(
characterSheetId = characterSheetId,
)
inventoryRepository.updateInventoryFlow(
characterSheetId = characterSheetId,
)
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
}
}
}
} }

View file

@ -10,6 +10,7 @@ import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent
import com.pixelized.shared.lwa.protocol.websocket.CharacterSheetEvent import com.pixelized.shared.lwa.protocol.websocket.CharacterSheetEvent
import com.pixelized.shared.lwa.protocol.websocket.GameAdminEvent
import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent
import com.pixelized.shared.lwa.protocol.websocket.RollEvent import com.pixelized.shared.lwa.protocol.websocket.RollEvent
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
@ -140,6 +141,8 @@ class TextMessageFactory(
is GameMasterEvent -> null is GameMasterEvent -> null
is GameAdminEvent -> null
is ApiSynchronisation -> null is ApiSynchronisation -> null
} }
} }

View file

@ -19,6 +19,7 @@ import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.ic_camping_24dp import lwacharactersheet.composeapp.generated.resources.ic_camping_24dp
import lwacharactersheet.composeapp.generated.resources.ic_sync_24dp
import lwacharactersheet.composeapp.generated.resources.ic_visibility_24dp import lwacharactersheet.composeapp.generated.resources.ic_visibility_24dp
import lwacharactersheet.composeapp.generated.resources.ic_visibility_off_24dp import lwacharactersheet.composeapp.generated.resources.ic_visibility_off_24dp
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@ -40,6 +41,11 @@ fun GMActionPage(
GMActionContent( GMActionContent(
actions = actions, actions = actions,
scroll = scroll, scroll = scroll,
onServerSync = {
scope.launch {
viewModel.onServerSync()
}
},
onPartyHeal = { onPartyHeal = {
scope.launch { scope.launch {
viewModel.onPartyHeal() viewModel.onPartyHeal()
@ -68,6 +74,7 @@ fun GMActionContent(
scroll: ScrollState, scroll: ScrollState,
spacing: Dp = 8.dp, spacing: Dp = 8.dp,
actions: State<ActionPageUio?>, actions: State<ActionPageUio?>,
onServerSync: () -> Unit,
onPartyHeal: () -> Unit, onPartyHeal: () -> Unit,
onPartyVisibility: () -> Unit, onPartyVisibility: () -> Unit,
onNpcVisibility: () -> Unit, onNpcVisibility: () -> Unit,
@ -78,6 +85,12 @@ fun GMActionContent(
.padding(vertical = spacing, horizontal = spacing), .padding(vertical = spacing, horizontal = spacing),
verticalArrangement = Arrangement.spacedBy(space = spacing), verticalArrangement = Arrangement.spacedBy(space = spacing),
) { ) {
GMAction(
modifier = Modifier.fillMaxWidth(),
icon = Res.drawable.ic_sync_24dp,
label = "Syncrhonization du serveur",
onAction = onServerSync,
)
GMAction( GMAction(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
icon = Res.drawable.ic_camping_24dp, icon = Res.drawable.ic_camping_24dp,

View file

@ -6,6 +6,7 @@ import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackUio import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackUio
import com.pixelized.shared.lwa.protocol.websocket.GameAdminEvent
import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@ -38,6 +39,19 @@ class GMActionViewModel(
initialValue = null, initialValue = null,
) )
suspend fun onServerSync() {
try {
networkRepository.share(
GameAdminEvent.ServerSynchronization(
timestamp = System.currentTimeMillis(),
)
)
} catch (exception: Exception) {
val message = ErrorSnackUio.from(exception = exception)
_error.emit(message)
}
}
suspend fun onPartyHeal() { suspend fun onPartyHeal() {
campaignRepository.campaignFlow().value.characters.forEach { characterSheetId -> campaignRepository.campaignFlow().value.characters.forEach { characterSheetId ->
val sheet = characterRepository.characterDetail( val sheet = characterRepository.characterDetail(

View file

@ -41,7 +41,7 @@ class AlterationStore(
fun alterationsFlow(): StateFlow<List<Alteration>> = alterationFlow fun alterationsFlow(): StateFlow<List<Alteration>> = alterationFlow
private fun updateAlterationFlow() { fun updateAlterationFlow() {
alterationFlow.value = try { alterationFlow.value = try {
load() load()
} catch (exception: Exception) { } catch (exception: Exception) {

View file

@ -38,7 +38,7 @@ class CampaignStore(
fun campaignFlow(): StateFlow<Campaign> = campaignFlow fun campaignFlow(): StateFlow<Campaign> = campaignFlow
private fun updateCampaignFlow() { fun updateCampaignFlow() {
campaignFlow.value = try { campaignFlow.value = try {
load() load()
} catch (exception: Exception) { } catch (exception: Exception) {

View file

@ -40,7 +40,7 @@ class CharacterSheetStore(
fun characterSheetsFlow(): StateFlow<List<CharacterSheet>> = characterSheetsFlow fun characterSheetsFlow(): StateFlow<List<CharacterSheet>> = characterSheetsFlow
private suspend fun updateCharacterFlow() { fun updateCharacterFlow() {
characterSheetsFlow.value = try { characterSheetsFlow.value = try {
load() load()
} catch (exception: Exception) { } catch (exception: Exception) {
@ -54,7 +54,7 @@ class CharacterSheetStore(
JsonCodingException::class, JsonCodingException::class,
JsonConversionException::class, JsonConversionException::class,
) )
suspend fun load( fun load(
directory: File = this.directory, directory: File = this.directory,
): List<CharacterSheet> { ): List<CharacterSheet> {
return directory return directory

View file

@ -40,7 +40,7 @@ class InventoryStore(
fun inventoryFlow(): StateFlow<Map<String, Inventory>> = inventoryFlow fun inventoryFlow(): StateFlow<Map<String, Inventory>> = inventoryFlow
private suspend fun updateInventoryFlow() { fun updateInventoryFlow() {
inventoryFlow.value = try { inventoryFlow.value = try {
load() load()
} catch (exception: Exception) { } catch (exception: Exception) {

View file

@ -43,7 +43,7 @@ class ItemStore(
fun itemsFlow(): StateFlow<List<Item>> = itemFlow fun itemsFlow(): StateFlow<List<Item>> = itemFlow
private fun updateItemsFlow() { fun updateItemsFlow() {
itemFlow.value = try { itemFlow.value = try {
load() load()
} catch (exception: Exception) { } catch (exception: Exception) {

View file

@ -33,18 +33,7 @@ class TagStore(
val scope = CoroutineScope(Dispatchers.IO + Job()) val scope = CoroutineScope(Dispatchers.IO + Job())
// load the initial data // load the initial data
scope.launch { scope.launch {
update( updateTagFlow()
flow = alterationTagsFlow,
file = alterationFile(),
)
update(
flow = characterTagsFlow,
file = characterFile(),
)
update(
flow = itemTagsFlow,
file = itemFile(),
)
} }
} }
@ -52,6 +41,21 @@ class TagStore(
fun characterTags(): StateFlow<Map<String, TagJson>> = characterTagsFlow fun characterTags(): StateFlow<Map<String, TagJson>> = characterTagsFlow
fun itemTags(): StateFlow<Map<String, TagJson>> = itemTagsFlow fun itemTags(): StateFlow<Map<String, TagJson>> = itemTagsFlow
fun updateTagFlow() {
update(
flow = alterationTagsFlow,
file = alterationFile(),
)
update(
flow = characterTagsFlow,
file = characterFile(),
)
update(
flow = itemTagsFlow,
file = itemFile(),
)
}
private fun update( private fun update(
flow: MutableStateFlow<Map<String, TagJson>>, flow: MutableStateFlow<Map<String, TagJson>>,
file: File, file: File,

View file

@ -1,15 +1,22 @@
package com.pixelized.server.lwa.server package com.pixelized.server.lwa.server
import com.pixelized.server.lwa.model.alteration.AlterationService 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.CampaignService
import com.pixelized.server.lwa.model.campaign.CampaignStore
import com.pixelized.server.lwa.model.character.CharacterSheetService import com.pixelized.server.lwa.model.character.CharacterSheetService
import com.pixelized.server.lwa.model.character.CharacterSheetStore
import com.pixelized.server.lwa.model.inventory.InventoryService import com.pixelized.server.lwa.model.inventory.InventoryService
import com.pixelized.server.lwa.model.inventory.InventoryStore
import com.pixelized.server.lwa.model.item.ItemService import com.pixelized.server.lwa.model.item.ItemService
import com.pixelized.server.lwa.model.item.ItemStore
import com.pixelized.server.lwa.model.tag.TagService import com.pixelized.server.lwa.model.tag.TagService
import com.pixelized.server.lwa.model.tag.TagStore
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent
import com.pixelized.shared.lwa.protocol.websocket.CharacterSheetEvent import com.pixelized.shared.lwa.protocol.websocket.CharacterSheetEvent
import com.pixelized.shared.lwa.protocol.websocket.GameAdminEvent
import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent
import com.pixelized.shared.lwa.protocol.websocket.RollEvent import com.pixelized.shared.lwa.protocol.websocket.RollEvent
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
@ -23,6 +30,12 @@ class Engine(
val inventoryService: InventoryService, val inventoryService: InventoryService,
val tagService: TagService, val tagService: TagService,
val campaignJsonFactory: CampaignJsonFactory, val campaignJsonFactory: CampaignJsonFactory,
private val campaignStore: CampaignStore,
private val characterStore: CharacterSheetStore,
private val alterationStore: AlterationStore,
private val itemStore: ItemStore,
private val inventoryStore: InventoryStore,
private val tagStore: TagStore,
) { ) {
val webSocket = MutableSharedFlow<SocketMessage>() val webSocket = MutableSharedFlow<SocketMessage>()
@ -94,6 +107,23 @@ class Engine(
is CampaignEvent.UpdateScene -> Unit is CampaignEvent.UpdateScene -> Unit
} }
is GameAdminEvent -> when (message) {
is GameAdminEvent.ServerSynchronization -> {
characterStore.updateCharacterFlow()
campaignStore.updateCampaignFlow()
alterationStore.updateAlterationFlow()
itemStore.updateItemsFlow()
inventoryStore.updateInventoryFlow()
tagStore.updateTagFlow()
webSocket.emit(
value = GameAdminEvent.ServerSynchronization(
timestamp = System.currentTimeMillis(),
),
)
}
}
is ApiSynchronisation -> Unit // Nothing to do there. is ApiSynchronisation -> Unit // Nothing to do there.
} }
} }

View file

@ -10,7 +10,7 @@ import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson
class CharacterSheetJsonFactory( class CharacterSheetJsonFactory(
private val v1: CharacterSheetJsonV1Factory, private val v1: CharacterSheetJsonV1Factory,
) { ) {
suspend fun convertFromJson( fun convertFromJson(
json: CharacterSheetJson, json: CharacterSheetJson,
): CharacterSheet { ): CharacterSheet {
return when (json) { return when (json) {

View file

@ -7,7 +7,7 @@ import com.pixelized.shared.lwa.usecase.CharacterSheetUseCase
class CharacterSheetJsonV1Factory( class CharacterSheetJsonV1Factory(
private val characterSheetUseCase: CharacterSheetUseCase, private val characterSheetUseCase: CharacterSheetUseCase,
) { ) {
suspend fun convertFromJson( fun convertFromJson(
json: CharacterSheetJsonV1, json: CharacterSheetJsonV1,
): CharacterSheet = characterSheetUseCase.run { ): CharacterSheet = characterSheetUseCase.run {
CharacterSheet( CharacterSheet(

View file

@ -0,0 +1,10 @@
package com.pixelized.shared.lwa.protocol.websocket
import kotlinx.serialization.Serializable
@Serializable
sealed interface GameAdminEvent : SocketMessage {
@Serializable
data class ServerSynchronization(override val timestamp: Long) : GameAdminEvent
}