TypeEffectivenessServiceImpl.kt

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

import io.github.lishangbu.avalon.dataset.api.model.TypeEffectivenessCell
import io.github.lishangbu.avalon.dataset.api.model.TypeEffectivenessChart
import io.github.lishangbu.avalon.dataset.api.model.TypeEffectivenessCompleteness
import io.github.lishangbu.avalon.dataset.api.model.TypeEffectivenessMatchup
import io.github.lishangbu.avalon.dataset.api.model.TypeEffectivenessMatrixCellInput
import io.github.lishangbu.avalon.dataset.api.model.TypeEffectivenessResult
import io.github.lishangbu.avalon.dataset.api.model.TypeEffectivenessRow
import io.github.lishangbu.avalon.dataset.api.model.TypeEffectivenessTypeView
import io.github.lishangbu.avalon.dataset.api.model.UpsertTypeEffectivenessMatrixCommand
import io.github.lishangbu.avalon.dataset.api.service.TypeEffectivenessService
import io.github.lishangbu.avalon.dataset.entity.Type
import io.github.lishangbu.avalon.dataset.entity.TypeEffectivenessEntry
import io.github.lishangbu.avalon.dataset.entity.TypeEffectivenessEntryId
import io.github.lishangbu.avalon.dataset.repository.TypeEffectivenessEntryRepository
import io.github.lishangbu.avalon.dataset.repository.TypeRepository
import org.babyfish.jimmer.sql.ast.mutation.SaveMode
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.Locale

