This commit is contained in:
Andres Gomez, Thomas (ITDV RL) 2025-03-28 16:57:39 +01:00 committed by Thomas Andres Gomez
parent 76336dfbb0
commit 02987a0a53
54 changed files with 1487 additions and 332 deletions

View file

@ -4,6 +4,7 @@ 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.CharacterSheetStore
import com.pixelized.server.lwa.model.tag.TagStore
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.utils.PathProvider
import org.koin.core.module.dsl.createdAtStart
@ -35,6 +36,7 @@ val storeDependencies
singleOf(::CharacterSheetStore)
singleOf(::CampaignStore)
singleOf(::AlterationStore)
singleOf(::TagStore)
}
val serviceDependencies

View file

@ -1,6 +1,9 @@
package com.pixelized.server.lwa.model.alteration
import com.pixelized.server.lwa.model.tag.TagStore
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.alteration.AlterationJsonFactory
import com.pixelized.shared.lwa.model.tag.TagJson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -9,25 +12,45 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class AlterationService(
store: AlterationStore,
private val alterationStore: AlterationStore,
tagStore: TagStore,
factory: AlterationJsonFactory,
) {
private val scope = CoroutineScope(Dispatchers.IO + Job())
private val alterationsFlow = store.alterationsFlow()
private val alterationHashFlow = alterationsFlow
.map { data -> data.associateBy { it.id } }
private val alterationHashFlow = alterationStore.alterationsFlow()
.map { alterations -> alterations.associate { it.id to factory.convertToJson(it) } }
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyMap()
)
private val alterationTags = tagStore.alterationTags()
.map { it.values.toList() }
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
fun alterations(): List<AlterationJson> {
return alterationsFlow.value
return alterationHashFlow.value.values.toList()
}
fun tags(): List<TagJson> {
return alterationTags.value
}
fun alteration(alterationId: String): AlterationJson? {
return alterationHashFlow.value[alterationId]
}
fun update(json: AlterationJson) {
alterationStore.save(alteration = json)
}
fun delete(alterationId: String): Boolean {
return alterationStore.delete(id = alterationId)
}
}

View file

