Repository: meowdding/skyblock-pv
Pull request #42: feat: bestiary page
Author: login: j10a1n15
Created: 2025-04-06T10:48:34Z | Merged: 2025-04-13T16:26:01Z



Diff:
diff --git a/TODO.md b/TODO.md
index ae6ef8d6..52cf9a2b 100644
--- a/TODO.md
+++ b/TODO.md
@@ -21,7 +21,6 @@ TODO:
 <details>
 <summary>combat</summary>
 
-- Bestiary
 - Kuudra
 - Crimson Isle
 
diff --git a/src/main/kotlin/tech/thatgravyboat/skyblockpv/SkyBlockPv.kt b/src/main/kotlin/tech/thatgravyboat/skyblockpv/SkyBlockPv.kt
index 698ae45e..a83b90e8 100644
--- a/src/main/kotlin/tech/thatgravyboat/skyblockpv/SkyBlockPv.kt
+++ b/src/main/kotlin/tech/thatgravyboat/skyblockpv/SkyBlockPv.kt
@@ -53,6 +53,7 @@ object SkyBlockPv : ModInitializer, Logger by LoggerFactory.getLogger("SkyBlockP
             SacksParser,
             CfCodecs,
             RiftCodecs,
+            BestiaryCodecs,
             SackCodecs,
             LegacyDataFixer,
         )
diff --git a/src/main/kotlin/tech/thatgravyboat/skyblockpv/api/data/SkyBlockProfile.kt b/src/main/kotlin/tech/thatgravyboat/skyblockpv/api/data/SkyBlockProfile.kt
index d161f03c..4b469599 100644
--- a/src/main/kotlin/tech/thatgravyboat/skyblockpv/api/data/SkyBlockProfile.kt
+++ b/src/main/kotlin/tech/thatgravyboat/skyblockpv/api/data/SkyBlockProfile.kt
@@ -14,6 +14,7 @@ import tech.thatgravyboat.skyblockpv.data.api.CollectionItem
 import tech.thatgravyboat.skyblockpv.data.api.Currency
 import tech.thatgravyboat.skyblockpv.data.api.RiftData
 import tech.thatgravyboat.skyblockpv.data.api.skills.*
+import tech.thatgravyboat.skyblockpv.data.api.skills.combat.BestiaryMobData
 import tech.thatgravyboat.skyblockpv.data.api.skills.combat.DungeonData
 import tech.thatgravyboat.skyblockpv.data.api.skills.combat.MobData
 import tech.thatgravyboat.skyblockpv.data.api.skills.combat.SlayerTypeData
@@ -21,8 +22,8 @@ import tech.thatgravyboat.skyblockpv.data.api.skills.farming.FarmingData
 import tech.thatgravyboat.skyblockpv.data.api.skills.farming.GardenData
 import tech.thatgravyboat.skyblockpv.data.repo.EssenceData
 import tech.thatgravyboat.skyblockpv.utils.ChatUtils
-import tech.thatgravyboat.skyblockpv.utils.getPath
 import tech.thatgravyboat.skyblockpv.utils.Utils.toDashlessString
