feat(internal): Add a tab list api

This commit is contained in:
Linnea Gräf
2025-06-26 18:21:02 +02:00
parent e926550bd1
commit 1c5d0df368
13 changed files with 1426 additions and 18 deletions

View File

@@ -0,0 +1,31 @@
package moe.nea.firmament.mixins.accessor;
import net.minecraft.client.gui.hud.PlayerListHud;
import net.minecraft.client.network.PlayerListEntry;
import net.minecraft.text.Text;
import org.jetbrains.annotations.Nullable;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
import org.spongepowered.asm.mixin.gen.Invoker;
import java.util.Comparator;
import java.util.List;
@Mixin(PlayerListHud.class)
public interface AccessorPlayerListHud {
@Accessor("ENTRY_ORDERING")
static Comparator<PlayerListEntry> getEntryOrdering() {
throw new AssertionError();
}
@Invoker("collectPlayerEntries")
List<PlayerListEntry> collectPlayerEntries_firmament();
@Accessor("footer")
@Nullable Text getFooter_firmament();
@Accessor("header")
@Nullable Text getHeader_firmament();
}

View File

@@ -12,6 +12,7 @@ import moe.nea.firmament.apis.UrsaManager
import moe.nea.firmament.events.CommandEvent import moe.nea.firmament.events.CommandEvent
import moe.nea.firmament.events.FirmamentEventBus import moe.nea.firmament.events.FirmamentEventBus
import moe.nea.firmament.features.debug.DebugLogger import moe.nea.firmament.features.debug.DebugLogger
import moe.nea.firmament.features.debug.DeveloperFeatures
import moe.nea.firmament.features.debug.PowerUserTools import moe.nea.firmament.features.debug.PowerUserTools
import moe.nea.firmament.features.inventory.buttons.InventoryButtons import moe.nea.firmament.features.inventory.buttons.InventoryButtons
import moe.nea.firmament.features.inventory.storageoverlay.StorageOverlayScreen import moe.nea.firmament.features.inventory.storageoverlay.StorageOverlayScreen
@@ -202,7 +203,7 @@ fun firmamentCommand() = literal("firmament") {
} }
} }
} }
thenLiteral("dev") { thenLiteral(DeveloperFeatures.DEVELOPER_SUBCOMMAND) {
thenLiteral("simulate") { thenLiteral("simulate") {
thenArgument("message", RestArgumentType) { message -> thenArgument("message", RestArgumentType) { message ->
thenExecute { thenExecute {

View File

@@ -62,7 +62,7 @@ object AnimatedClothingScanner {
@Subscribe @Subscribe
fun onSubCommand(event: CommandEvent.SubCommand) { fun onSubCommand(event: CommandEvent.SubCommand) {
event.subcommand("dev") { event.subcommand(DeveloperFeatures.DEVELOPER_SUBCOMMAND) {
thenLiteral("stealthisfit") { thenLiteral("stealthisfit") {
thenLiteral("clear") { thenLiteral("clear") {
thenExecute { thenExecute {

View File

@@ -25,6 +25,7 @@ import moe.nea.firmament.util.asm.AsmAnnotationUtil
import moe.nea.firmament.util.iterate import moe.nea.firmament.util.iterate
object DeveloperFeatures : FirmamentFeature { object DeveloperFeatures : FirmamentFeature {
val DEVELOPER_SUBCOMMAND: String = "dev"
override val identifier: String override val identifier: String
get() = "developer" get() = "developer"
override val config: TConfig override val config: TConfig
@@ -103,9 +104,12 @@ object DeveloperFeatures : FirmamentFeature {
MC.sendChat(Text.translatable("firmament.dev.resourcerebuild.start")) MC.sendChat(Text.translatable("firmament.dev.resourcerebuild.start"))
val startTime = TimeMark.now() val startTime = TimeMark.now()
process.toHandle().onExit().thenApply { process.toHandle().onExit().thenApply {
MC.sendChat(Text.stringifiedTranslatable( MC.sendChat(
"firmament.dev.resourcerebuild.done", Text.stringifiedTranslatable(
startTime.passedTime())) "firmament.dev.resourcerebuild.done",
startTime.passedTime()
)
)
Unit Unit
} }
} else { } else {

View File

@@ -20,7 +20,7 @@ object SoundVisualizer {
@Subscribe @Subscribe
fun onSubCommand(event: CommandEvent.SubCommand) { fun onSubCommand(event: CommandEvent.SubCommand) {
event.subcommand("dev") { event.subcommand(DeveloperFeatures.DEVELOPER_SUBCOMMAND) {
thenLiteral("sounds") { thenLiteral("sounds") {
thenExecute { thenExecute {
showSounds = !showSounds showSounds = !showSounds

View File

@@ -26,6 +26,7 @@ import moe.nea.firmament.commands.thenExecute
import moe.nea.firmament.commands.thenLiteral import moe.nea.firmament.commands.thenLiteral
import moe.nea.firmament.events.CommandEvent import moe.nea.firmament.events.CommandEvent
import moe.nea.firmament.events.HandledScreenKeyPressedEvent import moe.nea.firmament.events.HandledScreenKeyPressedEvent
import moe.nea.firmament.features.debug.DeveloperFeatures
import moe.nea.firmament.features.debug.ExportedTestConstantMeta import moe.nea.firmament.features.debug.ExportedTestConstantMeta
import moe.nea.firmament.features.debug.PowerUserTools import moe.nea.firmament.features.debug.PowerUserTools
import moe.nea.firmament.repo.RepoDownloadManager import moe.nea.firmament.repo.RepoDownloadManager
@@ -97,7 +98,7 @@ object ItemExporter {
@Subscribe @Subscribe
fun onCommand(event: CommandEvent.SubCommand) { fun onCommand(event: CommandEvent.SubCommand) {
event.subcommand("dev") { event.subcommand(DeveloperFeatures.DEVELOPER_SUBCOMMAND) {
thenLiteral("reexportlore") { thenLiteral("reexportlore") {
thenArgument("itemid", StringArgumentType.string()) { itemid -> thenArgument("itemid", StringArgumentType.string()) { itemid ->
suggestsList { RepoManager.neuRepo.items.items.keys } suggestsList { RepoManager.neuRepo.items.items.keys }

View File

@@ -9,6 +9,8 @@ object StringUtil {
return string.replace(",", "").toInt() return string.replace(",", "").toInt()
} }
fun String.title() = replaceFirstChar { it.titlecase() }
fun Iterable<String>.unwords() = joinToString(" ") fun Iterable<String>.unwords() = joinToString(" ")
fun nextLexicographicStringOfSameLength(string: String): String { fun nextLexicographicStringOfSameLength(string: String): String {
val next = StringBuilder(string) val next = StringBuilder(string)

View File

@@ -0,0 +1,96 @@
package moe.nea.firmament.util.mc
import com.mojang.serialization.Codec
import com.mojang.serialization.codecs.RecordCodecBuilder
import java.util.Optional
import org.jetbrains.annotations.TestOnly
import net.minecraft.client.gui.hud.PlayerListHud
import net.minecraft.nbt.NbtOps
import net.minecraft.scoreboard.Team
import net.minecraft.text.Text
import net.minecraft.text.TextCodecs
import moe.nea.firmament.annotations.Subscribe
import moe.nea.firmament.commands.thenExecute
import moe.nea.firmament.commands.thenLiteral
import moe.nea.firmament.events.CommandEvent
import moe.nea.firmament.events.TickEvent
import moe.nea.firmament.features.debug.DeveloperFeatures
import moe.nea.firmament.features.debug.ExportedTestConstantMeta
import moe.nea.firmament.mixins.accessor.AccessorPlayerListHud
import moe.nea.firmament.util.ClipboardUtils
import moe.nea.firmament.util.MC
import moe.nea.firmament.util.intoOptional
import moe.nea.firmament.util.mc.SNbtFormatter.Companion.toPrettyString
object MCTabListAPI {
fun PlayerListHud.cast() = this as AccessorPlayerListHud
@Subscribe
fun onTick(event: TickEvent) {
_currentTabList = null
}
@Subscribe
fun devCommand(event: CommandEvent.SubCommand) {
event.subcommand(DeveloperFeatures.DEVELOPER_SUBCOMMAND) {
thenLiteral("copytablist") {
thenExecute {
currentTabList.body.forEach {
MC.sendChat(Text.literal(TextCodecs.CODEC.encodeStart(NbtOps.INSTANCE, it).orThrow.toString()))
}
var compound = CurrentTabList.CODEC.encodeStart(NbtOps.INSTANCE, currentTabList).orThrow
compound = ExportedTestConstantMeta.SOURCE_CODEC.encode(
ExportedTestConstantMeta.current,
NbtOps.INSTANCE,
compound
).orThrow
ClipboardUtils.setTextContent(
compound.toPrettyString()
)
}
}
}
}
@get:TestOnly
@set:TestOnly
var _currentTabList: CurrentTabList? = null
val currentTabList get() = _currentTabList ?: getTabListNow().also { _currentTabList = it }
data class CurrentTabList(
val header: Optional<Text>,
val footer: Optional<Text>,
val body: List<Text>,
) {
companion object {
val CODEC: Codec<CurrentTabList> = RecordCodecBuilder.create {
it.group(
TextCodecs.CODEC.optionalFieldOf("header").forGetter(CurrentTabList::header),
TextCodecs.CODEC.optionalFieldOf("footer").forGetter(CurrentTabList::footer),
TextCodecs.CODEC.listOf().fieldOf("body").forGetter(CurrentTabList::body),
).apply(it, ::CurrentTabList)
}
}
}
private fun getTabListNow(): CurrentTabList {
// This is a precondition for PlayerListHud.collectEntries to be valid
MC.networkHandler ?: return CurrentTabList(Optional.empty(), Optional.empty(), emptyList())
val hud = MC.inGameHud.playerListHud.cast()
val entries = hud.collectPlayerEntries_firmament()
.map {
it.displayName ?: run {
val team = it.scoreboardTeam
val name = it.profile.name
Team.decorateName(team, Text.literal(name))
}
}
return CurrentTabList(
header = hud.header_firmament.intoOptional(),
footer = hud.footer_firmament.intoOptional(),
body = entries,
)
}
}

View File

@@ -0,0 +1,41 @@
package moe.nea.firmament.util.skyblock
import org.intellij.lang.annotations.Language
import net.minecraft.text.Text
import moe.nea.firmament.util.StringUtil.title
import moe.nea.firmament.util.StringUtil.unwords
import moe.nea.firmament.util.mc.MCTabListAPI
import moe.nea.firmament.util.unformattedString
object TabListAPI {
fun getWidgetLines(widgetName: WidgetName, includeTitle: Boolean = false, from: MCTabListAPI.CurrentTabList = MCTabListAPI.currentTabList): List<Text> {
return from.body
.dropWhile { !widgetName.matchesTitle(it) }
.takeWhile { it.string.isNotBlank() && !it.string.startsWith(" ") }
.let { if (includeTitle) it else it.drop(1) }
}
enum class WidgetName(regex: Regex?) {
COMMISSIONS,
SKILLS("Skills:( .*)?"),
PROFILE("Profile: (.*)"),
COLLECTION,
ESSENCE,
PET
;
fun matchesTitle(it: Text): Boolean {
return regex.matches(it.unformattedString)
}
constructor() : this(null)
constructor(@Language("RegExp") regex: String) : this(Regex(regex))
val label =
name.split("_").map { it.lowercase().title() }.unwords()
val regex = regex ?: Regex.fromLiteral("$label:")
}
}

View File

@@ -2,7 +2,9 @@ accessWidener v2 named
accessible class net/minecraft/client/render/RenderLayer$MultiPhase accessible class net/minecraft/client/render/RenderLayer$MultiPhase
accessible class net/minecraft/client/render/RenderLayer$MultiPhaseParameters accessible class net/minecraft/client/render/RenderLayer$MultiPhaseParameters
accessible class net/minecraft/client/font/TextRenderer$Drawer accessible class net/minecraft/client/font/TextRenderer$Drawer
accessible field net/minecraft/client/gui/hud/InGameHud SCOREBOARD_ENTRY_COMPARATOR Ljava/util/Comparator; accessible field net/minecraft/client/gui/hud/InGameHud SCOREBOARD_ENTRY_COMPARATOR Ljava/util/Comparator;
accessible field net/minecraft/client/network/ClientPlayNetworkHandler combinedDynamicRegistries Lnet/minecraft/registry/DynamicRegistryManager$Immutable; accessible field net/minecraft/client/network/ClientPlayNetworkHandler combinedDynamicRegistries Lnet/minecraft/registry/DynamicRegistryManager$Immutable;
accessible method net/minecraft/registry/RegistryOps <init> (Lcom/mojang/serialization/DynamicOps;Lnet/minecraft/registry/RegistryOps$RegistryInfoGetter;)V accessible method net/minecraft/registry/RegistryOps <init> (Lcom/mojang/serialization/DynamicOps;Lnet/minecraft/registry/RegistryOps$RegistryInfoGetter;)V
accessible class net/minecraft/registry/RegistryOps$CachedRegistryInfoGetter accessible class net/minecraft/registry/RegistryOps$CachedRegistryInfoGetter

View File

@@ -1,8 +1,6 @@
package moe.nea.firmament.test.testutil package moe.nea.firmament.test.testutil
import com.mojang.datafixers.DSL import com.mojang.datafixers.DSL
import com.mojang.datafixers.DataFixUtils
import com.mojang.datafixers.types.templates.Named
import com.mojang.serialization.Dynamic import com.mojang.serialization.Dynamic
import com.mojang.serialization.JsonOps import com.mojang.serialization.JsonOps
import net.minecraft.SharedConstants import net.minecraft.SharedConstants
@@ -20,6 +18,7 @@ import net.minecraft.text.TextCodecs
import moe.nea.firmament.features.debug.ExportedTestConstantMeta import moe.nea.firmament.features.debug.ExportedTestConstantMeta
import moe.nea.firmament.test.FirmTestBootstrap import moe.nea.firmament.test.FirmTestBootstrap
import moe.nea.firmament.util.MC import moe.nea.firmament.util.MC
import moe.nea.firmament.util.mc.MCTabListAPI
object ItemResources { object ItemResources {
init { init {
@@ -36,11 +35,12 @@ object ItemResources {
fun loadSNbt(path: String): NbtCompound { fun loadSNbt(path: String): NbtCompound {
return StringNbtReader.readCompound(loadString(path)) return StringNbtReader.readCompound(loadString(path))
} }
fun getNbtOps(): RegistryOps<NbtElement> = MC.currentOrDefaultRegistries.getOps(NbtOps.INSTANCE) fun getNbtOps(): RegistryOps<NbtElement> = MC.currentOrDefaultRegistries.getOps(NbtOps.INSTANCE)
fun tryMigrateNbt( fun tryMigrateNbt(
nbtCompound: NbtCompound, nbtCompound: NbtCompound,
typ: DSL.TypeReference, typ: DSL.TypeReference?,
): NbtElement { ): NbtElement {
val source = nbtCompound.get("source", ExportedTestConstantMeta.CODEC) val source = nbtCompound.get("source", ExportedTestConstantMeta.CODEC)
nbtCompound.remove("source") nbtCompound.remove("source")
@@ -49,21 +49,33 @@ object ItemResources {
// Per 1.21.5 text components are wrapped in a string, which firmament unwrapped in the snbt files // Per 1.21.5 text components are wrapped in a string, which firmament unwrapped in the snbt files
NbtString.of( NbtString.of(
NbtOps.INSTANCE.convertTo(JsonOps.INSTANCE, nbtCompound) NbtOps.INSTANCE.convertTo(JsonOps.INSTANCE, nbtCompound)
.toString()) .toString()
)
} else { } else {
nbtCompound nbtCompound
} }
return Schemas.getFixer() if (typ != null) {
.update( return Schemas.getFixer()
typ, .update(
Dynamic(NbtOps.INSTANCE, wrappedNbtSource), typ,
source.get().dataVersion, Dynamic(NbtOps.INSTANCE, wrappedNbtSource),
SharedConstants.getGameVersion().saveVersion.id source.get().dataVersion,
).value SharedConstants.getGameVersion().saveVersion.id
).value
} else {
wrappedNbtSource
}
} }
return nbtCompound return nbtCompound
} }
fun loadTablist(name: String): MCTabListAPI.CurrentTabList {
return MCTabListAPI.CurrentTabList.CODEC.parse(
getNbtOps(),
tryMigrateNbt(loadSNbt("testdata/tablist/$name.snbt"), null),
).getOrThrow { IllegalStateException("Could not load tablist '$name': $it") }
}
fun loadText(name: String): Text { fun loadText(name: String): Text {
return TextCodecs.CODEC.parse( return TextCodecs.CODEC.parse(
getNbtOps(), getNbtOps(),

View File

@@ -0,0 +1,48 @@
package moe.nea.firmament.test.util.skyblock
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import moe.nea.firmament.test.testutil.ItemResources
import moe.nea.firmament.util.skyblock.TabListAPI
class TabListAPITest {
val tablist = ItemResources.loadTablist("dungeon_hub")
@Test
fun checkWithTitle() {
Assertions.assertEquals(
listOf(
"Profile: Strawberry",
" SB Level: [210] 26/100 XP",
" Bank: 1.4B",
" Interest: 12 Hours (689.1k)",
),
TabListAPI.getWidgetLines(TabListAPI.WidgetName.PROFILE, includeTitle = true, from = tablist).map { it.string })
}
@Test
fun checkEndOfColumn() {
Assertions.assertEquals(
listOf(
" Bonzo IV: 110/150",
" Scarf II: 25/50",
" The Professor IV: 141/150",
" Thorn I: 29/50",
" Livid II: 91/100",
" Sadan V: 388/500",
" Necron VI: 531/750",
),
TabListAPI.getWidgetLines(TabListAPI.WidgetName.COLLECTION, from = tablist).map { it.string }
)
}
@Test
fun checkWithoutTitle() {
Assertions.assertEquals(
listOf(
" Undead: 1,907",
" Wither: 318",
),
TabListAPI.getWidgetLines(TabListAPI.WidgetName.ESSENCE, from = tablist).map { it.string })
}
}

File diff suppressed because it is too large Load Diff