@ -1,22 +1,27 @@
package com.pixelized.server.lwa.model.alteration
import com.pixelized.shared.lwa.model.alteration.Alteration
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.alteration.AlterationJsonFactory
import com.pixelized.shared.lwa.utils.PathProvider
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.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.io.File
class AlterationStore(
private val pathProvider: PathProvider,
private val factory: AlterationJsonFactory,
private val json: Json,
) {
private val directory = File(pathProvider.alterationsPath()).also { it.mkdirs() }
private val alterationsFlow = MutableStateFlow<List<AlterationJson>>(emptyList())
private val alterationFlow = MutableStateFlow<List<Alteration>>(emptyList())
init {
// build a coroutine scope for async calls
@ -27,10 +32,10 @@ class AlterationStore(
}
}
fun alterationsFlow(): StateFlow<List<AlterationJson>> = alterationsFlow
fun alterationsFlow(): StateFlow<List<Alteration>> = alterationFlow
private fun updateAlterations() {
alterationsFlow.value = try {
alterationFlow.value = try {
loadAlterations()
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
@ -42,7 +47,7 @@ class AlterationStore(
FileReadException::class,
JsonConversionException::class,
)
private fun loadAlterations(): List<AlterationJson> {
private fun loadAlterations(): List<Alteration> {
return directory
.listFiles()
?.mapNotNull { file ->
@ -56,7 +61,8 @@ class AlterationStore(
return@mapNotNull null
}
try {
this.json.decodeFromString<AlterationJson>(json)
val data = this.json.decodeFromString<AlterationJson>(json)
factory.convertFromJson(data)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
@ -64,6 +70,73 @@ class AlterationStore(
?: emptyList()
}
@Throws(JsonConversionException::class, FileWriteException::class)
fun save(
alteration: Alteration,
) {
val json = try {
factory.convertToJson(data = alteration)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
save(alteration = json)
}
@Throws(FileWriteException::class)
fun save(
alteration: AlterationJson,
) {
// encode the json into a string
val data = try {
json.encodeToString(alteration)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
// write the alteration into a file.
try {
val file = alterationFile(id = alteration.id)
file.writeText(
text = data,
charset = Charsets.UTF_8,
)
} catch (exception: Exception) {
throw FileWriteException(
root = exception
)
}
// Update the dataflow.
alterationFlow.update { alterations ->
val index = alterations.indexOfFirst { it.id == alteration.id }
val alt = factory.convertFromJson(alteration)
alterations.toMutableList().also {
if (index >= 0) {
it[index] = alt
} else {
it.add(alt)
}
}
}
}
fun delete(id: String): Boolean {
val file = alterationFile(id = id)
val deleted = file.delete()
if (deleted) {
alterationFlow.update { alterations ->
alterations.toMutableList().also { alteration ->
alteration.removeIf { it.id == id }
}
}
}
return deleted
}
private fun alterationFile(id: String): File {
return File("${pathProvider.alterationsPath()}${id}.json")
}
sealed class AlterationStoreException(root: Exception) : Exception(root)
class JsonConversionException(root: Exception) : AlterationStoreException(root)
class FileWriteException(root: Exception) : AlterationStoreException(root)

View file

@ -1,8 +1,10 @@
package com.pixelized.server.lwa.model.character
import com.pixelized.server.lwa.model.tag.TagStore
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.model.characterSheet.factory.CharacterSheetJsonFactory
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -12,12 +14,13 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class CharacterSheetService(
private val store: CharacterSheetStore,
private val characterStore: CharacterSheetStore,
private val tagStore: TagStore,
private val factory: CharacterSheetJsonFactory,
) {
private val scope = CoroutineScope(Dispatchers.IO + Job())
private val sheets get() = sheetsFlow.value
private val sheetsFlow = store.characterSheetsFlow()
private val sheetsFlow = characterStore.characterSheetsFlow()
.map { entry -> entry.associateBy { character -> character.id } }
.stateIn(
scope = scope,
@ -25,10 +28,22 @@ class CharacterSheetService(
initialValue = emptyMap()
)
private val alterationTags = tagStore.characterTags()
.map { it.values.toList() }
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
fun character(characterSheetId: String): CharacterSheet? {
return sheets[characterSheetId]
}
fun tags() : Collection<TagJson> {
return alterationTags.value
}
fun charactersJson(): List<CharacterPreviewJson> {
return sheets.map { factory.convertToPreviewJson(sheet = it.value) }
}
@ -38,13 +53,13 @@ class CharacterSheetService(
}
suspend fun updateCharacterSheet(character: CharacterSheetJson) {
return store.save(
return characterStore.save(
sheet = factory.convertFromJson(character)
)
}
fun deleteCharacterSheet(characterSheetId: String): Boolean {
return store.delete(id = characterSheetId)
return characterStore.delete(id = characterSheetId)
}
// Data manipulation through WebSocket.
@ -60,7 +75,7 @@ class CharacterSheetService(
val alterations = character.alterations.toMutableList().also {
it.add(alterationId)
}
store.save(
characterStore.save(
sheet = character.copy(
alterations = alterations,
)
@ -70,7 +85,7 @@ class CharacterSheetService(
val alterations = character.alterations.toMutableList().also {
it.remove(alterationId)
}
store.save(
characterStore.save(
sheet = character.copy(
alterations = alterations,
)
@ -85,7 +100,7 @@ class CharacterSheetService(
) {
sheets[characterSheetId]?.let { character ->
val update = character.copy(damage = damage)
store.save(sheet = update)
characterStore.save(sheet = update)
}
}
@ -95,7 +110,7 @@ class CharacterSheetService(
) {
sheets[characterSheetId]?.let { character ->
val update = character.copy(diminished = diminished)
store.save(sheet = update)
characterStore.save(sheet = update)
}
}
@ -105,7 +120,7 @@ class CharacterSheetService(
) {
sheets[characterSheetId]?.let { character ->
val update = character.copy(fatigue = fatigue)
store.save(sheet = update)
characterStore.save(sheet = update)
}
}
@ -126,7 +141,7 @@ class CharacterSheetService(
skill.takeIf { skill.id == skillId }?.copy(used = used) ?: skill
},
)
store.save(sheet = update)
characterStore.save(sheet = update)
}
}
}

View file

@ -76,7 +76,7 @@ class CharacterSheetStore(
)
fun save(sheet: CharacterSheet) {
// convert the character sheet into json format.
val json = try {
val data = try {
factory.convertToJson(sheet = sheet).let(json::encodeToString)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
@ -85,7 +85,7 @@ class CharacterSheetStore(
try {
val file = characterSheetFile(id = sheet.id)
file.writeText(
text = json,
text = data,
charset = Charsets.UTF_8,
)
} catch (exception: Exception) {

View file

@ -0,0 +1,108 @@
package com.pixelized.server.lwa.model.tag
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.utils.PathProvider
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.json.Json
import java.io.File
private const val CHARACTER = "character"
private const val ALTERATION = "alteration"
class TagStore(
private val pathProvider: PathProvider,
private val json: Json,
) {
private val alterationTagsFlow = MutableStateFlow<Map<String, TagJson>>(emptyMap())
private val characterTagsFlow = MutableStateFlow<Map<String, TagJson>>(emptyMap())
init {
// make the file path.
File(pathProvider.tagsPath()).mkdirs()
// build a coroutine scope for async calls
val scope = CoroutineScope(Dispatchers.IO + Job())
// load the initial data
scope.launch {
update(
flow = alterationTagsFlow,
file = alterationFile(),
)
update(
flow = characterTagsFlow,
file = characterFile(),
)
}
}
fun alterationTags(): StateFlow<Map<String, TagJson>> = alterationTagsFlow
fun characterTags(): StateFlow<Map<String, TagJson>> = characterTagsFlow
private fun update(
flow: MutableStateFlow<Map<String, TagJson>>,
file: File,
) {
flow.value = try {
file.readTags().associateBy { it.id }
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
emptyMap()
}
}
@Throws(FileReadException::class, JsonConversionException::class)
private fun File.readTags(): List<TagJson> {
// read the file (force the UTF8 format)
val data = try {
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 (data.isBlank()) {
return emptyList()
}
return try {
json.decodeFromString<List<TagJson>>(data)
} catch (exception: Exception) {
throw JsonConversionException(
root = exception
)
}
}
@Throws(JsonConversionException::class, FileWriteException::class)
private fun saveAlterationTags(tags: List<TagJson>) {
// convert the data to json format
val json = try {
this.json.encodeToString(tags)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
// write the file
try {
val file = alterationFile()
file.writeText(
text = json,
charset = Charsets.UTF_8,
)
} catch (exception: Exception) {
throw FileWriteException(root = exception)
}
// Update the dataflow.
alterationTagsFlow.value = tags.associateBy { it.id }
}
private fun characterFile() = File("${pathProvider.tagsPath()}$CHARACTER.json")
private fun alterationFile() = File("${pathProvider.tagsPath()}$ALTERATION.json")
sealed class TagStoreException(root: Exception) : Exception(root)
class JsonConversionException(root: Exception) : TagStoreException(root)
class FileWriteException(root: Exception) : TagStoreException(root)
class FileReadException(root: Exception) : TagStoreException(root)
}

View file

@ -1,8 +1,11 @@
package com.pixelized.server.lwa.server
import com.pixelized.server.lwa.server.rest.alteration.deleteAlteration
import com.pixelized.server.lwa.server.rest.alteration.getAlteration
import com.pixelized.server.lwa.server.rest.alteration.getAlterationTags
import com.pixelized.server.lwa.server.rest.alteration.getAlterations
import com.pixelized.server.lwa.server.rest.alteration.putAlteration
import com.pixelized.server.lwa.server.rest.campaign.removeCampaignNpc
import com.pixelized.server.lwa.server.rest.campaign.getCampaign
import com.pixelized.server.lwa.server.rest.campaign.putCampaignCharacter
@ -11,6 +14,7 @@ import com.pixelized.server.lwa.server.rest.campaign.putCampaignScene
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.getCharacterTags
import com.pixelized.server.lwa.server.rest.character.getCharacters
import com.pixelized.server.lwa.server.rest.character.putCharacter
import com.pixelized.server.lwa.server.rest.character.putCharacterAlteration
@ -126,6 +130,18 @@ class LocalServer {
path = "/detail",
body = engine.getAlteration(),
)
get(
path = "/tags",
body = engine.getAlterationTags(),
)
put(
path = "/update",
body = engine.putAlteration()
)
delete(
path = "/delete",
body = engine.deleteAlteration()
)
}
route(
path = "/character",
@ -134,6 +150,10 @@ class LocalServer {
path = "/all",
body = engine.getCharacters(),
)
get(
path = "/tags",
body = engine.getCharacterTags(),
)
get(
path = "/detail",
body = engine.getCharacter(),

View file

@ -0,0 +1,37 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.alterationId
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
fun Engine.deleteAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
try {
val alterationId = call.parameters.alterationId
val deleted = alterationService.delete(
alterationId = alterationId
)
if (deleted.not()) error("Unexpected error occurred")
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
)
webSocket.emit(
value = ApiSynchronisation.AlterationDelete(
timestamp = System.currentTimeMillis(),
alterationId = alterationId,
),
)
} catch (exception: Exception) {
call.respondText(
text = "${HttpStatusCode.UnprocessableEntity}",
status = HttpStatusCode.UnprocessableEntity,
)
}
}
}

View file

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

View file

@ -0,0 +1,34 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respondText
fun Engine.putAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
try {
val form = call.receive<AlterationJson>()
alterationService.update(json = form)
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
)
webSocket.emit(
value = ApiSynchronisation.AlterationUpdate(
timestamp = System.currentTimeMillis(),
alterationId = form.id,
),
)
} catch (exception : Exception) {
call.respondText(
text = "${HttpStatusCode.UnprocessableEntity}",
status = HttpStatusCode.UnprocessableEntity,
)
}
}
}

View file

@ -14,6 +14,7 @@ fun Engine.deleteCharacter(): suspend io.ktor.server.routing.RoutingContext.() -
characterSheetId = characterSheetId
)
// Remove the character fom the campaign if needed.
// TODO probably useless because all data will not be cleaned up (all campaign / screnes)
campaignService.removeInstance(
characterSheetId = characterSheetId,
)

View file

@ -0,0 +1,12 @@
package com.pixelized.server.lwa.server.rest.character
import com.pixelized.server.lwa.server.Engine
import io.ktor.server.response.respond
fun Engine.getCharacterTags(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
call.respond(
message = characterService.tags(),
)
}
}