Add inventory managment to server.

This commit is contained in:
Thomas Andres Gomez 2025-04-12 22:36:25 +02:00
parent 04b203239d
commit 4f33492b23
18 changed files with 499 additions and 7 deletions

View file

@ -4,6 +4,8 @@ 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.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.ItemStore
import com.pixelized.server.lwa.model.tag.TagService
@ -36,18 +38,20 @@ val engineDependencies
val storeDependencies
get() = module {
singleOf(::CharacterSheetStore)
singleOf(::CampaignStore)
singleOf(::AlterationStore)
singleOf(::TagStore)
singleOf(::CampaignStore)
singleOf(::CharacterSheetStore)
singleOf(::InventoryStore)
singleOf(::ItemStore)
singleOf(::TagStore)
}
val serviceDependencies
get() = module {
singleOf(::CharacterSheetService)
singleOf(::CampaignService)
singleOf(::AlterationService)
singleOf(::CampaignService)
singleOf(::CharacterSheetService)
singleOf(::InventoryService)
singleOf(::ItemService)
singleOf(::TagService)
}

View file

@ -0,0 +1,52 @@
package com.pixelized.server.lwa.model.inventory
import com.pixelized.shared.lwa.model.inventory.Inventory
import com.pixelized.shared.lwa.model.inventory.InventoryJson
import com.pixelized.shared.lwa.model.inventory.factory.InventoryJsonFactory
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 InventoryService(
private val inventoryStore: InventoryStore,
private val factory: InventoryJsonFactory,
) {
private val scope = CoroutineScope(Dispatchers.IO + Job())
private val inventoryJson = inventoryStore.inventoryFlow()
.map { it.mapValues { entry -> factory.convertToJson(inventory = entry.value) } }
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyMap()
)
fun inventory(characterSheetId: String): Inventory {
return inventoryStore.inventoryFlow().value[characterSheetId]
?: Inventory.empty(characterSheetId = characterSheetId)
}
fun inventoryJson(characterSheetId: String): InventoryJson {
return inventoryJson.value[characterSheetId]
?: factory.convertToJson(Inventory.empty(characterSheetId = characterSheetId))
}
@Throws
fun save(
inventoryJson: InventoryJson,
create: Boolean,
) {
inventoryStore.save(
inventory = factory.convertFromJson(json = inventoryJson),
create = create,
)
}
@Throws
fun delete(characterSheetId: String) {
inventoryStore.delete(characterSheetId = characterSheetId)
}
}

View file

@ -0,0 +1,158 @@
package com.pixelized.server.lwa.model.inventory
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.server.lwa.server.exception.FileReadException
import com.pixelized.server.lwa.server.exception.FileWriteException
import com.pixelized.server.lwa.server.exception.JsonCodingException
import com.pixelized.server.lwa.server.exception.JsonConversionException
import com.pixelized.shared.lwa.model.inventory.Inventory
import com.pixelized.shared.lwa.model.inventory.InventoryJson
import com.pixelized.shared.lwa.model.inventory.factory.InventoryJsonFactory
import com.pixelized.shared.lwa.protocol.rest.APIResponse
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 InventoryStore(
private val pathProvider: PathProvider,
private val factory: InventoryJsonFactory,
private val json: Json,
) {
private val directory = File(pathProvider.inventoryPath()).also { it.mkdirs() }
private val inventoryFlow = MutableStateFlow<Map<String, Inventory>>(value = emptyMap())
init {
// build a coroutine scope for async calls
val scope = CoroutineScope(Dispatchers.IO + Job())
// load the initial data
scope.launch {
updateInventoryFlow()
}
}
fun inventoryFlow(): StateFlow<Map<String, Inventory>> = inventoryFlow
private suspend fun updateInventoryFlow() {
inventoryFlow.value = try {
load()
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
emptyMap()
}
}
@Throws(
FileReadException::class,
JsonCodingException::class,
JsonConversionException::class,
)
private fun load(
directory: File = this.directory,
): Map<String, Inventory> {
return directory
.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
}
// decode the file
val data = try {
this.json.decodeFromString<InventoryJson>(json)
} catch (exception: Exception) {
throw JsonCodingException(root = exception)
}
// parse the json string.
val inventory = try {
factory.convertFromJson(data)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
file.name to inventory
}
?.toMap()
?: emptyMap()
}
@Throws(
BusinessException::class,
JsonConversionException::class,
JsonCodingException::class,
FileWriteException::class,
)
fun save(
inventory: Inventory,
create: Boolean,
) {
val file = inventoryFile(id = inventory.characterSheetId)
if (create && file.exists()) {
throw BusinessException(message = "Inventory already exist, creation is impossible.")
}
val json = try {
factory.convertToJson(inventory = inventory)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
val data = try {
this.json.encodeToString(json)
} catch (exception: Exception) {
throw JsonCodingException(root = exception)
}
try {
file.writeText(
text = data,
charset = Charsets.UTF_8,
)
} catch (exception: Exception) {
throw FileWriteException(root = exception)
}
inventoryFlow.update { flow ->
flow.toMutableMap().also { data ->
data[inventory.characterSheetId] = inventory
}
}
}
@Throws(BusinessException::class)
fun delete(characterSheetId: String) {
val file = inventoryFile(id = characterSheetId)
if (file.exists().not()) {
throw BusinessException(
message = "Inventory file with id:$characterSheetId doesn't not exist.",
code = APIResponse.ErrorCode.CharacterSheetId
)
}
if (file.delete().not()) {
throw BusinessException(
message = "Inventory file have not been deleted for unknown reason.",
)
}
inventoryFlow.update { characters ->
characters.toMutableMap().also { data -> data.remove(characterSheetId) }
}
}
private fun inventoryFile(id: String): File {
return File("${pathProvider.inventoryPath()}${id}.json")
}
}

