Refactor project data to allow server handling.

This commit is contained in:
Thomas Andres Gomez 2025-02-22 12:54:19 +01:00
parent 3c8eecdab5
commit 1e5f0d88ae
58 changed files with 742 additions and 469 deletions

View file

@ -0,0 +1,38 @@
import com.pixelized.server.lwa.model.character.CharacterSheetRepository
import com.pixelized.server.lwa.model.character.CharacterSheetStore
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val serverModuleDependencies
get() = listOf(
parserDependencies,
factoryDependencies,
useCaseDependencies,
storeDependencies,
repositoryDependencies,
)
val storeDependencies
get() = module {
singleOf(::CharacterSheetStore)
}
val repositoryDependencies
get() = module {
singleOf(::CharacterSheetRepository)
}
val factoryDependencies
get() = module {
}
val parserDependencies
get() = module {
}
val useCaseDependencies
get() = module {
}

View file

@ -1,6 +1,9 @@
package com.pixelized.server.lwa
import com.pixelized.server.lwa.server.LocalServer
import com.pixelized.shared.lwa.sharedModuleDependencies
import org.koin.core.context.startKoin
import org.koin.java.KoinJavaComponent.inject
fun main() {
LocalServer().create().start()

View file

@ -1,6 +1,6 @@
package com.pixelized.server.lwa.extention
import com.pixelized.server.lwa.protocol.Message
import com.pixelized.shared.lwa.protocol.Message
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.serialization.json.Json

View file

@ -0,0 +1,27 @@
package com.pixelized.server.lwa.model.character
import com.pixelized.shared.lwa.model.characterSheet.model.CharacterSheet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
class CharacterSheetRepository(
store: CharacterSheetStore,
) {
private val scope = CoroutineScope(Dispatchers.IO + Job())
private val sheets = store.characterSheetFlow()
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
fun characterSheetFlow(): StateFlow<List<CharacterSheet>> {
return sheets
}
}

View file

@ -0,0 +1,119 @@
package com.pixelized.server.lwa.model.character
import com.pixelized.shared.lwa.characterStorePath
import com.pixelized.shared.lwa.model.characterSheet.model.CharacterSheet
import com.pixelized.shared.lwa.model.characterSheet.model.CharacterSheetJson
import com.pixelized.shared.lwa.model.characterSheet.model.CharacterSheetJsonFactory
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
import java.text.Collator
class CharacterSheetStore(
private val factory: CharacterSheetJsonFactory,
private val jsonFormatter: Json,
) {
private val characterDirectory = File(characterStorePath()).also { it.mkdirs() }
private val flow = MutableStateFlow<List<CharacterSheet>>(value = emptyList())
init {
val scope = CoroutineScope(Dispatchers.IO + Job())
scope.launch {
flow.value = load()
}
}
fun characterSheetFlow(): StateFlow<List<CharacterSheet>> = flow
@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(jsonFormatter::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.
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)
flow.value = flow.value.toMutableList()
.also { data ->
data.removeIf { it.id == id }
}
.sortedBy {
it.name
}
return file.delete()
}
@Throws(
CharacterSheetStoreException::class,
FileReadException::class,
JsonConversionException::class,
)
suspend fun load(): List<CharacterSheet> {
return characterDirectory
.listFiles()
?.mapNotNull { file ->
val json = try {
file.readText(charset = Charsets.UTF_8)
} catch (exception: Exception) {
throw FileReadException(root = exception)
}
// Guard, if the json is blank no character have been save, ignore this file.
if (json.isBlank()) {
return@mapNotNull null
}
try {
val sheet = jsonFormatter.decodeFromString<CharacterSheetJson>(json)
factory.convertFromJson(sheet)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
}
?.sortedWith(compareBy(Collator.getInstance()) { it.name })
?: emptyList()
}
private fun characterSheetFile(id: String): File {
return File("${characterStorePath()}${id}.json")
}
sealed class CharacterSheetStoreException(root: Exception) : Exception(root)
class JsonConversionException(root: Exception) : CharacterSheetStoreException(root)
class FileWriteException(root: Exception) : CharacterSheetStoreException(root)
class FileReadException(root: Exception) : CharacterSheetStoreException(root)
}

View file

@ -1,16 +1,25 @@
package com.pixelized.server.lwa.server
import com.pixelized.server.lwa.SERVER_PORT
import com.pixelized.server.lwa.extention.decodeFromFrame
import com.pixelized.server.lwa.extention.encodeToFrame
import com.pixelized.server.lwa.protocol.Message
import com.pixelized.server.lwa.model.character.CharacterSheetRepository
import com.pixelized.shared.lwa.SERVER_PORT
import com.pixelized.shared.lwa.model.characterSheet.model.CharacterSheetJsonFactory
import com.pixelized.shared.lwa.protocol.Message
import com.pixelized.shared.lwa.sharedModuleDependencies
import io.ktor.http.ContentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.netty.NettyApplicationEngine
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.pingPeriod
import io.ktor.server.websocket.timeout
@ -21,6 +30,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import org.koin.ktor.ext.inject
import org.koin.ktor.plugin.Koin
import serverModuleDependencies
import kotlin.time.Duration.Companion.seconds
// https://ktor.io/docs/server-websockets.html#handle-multiple-session
@ -28,33 +40,78 @@ typealias Server = EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine
class LocalServer {
private var server: Server? = null
private val json = Json { explicitNulls = true }
private val outgoingMessageBuffer = MutableSharedFlow<Message>()
fun create(): LocalServer {
server = build {
val job = launch {
// send local message to the clients
outgoingMessageBuffer.collect { message ->
send(json.encodeToFrame(message))
fun create(
port: Int = SERVER_PORT, // 16030
): LocalServer {
server = embeddedServer(
factory = Netty,
port = port,
module = {
install(Koin) {
modules(sharedModuleDependencies + serverModuleDependencies)
}
val json by inject<Json>()
install(ContentNegotiation) {
json(json)
}
install(WebSockets) {
pingPeriod = 15.seconds
timeout = 15.seconds
maxFrameSize = Long.MAX_VALUE
masking = false
}
val repository by inject<CharacterSheetRepository>()
val factory by inject<CharacterSheetJsonFactory>()
routing {
get(
path = "/",
body = {
call.respondText(contentType = ContentType.Text.Html) {
"<a href=\"http://127.0.0.1:16030/characters\">characters</a>"
}
}
)
get(
path = "/characters",
body = {
val body = repository.characterSheetFlow().value.map(factory::convertToJson)
call.respond(body)
},
)
webSocket(
path = "/ws",
handler = {
val job = launch {
// send local message to the clients
outgoingMessageBuffer.collect { message ->
send(json.encodeToFrame(message))
}
}
runCatching {
// watching for clients incoming message
incoming.consumeEach { frame ->
if (frame is Frame.Text) {
val message = Json.decodeFromFrame(frame = frame)
println(message)
// broadcast to clients the message
outgoingMessageBuffer.emit(message)
}
}
}.onFailure { exception ->
println("WebSocket exception: ${exception.localizedMessage}")
}.also {
job.cancel()
}
}
)
}
}
runCatching {
// watching for clients incoming message
incoming.consumeEach { frame ->
if (frame is Frame.Text) {
val message = Json.decodeFromFrame(frame = frame)
println(message)
// broadcast to clients the message
outgoingMessageBuffer.emit(message)
}
}
}.onFailure { exception ->
println("WebSocket exception: ${exception.localizedMessage}")
}.also {
job.cancel()
}
}
)
return this
}
@ -70,28 +127,4 @@ class LocalServer {
}
}
}
private fun build(
port: Int = SERVER_PORT,
handler: suspend DefaultWebSocketServerSession.() -> Unit,
): EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration> {
return embeddedServer(
factory = Netty,
port = port,
module = {
install(WebSockets) {
pingPeriod = 15.seconds
timeout = 15.seconds
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
webSocket(
path = "/ws",
handler = handler,
)
}
},
)
}
}