+import tech.thatgravyboat.skyblockpv.utils.getPath
 import java.util.*
 
 data class SkyBlockProfile(
@@ -40,6 +41,7 @@ data class SkyBlockProfile(
     val skill: Map<String, Long>,
     val collections: List<CollectionItem>,
     val mobData: List<MobData>,
+    val bestiaryData: List<BestiaryMobData>,
     val slayer: Map<String, SlayerTypeData>,
     val dungeonData: DungeonData?,
     val mining: MiningCore?,
@@ -94,6 +96,7 @@ data class SkyBlockProfile(
                 skill = playerData.getSkillData(),
                 collections = member.getCollectionData(),
                 mobData = playerStats?.getMobData() ?: emptyList(),
+                bestiaryData = member.getPath("bestiary")?.asJsonObject?.getBestiaryMobData() ?: emptyList(),
                 slayer = member.getAsJsonObject("slayer")?.getSlayerData() ?: emptyMap(),
                 dungeonData = member.getAsJsonObject("dungeons")?.let { DungeonData.fromJson(it) },
                 mining = member.getAsJsonObject("mining_core")?.let { MiningCore.fromJson(it) },
@@ -150,7 +153,7 @@ data class SkyBlockProfile(
             val deaths = this["deaths"].asMap { id, amount -> id to amount.asLong(0) }
             val kills = this["kills"].asMap { id, amount -> id to amount.asLong(0) }
 
-            return (deaths.keys + kills.keys).map { id ->
+            return (deaths.keys + kills.keys).toSet().map { id ->
                 MobData(
                     mobId = id,
                     kills = kills[id] ?: 0,
@@ -159,6 +162,19 @@ data class SkyBlockProfile(
             }
         }
 
+        private fun JsonObject.getBestiaryMobData(): List<BestiaryMobData> {
+            val deaths = this["deaths"].asMap { id, amount -> id to amount.asLong(0) }
+            val kills = this["kills"].asMap { id, amount -> id to amount.asLong(0) }
+
+            return (deaths.keys + kills.keys).toSet().map { id ->
+                BestiaryMobData(
+                    mobId = id,
+                    kills = kills[id] ?: 0,
+                    deaths = deaths[id] ?: 0,
+                )
+            }
+        }
+
         private fun JsonObject.getSlayerData() = this["slayer_bosses"].asMap { name, jsonElement ->
             val data = jsonElement.asJsonObject
             val maxTier = when (name) {
diff --git a/src/main/kotlin/tech/thatgravyboat/skyblockpv/data/api/skills/combat/MobData.kt b/src/main/kotlin/tech/thatgravyboat/skyblockpv/data/api/skills/combat/MobData.kt
index e7b97418..d96ce64f 100644
--- a/src/main/kotlin/tech/thatgravyboat/skyblockpv/data/api/skills/combat/MobData.kt
+++ b/src/main/kotlin/tech/thatgravyboat/skyblockpv/data/api/skills/combat/MobData.kt
@@ -5,3 +5,9 @@ data class MobData(
     val kills: Long,
     val deaths: Long,
 )
+
+data class BestiaryMobData(
+    val mobId: String,
+    val kills: Long,
+    val deaths: Long,
+)
diff --git a/src/main/kotlin/tech/thatgravyboat/skyblockpv/BestiaryData.kt b/src/main/kotlin/tech/thatgravyboat/skyblockpv/data/repo/BestiaryCodecs.kt
similarity index 63%
rename from src/main/kotlin/tech/thatgravyboat/skyblockpv/BestiaryData.kt
rename to src/main/kotlin/tech/thatgravyboat/skyblockpv/data/repo/BestiaryCodecs.kt
index d0d5c871..c5a7b613 100644
--- a/src/main/kotlin/tech/thatgravyboat/skyblockpv/BestiaryData.kt
+++ b/src/main/kotlin/tech/thatgravyboat/skyblockpv/data/repo/BestiaryCodecs.kt
@@ -1,15 +1,23 @@
-package tech.thatgravyboat.skyblockpv
+package tech.thatgravyboat.skyblockpv.data.repo
 
+import com.google.gson.JsonObject
 import com.mojang.datafixers.util.Either
 import com.mojang.serialization.Codec
+import com.mojang.serialization.DataResult
+import com.mojang.serialization.JsonOps
 import com.mojang.serialization.MapCodec
 import com.mojang.serialization.codecs.RecordCodecBuilder
+import tech.thatgravyboat.skyblockpv.utils.Utils
+import tech.thatgravyboat.skyblockpv.utils.codecs.DispatchedCodec
 import tech.thatgravyboat.skyblockpv.utils.codecs.ReservedUnboundMapCodec
 
 typealias BestiaryIcon = Either<String, Pair<String, String>>
-typealias BestiaryCategoriesEntry = Either<BestiaryCategoryEntry, Map<String, BestiaryCategoryEntry>>
+typealias BestiaryCategoriesEntry = Either<BestiaryCategoryEntry, ComplexBestiaryCategoryEntry>
 
-object BestiaryData {
+object BestiaryCodecs {
+
+    var data: BestiaryRepoData? = null
+        private set
 
     private val ICON: MapCodec<BestiaryIcon> = Codec.mapEither(
         Codec.STRING.fieldOf("item"),
@@ -44,21 +52,28 @@ object BestiaryData {
             { it.left().orElseThrow() },
         ),
     )
-    private val COMPLEX_CATEGORY_CODEC: MapCodec<BestiaryCategoriesEntry> = MapCodec.assumeMapUnsafe(
-        ReservedUnboundMapCodec(
-            Codec.STRING,
-            CATEGORY_ENTRY_CODEC,
-            "name", "icon", "hasSubcategories"
-        ).xmap(
-            { Either.right(it) },
-            { it.right().orElseThrow() },
-        ),
+    private val COMPLEX_CATEGORY_CODEC: MapCodec<BestiaryCategoriesEntry> = RecordCodecBuilder.mapCodec {
+        it.group(
+            Codec.STRING.fieldOf("name").forGetter(ComplexBestiaryCategoryEntry::name),
+            ICON.fieldOf("icon").forGetter(ComplexBestiaryCategoryEntry::icon),
+            MapCodec.assumeMapUnsafe(
+                ReservedUnboundMapCodec(
+                    Codec.STRING,
+                    CATEGORY_ENTRY_CODEC,
+                    "name", "icon", "hasSubcategories",
+                ),
+            ).forGetter(ComplexBestiaryCategoryEntry::subcategories),
+        ).apply(it, ::ComplexBestiaryCategoryEntry)
+    }.xmap(
+        { Either.right(it) },
+        { it.right().orElseThrow() },
     )
-    private val CATEGORY_CODEC: Codec<BestiaryCategoriesEntry> = Codec.BOOL.dispatch(
-        "hasSubcategories",
+
+    private val CATEGORY_CODEC: Codec<BestiaryCategoriesEntry> = DispatchedCodec(
+        Codec.BOOL.optionalFieldOf("hasSubcategories", false),
         { it.right().isPresent },
-        { if (it) COMPLEX_CATEGORY_CODEC else SIMPLE_CATEGORY_CODEC },
-    )
+        { DataResult.success(if (it) COMPLEX_CATEGORY_CODEC else SIMPLE_CATEGORY_CODEC) },
+    ).codec()
 
     private val BRACKETS_CODEC: Codec<Map<Int, List<Int>>> = Codec.unboundedMap(
         Codec.STRING.xmap({ it.toIntOrNull() ?: 0 }, { it.toString() }),
@@ -72,13 +87,24 @@ object BestiaryData {
         ),
     )
 
-    val CODEC: Codec<BestiaryRepoData> = RecordCodecBuilder.create {
+    private val CODEC: Codec<BestiaryRepoData> = RecordCodecBuilder.create {
         it.group(
             BRACKETS_CODEC.fieldOf("brackets").forGetter(BestiaryRepoData::brackets),
             CATEGORIES_CODEC.forGetter(BestiaryRepoData::categories),
         ).apply(it, ::BestiaryRepoData)
     }
 
+    init {
+        val bestiaryData = Utils.loadFromRepo<JsonObject>("bestiary") ?: JsonObject()
+
+        CODEC.parse(JsonOps.INSTANCE, bestiaryData).let {
+            if (it.isError) {
+                throw RuntimeException(it.error().get().message())
+            }
+            data = it.getOrThrow()
+        }
+    }
+
 }
 
 data class BestiaryRepoData(
@@ -92,6 +118,12 @@ data class BestiaryCategoryEntry(
     val mobs: List<BestiaryMobEntry>,
 )
 
+data class ComplexBestiaryCategoryEntry(
+    val name: String,
+    val icon: BestiaryIcon,
+    val subcategories: Map<String, BestiaryCategoryEntry>,
+)
+
 data class BestiaryMobEntry(
     val name: String,
     val icon: BestiaryIcon,
diff --git a/src/main/kotlin/tech/thatgravyboat/skyblockpv/data/repo/StaticGardenData.kt b/src/main/kotlin/tech/thatgravyboat/skyblockpv/data/repo/StaticGardenData.kt
index 01c7cd14..aff671d2 100644
--- a/src/main/kotlin/tech/thatgravyboat/skyblockpv/data/repo/StaticGardenData.kt
+++ b/src/main/kotlin/tech/thatgravyboat/skyblockpv/data/repo/StaticGardenData.kt
@@ -11,7 +11,6 @@ import eu.pb4.placeholders.api.parsers.TagParser
 import net.minecraft.core.registries.BuiltInRegistries
 import net.minecraft.network.chat.Component
 import net.minecraft.network.chat.MutableComponent
-import net.minecraft.resources.ResourceLocation
 import net.minecraft.util.ExtraCodecs
 import net.minecraft.util.StringRepresentable
 import net.minecraft.world.item.Item
@@ -315,8 +314,7 @@ data class StaticVisitorData(
     }
 
     val itemStack: ItemStack by lazy {
-        skin?.let { createSkull(it) } ?: ItemAPI.getItem(item).takeUnless { it.item == Items.BARRIER }
-        ?: BuiltInRegistries.ITEM.getValue(ResourceLocation.withDefaultNamespace(item)).defaultInstance
+        skin?.let { createSkull(it) } ?: ItemAPI.getItem(item).takeUnless { it.item == Items.BARRIER } ?: Utils.getMinecraftItem(item)
     }
 }
 
diff --git a/src/main/kotlin/tech/thatgravyboat/skyblockpv/screens/tabs/combat/BaseCombatScreen.kt b/src/main/kotlin/tech/thatgravyboat/skyblockpv/screens/tabs/combat/BaseCombatScreen.kt
index ace3945a..ffe5dcd1 100644
--- a/src/main/kotlin/tech/thatgravyboat/skyblockpv/screens/tabs/combat/BaseCombatScreen.kt
+++ b/src/main/kotlin/tech/thatgravyboat/skyblockpv/screens/tabs/combat/BaseCombatScreen.kt
@@ -13,12 +13,12 @@ import kotlin.reflect.KClass
 import kotlin.reflect.full.isSubclassOf
 
 abstract class BaseCombatScreen(gameProfile: GameProfile, profile: SkyBlockProfile? = null) : AbstractCategorizedScreen("COMBAT", gameProfile, profile) {
-
     override val categories: List<Category> get() = CombatCategory.entries
 }
 
 enum class CombatCategory(val screen: KClass<out BaseCombatScreen>, override val icon: ItemStack) : Category {
     DUNGEONS(DungeonScreen::class, SkullTextures.DUNGEONS.skull),
+    BESTIARY(BestiaryScreen::class, Items.WRITABLE_BOOK.defaultInstance),
     MOBS(MobScreen::class, Items.ZOMBIE_HEAD.defaultInstance),
     ;
 
diff --git a/src/main/kotlin/tech/thatgravyboat/skyblockpv/screens/tabs/combat/BestiaryScreen.kt b/src/main/kotlin/tech/thatgravyboat/skyblockpv/screens/tabs/combat/BestiaryScreen.kt
new file mode 100644
index 00000000..904af8f7
--- /dev/null
+++ b/src/main/kotlin/tech/thatgravyboat/skyblockpv/screens/tabs/combat/BestiaryScreen.kt
@@ -0,0 +1,153 @@
+package tech.thatgravyboat.skyblockpv.screens.tabs.combat
+
+import com.mojang.authlib.GameProfile
+import com.mojang.datafixers.util.Either
+import net.minecraft.core.component.DataComponents
+import net.minecraft.world.item.ItemStack
+import net.minecraft.world.item.Items
+import tech.thatgravyboat.skyblockapi.utils.extentions.toFormattedString
+import tech.thatgravyboat.skyblockapi.utils.text.Text
+import tech.thatgravyboat.skyblockapi.utils.text.TextColor
+import tech.thatgravyboat.skyblockapi.utils.text.TextStyle.color
+import tech.thatgravyboat.skyblockapi.utils.text.TextStyle.italic
+import tech.thatgravyboat.skyblockpv.api.data.SkyBlockProfile
+import tech.thatgravyboat.skyblockpv.data.repo.*
+import tech.thatgravyboat.skyblockpv.utils.LayoutBuild
+import tech.thatgravyboat.skyblockpv.utils.LayoutUtils.asScrollable
+import tech.thatgravyboat.skyblockpv.utils.LayoutUtils.centerHorizontally
+import tech.thatgravyboat.skyblockpv.utils.Utils
+import tech.thatgravyboat.skyblockpv.utils.Utils.append
+import tech.thatgravyboat.skyblockpv.utils.Utils.fixBase64Padding
+import tech.thatgravyboat.skyblockpv.utils.Utils.rightPad
+import tech.thatgravyboat.skyblockpv.utils.Utils.round
+import tech.thatgravyboat.skyblockpv.utils.components.CarouselWidget
+import tech.thatgravyboat.skyblockpv.utils.createSkull
+import tech.thatgravyboat.skyblockpv.utils.displays.Display
+import tech.thatgravyboat.skyblockpv.utils.displays.DisplayWidget
+import tech.thatgravyboat.skyblockpv.utils.displays.Displays
+import tech.thatgravyboat.skyblockpv.utils.displays.asTable
+import tech.thatgravyboat.skyblockpv.utils.withTooltip
+
+class BestiaryScreen(gameProfile: GameProfile, profile: SkyBlockProfile? = null) : BaseCombatScreen(gameProfile, profile) {
+    private var carousel: CarouselWidget? = null
+    private val MOBS_PER_ROW_SIMPLE = 5
+    private val MOBS_PER_ROW_COMPLEX = 8
+
+    override fun getLayout(bg: DisplayWidget) = LayoutBuild.vertical {
+        val categories = getCategories()
+        val inventories = categories.values.toList()
+        val icons = categories.keys.toList()
+
+        carousel = CarouselWidget(
+            inventories,
+            carousel?.index ?: 0,
+            246,
+        )
+
+        val buttonContainer = carousel!!.getIcons { icons.map { Displays.item(it, showTooltip = true) } }
+
+        widget(
+            LayoutBuild.vertical(5) {
+                widget(buttonContainer.centerHorizontally(uiWidth))
+                widget(carousel!!.centerHorizontally(uiWidth))
+            }.asScrollable(uiWidth, uiHeight),
+        )
+    }
+
+    private fun getCategories(): Map<ItemStack, Display> = BestiaryCodecs.data?.categories?.map { (_, v) ->
+        Either.unwrap(
+            v.mapBoth(
+                { it.icon.getItem(it.name) to it.getCategory() }, // Simple
+                { it.icon.getItem(it.name) to it.getCategory() }, // Complex
+            ),
+        )
+    }?.toMap() ?: emptyMap()
+
+    private fun BestiaryCategoryEntry.getCategory() = mobs.map { it.getItem() }.format(MOBS_PER_ROW_SIMPLE)
+
+    private fun ComplexBestiaryCategoryEntry.getCategory() = subcategories.flatMap { it.value.mobs.map { it.getItem() } }.format(MOBS_PER_ROW_COMPLEX)
+
+    private fun List<Display>.format(mobsPerRow: Int) = toMutableList()
+        .rightPad(mobsPerRow * 2, Displays.empty(16, 16))
+        .map { Displays.padding(2, it) }
+        .chunked(mobsPerRow)
+        .let {
+            Displays.inventoryBackground(
+                mobsPerRow, it.size,
+                Displays.padding(2, it.asTable()),
+            )
+        }
+
+    private fun BestiaryMobEntry.getItem(): Display {
+        val kills = profile?.bestiaryData?.filter { mobs.contains(it.mobId) }?.sumOf { it.kills } ?: 0
+        val fullBracket = BestiaryCodecs.data?.brackets?.get(bracket) ?: emptyList()
+        val maxLevel = fullBracket.indexOf(cap) + 1
+        val bracket = fullBracket.take(maxLevel)
+        val requiredKills = bracket.lastOrNull() ?: 0
+        val currentLevel = bracket.indexOfLast { kills >= it } + 1
+
+        val item = if (kills == 0L) Items.GRAY_DYE.defaultInstance
+        else icon.getItem()
+
+        item.withTooltip {
+            add(name)
+
+            add("Level: ") {
+                color = TextColor.GRAY
+                append("$currentLevel") { color = TextColor.YELLOW }
+                append("/") { color = TextColor.GOLD }
+                append("$maxLevel") { color = TextColor.YELLOW }
+            }
+
+            add("Kills: ") {
+                color = TextColor.GRAY
+                append(kills.toFormattedString()) { color = TextColor.YELLOW }
+
+                val percentage = kills / requiredKills.toDouble() * 100
+                if (percentage >= 100) {
+                    append(" Maxed!") {
+                        color = TextColor.RED
+                    }
+                } else {
+                    append("/") { color = TextColor.GOLD }
+                    append(requiredKills.toFormattedString()) { color = TextColor.YELLOW }
+
+                    append(" (")
+                    append(percentage.round()) {
+                        color = TextColor.GREEN
+                        append("%")
+                    }
+                    append(")")
+                }
+            }
+
+            if (currentLevel != maxLevel) {
+                add("Next Level: ") {
+                    color = TextColor.GRAY
+                    append(kills.toFormattedString()) { color = TextColor.YELLOW }
+                    append("/") { color = TextColor.GOLD }
+                    append(bracket[currentLevel].toFormattedString()) { color = TextColor.YELLOW }
+
+                    append(" (")
+                    append((kills / bracket[currentLevel].toDouble() * 100).round()) {
+                        color = TextColor.GREEN
+                        append("%")
+                    }
+                    append(")")
+                }
+            }
+        }
+        return Displays.item(item, customStackText = currentLevel, showTooltip = true)
+    }
+
+    private fun BestiaryIcon.getItem(name: String = ""): ItemStack = Either.unwrap(
+        this.mapBoth(
+            { Utils.getMinecraftItem(it) },
+            // NEU Repo can be weird, sometimes including that weird split or wrong length of base64
+            { createSkull(it.second.split("\", \"").first().fixBase64Padding()) },
+        ),
+    ).apply {
+        set(DataComponents.CUSTOM_NAME, Text.join(name) { italic = false; color = TextColor.WHITE })
+    }
+}
+
diff --git a/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/ItemUtils.kt b/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/ItemUtils.kt
index a687f28b..f4c3f11f 100644
--- a/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/ItemUtils.kt
+++ b/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/ItemUtils.kt
@@ -44,10 +44,10 @@ fun ItemStack.withTooltip(init: TooltipBuilder.() -> Unit = {}): ItemStack {
     val builder = TooltipBuilder().apply(init).lines().filterIsInstance<Component>()
     when {
         builder.isEmpty() -> this.set(DataComponents.TOOLTIP_DISPLAY, TooltipDisplay(true, ObjectSortedSets.emptySet()))
-        builder.size == 1 -> this.set(DataComponents.ITEM_NAME, builder.first())
+        builder.size == 1 -> this.set(DataComponents.CUSTOM_NAME, builder.first())
         else -> {
             val first = builder.first()
-            this.set(DataComponents.ITEM_NAME, first)
+            this.set(DataComponents.CUSTOM_NAME, first)
             val lore = builder.drop(1)
             this.set(DataComponents.LORE, ItemLore(lore, lore))
         }
diff --git a/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/Utils.kt b/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/Utils.kt
index b52517b9..48583259 100644
--- a/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/Utils.kt
+++ b/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/Utils.kt
@@ -6,7 +6,9 @@ import earth.terrarium.olympus.client.pipelines.RoundedRectanage
 import kotlinx.coroutines.runBlocking
 import net.minecraft.Util
 import net.minecraft.client.gui.GuiGraphics
+import net.minecraft.core.registries.BuiltInRegistries
 import net.minecraft.network.chat.MutableComponent
+import net.minecraft.resources.ResourceLocation
 import net.minecraft.world.level.block.entity.SkullBlockEntity
 import tech.thatgravyboat.skyblockapi.utils.json.Json.readJson
 import tech.thatgravyboat.skyblockapi.utils.text.Text
@@ -27,6 +29,8 @@ import kotlin.time.DurationUnit
 
 object Utils {
 
+    fun getMinecraftItem(id: String) = BuiltInRegistries.ITEM.getValue(ResourceLocation.withDefaultNamespace(id)).defaultInstance
+
     var isFetchingGameProfile = false
         private set
 
@@ -176,6 +180,7 @@ object Utils {
         }
         return this
     }
+
     fun MutableComponent.append(text: String, init: MutableComponent.() -> Unit): MutableComponent = this.append(Text.of(text, init))
 
     private val dateTimeFormatter = DateTimeFormatter.ofPattern("uuuu/MM/dd HH:mm:ss")
@@ -186,4 +191,6 @@ object Utils {
     fun UUID.toDashlessString(): String = this.toString().replace("-", "")
 
     fun <T> Map<out Number, T>.sortByKey(): Map<Number, T> = this.entries.sortedBy { it.key.toLong() }.associate { it.toPair() }
+
+    fun String.fixBase64Padding() = replace(Regex("=+$"), "").let { it + "=".repeat((4 - it.length % 4) % 4) }
 }
diff --git a/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/codecs/DispatchedCodec.kt b/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/codecs/DispatchedCodec.kt
new file mode 100644
index 00000000..606e4e14
--- /dev/null
+++ b/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/codecs/DispatchedCodec.kt
@@ -0,0 +1,47 @@
+package tech.thatgravyboat.skyblockpv.utils.codecs
+
+import com.mojang.serialization.DataResult
+import com.mojang.serialization.DynamicOps
+import com.mojang.serialization.MapCodec
+import com.mojang.serialization.MapLike
+import com.mojang.serialization.RecordBuilder
+import java.util.stream.Stream
+
+class DispatchedCodec<K, V>(
+    private val keyCodec: MapCodec<K>,
+    private val keyGetter: (V) -> K,
+    private val valueCodec: (K) -> DataResult<MapCodec<V>>,
+): MapCodec<V>() {
+
+    override fun <T : Any> decode(
+        ops: DynamicOps<T>,
+        input: MapLike<T>,
+    ): DataResult<V> {
+        if (ops.compressMaps()) error("DispatchedCodec does not support compressed maps")
+
+        return keyCodec.decode(ops, input).flatMap { key ->
+            valueCodec.invoke(key).flatMap { codec ->
+                codec.decode(ops, input)
+            }
+        }
+    }
+
+    override fun <T : Any> encode(
+        input: V,
+        ops: DynamicOps<T>,
+        prefix: RecordBuilder<T>,
+    ): RecordBuilder<T> {
+        if (ops.compressMaps()) error("DispatchedCodec does not support compressed maps")
+
+        val key = keyGetter.invoke(input)
+        val result = valueCodec.invoke(key)
+
+        if (result.isError) {
+            return prefix.withErrorsFrom(result)
+        }
+
+        return result.orThrow.encode(input, ops, keyCodec.encode(key, ops, prefix))
+    }
+
+    override fun <T : Any> keys(ops: DynamicOps<T>): Stream<T> = keyCodec.keys(ops)
+}
diff --git a/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/components/CarouselWidget.kt b/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/components/CarouselWidget.kt
index 77537bdb..53b9b7d2 100644
--- a/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/components/CarouselWidget.kt
+++ b/src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/components/CarouselWidget.kt
@@ -2,17 +2,24 @@ package tech.thatgravyboat.skyblockpv.utils.components
 
 import com.teamresourceful.resourcefullib.client.screens.CursorScreen
 import earth.terrarium.olympus.client.components.base.BaseWidget
+import earth.terrarium.olympus.client.components.buttons.Button
+import earth.terrarium.olympus.client.components.renderers.WidgetRenderers
+import earth.terrarium.olympus.client.layouts.Layouts
+import earth.terrarium.olympus.client.layouts.LinearViewLayout
+import earth.terrarium.olympus.client.ui.UIConstants
 import net.minecraft.client.gui.GuiGraphics
+import net.minecraft.client.gui.layouts.Layout
 import tech.thatgravyboat.skyblockapi.helpers.McFont
-import tech.thatgravyboat.skyblockpv.utils.Utils.pushPop
+import tech.thatgravyboat.skyblockpv.utils.ExtraWidgetRenderers
 import tech.thatgravyboat.skyblockpv.utils.Utils.scissorRange
+import tech.thatgravyboat.skyblockpv.utils.Utils.translated
 import tech.thatgravyboat.skyblockpv.utils.displays.Display
 import tech.thatgravyboat.skyblockpv.utils.displays.Displays
 
 class CarouselWidget(
     private val displays: List<Display>,
     var index: Int = 0,
-    width: Int
+    width: Int,
 ) : BaseWidget() {
 
     private var cursor = CursorScreen.Cursor.DEFAULT
@@ -29,6 +36,10 @@ class CarouselWidget(
         val last = displays.getOrNull((index - 1 + displays.size) % displays.size)
         val next = displays.getOrNull((index + 1) % displays.size)
 
+        // Aligning the carousel to the top
+        // @Sophie if theres a better solution pls tell me :3
+        this.height = curr.getHeight()
+
         val left = x + (width - curr.getWidth()) / 2
         val right = left + curr.getWidth()
 
@@ -41,8 +52,7 @@ class CarouselWidget(
                 last?.render(graphics, x, midY)
             }
 
-            graphics.pushPop {
-                translate(0f, 0f, 300f)
+            graphics.translated(0f, 0f, 300f) {
                 graphics.fill(x, midY, left, bottom, 0x7F000000)
 
                 if (graphics.containsPointInScissor(mouseX, mouseY)) {
@@ -58,8 +68,7 @@ class CarouselWidget(
                 next?.render(graphics, x + width, midY, alignmentX = 1f)
             }
 
-            graphics.pushPop {
-                translate(0f, 0f, 300f)
+            graphics.translated(0f, 0f, 300f) {
                 graphics.fill(right, midY, x + width, bottom, 0x7F000000)
 
                 if (graphics.containsPointInScissor(mouseX, mouseY)) {
@@ -70,8 +79,7 @@ class CarouselWidget(
                 }
             }
         }
-        graphics.pushPop {
-            translate(0f, 0f, 150f)
+        graphics.translated(0f, 0f, 150f) {
             curr.render(graphics, x + width / 2, y + height, alignmentX = 0.5f, alignmentY = 1f)
         }
     }
@@ -89,4 +97,27 @@ class CarouselWidget(
     }
 
     override fun getCursor(): CursorScreen.Cursor = cursor
+
+    fun getIcons(displays: () -> List<Display>): Layout {
+        val buttons = displays.invoke().mapIndexed { index, it ->
+            Button()
+                .withSize(20, 20)
+                .withRenderer(
+                    WidgetRenderers.layered(
+                        ExtraWidgetRenderers.conditional(
+                            WidgetRenderers.sprite(UIConstants.PRIMARY_BUTTON),
+                            WidgetRenderers.sprite(UIConstants.DARK_BUTTON),
+                        ) { this.index == index },
+                        WidgetRenderers.center(16, 20, ExtraWidgetRenderers.display(it)),
+                    ),
+                )
+                .withCallback {
+                    this.index = index
+                }
+        }
+
+        return buttons.chunked(9)
+            .map { it.fold(Layouts.row().withGap(1), LinearViewLayout::withChild) }
+            .fold(Layouts.column().withGap(1), LinearViewLayout::withChild)
+    }
 }


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.


<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/ItemUtils.kt:18**
* Ensure that the double decoding and encoding of the textureBase64 value is intentional, as this transformation may alter valid texture strings unexpectedly. Adding a brief comment explaining the rationale could improve clarity.
```
val texture = Base64.getEncoder().encodeToString(Base64.getMimeDecoder().decode(textureBase64))
```
**src/main/kotlin/tech/thatgravyboat/skyblockpv/screens/tabs/combat/BestiaryScreen.kt:100**
* Ensure the splitting logic on the texture string reliably handles all valid formats; consider adding validation or error handling to avoid potential index errors when the expected delimiter is missing.
```
createSkull(it.second.split(", ").first().fixBase64Padding())
```
**src/main/kotlin/tech/thatgravyboat/skyblockpv/data/repo/BestiaryCodecs.kt:101**
* Consider providing a default error message or additional context if 'it.error().get().message()' is null to improve the debugging experience when data parsing fails.
```
if (it.isError) { throw RuntimeException(it.error().get().message()) }
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-04-07T19:39:12Z
user: login: meowora
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T20:30:17Z
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.




state: COMMENTED
author_association: NONE
submitted_at: 2025-04-12T10:45:23Z
user: login: ThatGravyBoat
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-12T22:26:17Z
user: login: meowora
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-13T11:21:10Z

Review comments:
diff_hunk: @@ -0,0 +1,103 @@
+package tech.thatgravyboat.skyblockpv.screens.tabs.combat
+
+import com.mojang.authlib.GameProfile
+import com.mojang.datafixers.util.Either
+import net.minecraft.core.component.DataComponents
+import net.minecraft.world.item.ItemStack
+import net.minecraft.world.item.component.ItemLore
+import tech.thatgravyboat.skyblockapi.utils.text.Text
+import tech.thatgravyboat.skyblockapi.utils.text.TextColor
+import tech.thatgravyboat.skyblockapi.utils.text.TextStyle.color
+import tech.thatgravyboat.skyblockapi.utils.text.TextStyle.italic
+import tech.thatgravyboat.skyblockapi.utils.text.TextUtils.split
+import tech.thatgravyboat.skyblockpv.api.data.SkyBlockProfile
+import tech.thatgravyboat.skyblockpv.data.repo.*
+import tech.thatgravyboat.skyblockpv.utils.LayoutBuild
+import tech.thatgravyboat.skyblockpv.utils.LayoutUtils.centerHorizontally
+import tech.thatgravyboat.skyblockpv.utils.Utils
+import tech.thatgravyboat.skyblockpv.utils.Utils.fixBase64Padding
+import tech.thatgravyboat.skyblockpv.utils.Utils.rightPad
+import tech.thatgravyboat.skyblockpv.utils.components.CarouselWidget
+import tech.thatgravyboat.skyblockpv.utils.createSkull
+import tech.thatgravyboat.skyblockpv.utils.displays.*
+
+class BestiaryScreen(gameProfile: GameProfile, profile: SkyBlockProfile? = null) : BaseCombatScreen(gameProfile, profile) {
+    private var carousel: CarouselWidget? = null
+
+    override fun getLayout(bg: DisplayWidget) = LayoutBuild.vertical {
+        val categories = getCategories()
+        val inventories = categories.values.toList()
+        val icons = categories.keys.toList()
+
+        carousel = CarouselWidget(
+            inventories,
+            carousel?.index ?: 0,
+            246,
+        )
+
+        val buttonContainer = carousel!!.getIcons {
+            icons.mapIndexed { index, it ->
+                Displays.item(it, showTooltip = true)
+            }
path: src/main/kotlin/tech/thatgravyboat/skyblockpv/screens/tabs/combat/BestiaryScreen.kt
user: login: meowora
body: you dont use the index :3
created_at: 2025-04-08T20:30:14Z
author_association: MEMBER
diff_hunk: @@ -29,6 +36,10 @@ class CarouselWidget(
         val last = displays.getOrNull((index - 1 + displays.size) % displays.size)
         val next = displays.getOrNull((index + 1) % displays.size)
 
+        // Aligning the carousel to the top
+        // @Sophie if theres a better solution pls tell me :3
path: src/main/kotlin/tech/thatgravyboat/skyblockpv/utils/components/CarouselWidget.kt
user: login: Copilot
body: [nitpick] Consider revising this comment for grammar and formality (e.g., change 'if theres' to 'if there's' and remove the emoticon).
```suggestion
        // If there's a better solution, please let me know.
```
created_at: 2025-04-12T10:45:22Z
author_association: NONE
diff_hunk: @@ -0,0 +1,152 @@
+package tech.thatgravyboat.skyblockpv.screens.tabs.combat
+
+import com.mojang.authlib.GameProfile
+import com.mojang.datafixers.util.Either
+import net.minecraft.core.component.DataComponents
+import net.minecraft.world.item.ItemStack
+import net.minecraft.world.item.Items
+import tech.thatgravyboat.skyblockapi.utils.extentions.toFormattedString
+import tech.thatgravyboat.skyblockapi.utils.text.Text
+import tech.thatgravyboat.skyblockapi.utils.text.TextColor
+import tech.thatgravyboat.skyblockapi.utils.text.TextStyle.color
+import tech.thatgravyboat.skyblockapi.utils.text.TextStyle.italic
+import tech.thatgravyboat.skyblockpv.api.data.SkyBlockProfile
+import tech.thatgravyboat.skyblockpv.data.repo.*
+import tech.thatgravyboat.skyblockpv.utils.LayoutBuild
+import tech.thatgravyboat.skyblockpv.utils.LayoutUtils.asScrollable
+import tech.thatgravyboat.skyblockpv.utils.LayoutUtils.centerHorizontally
+import tech.thatgravyboat.skyblockpv.utils.Utils
+import tech.thatgravyboat.skyblockpv.utils.Utils.append
+import tech.thatgravyboat.skyblockpv.utils.Utils.fixBase64Padding
+import tech.thatgravyboat.skyblockpv.utils.Utils.rightPad
+import tech.thatgravyboat.skyblockpv.utils.Utils.round
+import tech.thatgravyboat.skyblockpv.utils.components.CarouselWidget
+import tech.thatgravyboat.skyblockpv.utils.createSkull
+import tech.thatgravyboat.skyblockpv.utils.displays.Display
+import tech.thatgravyboat.skyblockpv.utils.displays.DisplayWidget
+import tech.thatgravyboat.skyblockpv.utils.displays.Displays
+import tech.thatgravyboat.skyblockpv.utils.displays.asTable
+import tech.thatgravyboat.skyblockpv.utils.withTooltip
+
+class BestiaryScreen(gameProfile: GameProfile, profile: SkyBlockProfile? = null) : BaseCombatScreen(gameProfile, profile) {
+    private var carousel: CarouselWidget? = null
+    private val MOBS_PER_ROW_SIMPLE = 5
+    private val MOBS_PER_ROW_COMPLEX = 8
+
+    override fun getLayout(bg: DisplayWidget) = LayoutBuild.vertical {
+        val categories = getCategories()
+        val inventories = categories.values.toList()
+        val icons = categories.keys.toList()
+
+        carousel = CarouselWidget(
path: src/main/kotlin/tech/thatgravyboat/skyblockpv/screens/tabs/combat/BestiaryScreen.kt
user: login: ThatGravyBoat
body: this can be local
created_at: 2025-04-12T22:25:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,152 @@
+package tech.thatgravyboat.skyblockpv.screens.tabs.combat
+
+import com.mojang.authlib.GameProfile
+import com.mojang.datafixers.util.Either
+import net.minecraft.core.component.DataComponents
+import net.minecraft.world.item.ItemStack
+import net.minecraft.world.item.Items
+import tech.thatgravyboat.skyblockapi.utils.extentions.toFormattedString
+import tech.thatgravyboat.skyblockapi.utils.text.Text
+import tech.thatgravyboat.skyblockapi.utils.text.TextColor
+import tech.thatgravyboat.skyblockapi.utils.text.TextStyle.color
+import tech.thatgravyboat.skyblockapi.utils.text.TextStyle.italic
+import tech.thatgravyboat.skyblockpv.api.data.SkyBlockProfile
+import tech.thatgravyboat.skyblockpv.data.repo.*
+import tech.thatgravyboat.skyblockpv.utils.LayoutBuild
+import tech.thatgravyboat.skyblockpv.utils.LayoutUtils.asScrollable
+import tech.thatgravyboat.skyblockpv.utils.LayoutUtils.centerHorizontally
+import tech.thatgravyboat.skyblockpv.utils.Utils
+import tech.thatgravyboat.skyblockpv.utils.Utils.append
+import tech.thatgravyboat.skyblockpv.utils.Utils.fixBase64Padding
+import tech.thatgravyboat.skyblockpv.utils.Utils.rightPad
+import tech.thatgravyboat.skyblockpv.utils.Utils.round
+import tech.thatgravyboat.skyblockpv.utils.components.CarouselWidget
+import tech.thatgravyboat.skyblockpv.utils.createSkull
+import tech.thatgravyboat.skyblockpv.utils.displays.Display
+import tech.thatgravyboat.skyblockpv.utils.displays.DisplayWidget
+import tech.thatgravyboat.skyblockpv.utils.displays.Displays
+import tech.thatgravyboat.skyblockpv.utils.displays.asTable
+import tech.thatgravyboat.skyblockpv.utils.withTooltip
+
+class BestiaryScreen(gameProfile: GameProfile, profile: SkyBlockProfile? = null) : BaseCombatScreen(gameProfile, profile) {
+    private var carousel: CarouselWidget? = null
+    private val MOBS_PER_ROW_SIMPLE = 5
+    private val MOBS_PER_ROW_COMPLEX = 8
+
+    override fun getLayout(bg: DisplayWidget) = LayoutBuild.vertical {
+        val categories = getCategories()
+        val inventories = categories.values.toList()
+        val icons = categories.keys.toList()
+
+        carousel = CarouselWidget(
+            inventories,
+            carousel?.index ?: 0,
+            246,
+        )
+
+        val buttonContainer = carousel!!.getIcons { icons.map { Displays.item(it, showTooltip = true) } }
+
+        widget(
+            LayoutBuild.vertical(5) {
+                widget(buttonContainer.centerHorizontally(uiWidth))
+                widget(carousel!!.centerHorizontally(uiWidth))
+            }.asScrollable(uiWidth, uiHeight),
+        )
+    }
+
+    private fun getCategories(): Map<ItemStack, Display> = BestiaryCodecs.data?.categories?.map { (_, v) ->
+        Either.unwrap(
+            v.mapBoth(
+                { it.icon.getItem(it.name) to it.getCategory() }, // Simple
+                { it.icon.getItem(it.name) to it.getCategory() }, // Complex
+            ),
+        )
+    }?.toMap() ?: emptyMap()
+
+    private fun BestiaryCategoryEntry.getCategory() = mobs.map { it.getItem() }.format(MOBS_PER_ROW_SIMPLE)
+
+    private fun ComplexBestiaryCategoryEntry.getCategory() = subcategories.flatMap { it.value.mobs.map { it.getItem() } }.format(MOBS_PER_ROW_COMPLEX)
+
+    private fun List<Display>.format(mobsPerRow: Int) = toMutableList()
+        .rightPad(mobsPerRow * 2, Displays.empty(16, 16))
+        .map { Displays.padding(2, it) }
+        .chunked(mobsPerRow)
+        .let {
+            Displays.inventoryBackground(
+                mobsPerRow, it.size,
+                Displays.padding(2, it.asTable()),
+            )
+        }
+
+    private fun BestiaryMobEntry.getItem(): Display {
+        val kills = profile?.bestiaryData?.filter { mobs.contains(it.mobId) }?.sumOf { it.kills } ?: 0
+        val fullBracket = BestiaryCodecs.data?.brackets?.get(bracket) ?: emptyList()
+        val maxLevel = fullBracket.indexOf(cap) + 1
+        val bracket = fullBracket.take(maxLevel)
+        val requiredKills = bracket.lastOrNull() ?: 0
+        val currentLevel = bracket.indexOfLast { kills >= it } + 1
+
+        val item = if (kills == 0L) Items.GRAY_DYE.defaultInstance
+        else icon.getItem()
+
+        item.withTooltip {
+            add(name)
+
+            add("Kills: ") {
+                color = TextColor.GRAY
+                append(kills.toFormattedString()) { color = TextColor.YELLOW }
+                append("/") { color = TextColor.GOLD }
+                append(requiredKills.toFormattedString()) { color = TextColor.YELLOW }
+                val percentage = kills / requiredKills.toDouble() * 100
+
+                if (percentage >= 100) {
+                    append(" Maxed!") {
+                        color = TextColor.RED
+                    }
path: src/main/kotlin/tech/thatgravyboat/skyblockpv/screens/tabs/combat/BestiaryScreen.kt
user: login: meowora
body: To stay consistent with the other screens it may be better to hide the `/<amount>` after the family is maxed
created_at: 2025-04-13T11:15:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,152 @@
+package tech.thatgravyboat.skyblockpv.screens.tabs.combat
+
+import com.mojang.authlib.GameProfile
+import com.mojang.datafixers.util.Either
+import net.minecraft.core.component.DataComponents
+import net.minecraft.world.item.ItemStack
+import net.minecraft.world.item.Items
+import tech.thatgravyboat.skyblockapi.utils.extentions.toFormattedString
+import tech.thatgravyboat.skyblockapi.utils.text.Text
+import tech.thatgravyboat.skyblockapi.utils.text.TextColor
+import tech.thatgravyboat.skyblockapi.utils.text.TextStyle.color
+import tech.thatgravyboat.skyblockapi.utils.text.TextStyle.italic
+import tech.thatgravyboat.skyblockpv.api.data.SkyBlockProfile
+import tech.thatgravyboat.skyblockpv.data.repo.*
+import tech.thatgravyboat.skyblockpv.utils.LayoutBuild
+import tech.thatgravyboat.skyblockpv.utils.LayoutUtils.asScrollable
+import tech.thatgravyboat.skyblockpv.utils.LayoutUtils.centerHorizontally
+import tech.thatgravyboat.skyblockpv.utils.Utils
+import tech.thatgravyboat.skyblockpv.utils.Utils.append
+import tech.thatgravyboat.skyblockpv.utils.Utils.fixBase64Padding
+import tech.thatgravyboat.skyblockpv.utils.Utils.rightPad
+import tech.thatgravyboat.skyblockpv.utils.Utils.round
+import tech.thatgravyboat.skyblockpv.utils.components.CarouselWidget
+import tech.thatgravyboat.skyblockpv.utils.createSkull
+import tech.thatgravyboat.skyblockpv.utils.displays.Display
+import tech.thatgravyboat.skyblockpv.utils.displays.DisplayWidget
+import tech.thatgravyboat.skyblockpv.utils.displays.Displays
+import tech.thatgravyboat.skyblockpv.utils.displays.asTable
+import tech.thatgravyboat.skyblockpv.utils.withTooltip
+
+class BestiaryScreen(gameProfile: GameProfile, profile: SkyBlockProfile? = null) : BaseCombatScreen(gameProfile, profile) {
+    private var carousel: CarouselWidget? = null
+    private val MOBS_PER_ROW_SIMPLE = 5
+    private val MOBS_PER_ROW_COMPLEX = 8
+
+    override fun getLayout(bg: DisplayWidget) = LayoutBuild.vertical {
+        val categories = getCategories()
+        val inventories = categories.values.toList()
+        val icons = categories.keys.toList()
+
+        carousel = CarouselWidget(
+            inventories,
+            carousel?.index ?: 0,
+            246,
+        )
+
+        val buttonContainer = carousel!!.getIcons { icons.map { Displays.item(it, showTooltip = true) } }
+
+        widget(
+            LayoutBuild.vertical(5) {
+                widget(buttonContainer.centerHorizontally(uiWidth))
+                widget(carousel!!.centerHorizontally(uiWidth))
+            }.asScrollable(uiWidth, uiHeight),
+        )
+    }
+
+    private fun getCategories(): Map<ItemStack, Display> = BestiaryCodecs.data?.categories?.map { (_, v) ->
+        Either.unwrap(
+            v.mapBoth(
+                { it.icon.getItem(it.name) to it.getCategory() }, // Simple
+                { it.icon.getItem(it.name) to it.getCategory() }, // Complex
+            ),
+        )
+    }?.toMap() ?: emptyMap()
+
+    private fun BestiaryCategoryEntry.getCategory() = mobs.map { it.getItem() }.format(MOBS_PER_ROW_SIMPLE)
+
+    private fun ComplexBestiaryCategoryEntry.getCategory() = subcategories.flatMap { it.value.mobs.map { it.getItem() } }.format(MOBS_PER_ROW_COMPLEX)
+
+    private fun List<Display>.format(mobsPerRow: Int) = toMutableList()
+        .rightPad(mobsPerRow * 2, Displays.empty(16, 16))
+        .map { Displays.padding(2, it) }
+        .chunked(mobsPerRow)
+        .let {
+            Displays.inventoryBackground(
+                mobsPerRow, it.size,
+                Displays.padding(2, it.asTable()),
+            )
+        }
+
+    private fun BestiaryMobEntry.getItem(): Display {
+        val kills = profile?.bestiaryData?.filter { mobs.contains(it.mobId) }?.sumOf { it.kills } ?: 0
+        val fullBracket = BestiaryCodecs.data?.brackets?.get(bracket) ?: emptyList()
+        val maxLevel = fullBracket.indexOf(cap) + 1
+        val bracket = fullBracket.take(maxLevel)
+        val requiredKills = bracket.lastOrNull() ?: 0
+        val currentLevel = bracket.indexOfLast { kills >= it } + 1
+
+        val item = if (kills == 0L) Items.GRAY_DYE.defaultInstance
+        else icon.getItem()
+
+        item.withTooltip {
+            add(name)
+
+            add("Kills: ") {
+                color = TextColor.GRAY
+                append(kills.toFormattedString()) { color = TextColor.YELLOW }
+                append("/") { color = TextColor.GOLD }
+                append(requiredKills.toFormattedString()) { color = TextColor.YELLOW }
+                val percentage = kills / requiredKills.toDouble() * 100
+
+                if (percentage >= 100) {
+                    append(" Maxed!") {
+                        color = TextColor.RED
+                    }
+                } else {
+                    append(" (")
+                    append(percentage.round()) {
+                        color = TextColor.GREEN
+                        append("%")
+                    }
+                    append(")")
+                }
+            }
+
+            add("Level: ") {
+                color = TextColor.GRAY
+                append("$currentLevel") { color = TextColor.YELLOW }
path: src/main/kotlin/tech/thatgravyboat/skyblockpv/screens/tabs/combat/BestiaryScreen.kt
user: login: meowora
body: Maybe change the color to red while its not done, kinda like we do it with the essence perks
created_at: 2025-04-13T11:19:41Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-10T19:11:23Z
body: This pull request has conflicts, please resolve those before the pull request can be reviewed.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-11T08:58:53Z
body: This pull request has conflicts, please resolve those before the pull request can be reviewed.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-11T16:04:55Z
body: This pull request has conflicts, please resolve those before the pull request can be reviewed.
author_association: NONE