package club.mcscrims.speedhg.ranking import net.kyori.adventure.text.Component import net.kyori.adventure.text.minimessage.MiniMessage import org.bukkit.Color /** * Rang-Tier System für SpeedHG. * * ## Sub-Tier Berechnung * Jeder Rang (außer [UNRANKED] und [SCRIM_MASTER]) hat drei Unterränge I–III. * Die Spanne [minScore, nächsterRang.minScore) wird in drei gleichgroße Drittel * aufgeteilt — unterstes Drittel = III, mittleres = II, oberstes = I. * * Beispiel GOLD (1000–1499, Spanne 500): * Gold III → 1000–1165 (position < 166) * Gold II → 1166–1332 (position < 332) * Gold I → 1333–1499 (position ≥ 332) * * @param displayName Angezeigter Rang-Name. * @param colorTag MiniMessage-Farbtag ohne Inhalt (z. B. ""). * @param minScore Untere Score-Grenze (inklusiv). UNRANKED = [Int.MIN_VALUE]. * @param fireworkColor Feuerwerksfarbe für die Podium-Zeremonie. */ enum class Rank( val displayName: String, val colorTag: String, val minScore: Int, val fireworkColor: Color ) { UNRANKED ("Unranked", "", Int.MIN_VALUE, Color.GRAY ), BRONZE ("Bronze", "<#CD7F32>", 0, Color.fromRGB(0xCD7F32) ), SILVER ("Silver", "", 500, Color.fromRGB(0xC0C0C0) ), GOLD ("Gold", "", 1000, Color.YELLOW ), PLATINUM ("Platinum", "", 1500, Color.fromRGB(0x00FFFF) ), DIAMOND ("Diamond", "<#B9F2FF>", 2000, Color.fromRGB(0xB9F2FF) ), SCRIM_MASTER("Scrim-Master", "", 2500, Color.ORANGE ); // ── Vorberechnete Strings & Components (einmal pro Rang, nie pro Frame) ── /** MiniMessage-Tag: "Gold" */ val tag: String = "$colorTag$displayName" /** Klammer-Prefix: "[Gold]" */ val prefix: String = "$colorTag[$displayName]" /** * Deserialisiertes Adventure-Component des [tag]. * [lazy] → einmalige Deserialisierung, danach gecacht. * Da Enums Singletons sind, passiert das genau einmal pro Rang. */ val component: Component by lazy { MiniMessage.miniMessage().deserialize(tag) } /** Deserialisiertes Adventure-Component des [prefix]. */ val prefixComponent: Component by lazy { MiniMessage.miniMessage().deserialize(prefix) } // ── Sub-Tier Logik ──────────────────────────────────────────────────────── /** * Berechnet den Sub-Tier (1 = höchster, 3 = niedrigster) für diesen Rang. * * Gibt `null` zurück für [UNRANKED] und [SCRIM_MASTER] — sie haben keine Sub-Tiers. * * Algorithmus: * range = nächsterRang.minScore − this.minScore * position = (scrimScore − this.minScore).coerceIn(0, range − 1) * Drittel: position < range/3 → III, < 2*range/3 → II, sonst → I */ fun subTier(scrimScore: Int): Int? { if (this == UNRANKED || this == SCRIM_MASTER) return null // Obere Grenze (exklusiv) = minScore des nächsthöheren Rangs val upperBound = entries .filter { it != UNRANKED && it.minScore > this.minScore } .minOfOrNull { it.minScore } ?: return null // Sicherheitsnetz — sollte für reguläre Ränge nie eintreten val range = upperBound - this.minScore val position = (scrimScore - this.minScore).coerceIn(0, range - 1) return when { position < range / 3 -> 3 // Unteres Drittel → III position < 2 * range / 3 -> 2 // Mittleres Drittel → II else -> 1 // Oberes Drittel → I } } /** Kurzform: "I", "II", "III" oder `null`. */ fun subTierRoman(scrimScore: Int): String? = when (subTier(scrimScore)) { 1 -> "I"; 2 -> "II"; 3 -> "III"; else -> null } // ========================================================================= // Companion // ========================================================================= companion object { private val mm = MiniMessage.miniMessage() /** * Sichtbarer Rang eines Spielers inklusive Placement-Phasen-Check. * * Spieler mit weniger als [RankingManager.PLACEMENT_GAMES] abgeschlossenen * Spielen erhalten immer [UNRANKED], unabhängig vom internen Score. */ fun fromPlayer(scrimScore: Int, gamesPlayed: Int): Rank = if (gamesPlayed < RankingManager.PLACEMENT_GAMES) UNRANKED else fromScore(scrimScore) /** * Reines Score → Tier Mapping (ignoriert Placement-Phase). * Wird intern für Rank-Change-Erkennung genutzt. * Iteriert maximal 6 Einträge → O(1) in der Praxis. */ fun fromScore(scrimScore: Int): Rank = entries .asSequence() .filter { it != UNRANKED } .sortedByDescending { it.minScore } .firstOrNull { scrimScore >= it.minScore } ?: BRONZE /** * Gibt ein vollständiges [Component] zurück, z. B. "Gold II" oder "Scrim-Master". * * ⚠ Für häufige Aufrufe (Scoreboard) empfiehlt es sich, das Ergebnis * pro Spieler zu cachen und nur bei Score-Änderungen zu invalidieren. */ fun getFormattedRankName(scrimScore: Int, gamesPlayed: Int): Component = mm.deserialize(getFormattedRankTag(scrimScore, gamesPlayed)) /** * Gibt den MiniMessage-String zurück, geeignet als Platzhalter in * anderen MiniMessage-Templates. * * Beispiele: `"Gold II"`, `"Unranked"`. */ fun getFormattedRankTag(scrimScore: Int, gamesPlayed: Int): String { val rank = fromPlayer(scrimScore, gamesPlayed) val roman = rank.subTierRoman(scrimScore) return if (roman != null) "${rank.colorTag}${rank.displayName} $roman" else rank.tag } } }