/** 属性相克业务服务实现 */
@Service
class TypeEffectivenessServiceImpl(
    private val typeRepository: TypeRepository,
    private val typeEffectivenessEntryRepository: TypeEffectivenessEntryRepository,
) : TypeEffectivenessService {
    override fun calculate(
        attackingType: String,
        defendingTypes: List<String>,
    ): TypeEffectivenessResult {
        val context = loadContext()
        val attacking = context.requireType(normalizeTypeName(attackingType, "attackingType"), "attackingType")
        val normalizedDefendingTypes = normalizeDefendingTypes(defendingTypes)
        val matchupEvaluations =
            normalizedDefendingTypes.map { defendingType ->
                val defending = context.requireType(defendingType, "defendingTypes")
                MatchupEvaluation(
                    defending = defending,
                    multiplierPercent = context.storedMultipliers[attacking.internalName to defending.internalName],
                )
            }

        val configuredMultipliers = matchupEvaluations.map { evaluation -> evaluation.multiplierPercent }
        val finalMultiplierPercent =
            if (configuredMultipliers.any { multiplierPercent -> multiplierPercent == null }) {
                null
            } else {
                configuredMultipliers
                    .filterNotNull()
                    .fold(TypeEffectivenessMultiplierCodec.ONE_X_PERCENT) { acc, multiplierPercent ->
                        TypeEffectivenessMultiplierCodec.multiplyStoredPercents(acc, multiplierPercent)
                    }
            }

        return TypeEffectivenessResult(
            attackingType = attacking.apiView,
            defendingTypes =
                matchupEvaluations.map { evaluation ->
                    TypeEffectivenessMatchup(
                        defendingType = evaluation.defending.apiView,
                        multiplier = TypeEffectivenessMultiplierCodec.decode(evaluation.multiplierPercent),
                        status = cellStatusOf(evaluation.multiplierPercent),
                    )
                },
            finalMultiplier = TypeEffectivenessMultiplierCodec.decode(finalMultiplierPercent),
            status = if (finalMultiplierPercent == null) STATUS_INCOMPLETE else STATUS_COMPLETE,
            effectiveness = effectivenessOf(finalMultiplierPercent),
        )
    }

    override fun getChart(): TypeEffectivenessChart = buildChart(loadMatrixContext())

    @Transactional(rollbackFor = [Exception::class])
    override fun upsertMatrix(command: UpsertTypeEffectivenessMatrixCommand): TypeEffectivenessChart {
        val context = loadMatrixContext()
        val normalizedCells = normalizeMatrixCells(command.cells)
        normalizedCells.forEach { cell ->
            val attacking = context.requireType(cell.attackingType, "cells.attackingType")
            val defending = context.requireType(cell.defendingType, "cells.defendingType")
            val id =
                TypeEffectivenessEntryId {
                    attackingTypeId = attacking.id
                    defendingTypeId = defending.id
                }
            val multiplierPercent = cell.multiplierPercent
            if (multiplierPercent == null) {
                typeEffectivenessEntryRepository.deleteById(id)
            } else {
                // API 层传入的是自然倍率;真正写入数据库前必须先编码成整数百分比。
                typeEffectivenessEntryRepository.save(
                    TypeEffectivenessEntry {
                        this.id = id
                        this.multiplierPercent = multiplierPercent
                    },
                    SaveMode.UPSERT,
                )
            }
        }
        return buildChart(loadMatrixContext())
    }

    private fun buildChart(context: TypeEffectivenessContext): TypeEffectivenessChart {
        val expectedPairs = context.supportedTypes.size * context.supportedTypes.size
        val configuredPairs = context.storedMultipliers.count { (_, multiplierPercent) -> multiplierPercent != null }
        val rows =
            context.supportedTypes.map { attacking ->
                TypeEffectivenessRow(
                    attackingType = attacking.apiView,
                    cells =
                        context.supportedTypes.map { defending ->
                            val multiplierPercent = context.storedMultipliers[attacking.internalName to defending.internalName]
                            TypeEffectivenessCell(
                                defendingType = defending.apiView,
                                multiplier = TypeEffectivenessMultiplierCodec.decode(multiplierPercent),
                                status = cellStatusOf(multiplierPercent),
                            )
                        },
                )
            }
        return TypeEffectivenessChart(
            supportedTypes = context.supportedTypes.map { it.apiView },
            completeness =
                TypeEffectivenessCompleteness(
                    expectedPairs = expectedPairs,
                    configuredPairs = configuredPairs,
                    missingPairs = expectedPairs - configuredPairs,
                ),
            rows = rows,
        )
    }

    private fun loadContext(): TypeEffectivenessContext {
        val supportedTypes =
            loadSupportedTypes()
                .sortedBy { type -> type.id }

        return TypeEffectivenessContext(
            supportedTypes = supportedTypes,
            typesByInternalName = supportedTypes.associateBy { it.internalName },
            storedMultipliers = loadStoredMultipliers(supportedTypes),
        )
    }

    private fun loadMatrixContext(): TypeEffectivenessContext {
        val supportedTypes =
            loadSupportedTypes()
                .sortedBy { type -> type.id }

        return TypeEffectivenessContext(
            supportedTypes = supportedTypes,
            typesByInternalName = supportedTypes.associateBy { it.internalName },
            storedMultipliers = loadStoredMultipliers(supportedTypes),
        )
    }

    private fun loadSupportedTypes(): List<SupportedTypeView> =
        typeRepository
            .findAll()
            .map { entity -> entity.toSupportedTypeView() }

    private fun loadStoredMultipliers(supportedTypes: List<SupportedTypeView>): Map<Pair<String, String>, Int?> {
        val supportedTypeIds = supportedTypes.associateBy({ it.id }, { it.internalName })
        return typeEffectivenessEntryRepository
            .listByFilter(
                attackingTypeId = null,
                defendingTypeId = null,
                multiplierPercent = null,
            ).mapNotNull { entry ->
                // 上下文内部始终只缓存数据库中的定点整数,避免后续计算退回到浮点路径。
                val attackingInternalName = supportedTypeIds[entry.id.attackingTypeId] ?: return@mapNotNull null
                val defendingInternalName = supportedTypeIds[entry.id.defendingTypeId] ?: return@mapNotNull null
                (attackingInternalName to defendingInternalName) to entry.multiplierPercent
            }.toMap(LinkedHashMap())
    }

    private fun normalizeDefendingTypes(defendingTypes: List<String>): List<String> {
        require(defendingTypes.isNotEmpty()) { "defendingTypes must not be empty" }
        require(defendingTypes.size <= 2) { "defendingTypes supports at most 2 entries" }
        val normalized =
            defendingTypes.map { defendingType ->
                normalizeTypeName(defendingType, "defendingTypes")
            }
        require(normalized.distinct().size == normalized.size) { "defendingTypes must not contain duplicates" }
        return normalized
    }

    private fun normalizeMatrixCells(cells: List<TypeEffectivenessMatrixCellInput>): List<NormalizedMatrixCell> {
        val normalized =
            cells.map { cell ->
                NormalizedMatrixCell(
                    attackingType = normalizeTypeName(cell.attackingType, "cells.attackingType"),
                    defendingType = normalizeTypeName(cell.defendingType, "cells.defendingType"),
                    multiplierPercent = TypeEffectivenessMultiplierCodec.encodeEntryMultiplier(cell.multiplier),
                )
            }
        val keys = normalized.map { it.attackingType to it.defendingType }
        require(keys.distinct().size == keys.size) { "cells must not contain duplicate attackingType/defendingType pairs" }
        return normalized
    }

    private fun normalizeTypeName(
        value: String,
        fieldName: String,
    ): String {
        val normalized = value.trim().lowercase(Locale.ROOT)
        require(normalized.isNotEmpty()) { "$fieldName must not be blank" }
        return normalized
    }

    private fun Type.toSupportedTypeView(): SupportedTypeView {
        val internalName = requireNotNull(internalName) { "Type id=$id is missing internalName" }.trim().lowercase(Locale.ROOT)
        require(internalName.isNotEmpty()) { "Type id=$id has blank internalName" }
        return SupportedTypeView(
            id = id,
            apiView =
                TypeEffectivenessTypeView(
                    internalName = internalName,
                    name = requireNotNull(name) { "Type $internalName is missing name" },
                ),
        )
    }

    private fun cellStatusOf(multiplierPercent: Int?): String =
        if (multiplierPercent == null) {
            STATUS_MISSING
        } else {
            STATUS_CONFIGURED
        }

    private fun effectivenessOf(multiplierPercent: Int?): String =
        when {
            multiplierPercent == null -> EFFECTIVENESS_INCOMPLETE
            multiplierPercent == 0 -> EFFECTIVENESS_IMMUNE
            multiplierPercent < TypeEffectivenessMultiplierCodec.ONE_X_PERCENT -> EFFECTIVENESS_NOT_VERY_EFFECTIVE
            multiplierPercent > TypeEffectivenessMultiplierCodec.ONE_X_PERCENT -> EFFECTIVENESS_SUPER_EFFECTIVE
            else -> EFFECTIVENESS_NORMAL_EFFECTIVE
        }

    private data class MatchupEvaluation(
        val defending: SupportedTypeView,
        val multiplierPercent: Int?,
    )

    private data class SupportedTypeView(
        val id: Long,
        val apiView: TypeEffectivenessTypeView,
    ) {
        val internalName: String
            get() = apiView.internalName
    }

    private data class TypeEffectivenessContext(
        val supportedTypes: List<SupportedTypeView>,
        val typesByInternalName: Map<String, SupportedTypeView>,
        val storedMultipliers: Map<Pair<String, String>, Int?>,
    ) {
        fun requireType(
            internalName: String,
            fieldName: String,
        ): SupportedTypeView = requireNotNull(typesByInternalName[internalName]) { "Unsupported $fieldName: $internalName" }
    }

    private data class NormalizedMatrixCell(
        val attackingType: String,
        val defendingType: String,
        val multiplierPercent: Int?,
    )

    private companion object {
        const val STATUS_COMPLETE: String = "complete"
        const val STATUS_INCOMPLETE: String = "incomplete"
        const val STATUS_CONFIGURED: String = "configured"
        const val STATUS_MISSING: String = "missing"

        const val EFFECTIVENESS_IMMUNE: String = "immune"
        const val EFFECTIVENESS_NOT_VERY_EFFECTIVE: String = "not-very-effective"
        const val EFFECTIVENESS_NORMAL_EFFECTIVE: String = "normal-effective"
        const val EFFECTIVENESS_SUPER_EFFECTIVE: String = "super-effective"
        const val EFFECTIVENESS_INCOMPLETE: String = "incomplete"
    }
}