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.FirmamentEventBus
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.inventory.buttons.InventoryButtons
import moe.nea.firmament.features.inventory.storageoverlay.StorageOverlayScreen
@@ -202,7 +203,7 @@ fun firmamentCommand() = literal("firmament") {
}
}
}
thenLiteral("dev") {
thenLiteral(DeveloperFeatures.DEVELOPER_SUBCOMMAND) {
thenLiteral("simulate") {
thenArgument("message", RestArgumentType) { message ->
thenExecute {

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ object StringUtil {
return string.replace(",", "").toInt()
}
fun String.title() = replaceFirstChar { it.titlecase() }
fun Iterable<String>.unwords() = joinToString(" ")
fun nextLexicographicStringOfSameLength(string: String): 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$MultiPhaseParameters
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/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 class net/minecraft/registry/RegistryOps$CachedRegistryInfoGetter

View File

@@ -1,8 +1,6 @@
package moe.nea.firmament.test.testutil
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.JsonOps
import net.minecraft.SharedConstants
@@ -20,6 +18,7 @@ import net.minecraft.text.TextCodecs
import moe.nea.firmament.features.debug.ExportedTestConstantMeta
import moe.nea.firmament.test.FirmTestBootstrap
import moe.nea.firmament.util.MC
import moe.nea.firmament.util.mc.MCTabListAPI
object ItemResources {
init {
@@ -36,11 +35,12 @@ object ItemResources {
fun loadSNbt(path: String): NbtCompound {
return StringNbtReader.readCompound(loadString(path))
}
fun getNbtOps(): RegistryOps<NbtElement> = MC.currentOrDefaultRegistries.getOps(NbtOps.INSTANCE)
fun tryMigrateNbt(
nbtCompound: NbtCompound,
typ: DSL.TypeReference,
typ: DSL.TypeReference?,
): NbtElement {
val source = nbtCompound.get("source", ExportedTestConstantMeta.CODEC)
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
NbtString.of(
NbtOps.INSTANCE.convertTo(JsonOps.INSTANCE, nbtCompound)
.toString())
.toString()
)
} else {
nbtCompound
}
return Schemas.getFixer()
.update(
typ,
Dynamic(NbtOps.INSTANCE, wrappedNbtSource),
source.get().dataVersion,
SharedConstants.getGameVersion().saveVersion.id
).value
if (typ != null) {
return Schemas.getFixer()
.update(
typ,
Dynamic(NbtOps.INSTANCE, wrappedNbtSource),
source.get().dataVersion,
SharedConstants.getGameVersion().saveVersion.id
).value
} else {
wrappedNbtSource
}
}
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 {
return TextCodecs.CODEC.parse(
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