diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/DependencyInjection.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/DependencyInjection.kt index cc8bd3c25..e9d0d09a0 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/DependencyInjection.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/DependencyInjection.kt @@ -59,7 +59,6 @@ import cz.frantisekmasa.wfrp_master.common.core.firebase.functions.CloudFunction import cz.frantisekmasa.wfrp_master.common.core.firebase.repositories.FirestoreCharacterItemRepository import cz.frantisekmasa.wfrp_master.common.core.firebase.repositories.FirestoreCharacterRepository import cz.frantisekmasa.wfrp_master.common.core.firebase.repositories.FirestoreEncounterRepository -import cz.frantisekmasa.wfrp_master.common.core.firebase.repositories.FirestoreNpcRepository import cz.frantisekmasa.wfrp_master.common.core.firebase.repositories.FirestorePartyRepository import cz.frantisekmasa.wfrp_master.common.core.firebase.repositories.FirestoreSkillRepository import cz.frantisekmasa.wfrp_master.common.core.serialization.UuidSerializer @@ -67,7 +66,6 @@ import cz.frantisekmasa.wfrp_master.common.core.tips.DismissedUserTipsHolder import cz.frantisekmasa.wfrp_master.common.encounters.EncounterDetailScreenModel import cz.frantisekmasa.wfrp_master.common.encounters.EncountersScreenModel import cz.frantisekmasa.wfrp_master.common.encounters.domain.EncounterRepository -import cz.frantisekmasa.wfrp_master.common.encounters.domain.NpcRepository import cz.frantisekmasa.wfrp_master.common.gameMaster.GameMasterScreenModel import cz.frantisekmasa.wfrp_master.common.invitation.InvitationScreenModel import cz.frantisekmasa.wfrp_master.common.invitation.domain.FirestoreInvitationProcessor @@ -157,7 +155,6 @@ val appModule = DI.Module("Common") { } bindSingleton { FirestoreEncounterRepository(instance(), mapper()) } - bindSingleton { FirestoreNpcRepository(instance(), mapper()) } bindSingleton { CloudFunctionCharacterAvatarChanger(instance()) } @@ -196,7 +193,7 @@ val appModule = DI.Module("Common") { bindFactory { partyId: PartyId -> EncountersScreenModel(partyId, instance()) } bindFactory { partyId: PartyId -> PartyScreenModel(partyId, instance()) } bindFactory { encounterId: EncounterId -> - EncounterDetailScreenModel(encounterId, instance(), instance(), instance(), instance()) + EncounterDetailScreenModel(encounterId, instance(), instance(), instance()) } bindFactory { characterId: CharacterId -> SkillsScreenModel(characterId, instance(), instance(), instance(), instance()) @@ -316,7 +313,6 @@ val appModule = DI.Module("Common") { instance(), instance(), instance(), - instance(), ) } bindFactory { partyId: PartyId -> PartySettingsScreenModel(partyId, instance()) } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/combat/ActiveCombatScreen.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/combat/ActiveCombatScreen.kt index 648f7c83f..8e6eb4aa3 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/combat/ActiveCombatScreen.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/combat/ActiveCombatScreen.kt @@ -61,7 +61,6 @@ import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId import cz.frantisekmasa.wfrp_master.common.core.domain.party.combat.Advantage import cz.frantisekmasa.wfrp_master.common.core.domain.party.combat.GroupAdvantage import cz.frantisekmasa.wfrp_master.common.core.domain.party.settings.AdvantageSystem -import cz.frantisekmasa.wfrp_master.common.core.shared.Resources import cz.frantisekmasa.wfrp_master.common.core.ui.CharacterAvatar import cz.frantisekmasa.wfrp_master.common.core.ui.StatBlock import cz.frantisekmasa.wfrp_master.common.core.ui.StatBlockData @@ -81,7 +80,6 @@ import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.OptionsAction import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.Subtitle import cz.frantisekmasa.wfrp_master.common.encounters.CombatantItem import cz.frantisekmasa.wfrp_master.common.encounters.domain.Wounds -import cz.frantisekmasa.wfrp_master.common.npcs.NpcDetailScreen import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -152,13 +150,10 @@ class ActiveCombatScreen( isGroupAdvantageSystemEnabled = isGroupAdvantageSystemEnabled, onDetailOpenRequest = { navigation.navigate( - when (freshCombatant) { - is CombatantItem.Npc -> NpcDetailScreen(freshCombatant.npcId) - is CombatantItem.Character -> CharacterDetailScreen( - freshCombatant.characterId, - comingFromCombat = true, - ) - } + CharacterDetailScreen( + freshCombatant.characterId, + comingFromCombat = true, + ) ) coroutineScope.launch { bottomSheetState.hide() } }, @@ -279,7 +274,7 @@ class ActiveCombatScreen( @Stable private fun canEditCombatant(userId: UserId, isGameMaster: Boolean, combatant: CombatantItem) = - isGameMaster || (combatant is CombatantItem.Character && combatant.userId == userId) + isGameMaster || combatant.userId == userId @Composable private fun GroupAdvantageBar( @@ -593,16 +588,7 @@ class ActiveCombatScreen( Column { ListItem( - icon = { - when (combatant) { - is CombatantItem.Character -> { - CharacterAvatar(combatant.avatarUrl, ItemIcon.Size.Small) - } - is CombatantItem.Npc -> { - ItemIcon(Resources.Drawable.Npc, ItemIcon.Size.Small) - } - } - }, + icon = { CharacterAvatar(combatant.avatarUrl, ItemIcon.Size.Small) }, text = { Column { Text(combatant.name) diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/combat/CombatScreenModel.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/combat/CombatScreenModel.kt index bbe6893ee..eadda8b43 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/combat/CombatScreenModel.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/combat/CombatScreenModel.kt @@ -14,7 +14,6 @@ import cz.frantisekmasa.wfrp_master.common.core.domain.character.CharacterType import cz.frantisekmasa.wfrp_master.common.core.domain.character.CurrentConditions import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.CharacterId import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.EncounterId -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.NpcId import cz.frantisekmasa.wfrp_master.common.core.domain.party.Party import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyRepository @@ -33,8 +32,6 @@ import cz.frantisekmasa.wfrp_master.common.core.logging.Reporter import cz.frantisekmasa.wfrp_master.common.core.ui.StatBlockData import cz.frantisekmasa.wfrp_master.common.core.utils.right import cz.frantisekmasa.wfrp_master.common.encounters.CombatantItem -import cz.frantisekmasa.wfrp_master.common.encounters.domain.Npc -import cz.frantisekmasa.wfrp_master.common.encounters.domain.NpcRepository import cz.frantisekmasa.wfrp_master.common.encounters.domain.Wounds import io.github.aakira.napier.Napier import kotlinx.coroutines.async @@ -42,19 +39,16 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.transform import kotlin.random.Random class CombatScreenModel( private val partyId: PartyId, private val random: Random, private val parties: PartyRepository, - private val npcs: NpcRepository, private val characters: CharacterRepository, private val skills: SkillRepository, private val talents: TalentRepository, @@ -70,10 +64,6 @@ class CombatScreenModel( .mapLatest { it.activeCombat } .filterNotNull() - private val activeEncounterId: Flow = combatFlow - .mapLatest { EncounterId(partyId, it.encounterId) } - .distinctUntilChanged() - val turn: Flow = combatFlow .mapLatest { it.getTurn() } .distinctUntilChanged() @@ -86,9 +76,6 @@ class CombatScreenModel( .mapLatest { it.groupAdvantage } .distinctUntilChanged() - suspend fun loadNpcsFromEncounter(encounterId: Uuid): List = - npcs.findByEncounter(EncounterId(partyId, encounterId)).first() - suspend fun loadCharacters(): List = characters.inParty(partyId, CharacterType.PLAYER_CHARACTER).first() @@ -96,18 +83,6 @@ class CombatScreenModel( characters.inParty(partyId, CharacterType.NPC).first() suspend fun getStatBlockData(combatant: CombatantItem): StatBlockData { - if (combatant !is CombatantItem.Character) { - return StatBlockData( - "", - emptyList(), - emptyList(), - emptyList(), - emptyList(), - emptyList(), - emptyList(), - ) - } - val characterId = combatant.characterId return coroutineScope { @@ -133,31 +108,23 @@ class CombatScreenModel( suspend fun startCombat( encounterId: Uuid, characters: List, - npcs: List, npcCharacters: Map, ) { val globalEncounterId = EncounterId(partyId, encounterId) val combatants = characters.map { - characteristics(it) to Combatant.Character( + characteristics(it) to Combatant( id = uuid4(), characterId = it.id, initiative = 1, ) } + - npcs.map { - it.stats to Combatant.Npc( - id = uuid4(), - npcId = NpcId(globalEncounterId, it.id), - initiative = 1, - ) - } + npcCharacters.flatMap { (character, count) -> val characteristics = characteristics(character) if (count == 1) listOf( - characteristics to Combatant.Character( + characteristics to Combatant( id = uuid4(), name = character.publicName, characterId = character.id, @@ -165,7 +132,7 @@ class CombatScreenModel( ) ) else (1..count).map { index -> - characteristics to Combatant.Character( + characteristics to Combatant( id = uuid4(), characterId = character.id, initiative = 1, @@ -205,8 +172,6 @@ class CombatScreenModel( } fun combatants(): Flow> { - val npcsFlow = activeEncounterId.transform { emitAll(npcs.findByEncounter(it)) } - val charactersFlow = characters .inParty(partyId, CharacterType.values().toSet()) .distinctUntilChanged() @@ -215,36 +180,18 @@ class CombatScreenModel( .mapNotNull { it.activeCombat?.getCombatants() } .distinctUntilChanged() - return combineFlows( - combatantsFlow, - npcsFlow, - charactersFlow - ) { combatants, npcs, characters -> - val npcsById = npcs.associateBy { it.id } + return combatantsFlow.combine(charactersFlow) { combatants, characters -> val charactersById = characters.associateBy { it.id } combatants .map { combatant -> - when (combatant) { - is Combatant.Character -> { - val character = charactersById[combatant.characterId] ?: return@map null - - CombatantItem.Character( - characterId = CharacterId(partyId, character.id), - character = character, - combatant = combatant, - ) - } - is Combatant.Npc -> { - val npc = npcsById[combatant.npcId.npcId] ?: return@map null - - CombatantItem.Npc( - npcId = combatant.npcId, - npc = npc, - combatant = combatant, - ) - } - } + val character = charactersById[combatant.characterId] ?: return@map null + + CombatantItem( + characterId = CharacterId(partyId, character.id), + character = character, + combatant = combatant, + ) }.filterNotNull() } } @@ -282,15 +229,6 @@ class CombatScreenModel( else party.updateCombat(updatedCombat) } - private fun combineFlows( - first: Flow, - second: Flow, - third: Flow, - transform: suspend (T1, T2, T3) -> R, - ): Flow = - first.combine(second) { a, b -> Pair(a, b) } - .combine(third) { (a, b), c -> transform(a, b, c) } - suspend fun updateWounds(combatant: CombatantItem, wounds: Wounds) { if (combatant.combatant.wounds != null) { // Wounds are combatant specific (there may be multiple combatants of same character) @@ -298,27 +236,14 @@ class CombatScreenModel( return } - when (combatant) { - is CombatantItem.Character -> { - val character = characters.get(combatant.characterId) - val points = character.points - - if (points.wounds == wounds.current) { - return - } - - characters.save(partyId, character.updatePoints(points.copy(wounds = wounds.current))) - } - is CombatantItem.Npc -> { - val npc = npcs.get(combatant.npcId) - - if (npc.wounds == wounds) { - return - } + val character = characters.get(combatant.characterId) + val points = character.points - npcs.save(combatant.npcId.encounterId, npc.updateCurrentWounds(wounds.current)) - } + if (points.wounds == wounds.current) { + return } + + characters.save(partyId, character.updatePoints(points.copy(wounds = wounds.current))) } suspend fun updateConditions(combatant: CombatantItem, conditions: CurrentConditions) { @@ -328,20 +253,13 @@ class CombatScreenModel( return } - when (combatant) { - is CombatantItem.Character -> { - val character = characters.get(combatant.characterId) - - if (character.conditions == conditions) { - return - } + val character = characters.get(combatant.characterId) - characters.save(partyId, character.updateConditions(conditions)) - } - is CombatantItem.Npc -> { - // NPC do not have conditions, so this must have been handled as combatant specific - } + if (character.conditions == conditions) { + return } + + characters.save(partyId, character.updateConditions(conditions)) } suspend fun updateAdvantage(combatant: Combatant, advantage: Advantage) { diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/combat/StartCombatDialog.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/combat/StartCombatDialog.kt index e885bdf5d..d0f16f6a8 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/combat/StartCombatDialog.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/combat/StartCombatDialog.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import cz.frantisekmasa.wfrp_master.common.Str import cz.frantisekmasa.wfrp_master.common.core.domain.character.Character -import cz.frantisekmasa.wfrp_master.common.core.shared.IO import cz.frantisekmasa.wfrp_master.common.core.ui.buttons.CloseButton import cz.frantisekmasa.wfrp_master.common.core.ui.cards.CardContainer import cz.frantisekmasa.wfrp_master.common.core.ui.cards.CardTitle @@ -32,7 +31,6 @@ import cz.frantisekmasa.wfrp_master.common.core.ui.forms.NumberPicker import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.Spacing import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.TopBarAction import cz.frantisekmasa.wfrp_master.common.encounters.domain.Encounter -import cz.frantisekmasa.wfrp_master.common.encounters.domain.Npc import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -47,13 +45,11 @@ fun StartCombatDialog( screenModel: CombatScreenModel, ) { FullScreenDialog(onDismissRequest = onDismissRequest) { - val npcs: MutableMap = remember { mutableStateMapOf() } val characters: MutableMap = remember { mutableStateMapOf() } val npcCharacters: MutableMap = remember { mutableStateMapOf() } LaunchedEffect(encounter.id) { withContext(Dispatchers.IO) { - val npcsAsync = async { screenModel.loadNpcsFromEncounter(encounter.id) } val charactersAsync = async { screenModel.loadCharacters() } val npcCharactersAsync = async { screenModel.loadNpcs() @@ -61,7 +57,6 @@ fun StartCombatDialog( .filter { (_, count) -> count > 0 } } - npcs.putAll(npcsAsync.await().associateWith { true }) characters.putAll(charactersAsync.await().associateWith { true }) npcCharacters.putAll(npcCharactersAsync.await()) } @@ -83,14 +78,13 @@ fun StartCombatDialog( screenModel.startCombat( encounter.id, pickCheckedOnes(characters), - pickCheckedOnes(npcs), npcCharacters.filterValues { it > 0 }, ) onComplete() } }, enabled = !saving && - (isAtLeastOneChecked(npcs) || npcCharacters.any { it.value > 0 }) && + npcCharacters.any { it.value > 0 } && isAtLeastOneChecked(characters), ) } @@ -110,15 +104,7 @@ fun StartCombatDialog( nameFactory = { it.name }, ) - if (npcCharacters.isNotEmpty()) { - NpcCharacterList(npcCharacters) - } else { - CombatantList( - title = stringResource(Str.combat_title_npc_combatants), - items = npcs, - nameFactory = { it.name }, - ) - } + NpcCharacterList(npcCharacters) } } } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/identifiers/NpcId.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/identifiers/NpcId.kt deleted file mode 100644 index 86307e121..000000000 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/identifiers/NpcId.kt +++ /dev/null @@ -1,16 +0,0 @@ -package cz.frantisekmasa.wfrp_master.common.core.domain.identifiers - -import androidx.compose.runtime.Immutable -import cz.frantisekmasa.wfrp_master.common.core.shared.Parcelable -import cz.frantisekmasa.wfrp_master.common.core.shared.Parcelize -import kotlinx.serialization.Contextual -import kotlinx.serialization.Serializable -import java.util.UUID - -@Parcelize -@Serializable -@Immutable -data class NpcId( - val encounterId: EncounterId, - @Contextual val npcId: UUID, -) : Parcelable diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/party/combat/Combat.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/party/combat/Combat.kt index 2e3627fea..1874a737b 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/party/combat/Combat.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/party/combat/Combat.kt @@ -2,19 +2,17 @@ package cz.frantisekmasa.wfrp_master.common.core.domain.party.combat import androidx.compose.runtime.Immutable import com.benasher44.uuid.Uuid -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.NpcId import cz.frantisekmasa.wfrp_master.common.core.shared.Parcelable import cz.frantisekmasa.wfrp_master.common.core.shared.Parcelize import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable -import java.util.UUID @Parcelize @Serializable @Immutable data class Combat( @Contextual - val encounterId: UUID, + val encounterId: Uuid, private var combatants: List, private val turn: Int = 1, private val round: Int = 1, @@ -73,10 +71,6 @@ data class Combat( return removeFirstCombatant { it.id == id } } - fun removeNpc(npcId: NpcId): Combat? { - return removeFirstCombatant { it is Combatant.Npc && it.npcId == npcId } - } - private fun removeFirstCombatant(predicate: (Combatant) -> Boolean): Combat? { val removedIndex = combatants.indexOfFirst(predicate) diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/party/combat/Combatant.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/party/combat/Combatant.kt index a7bea31e0..d3e73e75c 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/party/combat/Combatant.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/party/combat/Combatant.kt @@ -3,75 +3,35 @@ package cz.frantisekmasa.wfrp_master.common.core.domain.party.combat import androidx.compose.runtime.Immutable import com.benasher44.uuid.Uuid import cz.frantisekmasa.wfrp_master.common.core.domain.character.CurrentConditions -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.NpcId -import cz.frantisekmasa.wfrp_master.common.core.shared.Parcelable -import cz.frantisekmasa.wfrp_master.common.core.shared.Parcelize import cz.frantisekmasa.wfrp_master.common.encounters.domain.Wounds +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize import kotlinx.serialization.Contextual -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonClassDiscriminator @Serializable @Immutable -@JsonClassDiscriminator("@type") -sealed class Combatant : Parcelable { - abstract val id: Uuid? - abstract val advantage: Advantage - abstract val initiative: Int - abstract val name: String? - abstract val wounds: Wounds? - abstract val conditions: CurrentConditions? +@Parcelize +data class Combatant( + val characterId: String, + @Contextual val id: Uuid? = null, + val initiative: Int, + val name: String? = null, + val wounds: Wounds? = null, + val advantage: Advantage = Advantage.ZERO, + val conditions: CurrentConditions? = null, +) : Parcelable { - abstract fun withAdvantage(advantage: Advantage): Combatant - abstract fun withInitiative(initiative: Int): Combatant - abstract fun withWounds(wounds: Wounds): Combatant - abstract fun withConditions(conditions: CurrentConditions): Combatant + fun withAdvantage(advantage: Advantage) = copy(advantage = advantage) + fun withInitiative(initiative: Int) = copy(initiative = initiative) + fun withWounds(wounds: Wounds) = copy(wounds = wounds) + fun withConditions(conditions: CurrentConditions) = copy(conditions = conditions) fun areSameEntity(other: Combatant): Boolean { if (id != null || other.id != null) { return id == other.id } - return (this is Character && other is Character && characterId == other.characterId) || - (this is Npc && other is Npc && npcId == other.npcId) - } - - @Parcelize - @Serializable - @SerialName("character") - @Immutable - data class Character( - val characterId: String, - @Contextual override val id: Uuid? = null, - override val initiative: Int, - override val name: String? = null, - override val wounds: Wounds? = null, - override val advantage: Advantage = Advantage.ZERO, - override val conditions: CurrentConditions? = null, - ) : Combatant() { - override fun withAdvantage(advantage: Advantage): Character = copy(advantage = advantage) - override fun withInitiative(initiative: Int): Character = copy(initiative = initiative) - override fun withWounds(wounds: Wounds) = copy(wounds = wounds) - override fun withConditions(conditions: CurrentConditions) = copy(conditions = conditions) - } - - @Parcelize - @Serializable - @SerialName("npc") - @Immutable - data class Npc( - val npcId: NpcId, - @Contextual override val id: Uuid? = null, - override val initiative: Int, - override val name: String? = null, - override val wounds: Wounds? = null, - override val advantage: Advantage = Advantage.ZERO, - override val conditions: CurrentConditions = CurrentConditions.none(), - ) : Combatant() { - override fun withAdvantage(advantage: Advantage): Npc = copy(advantage = advantage) - override fun withInitiative(initiative: Int): Npc = copy(initiative = initiative) - override fun withWounds(wounds: Wounds) = copy(wounds = wounds) - override fun withConditions(conditions: CurrentConditions) = copy(conditions = conditions) + return characterId == other.characterId } } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/firebase/Schema.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/firebase/Schema.kt index 9ccc766e8..5b54a22b2 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/firebase/Schema.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/firebase/Schema.kt @@ -7,11 +7,6 @@ object Schema { object Party { const val Encounters = "encounters" - - object Encounter { - // Tech debt. TODO: Rename to "npcs" - const val Npcs = "combatants" - } } object Character { diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/firebase/repositories/FirestoreNpcRepository.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/firebase/repositories/FirestoreNpcRepository.kt deleted file mode 100644 index 90bb0fb09..000000000 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/firebase/repositories/FirestoreNpcRepository.kt +++ /dev/null @@ -1,74 +0,0 @@ -package cz.frantisekmasa.wfrp_master.common.core.firebase.repositories - -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.EncounterId -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.NpcId -import cz.frantisekmasa.wfrp_master.common.core.firebase.AggregateMapper -import cz.frantisekmasa.wfrp_master.common.core.firebase.Schema -import cz.frantisekmasa.wfrp_master.common.core.firebase.documents -import cz.frantisekmasa.wfrp_master.common.encounters.domain.Npc -import cz.frantisekmasa.wfrp_master.common.encounters.domain.NpcNotFound -import cz.frantisekmasa.wfrp_master.common.encounters.domain.NpcRepository -import cz.frantisekmasa.wfrp_master.common.firebase.firestore.Firestore -import cz.frantisekmasa.wfrp_master.common.firebase.firestore.FirestoreException -import cz.frantisekmasa.wfrp_master.common.firebase.firestore.Query -import cz.frantisekmasa.wfrp_master.common.firebase.firestore.SetOptions -import kotlinx.coroutines.flow.Flow - -/* internal */ class FirestoreNpcRepository( - private val firestore: Firestore, - private val mapper: AggregateMapper -) : NpcRepository { - override fun findByEncounter(encounterId: EncounterId): Flow> = - npcs(encounterId) - .orderBy("position", Query.Direction.ASCENDING) - .documents(mapper) - - override suspend fun get(id: NpcId): Npc { - try { - val data = npcs(id.encounterId).document(id.npcId.toString()).get().data - ?: throw NpcNotFound(id) - - return mapper.fromDocumentData(data) - } catch (e: FirestoreException) { - throw NpcNotFound(id, e) - } - } - - override suspend fun save(encounterId: EncounterId, vararg npcs: Npc) { - val collection = npcs(encounterId) - - firestore.runTransaction { transaction -> - npcs.forEach { npc -> - val data = mapper.toDocumentData(npc) - - transaction.set( - collection.document(npc.id.toString()), - data, - SetOptions.mergeFields(data.keys), - ) - } - } - } - - override suspend fun remove(id: NpcId) { - npcs(id.encounterId).document(id.npcId.toString()).delete() - } - - override suspend fun getNextPosition(encounterId: EncounterId): Int { - val snapshot = npcs(encounterId) - .orderBy("position", Query.Direction.DESCENDING) - .get() - - val lastPosition = snapshot.documents.map(mapper::fromDocumentSnapshot) - .getOrNull(0)?.position ?: -1 - - return lastPosition + 1 - } - - private fun npcs(encounterId: EncounterId) = - firestore.collection(Schema.Parties) - .document(encounterId.partyId.toString()) - .collection(Schema.Party.Encounters) - .document(encounterId.encounterId.toString()) - .collection(Schema.Party.Encounter.Npcs) -} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/CombatantItem.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/CombatantItem.kt index b3ee2847f..c8bbcaf6c 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/CombatantItem.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/CombatantItem.kt @@ -2,68 +2,31 @@ package cz.frantisekmasa.wfrp_master.common.encounters import androidx.compose.runtime.Immutable import cz.frantisekmasa.wfrp_master.common.core.auth.UserId -import cz.frantisekmasa.wfrp_master.common.core.domain.Stats import cz.frantisekmasa.wfrp_master.common.core.domain.character.CurrentConditions import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.CharacterId -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.NpcId import cz.frantisekmasa.wfrp_master.common.core.domain.party.combat.Combatant -import cz.frantisekmasa.wfrp_master.common.encounters.domain.Wounds -import cz.frantisekmasa.wfrp_master.common.encounters.domain.Npc as NpcEntity @Immutable -sealed class CombatantItem { - abstract val combatant: Combatant - abstract val name: String - abstract val characteristics: Stats - abstract val wounds: Wounds - abstract val conditions: CurrentConditions - +data class CombatantItem( + val characterId: CharacterId, + private val character: cz.frantisekmasa.wfrp_master.common.core.domain.character.Character, + val combatant: Combatant, +) { fun areSameEntity(other: CombatantItem): Boolean = combatant.areSameEntity(other.combatant) - @Immutable - data class Character( - val characterId: CharacterId, - private val character: cz.frantisekmasa.wfrp_master.common.core.domain.character.Character, - override val combatant: Combatant.Character, - ) : CombatantItem() { - - val userId: UserId? - get() = character.userId - - val avatarUrl: String? - get() = character.avatarUrl - - override val name - get() = combatant.name ?: character.name - - override val characteristics - get() = character.characteristics - - override val wounds - get() = combatant.wounds ?: character.wounds + val userId: UserId? + get() = character.userId - override val conditions: CurrentConditions - get() = combatant.conditions ?: character.conditions + val avatarUrl: String? + get() = character.avatarUrl - val note: String get() = character.note - } + val name get() = combatant.name ?: character.name - @Immutable - data class Npc( - val npcId: NpcId, - private val npc: NpcEntity, - override val combatant: Combatant.Npc, - ) : CombatantItem() { - override val name - get() = combatant.name ?: npc.name + val characteristics get() = character.characteristics - override val characteristics - get() = npc.stats + val wounds get() = combatant.wounds ?: character.wounds - override val wounds - get() = combatant.wounds ?: npc.wounds + val conditions: CurrentConditions get() = combatant.conditions ?: character.conditions - override val conditions: CurrentConditions - get() = combatant.conditions - } + val note: String get() = character.note } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/EncounterDetailScreen.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/EncounterDetailScreen.kt index 6cdef66f4..0997b8eea 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/EncounterDetailScreen.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/EncounterDetailScreen.kt @@ -14,12 +14,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.ContentAlpha import androidx.compose.material.ExtendedFloatingActionButton import androidx.compose.material.FabPosition import androidx.compose.material.Icon import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentAlpha import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Switch @@ -31,7 +29,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.primarySurface import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -52,9 +49,7 @@ import cz.frantisekmasa.wfrp_master.common.core.PartyScreenModel import cz.frantisekmasa.wfrp_master.common.core.domain.character.Character import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.CharacterId import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.EncounterId -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.NpcId import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId -import cz.frantisekmasa.wfrp_master.common.core.shared.IO import cz.frantisekmasa.wfrp_master.common.core.shared.Resources import cz.frantisekmasa.wfrp_master.common.core.shared.drawableResource import cz.frantisekmasa.wfrp_master.common.core.ui.CharacterAvatar @@ -79,8 +74,6 @@ import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.OptionsAction import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.SubheadBar import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.Subtitle import cz.frantisekmasa.wfrp_master.common.encounters.domain.Encounter -import cz.frantisekmasa.wfrp_master.common.npcs.NpcCreationScreen -import cz.frantisekmasa.wfrp_master.common.npcs.NpcDetailScreen import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -229,14 +222,6 @@ class EncounterDetailScreen( screenModel: EncounterDetailScreenModel ) { val coroutineScope = rememberCoroutineScope { EmptyCoroutineContext + Dispatchers.IO } - val npcs = screenModel.npcs.collectWithLifecycle(null).value - - if (npcs == null) { - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - return - } Column( Modifier @@ -269,17 +254,7 @@ class EncounterDetailScreen( val navigation = LocalNavigationTransaction.current - if (npcs.isEmpty()) { - NpcCharacterList(encounterId.partyId, encounter, screenModel) - } else { - NpcsCard( - npcs, - onCreateRequest = { navigation.navigate(NpcCreationScreen(encounterId)) }, - onEditRequest = { navigation.navigate(NpcDetailScreen(it)) }, - onRemoveRequest = { screenModel.removeNpc(it) }, - onDuplicateRequest = { coroutineScope.launch { screenModel.duplicateNpc(it.npcId) } } - ) - } + NpcCharacterList(encounterId.partyId, encounter, screenModel) } } @@ -304,76 +279,6 @@ class EncounterDetailScreen( Text(encounter.description, Modifier.padding(horizontal = 8.dp)) } } - - @Composable - private fun NpcsCard( - npcs: List, - onCreateRequest: () -> Unit, - onEditRequest: (NpcId) -> Unit, - onRemoveRequest: (NpcId) -> Unit, - onDuplicateRequest: (NpcId) -> Unit, - ) { - CardContainer( - Modifier - .fillMaxWidth() - .padding(8.dp) - ) { - CardTitle(stringResource(Str.npcs_title_plural)) - - Column(Modifier.fillMaxWidth()) { - NpcList( - npcs, - onEditRequest = onEditRequest, - onRemoveRequest = onRemoveRequest, - onDuplicateRequest = onDuplicateRequest, - ) - - Box( - Modifier.fillMaxWidth(), - contentAlignment = Alignment.TopCenter - ) { - PrimaryButton(stringResource(Str.npcs_button_add_npc), onClick = onCreateRequest) - } - } - } - } - - @Composable - private fun NpcList( - npcs: List, - onEditRequest: (NpcId) -> Unit, - onRemoveRequest: (NpcId) -> Unit, - onDuplicateRequest: (NpcId) -> Unit, - ) { - for (npc in npcs) { - val alpha = if (npc.alive) ContentAlpha.high else ContentAlpha.disabled - - CompositionLocalProvider(LocalContentAlpha provides alpha) { - CardItem( - name = npc.name, - icon = { - ItemIcon( - if (npc.alive) - Resources.Drawable.Npc - else Resources.Drawable.Dead, - ItemIcon.Size.Small - ) - }, - onClick = { onEditRequest(npc.id) }, - contextMenuItems = listOf( - ContextMenu.Item( - text = stringResource(Str.common_ui_button_duplicate), - onClick = { onDuplicateRequest(npc.id) }, - ), - ContextMenu.Item( - text = stringResource(Str.common_ui_button_remove), - onClick = { onRemoveRequest(npc.id) }, - ), - ), - ) - } - } - } } @Composable diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/EncounterDetailScreenModel.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/EncounterDetailScreenModel.kt index 049778695..a7d52b215 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/EncounterDetailScreenModel.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/EncounterDetailScreenModel.kt @@ -1,45 +1,23 @@ package cz.frantisekmasa.wfrp_master.common.encounters import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.coroutineScope -import cz.frantisekmasa.wfrp_master.common.core.domain.Stats import cz.frantisekmasa.wfrp_master.common.core.domain.character.Character import cz.frantisekmasa.wfrp_master.common.core.domain.character.CharacterRepository import cz.frantisekmasa.wfrp_master.common.core.domain.character.CharacterType import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.EncounterId -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.NpcId import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyRepository -import cz.frantisekmasa.wfrp_master.common.core.domain.trappings.Armour -import cz.frantisekmasa.wfrp_master.common.core.shared.IO -import cz.frantisekmasa.wfrp_master.common.core.utils.mapItems import cz.frantisekmasa.wfrp_master.common.core.utils.right import cz.frantisekmasa.wfrp_master.common.encounters.domain.Encounter import cz.frantisekmasa.wfrp_master.common.encounters.domain.EncounterRepository -import cz.frantisekmasa.wfrp_master.common.encounters.domain.Npc -import cz.frantisekmasa.wfrp_master.common.encounters.domain.NpcRepository -import cz.frantisekmasa.wfrp_master.common.encounters.domain.Wounds -import io.github.aakira.napier.Napier -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch -import java.util.UUID -import kotlin.math.min class EncounterDetailScreenModel( private val encounterId: EncounterId, private val encounters: EncounterRepository, - private val npcRepository: NpcRepository, private val characters: CharacterRepository, private val parties: PartyRepository, ) : ScreenModel { val encounter: Flow = encounters.getLive(encounterId).right() - val npcs: Flow> = npcRepository - .findByEncounter(encounterId) - .mapItems { NpcListItem(NpcId(encounterId, it.id), it.name, it.alive) } - .distinctUntilChanged() val allNpcsCharacters: Flow> = characters.inParty(encounterId.partyId, CharacterType.NPC) @@ -56,103 +34,4 @@ class EncounterDetailScreenModel( suspend fun updateEncounter(encounter: Encounter) { encounters.save(encounterId.partyId, encounter) } - - suspend fun addNpc( - name: String, - note: String, - wounds: Wounds, - stats: Stats, - armor: Armour, - enemy: Boolean, - alive: Boolean, - traits: List, - trappings: List - ) { - npcRepository.save( - encounterId, - Npc( - UUID.randomUUID(), - name = name, - note = note, - wounds = wounds, - stats = stats, - armor = armor, - enemy = enemy, - alive = alive, - traits = traits, - trappings = trappings, - position = npcRepository.getNextPosition(encounterId) - ) - ) - } - - suspend fun duplicateNpc(id: UUID) { - val npc = npcRepository.get(NpcId(encounterId, id)) - - npcRepository.save( - encounterId, - npc.duplicate(position = npcRepository.getNextPosition(encounterId)) - ) - } - - suspend fun updateNpc( - id: UUID, - name: String, - note: String, - maxWounds: Int, - stats: Stats, - armor: Armour, - enemy: Boolean, - alive: Boolean, - traits: List, - trappings: List - ) { - val npc = npcRepository.get(NpcId(encounterId, id)) - - npcRepository.save( - encounterId, - npc.copy( - name = name, - note = note, - wounds = Wounds(min(npc.wounds.current, maxWounds), maxWounds), - stats = stats, - armor = armor, - enemy = enemy, - alive = alive, - traits = traits, - trappings = trappings, - ) - ) - } - - fun npcFlow(npcId: NpcId): StateFlow { - val flow = MutableStateFlow(null) - - coroutineScope.launch(Dispatchers.IO) { - try { - val npc = npcRepository.get(npcId) - flow.value = npc - } catch (e: Throwable) { - Napier.e(e.toString(), e) - throw e - } - } - - return flow - } - - fun removeNpc(npcId: NpcId) = coroutineScope.launch(Dispatchers.IO) { - parties.update(encounterId.partyId) { party -> - val combat = party.activeCombat - ?: return@update party // Skip update - - when (val updatedCombat = combat.removeNpc(npcId)) { - null -> party.endCombat() - combat -> party - else -> party.updateCombat(updatedCombat) - } - } - - npcRepository.remove(npcId) - } } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/NpcListItem.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/NpcListItem.kt deleted file mode 100644 index 361266116..000000000 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/NpcListItem.kt +++ /dev/null @@ -1,9 +0,0 @@ -package cz.frantisekmasa.wfrp_master.common.encounters - -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.NpcId - -data class NpcListItem( - val id: NpcId, - val name: String, - val alive: Boolean, -) diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/domain/Npc.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/domain/Npc.kt deleted file mode 100644 index e31aef93c..000000000 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/domain/Npc.kt +++ /dev/null @@ -1,46 +0,0 @@ -package cz.frantisekmasa.wfrp_master.common.encounters.domain - -import androidx.compose.runtime.Immutable -import cz.frantisekmasa.wfrp_master.common.core.domain.Stats -import cz.frantisekmasa.wfrp_master.common.core.domain.character.CurrentConditions -import cz.frantisekmasa.wfrp_master.common.core.domain.trappings.Armour -import cz.frantisekmasa.wfrp_master.common.core.utils.duplicateName -import kotlinx.serialization.Contextual -import kotlinx.serialization.Serializable -import java.util.UUID - -@Serializable -@Immutable -data class Npc( - @Contextual val id: UUID, - val name: String, - val note: String, - val wounds: Wounds, - val stats: Stats, - val armor: Armour, - val enemy: Boolean, - val alive: Boolean, - val traits: List, - val trappings: List, - val position: Int, - val conditions: CurrentConditions = CurrentConditions.none(), -) { - init { - require(name.isNotBlank() && name.length <= NAME_MAX_LENGTH) - require(note.length <= NOTE_MAX_LENGTH) - require(position >= 0) - } - - fun duplicate(position: Int) = copy( - id = UUID.randomUUID(), - position = position, - name = duplicateName(name), - ) - - fun updateCurrentWounds(wounds: Int) = copy(wounds = this.wounds.copy(current = wounds)) - - companion object { - const val NAME_MAX_LENGTH = 100 - const val NOTE_MAX_LENGTH = 400 - } -} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/domain/NpcNotFound.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/domain/NpcNotFound.kt deleted file mode 100644 index 898c11632..000000000 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/domain/NpcNotFound.kt +++ /dev/null @@ -1,5 +0,0 @@ -package cz.frantisekmasa.wfrp_master.common.encounters.domain - -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.NpcId - -class NpcNotFound(id: NpcId, cause: Throwable? = null) : Exception("Npc $id was not found", cause) diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/domain/NpcRepository.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/domain/NpcRepository.kt deleted file mode 100644 index bf1aa5361..000000000 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/encounters/domain/NpcRepository.kt +++ /dev/null @@ -1,29 +0,0 @@ -package cz.frantisekmasa.wfrp_master.common.encounters.domain - -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.EncounterId -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.NpcId -import kotlinx.coroutines.flow.Flow - -interface NpcRepository { - fun findByEncounter(encounterId: EncounterId): Flow> - - /** - * @throws NpcNotFound - */ - suspend fun get(id: NpcId): Npc - - /** - * Creates or updates given NPC - */ - suspend fun save(encounterId: EncounterId, vararg npcs: Npc) - - /** - * Removes NPC if she exists or does nothing - */ - suspend fun remove(id: NpcId) - - /** - * Returns value that can be used for new NPC so that it's sorted at the end - */ - suspend fun getNextPosition(encounterId: EncounterId): Int -} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/npcs/NpcCreationScreen.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/npcs/NpcCreationScreen.kt deleted file mode 100644 index 8a560b1d6..000000000 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/npcs/NpcCreationScreen.kt +++ /dev/null @@ -1,88 +0,0 @@ -package cz.frantisekmasa.wfrp_master.common.npcs - -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import cafe.adriel.voyager.core.screen.Screen -import cz.frantisekmasa.wfrp_master.common.Str -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.EncounterId -import cz.frantisekmasa.wfrp_master.common.core.shared.IO -import cz.frantisekmasa.wfrp_master.common.core.ui.buttons.BackButton -import cz.frantisekmasa.wfrp_master.common.core.ui.navigation.LocalNavigationTransaction -import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.rememberScreenModel -import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.SaveAction -import cz.frantisekmasa.wfrp_master.common.encounters.EncounterDetailScreenModel -import cz.frantisekmasa.wfrp_master.common.encounters.domain.Wounds -import cz.frantisekmasa.wfrp_master.common.npcs.form.FormData -import cz.frantisekmasa.wfrp_master.common.npcs.form.NpcForm -import dev.icerock.moko.resources.compose.stringResource -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -class NpcCreationScreen( - private val encounterId: EncounterId, -) : Screen { - @Composable - override fun Content() { - val coroutineScope = rememberCoroutineScope() - val viewModel: EncounterDetailScreenModel = rememberScreenModel(arg = encounterId) - - val data = FormData.empty() - val validate = remember { mutableStateOf(false) } - val submitEnabled = remember { mutableStateOf(true) } - - Scaffold( - topBar = { - val navigation = LocalNavigationTransaction.current - - TopBar( - stringResource(Str.npcs_title_add), - onSave = { - if (!data.isValid()) { - validate.value = true - } else { - submitEnabled.value = false - coroutineScope.launch(Dispatchers.IO) { - coroutineScope.launch { - viewModel.addNpc( - name = data.name.value, - note = data.note.value, - wounds = Wounds.fromMax(data.wounds.value.toInt()), - stats = data.characteristics.toCharacteristics(), - armor = data.armor.toArmor(), - enemy = data.enemy.value, - alive = data.alive.value, - traits = emptyList(), - trappings = emptyList(), - ) - - navigation.goBack() - } - } - } - }, - actionsEnabled = submitEnabled.value, - ) - } - ) { - NpcForm(data, validate = validate.value) - } - } - - @Composable - private fun TopBar( - title: String, - onSave: () -> Unit, - actionsEnabled: Boolean, - ) { - TopAppBar( - navigationIcon = { BackButton() }, - title = { Text(title) }, - actions = { SaveAction(onClick = onSave, enabled = actionsEnabled) } - ) - } -} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/npcs/NpcDetailScreen.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/npcs/NpcDetailScreen.kt deleted file mode 100644 index b109e2b02..000000000 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/npcs/NpcDetailScreen.kt +++ /dev/null @@ -1,106 +0,0 @@ -package cz.frantisekmasa.wfrp_master.common.npcs - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import cafe.adriel.voyager.core.screen.Screen -import cz.frantisekmasa.wfrp_master.common.Str -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.NpcId -import cz.frantisekmasa.wfrp_master.common.core.shared.IO -import cz.frantisekmasa.wfrp_master.common.core.ui.buttons.BackButton -import cz.frantisekmasa.wfrp_master.common.core.ui.flow.collectWithLifecycle -import cz.frantisekmasa.wfrp_master.common.core.ui.navigation.LocalNavigationTransaction -import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.rememberScreenModel -import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.SaveAction -import cz.frantisekmasa.wfrp_master.common.encounters.EncounterDetailScreenModel -import cz.frantisekmasa.wfrp_master.common.npcs.form.FormData -import cz.frantisekmasa.wfrp_master.common.npcs.form.NpcForm -import dev.icerock.moko.resources.compose.stringResource -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -class NpcDetailScreen( - private val npcId: NpcId, -) : Screen { - - @Composable - override fun Content() { - val coroutineScope = rememberCoroutineScope() - val viewModel: EncounterDetailScreenModel = rememberScreenModel(arg = npcId.encounterId) - - val npc = remember { viewModel.npcFlow(npcId) }.collectWithLifecycle().value - val data = npc?.let { FormData.fromExistingNpc(it) } - - val validate = remember { mutableStateOf(false) } - val submitEnabled = remember { mutableStateOf(true) } - - val navigation = LocalNavigationTransaction.current - - Scaffold( - topBar = { - TopBar( - title = stringResource(Str.npcs_title), - onSave = { - if (data == null) { - return@TopBar - } - - if (!data.isValid()) { - validate.value = true - } else { - submitEnabled.value = false - } - - coroutineScope.launch(Dispatchers.IO) { - viewModel.updateNpc( - id = npc.id, - name = data.name.value, - note = data.note.value, - maxWounds = data.wounds.value.toInt(), - stats = data.characteristics.toCharacteristics(), - armor = data.armor.toArmor(), - enemy = data.enemy.value, - alive = data.alive.value, - traits = emptyList(), - trappings = emptyList(), - ) - - navigation.goBack() - } - }, - actionsEnabled = submitEnabled.value && data != null, - ) - } - ) { - if (data == null) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } else { - NpcForm(data, validate = validate.value) - } - } - } - - @Composable - private fun TopBar( - title: String, - onSave: () -> Unit, - actionsEnabled: Boolean, - ) { - TopAppBar( - navigationIcon = { BackButton() }, - title = { Text(title) }, - actions = { SaveAction(onClick = onSave, enabled = actionsEnabled) } - ) - } -} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/npcs/form/NpcForm.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/npcs/form/NpcForm.kt deleted file mode 100644 index a458fffc2..000000000 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/npcs/form/NpcForm.kt +++ /dev/null @@ -1,347 +0,0 @@ -package cz.frantisekmasa.wfrp_master.common.npcs.form - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.Stable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import cz.frantisekmasa.wfrp_master.common.Str -import cz.frantisekmasa.wfrp_master.common.core.domain.Characteristic -import cz.frantisekmasa.wfrp_master.common.core.domain.Stats -import cz.frantisekmasa.wfrp_master.common.core.domain.trappings.Armour -import cz.frantisekmasa.wfrp_master.common.core.ui.forms.CheckboxWithText -import cz.frantisekmasa.wfrp_master.common.core.ui.forms.InputValue -import cz.frantisekmasa.wfrp_master.common.core.ui.forms.Rules -import cz.frantisekmasa.wfrp_master.common.core.ui.forms.TextInput -import cz.frantisekmasa.wfrp_master.common.core.ui.forms.checkboxValue -import cz.frantisekmasa.wfrp_master.common.core.ui.forms.inputValue -import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.HorizontalLine -import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.Spacing -import cz.frantisekmasa.wfrp_master.common.encounters.domain.Npc -import dev.icerock.moko.resources.compose.stringResource - -@Composable -fun NpcForm(data: FormData, validate: Boolean) { - Column( - Modifier - .verticalScroll(rememberScrollState()) - .padding(Spacing.bodyPadding) - .padding(bottom = 30.dp) - ) { - Column(verticalArrangement = Arrangement.spacedBy(Spacing.small)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Spacing.large) - ) { - - TextInput( - modifier = Modifier.weight(0.7f), - label = stringResource(Str.npcs_label_name), - value = data.name, - maxLength = Npc.NAME_MAX_LENGTH, - validate = validate, - ) - - TextInput( - modifier = Modifier.weight(0.3f), - label = stringResource(Str.points_wounds), - value = data.wounds, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), - maxLength = 3, - validate = validate, - ) - } - - TextInput( - label = stringResource(Str.npcs_label_description), - value = data.note, - validate = validate, - multiLine = true, - ) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - CheckboxWithText( - text = stringResource(Str.npcs_label_enemy), - checked = data.enemy.value, - onCheckedChange = { data.enemy.value = it } - ) - CheckboxWithText( - text = stringResource(Str.npcs_label_alive), - checked = data.alive.value, - onCheckedChange = { data.alive.value = it } - ) - } - } - - HorizontalLine() - - Text( - stringResource(Str.npcs_title_characteristics), - style = MaterialTheme.typography.h6, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(top = 20.dp, bottom = 16.dp) - .fillMaxWidth() - ) - - CharacteristicsSegment(data.characteristics, validate) - - HorizontalLine() - - Text( - stringResource(Str.npcs_title_armour), - style = MaterialTheme.typography.h6, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(top = 20.dp, bottom = 16.dp) - .fillMaxWidth() - ) - - ArmorSegment(data.armor, validate) - } -} - -@Composable -private fun CharacteristicsSegment(data: CharacteristicsFormData, validate: Boolean) { - val characteristics = listOf( - Characteristic.WEAPON_SKILL to data.weaponSkill, - Characteristic.BALLISTIC_SKILL to data.ballisticSkill, - Characteristic.STRENGTH to data.strength, - Characteristic.TOUGHNESS to data.toughness, - Characteristic.INITIATIVE to data.initiative, - Characteristic.AGILITY to data.agility, - Characteristic.DEXTERITY to data.dexterity, - Characteristic.INTELLIGENCE to data.intelligence, - Characteristic.WILL_POWER to data.willPower, - Characteristic.FELLOWSHIP to data.fellowship, - ) - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - for (rowCharacteristics in characteristics.chunked(characteristics.size / 2)) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - for ((characteristic, value) in rowCharacteristics) { - TextInput( - label = characteristic.getShortcutName(), - value = value, - placeholder = "0", - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), - validate = validate, - maxLength = 3, - modifier = Modifier.weight(1f), - ) - } - } - } - } -} - -@Composable -private fun ArmorSegment(data: ArmorFormData, validate: Boolean) { - val rows = listOf( - listOf( - stringResource(Str.combat_hit_locations_head) to data.head, - stringResource(Str.combat_hit_locations_body) to data.body, - stringResource(Str.armour_shield) to data.shield, - "" to null // Empty container - ), - - listOf( - stringResource(Str.combat_hit_locations_left_arm) to data.leftArm, - stringResource(Str.combat_hit_locations_right_arm) to data.rightArm, - stringResource(Str.combat_hit_locations_left_leg) to data.leftLeg, - stringResource(Str.combat_hit_locations_right_leg) to data.rightLeg, - ) - ) - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - for (rowParts in rows) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth(), - ) { - for ((label, value) in rowParts) { - if (value == null) { - Spacer(Modifier.weight(1f)) - continue - } - - TextInput( - label = label, - value = value, - placeholder = "0", - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), - validate = validate, - maxLength = 3, - modifier = Modifier.weight(1f), - ) - } - } - } - } -} - -@Stable -class CharacteristicsFormData( - val weaponSkill: InputValue, - val ballisticSkill: InputValue, - val strength: InputValue, - val toughness: InputValue, - val initiative: InputValue, - val agility: InputValue, - val dexterity: InputValue, - val intelligence: InputValue, - val willPower: InputValue, - val fellowship: InputValue, -) { - companion object { - @Composable - fun fromCharacteristics(characteristics: Stats?) = CharacteristicsFormData( - weaponSkill = characteristicValue(characteristics?.weaponSkill), - ballisticSkill = characteristicValue(characteristics?.ballisticSkill), - strength = characteristicValue(characteristics?.strength), - toughness = characteristicValue(characteristics?.toughness), - initiative = characteristicValue(characteristics?.initiative), - agility = characteristicValue(characteristics?.agility), - dexterity = characteristicValue(characteristics?.dexterity), - intelligence = characteristicValue(characteristics?.intelligence), - willPower = characteristicValue(characteristics?.willPower), - fellowship = characteristicValue(characteristics?.fellowship), - ) - - @Composable - private fun characteristicValue(value: Int?): InputValue = - inputValue( - when (value) { - null, 0 -> "" - else -> value.toString() - }, - // We use rule without message, because text these inputs are too small to show error messages - Rules.withEmptyMessage { - it.isBlank() || (it.toIntOrNull() != null && it.toInt() in 0..100) - }, - ) - } - - fun toCharacteristics() = Stats( - weaponSkill = weaponSkill.value.toIntOrNull() ?: 0, - ballisticSkill = ballisticSkill.value.toIntOrNull() ?: 0, - strength = strength.value.toIntOrNull() ?: 0, - toughness = toughness.value.toIntOrNull() ?: 0, - initiative = initiative.value.toIntOrNull() ?: 0, - agility = agility.value.toIntOrNull() ?: 0, - dexterity = dexterity.value.toIntOrNull() ?: 0, - intelligence = intelligence.value.toIntOrNull() ?: 0, - willPower = willPower.value.toIntOrNull() ?: 0, - fellowship = fellowship.value.toIntOrNull() ?: 0, - ) - - fun isValid() = - listOf( - weaponSkill, - ballisticSkill, - strength, - toughness, - initiative, - agility, - dexterity, - intelligence, - willPower, - fellowship, - ).all { it.isValid() } -} - -@Stable -class ArmorFormData( - val head: InputValue, - val body: InputValue, - val shield: InputValue, - val leftArm: InputValue, - val rightArm: InputValue, - val leftLeg: InputValue, - val rightLeg: InputValue, -) { - companion object { - @Composable - fun fromArmor(armor: Armour?) = ArmorFormData( - head = armorValue(armor?.head), - body = armorValue(armor?.body), - shield = armorValue(armor?.shield), - leftArm = armorValue(armor?.leftArm), - rightArm = armorValue(armor?.rightArm), - leftLeg = armorValue(armor?.leftLeg), - rightLeg = armorValue(armor?.rightLeg), - ) - - @Composable - private fun armorValue(default: Int?) = inputValue( - when (default) { - 0, null -> "" - else -> default.toString() - }, - // We use rule without message, because text these inputs are too small to show error messages - Rules.withEmptyMessage { it.isBlank() || (it.toIntOrNull() != null && it.toInt() in 1..100) } - ) - } - - fun toArmor() = Armour( - body = body.value.toIntOrNull() ?: 0, - shield = shield.value.toIntOrNull() ?: 0, - leftArm = leftArm.value.toIntOrNull() ?: 0, - rightArm = rightArm.value.toIntOrNull() ?: 0, - leftLeg = leftLeg.value.toIntOrNull() ?: 0, - rightLeg = rightLeg.value.toIntOrNull() ?: 0, - head = head.value.toIntOrNull() ?: 0, - ) - - fun isValid(): Boolean = - listOf(body, shield, leftArm, rightArm, leftLeg, rightLeg, head).all { it.isValid() } -} - -@Stable -class FormData( - val name: InputValue, - val note: InputValue, - val wounds: InputValue, - val enemy: MutableState, - val alive: MutableState, - val characteristics: CharacteristicsFormData, - val armor: ArmorFormData, -) { - companion object { - @Composable - fun empty() = fromNpc(null) - - @Composable - fun fromExistingNpc(npc: Npc) = fromNpc(npc) - - @Composable - private fun fromNpc(npc: Npc?) = FormData( - name = inputValue(npc?.name ?: "", Rules.NotBlank()), - note = inputValue(npc?.note ?: ""), - wounds = inputValue(npc?.wounds?.max?.toString() ?: "", Rules.PositiveInteger()), - enemy = checkboxValue(npc?.enemy ?: true), - alive = checkboxValue(npc?.alive ?: true), - characteristics = CharacteristicsFormData.fromCharacteristics(npc?.stats), - armor = ArmorFormData.fromArmor(npc?.armor), - ) - } - - fun isValid() = - characteristics.isValid() && armor.isValid() && name.isValid() && wounds.isValid() -} diff --git a/common/src/commonMain/resources/MR/base/strings.xml b/common/src/commonMain/resources/MR/base/strings.xml index 215dc8092..ad4524c29 100644 --- a/common/src/commonMain/resources/MR/base/strings.xml +++ b/common/src/commonMain/resources/MR/base/strings.xml @@ -335,18 +335,12 @@ Edit Miracle New Miracle Add NPC - Alive - Description (Optional) - Enemy - Name No NPCs There are no NPCs yet. Add first one. Do you really want to permanently remove this NPC? Search in NPCs… NPC Add NPC - Armour - Characteristics NPCs Invite Join party diff --git a/common/src/commonMain/resources/MR/de/strings.xml b/common/src/commonMain/resources/MR/de/strings.xml index 651698955..f1da239dc 100644 --- a/common/src/commonMain/resources/MR/de/strings.xml +++ b/common/src/commonMain/resources/MR/de/strings.xml @@ -53,8 +53,6 @@ Erlaubte Operatoren: +,-,/,*,(,), MIN(…), MAX(…) und Variablen: %s Avatar wurde geändert Avatar wurde entfernt - Lebendig - Rüstung Alle Mitglieder verlieren ihren Zugang. Alternativ können Sie auch Ihren GM um einen Einladungslink bitten. Auto. diff --git a/common/src/commonMain/resources/MR/fr/strings.xml b/common/src/commonMain/resources/MR/fr/strings.xml index 163ccac42..711c92935 100644 --- a/common/src/commonMain/resources/MR/fr/strings.xml +++ b/common/src/commonMain/resources/MR/fr/strings.xml @@ -10,7 +10,6 @@ Ajoutez une première rencontre pour votre groupe. Ajouter un miracle Ajouter un PNJ - En vie Avancé Avancé Ajouter un sort @@ -106,12 +105,8 @@ Éditer la rencontre Échec de la connexion à Google Choisissez le miracle du compendium… - Description (optionnel) Éditer le miracle - Ennemi Voulez-vous vraiment supprimer définitivement ce PNJ \? - Armure - Caractéristiques Vous pouvez également demander le lien d\'invitation à votre GM. La carte Compendium a été déplacée vers l\'onglet Monde. Tous les membres perdront leur accès. @@ -358,7 +353,6 @@ Nom Aucun miracle Miracles - Nom Nouveau miracle Aucun PNJ PNJ diff --git a/common/src/commonMain/resources/MR/it/strings.xml b/common/src/commonMain/resources/MR/it/strings.xml index 509ca534f..15998d953 100644 --- a/common/src/commonMain/resources/MR/it/strings.xml +++ b/common/src/commonMain/resources/MR/it/strings.xml @@ -298,18 +298,12 @@ Modifica Miracolo Nuovo Miracolo Aggiungi PNG - Vivo - Descrizione (Opzionale) - Nemico - Nome Nessun PNG Non ci sono ancora PNG. Aggiungi il primo. Vuoi davvero eliminare definitivamente questo PNG? Cerca nei PNG… PNG Aggiungi PNG - Armatura - Caratteristiche PNG Invita Unisciti al gruppo diff --git a/common/src/commonTest/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/combat/CombatTest.kt b/common/src/commonTest/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/combat/CombatTest.kt index ce52df9b5..201f3b441 100644 --- a/common/src/commonTest/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/combat/CombatTest.kt +++ b/common/src/commonTest/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/combat/CombatTest.kt @@ -1,41 +1,36 @@ package cz.frantisekmasa.wfrp_master.common.core.domain.combat import com.benasher44.uuid.uuid4 -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.EncounterId -import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.NpcId -import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId import cz.frantisekmasa.wfrp_master.common.core.domain.party.combat.Advantage import cz.frantisekmasa.wfrp_master.common.core.domain.party.combat.Combat import cz.frantisekmasa.wfrp_master.common.core.domain.party.combat.Combatant -import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull class CombatTest { - private val encounterId = EncounterId(PartyId.generate(), UUID.randomUUID()) - private val npcId1 = NpcId(encounterId, UUID.randomUUID()) - private val npcId2 = NpcId(encounterId, UUID.randomUUID()) - private val combat = Combat( - encounterId = encounterId.encounterId, + encounterId = uuid4(), combatants = listOf( - Combatant.Npc(npcId1, initiative = 10, advantage = Advantage.ZERO, id = uuid4()), - Combatant.Character(characterId = "foo", initiative = 10, advantage = Advantage.ZERO, id = uuid4()), - Combatant.Npc(npcId2, initiative = 10, advantage = Advantage.ZERO, id = uuid4()), + Combatant(characterId = uuid4().toString(), initiative = 10, advantage = Advantage.ZERO, id = uuid4()), + Combatant(characterId = uuid4().toString(), initiative = 10, advantage = Advantage.ZERO, id = uuid4()), + Combatant(characterId = uuid4().toString(), initiative = 10, advantage = Advantage.ZERO, id = uuid4()), ) ) @Test - fun `removeNpc() does nothing if npc is not in combat`() { - assertEquals(combat, combat.removeNpc(NpcId(encounterId, UUID.randomUUID()))) + fun `removeCombatant() does nothing if combatant is not in combat`() { + assertEquals( + combat, + combat.removeCombatant(uuid4()), + ) } @Test - fun `removeNpc() removes NPC from combat`() { + fun `removeCombatant() removes combatant from combat`() { assertEquals( combat.copy(combatants = listOf(combat.getCombatants()[1], combat.getCombatants()[2])), - combat.removeNpc(npcId1), + combat.removeCombatant(combat.getCombatants()[0].id!!), ) } @@ -46,7 +41,7 @@ class CombatTest { combatants = listOf(combat.getCombatants()[1], combat.getCombatants()[2]), ), combat.copy(turn = 2) - .removeNpc(npcId1) + .removeCombatant(combat.getCombatants()[0].id!!) ) } @@ -55,7 +50,7 @@ class CombatTest { assertEquals( 1000, combat.copy(round = 1000) - .removeNpc(npcId1) + .removeCombatant(combat.getCombatants()[0].id!!) ?.getRound() ) } @@ -68,7 +63,7 @@ class CombatTest { combatants = listOf(combat.getCombatants()[1], combat.getCombatants()[2]), ), combat.copy(turn = 2) - .removeNpc(npcId1) + .removeCombatant(combat.getCombatants()[0].id!!) ) } @@ -81,7 +76,7 @@ class CombatTest { combatants = listOf(combat.getCombatants()[0], combat.getCombatants()[1]), ), combat.copy(turn = 3) - .removeNpc(npcId2) + .removeCombatant(combat.getCombatants()[2].id!!) ) } @@ -89,7 +84,7 @@ class CombatTest { fun `combat is ended when last combatant is removed`() { assertNull( combat.copy(combatants = listOf(combat.getCombatants()[0])) - .removeNpc(npcId1) + .removeCombatant(combat.getCombatants()[0].id!!) ) } } diff --git a/firebase/firestore.rules b/firebase/firestore.rules index d69ca7e9b..81fe03ea8 100644 --- a/firebase/firestore.rules +++ b/firebase/firestore.rules @@ -254,42 +254,6 @@ service cloud.firestore { && isGameMaster(); allow delete: if hasAccessToParty() && isGameMaster(); - match /combatants/{npcId} { - allow read: if hasAccessToParty(); - - allow create: if isValidNpc(request.resource.data) - && hasAccessToParty() - && isGameMaster(); - allow update: if isValidNpc(request.resource.data) - && hasAccessToParty() - && isGameMaster(); - allow delete: if hasAccessToParty() && isGameMaster(); - - function isValidNpc(npc) { - return npc is map - && ["id", "name", "note", "wounds", "stats", "armor", "enemy", "alive", "traits", "trappings", "position", "conditions"].hasAll(npc.keys()) - && npc.id is string && isValidUuid(npc.id) && npc.id == npcId - && npc.name is string && isNotBlank(npc.name) && npc.name.size() <= 100 - && npc.note is string && npc.note.size() <= 400 - && areValidWounds(npc.wounds) - && areStatsValid(npc.stats) - && isArmorValid(npc.armor) - && npc.enemy is bool - && npc.alive is bool - && npc.traits is list - && npc.trappings is list - && (! ("conditions" in npc) || areValidConditions(npc.conditions)) - && npc.position is number && npc.position >= 0; - } - - function areValidWounds(wounds) { - return wounds is map - && wounds.keys().toSet() == ["max", "current"].toSet() - && wounds.max is int && wounds.max > 0 - && wounds.current is int && wounds.current >= 0 && wounds.current <= wounds.max; - } - } - function isValidEncounter(encounter) { return encounter is map && ["id", "name", "description", "position", "completed", "characters"].hasAll(encounter.keys()) @@ -855,7 +819,7 @@ service cloud.firestore { && request.resource.data.users.toSet() == resource.data.users.toSet() && userId in request.resource.data.users && isValidCombat(combat) - && combat.diff(oldCombat).affectedKeys() == ["combatants"].toSet(); + && ["combatants"].toSet().hasAll(combat.diff(oldCombat).affectedKeys()); } } diff --git a/firebase/migrations/v0.0.10__migrate_npcs_to_characters.ts b/firebase/migrations/v0.0.10__migrate_npcs_to_characters.ts new file mode 100644 index 000000000..8b6790332 --- /dev/null +++ b/firebase/migrations/v0.0.10__migrate_npcs_to_characters.ts @@ -0,0 +1,139 @@ +export async function migrate({firestore}: { firestore: firebase.firestore.Firestore }): Promise { + const firestore = getFirestore(); + + const allNpcs = await firestore.collectionGroup("combatants").get(); + + // We will create a new NPC Character for each NPC. + // The Character will have same ID as original NPC. + // Then we remove the NPC altogether + for (const npc of allNpcs.docs) { + const encounterDoc = npc.ref.parent.parent; + const encounter = await encounterDoc.get(); + + const npcData = npc.data(); + + if (!encounter.exists) { + // I have checked that no such NPCs are in an active combat + console.log("Encounter does not exist"); + await npc.ref.delete(); + + continue; + } + + const partyDoc = encounterDoc.parent.parent; + const encounterData = encounter.data(); + + const armour = Object.keys(npcData.armor) + .filter(key => npcData.armor[key] > 0) + .map(key => { + const part = key.replace(/[A-Z]/g, letter => ` ${letter.toLowerCase()}`) + + return `${part} - ${npcData.armor[key]}`; + }) + + const character = { + id: npcData.id, + type: "NPC", + name: npcData.name, + publicName: null, + userId: null, + career: "", + compendiumCareer: null, + socialClass: "", + status: { + tier: "BRASS", + standing: 0, + }, + psychology: "", + motivation: "", + race: null, + characteristicsBase: npcData.stats, + characteristicsAdvances: { + weaponSkill: 0, + dexterity: 0, + ballisticSkill: 0, + strength: 0, + toughness: 0, + agility: 0, + intelligence: 0, + initiative: 0, + willPower: 0, + fellowship: 0, + }, + points: { + corruption: 0, + fate: 0, + fortune: 0, + wounds: npcData.wounds.current, + maxWounds: npcData.wounds.max, + resilience: 0, + resolve: 0, + sin: 0, + experience: 0, + spentExperience: 0, + }, + ambitions: { + shortTerm: "", + longTerm: "", + }, + conditions: ("conditions" in npcData) ? npcData.conditions : {conditions: {}}, + mutation: "", + note: "Automatically migrated from NPC" + (armour.length > 0 ? `\n\n**Armour:**\n${armour.join('\n')}` : ""), + hardyTalent: false, + woundsModifiers: { + afterMultiplier: 1, + extraToughnessBonusMultiplier: 0, + isConstruct: false, + }, + encumbranceBonus: 0, + archived: false, + avatarUrl: null, + money: {pennies: 0}, + hiddenTabs: [], + size: null, + } + + await partyDoc.collection("characters") + .doc(npcData.id) + .set(character); + + await npc.ref.delete(); + } + + const partiesWithActiveCombat = await firestore.collection("parties").where("activeCombat", "!=", null) + .get() + + // NPCs may be combatants in active combats + // We will replace them by newly created NPC Characters + for (const party of partiesWithActiveCombat.docs) { + const partyData = party.data(); + const combat = partyData.activeCombat; + + if (!combat.combatants.some(combatant => combatant['@type'] == 'npc')) { + continue; + } + + await party.ref.update( + { + ...partyData, + activeCombat: { + ...combat, + combatants: combat.combatants.map(combatant => { + if (combatant["@type"] != 'npc') { + return combatant; + } + + const newCombatant = { + ...combatant, + '@type': 'character', + 'characterId': combatant.npcId.npcId, + }; + delete newCombatant['npcId']; + + return newCombatant; + }) + } + } + ) + } +} \ No newline at end of file diff --git a/firebase/tests/Encounters.ts b/firebase/tests/Encounters.ts index 99bbf7f81..06590373a 100644 --- a/firebase/tests/Encounters.ts +++ b/firebase/tests/Encounters.ts @@ -98,46 +98,6 @@ class Encounters extends Suite { ) } - @test - async "GM can add combatant"() { - const combatantsCollection = await this.combatantCollection(); - - const npc = this.validNpc(); - await assertSucceeds(combatantsCollection.doc(npc.id).set(npc)); - - // conditions are introduced in 1.X and should be optional for BC (TODO: Remove later) - const npc2 = withoutField(this.validNpc(), "conditions"); - await assertSucceeds(combatantsCollection.doc(npc2.id).set(npc2)); - } - - @test - async "GM can remove combatant"() { - const combatantsCollection = await this.combatantCollection(); - const combatant = this.validNpc(); - const document = combatantsCollection.doc(combatant.id); - - await document.set(combatant); - - await assertSucceeds(document.delete()); - } - - @test - async "Player CANNOT add combatant"() { - const party = await this.createUserAccessibleParty("user123"); - const encounter = await this.createValidEncounter(party); - - const npcsCollection = this.authedApp("user123") - .collection("parties") - .doc(party.id) - .collection("encounters") - .doc(encounter.id) - .collection("combatants"); - - const npc = this.validNpc(); - - await assertFails(npcsCollection.doc(npc.id).set(npc)); - } - @test async "GM can initiate combat"() { const party = await this.createValidParty(); @@ -158,15 +118,11 @@ class Encounters extends Suite { encounterId: encounter.id, combatants: [ { + id: uuid(), characterId: "123", advantage: 0, initiative: 1, }, - { - npcId: uuid(), - advantage: 0, - initiative: 1, - } ]} )); await assertSucceeds(partyDocument.update("activeCombat", null)); @@ -211,7 +167,7 @@ class Encounters extends Suite { turn: 1, round: 1, encounterId: encounter.id, - combatants: [{userId: "123"}, {npcId: uuid()}] + combatants: [{userId: "123"}] }); await assertSucceeds( @@ -222,7 +178,7 @@ class Encounters extends Suite { turn: 1, round: 1, encounterId: encounter.id, - combatants: [{userId: "123"}, {npcId: uuid()}, {npcId: uuid()}] + combatants: [{userId: "124"}], }) ); } @@ -264,45 +220,4 @@ class Encounters extends Suite { completed: false, } } - - private validNpc(): Npc { - return { - id: uuid(), - name: "Toby", - note: "", - enemy: true, - alive: true, - trappings: ["short sword"], - traits: ["Fear 1", "Luck 1"], - stats: { - agility: 20, - ballisticSkill: 12, - dexterity: 15, - fellowship: 10, - initiative: 40, - intelligence: 64, - strength: 32, - toughness: 15, - weaponSkill: 13, - willPower: 10, - }, - armor: { - head: 1, - body: 1, - leftArm: 1, - rightArm: 1, - leftLeg: 1, - rightLeg: 1, - shield: 1, - }, - wounds: { - current: 2, - max: 5, - }, - position: 0, - conditions: { - conditions: {} - } - } - } }