View file

@ -27,7 +27,6 @@ class ItemStore(
private val json: Json,
) {
private val directory = File(pathProvider.itemsPath()).also { it.mkdirs() }
private val itemFlow = MutableStateFlow<List<Item>>(emptyList())
init {

View file

@ -3,6 +3,7 @@ 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.server.lwa.model.inventory.InventoryService
import com.pixelized.server.lwa.model.item.ItemService
import com.pixelized.server.lwa.model.tag.TagService
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory
@ -19,6 +20,7 @@ class Engine(
val campaignService: CampaignService,
val alterationService: AlterationService,
val itemService: ItemService,
val inventoryService: InventoryService,
val tagService: TagService,
val campaignJsonFactory: CampaignJsonFactory,
) {

View file

@ -19,6 +19,9 @@ import com.pixelized.server.lwa.server.rest.character.putCharacterAlteration
import com.pixelized.server.lwa.server.rest.character.putCharacterDamage
import com.pixelized.server.lwa.server.rest.character.putCharacterDiminished
import com.pixelized.server.lwa.server.rest.character.putCharacterFatigue
import com.pixelized.server.lwa.server.rest.inventory.deleteInventory
import com.pixelized.server.lwa.server.rest.inventory.getInventory
import com.pixelized.server.lwa.server.rest.inventory.putInventory
import com.pixelized.server.lwa.server.rest.item.deleteItem
import com.pixelized.server.lwa.server.rest.item.getItem
import com.pixelized.server.lwa.server.rest.item.getItems
@ -246,6 +249,20 @@ class LocalServer {
body = engine.getItemTags(),
)
}
route(path = "inventory") {
get(
path = "/detail",
body = engine.getInventory(),
)
put(
path = "/update",
body = engine.putInventory()
)
delete(
path = "/delete",
body = engine.deleteInventory()
)
}
}
}
)

View file

@ -0,0 +1,36 @@
package com.pixelized.server.lwa.server.rest.inventory
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.deleteInventory(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val characterSheetId = call.queryParameters.characterSheetId
// delete the alteration.
inventoryService.delete(
characterSheetId = characterSheetId,
)
// API & WebSocket responses.
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = ApiSynchronisation.InventoryDelete(
timestamp = System.currentTimeMillis(),
characterSheetId = characterSheetId,
),
)
} catch (exception: Exception) {
call.exception(
exception = exception,
)
}
}
}

View file

@ -0,0 +1,31 @@
package com.pixelized.server.lwa.server.rest.inventory
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.getInventory(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val characterSheetId = call.queryParameters.characterSheetId
// get the character inventory
val inventory = inventoryService.inventoryJson(
characterSheetId = characterSheetId,
)
// send it back to the user.
call.respond(
message = APIResponse.success(
data = inventory
)
)
} catch (exception: Exception) {
call.exception(
exception = exception
)
}
}
}

View file

@ -0,0 +1,42 @@
package com.pixelized.server.lwa.server.rest.inventory
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.server.lwa.utils.extentions.create
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.model.inventory.InventoryJson
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.putInventory(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val form = call.receive<InventoryJson>()
val create = call.queryParameters.create
// get the character inventory
inventoryService.save(
inventoryJson = form,
create = create,
)
// send it back to the user.
call.respond(
message = APIResponse.success()
)
webSocket.emit(
value = ApiSynchronisation.InventoryUpdate(
timestamp = System.currentTimeMillis(),
characterSheetId = form.characterSheetId,
),
)
} catch (exception: Exception) {
call.exception(
exception = exception
)
}
}
}

View file

