StatCalculatorServiceImpl.kt

package io.github.lishangbu.avalon.dataset.service.impl

import io.github.lishangbu.avalon.dataset.entity.dto.CreatureView
import io.github.lishangbu.avalon.dataset.entity.dto.NatureView
import io.github.lishangbu.avalon.dataset.model.StatCalculatorCreaturePresetView
import io.github.lishangbu.avalon.dataset.model.StatCalculatorCreatureStatPresetView
import io.github.lishangbu.avalon.dataset.model.StatCalculatorEntryRequest
import io.github.lishangbu.avalon.dataset.model.StatCalculatorEntryResultView
import io.github.lishangbu.avalon.dataset.model.StatCalculatorNatureView
import io.github.lishangbu.avalon.dataset.model.StatCalculatorRequest
import io.github.lishangbu.avalon.dataset.model.StatCalculatorResultView
import io.github.lishangbu.avalon.dataset.repository.CreatureRepository
import io.github.lishangbu.avalon.dataset.repository.CreatureStatRepository
import io.github.lishangbu.avalon.dataset.repository.NatureRepository
import io.github.lishangbu.avalon.dataset.repository.StatRepository
import io.github.lishangbu.avalon.dataset.service.StatCalculatorService
import io.github.lishangbu.avalon.game.calculator.stat.StatCalculatorFactory
import org.springframework.stereotype.Service

@Service
class StatCalculatorServiceImpl(
    private val creatureRepository: CreatureRepository,
    private val creatureStatRepository: CreatureStatRepository,
    private val statRepository: StatRepository,
    private val natureRepository: NatureRepository,
    private val statCalculatorFactory: StatCalculatorFactory,
) : StatCalculatorService {
    override fun getCreaturePreset(
        creatureId: String?,
    ): StatCalculatorCreaturePresetView {
        val creature = resolveCreature(creatureId)
        val rows = creatureStatRepository.findAll().filter { row -> row.id.creatureId == creature.id.toLong() }
        val rowsByStatId = rows.associateBy { row -> row.id.statId }
        val coreStats = statRepository.listCoreStats()
        val presetStats =
            coreStats
                .mapNotNull { stat ->
                    if (stat.battleOnly != false) {
                        return@mapNotNull null
                    }
                    val row = rowsByStatId[stat.id] ?: return@mapNotNull null
                    val internalName = stat.internalName ?: return@mapNotNull null
                    StatCalculatorCreatureStatPresetView(
                        statId = stat.id.toString(),
                        statInternalName = internalName,
                        statName = stat.name ?: internalName,
                        baseStat = row.baseStat ?: 0,
                        effortYield = 0,
                    )
                }

        val creatureInternalNameValue = creature.internalName ?: error("Creature internalName must not be null.")
        return StatCalculatorCreaturePresetView(
            creatureId = creature.id,
            creatureInternalName = creatureInternalNameValue,
            creatureName = creature.name ?: creatureInternalNameValue,
            creatureSpeciesId = creature.creatureSpecies?.id,
            creatureSpeciesInternalName = creature.creatureSpecies?.internalName,
            creatureSpeciesName = creature.creatureSpecies?.name,
            stats = presetStats,
        )
    }

    override fun calculate(request: StatCalculatorRequest): StatCalculatorResultView {
        require(request.level in 1..100) { "level must be between 1 and 100." }
        require(request.stats.isNotEmpty()) { "stats must not be empty." }

        val nature = resolveNature(request.natureId)
        val statsById =
            statRepository
                .findAll()
                .associateBy { stat -> stat.id }

        val duplicatedStat =
            request.stats
                .groupBy { entry -> entry.statId }
                .entries
                .firstOrNull { entry -> entry.value.size > 1 }
                ?.key
        require(duplicatedStat == null) { "Duplicate statId '$duplicatedStat' is not allowed." }

        val results =
            request.stats.map { entry ->
                validateEntry(entry)
                val stat = statsById[entry.statId] ?: error("Stat '${entry.statId}' was not found in dataset.")
                require(stat.battleOnly == false) {
                    "Unsupported statId '${entry.statId}'."
                }
                val statInternalName =
                    stat.internalName ?: error("Stat '${entry.statId}' internalName must not be null.")
                val natureModifier = resolveNatureModifier(statInternalName, nature)
                val actualValue =
                    statCalculatorFactory.calculateStat(
                        statInternalName,
                        entry.baseStat,
                        entry.iv,
                        entry.ev,
                        request.level,
                        natureModifier,
                    )
                val minimumValue =
                    statCalculatorFactory.calculateStat(
                        statInternalName,
                        entry.baseStat,
                        0,
                        entry.ev,
                        request.level,
                        natureModifier,
                    )
                val maximumValue =
                    statCalculatorFactory.calculateStat(
                        statInternalName,
                        entry.baseStat,
                        31,
                        entry.ev,
                        request.level,
                        natureModifier,
                    )

                StatCalculatorEntryResultView(
                    statId = stat.id.toString(),
                    statInternalName = statInternalName,
                    statName = stat.name ?: statInternalName,
                    baseStat = entry.baseStat,
                    iv = entry.iv,
                    ev = entry.ev,
                    actualValue = actualValue,
                    minimumValue = minimumValue,
                    maximumValue = maximumValue,
                    natureModifier = natureModifier,
                )
            }

        return StatCalculatorResultView(
            level = request.level,
            totalEv = request.stats.sumOf { entry -> entry.ev },
            nature = nature?.toCalculatorView(),
            stats = results,
        )
    }

    private fun resolveCreature(
        creatureId: String?,
    ): CreatureView {
        require(!creatureId.isNullOrBlank()) {
            "creatureId must not be blank."
        }

        val parsedCreatureId = creatureId.toLongOrNull() ?: error("creatureId must be a valid long value.")
        return requireNotNull(creatureRepository.loadViewById(parsedCreatureId)) {
            "Creature '$creatureId' was not found."
        }
    }

    private fun resolveNature(
        natureId: Long?,
    ): NatureView? {
        if (natureId == null) {
            return null
        }

        return requireNotNull(natureRepository.loadViewById(natureId)) {
            "Nature '$natureId' was not found."
        }
    }

    private fun validateEntry(entry: StatCalculatorEntryRequest) {
        require(entry.statId >= 1) { "statId must be greater than or equal to 1." }
        require(entry.baseStat >= 1) { "baseStat must be greater than or equal to 1." }
        require(entry.iv in 0..31) { "iv must be between 0 and 31." }
        require(entry.ev in 0..252) { "ev must be between 0 and 252." }
    }

    private fun resolveNatureModifier(
        statInternalName: String,
        nature: NatureView?,
    ): Int {
        if (nature == null || statInternalName == "hp") {
            return 100
        }

        val increasedStat = nature.increasedStat?.internalName
        val decreasedStat = nature.decreasedStat?.internalName

        return when {
            increasedStat == statInternalName && decreasedStat != statInternalName -> 110
            decreasedStat == statInternalName && increasedStat != statInternalName -> 90
            else -> 100
        }
    }

    private fun NatureView.toCalculatorView(): StatCalculatorNatureView =
        (internalName ?: error("Nature internalName must not be null.")).let { internalNameValue ->
            StatCalculatorNatureView(
                id = id,
                internalName = internalNameValue,
                name = name ?: internalNameValue,
                increasedStatId = increasedStat?.id,
                increasedStatInternalName = increasedStat?.internalName,
                increasedStatName = increasedStat?.name,
                decreasedStatId = decreasedStat?.id,
                decreasedStatInternalName = decreasedStat?.internalName,
                decreasedStatName = decreasedStat?.name,
            )
        }
}