@ -12,7 +12,7 @@ fun Engine.deleteItem(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val itemId = call.parameters.itemId
val itemId = call.queryParameters.itemId
// delete the alteration.
itemService.delete(
itemId = itemId

View file

@ -7,6 +7,8 @@ import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonV1Factory
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonV2Factory
import com.pixelized.shared.lwa.model.characterSheet.factory.CharacterSheetJsonFactory
import com.pixelized.shared.lwa.model.characterSheet.factory.CharacterSheetJsonV1Factory
import com.pixelized.shared.lwa.model.inventory.factory.InventoryJsonFactory
import com.pixelized.shared.lwa.model.inventory.factory.InventoryJsonFactoryV1
import com.pixelized.shared.lwa.model.item.factory.ItemJsonFactory
import com.pixelized.shared.lwa.model.item.factory.ItemJsonFactoryV1
import com.pixelized.shared.lwa.model.tag.TagJsonFactory
@ -52,6 +54,8 @@ val factoryDependencies
factoryOf(::TagJsonFactory)
factoryOf(::ItemJsonFactory)
factoryOf(::ItemJsonFactoryV1)
factoryOf(::InventoryJsonFactory)
factoryOf(::InventoryJsonFactoryV1)
}
val parserDependencies

View file

@ -0,0 +1,30 @@
package com.pixelized.shared.lwa.model.inventory
data class Inventory(
val characterSheetId: String,
val purse: Purse,
val items: List<Item>,
) {
data class Purse(
val gold: Int,
val silver: Int,
val copper: Int,
)
data class Item(
val itemId: String,
val count: Int,
)
companion object {
fun empty(characterSheetId: String) = Inventory(
characterSheetId = characterSheetId,
purse = Purse(
gold = 0,
silver = 0,
copper = 0,
),
items = emptyList(),
)
}
}

View file

@ -0,0 +1,8 @@
package com.pixelized.shared.lwa.model.inventory
import kotlinx.serialization.Serializable
@Serializable
sealed interface InventoryJson {
val characterSheetId: String
}

View file

@ -0,0 +1,24 @@
package com.pixelized.shared.lwa.model.inventory
import kotlinx.serialization.Serializable
@Serializable
data class InventoryJsonV1(
override val characterSheetId: String,
val purse: PurseJson,
val items: List<ItemJson>,
) : InventoryJson {
@Serializable
data class PurseJson(
val gold: Int,
val silver: Int,
val copper: Int,
)
@Serializable
data class ItemJson(
val itemId: String,
val count: Int,
)
}

View file

@ -0,0 +1,19 @@
package com.pixelized.shared.lwa.model.inventory.factory
import com.pixelized.shared.lwa.model.inventory.Inventory
import com.pixelized.shared.lwa.model.inventory.InventoryJson
import com.pixelized.shared.lwa.model.inventory.InventoryJsonV1
class InventoryJsonFactory(
private val v1: InventoryJsonFactoryV1,
) {
fun convertFromJson(json: InventoryJson): Inventory {
return when (json) {
is InventoryJsonV1 -> v1.convertFromJson(json = json)
}
}
fun convertToJson(inventory: Inventory): InventoryJson {
return v1.convertToJson(inventory = inventory)
}
}

View file

@ -0,0 +1,41 @@
package com.pixelized.shared.lwa.model.inventory.factory
import com.pixelized.shared.lwa.model.inventory.Inventory
import com.pixelized.shared.lwa.model.inventory.InventoryJsonV1
class InventoryJsonFactoryV1 {
fun convertFromJson(json: InventoryJsonV1): Inventory {
return Inventory(
characterSheetId = json.characterSheetId,
purse = Inventory.Purse(
gold = json.purse.gold,
silver = json.purse.silver,
copper = json.purse.copper,
),
items = json.items.map {
Inventory.Item(
itemId = it.itemId,
count = it.count,
)
},
)
}
fun convertToJson(inventory: Inventory): InventoryJsonV1 {
return InventoryJsonV1(
characterSheetId = inventory.characterSheetId,
purse = InventoryJsonV1.PurseJson(
gold = inventory.purse.gold,
silver = inventory.purse.silver,
copper = inventory.purse.copper,
),
items = inventory.items.map {
InventoryJsonV1.ItemJson(
itemId = it.itemId,
count = it.count,
)
},
)
}
}

View file

@ -49,4 +49,19 @@ sealed interface ApiSynchronisation : SocketMessage {
override val timestamp: Long,
val itemId: String,
) : ItemApiSynchronisation
@Serializable
sealed interface InventoryApiSynchronisation : ApiSynchronisation, CharacterSheetIdMessage
@Serializable
data class InventoryUpdate(
override val timestamp: Long,
override val characterSheetId: String,
) : InventoryApiSynchronisation
@Serializable
data class InventoryDelete(
override val timestamp: Long,
override val characterSheetId: String,
) : InventoryApiSynchronisation
}

View file

@ -73,4 +73,14 @@ class PathProvider(
OperatingSystem.Macintosh -> "${storePath(os = os, app = app)}tags/"
}
}
fun inventoryPath(
os: OperatingSystem = this.operatingSystem,
app: String = this.appName,
): String {
return when (os) {
OperatingSystem.Windows -> "${storePath(os = os, app = app)}inventory\\"
OperatingSystem.Macintosh -> "${storePath(os = os, app = app)}inventory/"
}
}
}