feat: Add more complex entity equipment scraper

This commit is contained in:
Linnea Gräf
2025-05-07 23:09:10 +02:00
parent 38fd61fdcc
commit 63669bc28b
5 changed files with 349 additions and 56 deletions

View File

@@ -7,6 +7,8 @@ import net.minecraft.entity.LivingEntity
import net.minecraft.entity.data.DataTracker import net.minecraft.entity.data.DataTracker
import net.minecraft.item.ItemStack import net.minecraft.item.ItemStack
import net.minecraft.network.packet.s2c.play.EntityAttributesS2CPacket import net.minecraft.network.packet.s2c.play.EntityAttributesS2CPacket
import moe.nea.firmament.annotations.Subscribe
import moe.nea.firmament.util.MC
/** /**
* This event is fired when some entity properties are updated. * This event is fired when some entity properties are updated.
@@ -15,7 +17,27 @@ import net.minecraft.network.packet.s2c.play.EntityAttributesS2CPacket
* *after* the values have been applied to the entity. * *after* the values have been applied to the entity.
*/ */
sealed class EntityUpdateEvent : FirmamentEvent() { sealed class EntityUpdateEvent : FirmamentEvent() {
companion object : FirmamentEventBus<EntityUpdateEvent>() companion object : FirmamentEventBus<EntityUpdateEvent>() {
@Subscribe
fun onPlayerInventoryUpdate(event: PlayerInventoryUpdate) {
val p = MC.player ?: return
val updatedSlots = listOf(
EquipmentSlot.HEAD to 39,
EquipmentSlot.CHEST to 38,
EquipmentSlot.LEGS to 37,
EquipmentSlot.FEET to 36,
EquipmentSlot.OFFHAND to 40,
EquipmentSlot.MAINHAND to p.inventory.selectedSlot, // TODO: also equipment update when you swap your selected slot perhaps
).mapNotNull { (slot, stackIndex) ->
val slotIndex = p.playerScreenHandler.getSlotIndex(p.inventory, stackIndex).asInt
event.getOrNull(slotIndex)?.let {
Pair.of(slot, it)
}
}
if (updatedSlots.isNotEmpty())
publish(EquipmentUpdate(p, updatedSlots))
}
}
abstract val entity: Entity abstract val entity: Entity

View File

@@ -1,11 +1,22 @@
package moe.nea.firmament.events package moe.nea.firmament.events
import net.minecraft.item.ItemStack import net.minecraft.item.ItemStack
sealed class PlayerInventoryUpdate : FirmamentEvent() { sealed class PlayerInventoryUpdate : FirmamentEvent() {
companion object : FirmamentEventBus<PlayerInventoryUpdate>() companion object : FirmamentEventBus<PlayerInventoryUpdate>()
data class Single(val slot: Int, val stack: ItemStack) : PlayerInventoryUpdate() data class Single(val slot: Int, val stack: ItemStack) : PlayerInventoryUpdate() {
data class Multi(val contents: List<ItemStack>) : PlayerInventoryUpdate() override fun getOrNull(slot: Int): ItemStack? {
if (slot == this.slot) return stack
return null
}
}
data class Multi(val contents: List<ItemStack>) : PlayerInventoryUpdate() {
override fun getOrNull(slot: Int): ItemStack? {
return contents.getOrNull(slot)
}
}
abstract fun getOrNull(slot: Int): ItemStack?
} }

View File

@@ -2,13 +2,12 @@ package moe.nea.firmament.features.debug
import net.minecraft.command.argument.RegistryKeyArgumentType import net.minecraft.command.argument.RegistryKeyArgumentType
import net.minecraft.component.ComponentType import net.minecraft.component.ComponentType
import net.minecraft.component.DataComponentTypes
import net.minecraft.entity.Entity import net.minecraft.entity.Entity
import net.minecraft.entity.decoration.ArmorStandEntity
import net.minecraft.item.ItemStack import net.minecraft.item.ItemStack
import net.minecraft.nbt.NbtElement import net.minecraft.nbt.NbtElement
import net.minecraft.nbt.NbtOps import net.minecraft.nbt.NbtOps
import net.minecraft.registry.RegistryKeys import net.minecraft.registry.RegistryKeys
import net.minecraft.util.Identifier
import moe.nea.firmament.annotations.Subscribe import moe.nea.firmament.annotations.Subscribe
import moe.nea.firmament.commands.get import moe.nea.firmament.commands.get
import moe.nea.firmament.commands.thenArgument import moe.nea.firmament.commands.thenArgument
@@ -16,16 +15,17 @@ 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.EntityUpdateEvent import moe.nea.firmament.events.EntityUpdateEvent
import moe.nea.firmament.events.WorldReadyEvent
import moe.nea.firmament.util.ClipboardUtils import moe.nea.firmament.util.ClipboardUtils
import moe.nea.firmament.util.MC import moe.nea.firmament.util.MC
import moe.nea.firmament.util.math.GChainReconciliation
import moe.nea.firmament.util.math.GChainReconciliation.shortenCycle
import moe.nea.firmament.util.mc.NbtPrism import moe.nea.firmament.util.mc.NbtPrism
import moe.nea.firmament.util.skyBlockId
import moe.nea.firmament.util.tr import moe.nea.firmament.util.tr
object AnimatedClothingScanner { object AnimatedClothingScanner {
data class SubjectOfFashionTheft<T>( data class LensOfFashionTheft<T>(
val observedEntity: Entity,
val prism: NbtPrism, val prism: NbtPrism,
val component: ComponentType<T>, val component: ComponentType<T>,
) { ) {
@@ -36,75 +36,158 @@ object AnimatedClothingScanner {
} }
} }
var subject: SubjectOfFashionTheft<*>? = null var lens: LensOfFashionTheft<*>? = null
var subject: Entity? = null
var history: MutableList<String> = mutableListOf()
val metaHistory: MutableList<List<String>> = mutableListOf()
@OptIn(ExperimentalStdlibApi::class) @OptIn(ExperimentalStdlibApi::class)
@Subscribe @Subscribe
fun onUpdate(event: EntityUpdateEvent) { fun onUpdate(event: EntityUpdateEvent) {
val s = subject ?: return val s = subject ?: return
if (event.entity != s.observedEntity) return if (event.entity != s) return
val l = lens ?: return
if (event is EntityUpdateEvent.EquipmentUpdate) { if (event is EntityUpdateEvent.EquipmentUpdate) {
val lines = mutableListOf<String>()
event.newEquipment.forEach { event.newEquipment.forEach {
val formatted = (s.observe(it.second)).joinToString() val formatted = (l.observe(it.second)).joinToString()
lines.add(formatted) history.add(formatted)
MC.sendChat( // TODO: add a slot filter
tr(
"firmament.fitstealer.update",
"[FIT CHECK][${MC.currentTick}] ${it.first.asString()} => $formatted"
)
)
}
if (lines.isNotEmpty()) {
val contents = ClipboardUtils.getTextContents()
if (contents.startsWith(EXPORT_WATERMARK))
ClipboardUtils.setTextContent(
contents + "\n" + lines.joinToString("\n")
)
} }
} }
} }
val EXPORT_WATERMARK = "[CLOTHES EXPORT]" fun reduceHistory(reducer: (List<String>, List<String>) -> List<String>): List<String> {
return metaHistory.fold(history, reducer).shortenCycle()
}
@Subscribe @Subscribe
fun onSubCommand(event: CommandEvent.SubCommand) { fun onSubCommand(event: CommandEvent.SubCommand) {
event.subcommand("dev") { event.subcommand("dev") {
thenLiteral("stealthisfit") { thenLiteral("stealthisfit") {
thenLiteral("clear") {
thenExecute {
subject = null
metaHistory.clear()
history.clear()
MC.sendChat(tr("firmament.fitstealer.clear", "Cleared fit stealing history"))
}
}
thenLiteral("copy") {
thenExecute {
val history = reduceHistory { a, b -> a + b }
copyHistory(history)
MC.sendChat(tr("firmament.fitstealer.copied", "Copied the history"))
}
thenLiteral("deduplicated") {
thenExecute {
val history = reduceHistory { a, b ->
(a.toMutableSet() + b).toList()
}
copyHistory(history)
MC.sendChat(
tr(
"firmament.fitstealer.copied.deduplicated",
"Copied the deduplicated history"
)
)
}
}
thenLiteral("merged") {
thenExecute {
val history = reduceHistory(GChainReconciliation::reconcileCycles)
copyHistory(history)
MC.sendChat(tr("firmament.fitstealer.copied.merged", "Copied the merged history"))
}
}
}
thenLiteral("target") {
thenLiteral("self") {
thenExecute {
toggleObserve(MC.player!!)
}
}
thenLiteral("pet") {
thenExecute {
source.sendFeedback(
tr(
"firmament.fitstealer.stealingpet",
"Observing nearest marker armourstand"
)
)
val p = MC.player!!
val nearestPet = p.world.getEntitiesByClass(
ArmorStandEntity::class.java,
p.boundingBox.expand(10.0),
{ it.isMarker })
.minBy { it.squaredDistanceTo(p) }
toggleObserve(nearestPet)
}
}
thenExecute {
val ent = MC.instance.targetedEntity
if (ent == null) {
source.sendFeedback(
tr(
"firmament.fitstealer.notargetundercursor",
"No entity under cursor"
)
)
} else {
toggleObserve(ent)
}
}
}
thenLiteral("path") {
thenArgument( thenArgument(
"component", "component",
RegistryKeyArgumentType.registryKey(RegistryKeys.DATA_COMPONENT_TYPE) RegistryKeyArgumentType.registryKey(RegistryKeys.DATA_COMPONENT_TYPE)
) { component -> ) { component ->
thenArgument("path", NbtPrism.Argument) { path -> thenArgument("path", NbtPrism.Argument) { path ->
thenExecute { thenExecute {
subject = lens = LensOfFashionTheft(
if (subject == null) run {
val entity = MC.instance.targetedEntity ?: return@run null
val clipboard = ClipboardUtils.getTextContents()
if (!clipboard.startsWith(EXPORT_WATERMARK)) {
ClipboardUtils.setTextContent(EXPORT_WATERMARK)
} else {
ClipboardUtils.setTextContent("$clipboard\n\n[NEW SCANNER]")
}
SubjectOfFashionTheft(
entity,
get(path), get(path),
MC.unsafeGetRegistryEntry(get(component))!!, MC.unsafeGetRegistryEntry(get(component))!!,
) )
} else null source.sendFeedback(
tr(
"firmament.fitstealer.lensset",
"Analyzing path ${get(path)} for component ${get(component).value}"
)
)
}
}
}
}
}
}
}
private fun copyHistory(toCopy: List<String>) {
ClipboardUtils.setTextContent(toCopy.joinToString("\n"))
}
@Subscribe
fun onWorldSwap(event: WorldReadyEvent) {
subject = null
if (history.isNotEmpty()) {
metaHistory.add(history)
history = mutableListOf()
}
}
private fun toggleObserve(entity: Entity?) {
subject = if (subject == null) entity else null
if (subject == null) {
metaHistory.add(history)
history = mutableListOf()
}
MC.sendChat( MC.sendChat(
subject?.let { subject?.let {
tr( tr(
"firmament.fitstealer.targeted", "firmament.fitstealer.targeted",
"Observing the equipment of ${it.observedEntity.name}." "Observing the equipment of ${it.name}."
) )
} ?: tr("firmament.fitstealer.targetlost", "No longer logging equipment."), } ?: tr("firmament.fitstealer.targetlost", "No longer logging equipment."),
) )
} }
}
}
}
}
}
} }

View File

@@ -0,0 +1,102 @@
package moe.nea.firmament.util.math
import kotlin.math.min
/**
* Algorithm for (sort of) cheap reconciliation of two cycles with missing frames.
*/
object GChainReconciliation {
// Step one: Find the most common element and shift the arrays until it is at the start in both (this could be just rotating until minimal levenshtein distance or smth. that would be way better for cycles with duplicates, but i do not want to implement levenshtein as well)
// Step two: Find the first different element.
// Step three: Find the next index of both of the elements.
// Step four: Insert the element that is further away.
fun <T> Iterable<T>.frequencies(): Map<T, Int> {
val acc = mutableMapOf<T, Int>()
for (t in this) {
acc.compute(t, { _, old -> (old ?: 0) + 1 })
}
return acc
}
fun <T> findMostCommonlySharedElement(
leftChain: List<T>,
rightChain: List<T>,
): T {
val lf = leftChain.frequencies()
val rf = rightChain.frequencies()
val mostCommonlySharedElement = lf.maxByOrNull { min(it.value, rf[it.key] ?: 0) }?.key
if (mostCommonlySharedElement == null || mostCommonlySharedElement !in rf)
error("Could not find a shared element")
return mostCommonlySharedElement
}
fun <T> List<T>.getMod(index: Int): T {
return this[index.mod(size)]
}
fun <T> List<T>.rotated(offset: Int): List<T> {
val newList = mutableListOf<T>()
for (index in indices) {
newList.add(getMod(index - offset))
}
return newList
}
fun <T> shiftToFront(list: List<T>, element: T): List<T> {
val shiftDistance = list.indexOf(element)
require(shiftDistance >= 0)
return list.rotated(-shiftDistance)
}
fun <T> List<T>.indexOfOrMaxInt(element: T): Int = indexOf(element).takeUnless { it < 0 } ?: Int.MAX_VALUE
fun <T> reconcileCycles(
leftChain: List<T>,
rightChain: List<T>,
): List<T> {
val mostCommonElement = findMostCommonlySharedElement(leftChain, rightChain)
val left = shiftToFront(leftChain, mostCommonElement).toMutableList()
val right = shiftToFront(rightChain, mostCommonElement).toMutableList()
var index = 0
while (index < left.size && index < right.size) {
val leftEl = left[index]
val rightEl = right[index]
if (leftEl == rightEl) {
index++
continue
}
val nextLeftInRight = right.subList(index, right.size)
.indexOfOrMaxInt(leftEl)
val nextRightInLeft = left.subList(index, left.size)
.indexOfOrMaxInt(rightEl)
if (nextLeftInRight < nextRightInLeft) {
left.add(index, rightEl)
} else if (nextRightInLeft < nextLeftInRight) {
right.add(index, leftEl)
} else {
index++
}
}
return if (left.size < right.size) right else left
}
fun <T> isValidCycle(longList: List<T>, cycle: List<T>): Boolean {
for ((i, value) in longList.withIndex()) {
if (cycle.getMod(i) != value)
return false
}
return true
}
fun <T> List<T>.shortenCycle(): List<T> {
for (i in (1..<size)) {
if (isValidCycle(this, subList(0, i)))
return subList(0, i)
}
return this
}
}

View File

@@ -0,0 +1,75 @@
package moe.nea.firmament.test.util.math
import io.kotest.core.spec.style.AnnotationSpec
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Assertions.*
import moe.nea.firmament.util.math.GChainReconciliation
import moe.nea.firmament.util.math.GChainReconciliation.rotated
class GChainReconciliationTest : AnnotationSpec() {
fun <T> assertEqualCycles(
expected: List<T>,
actual: List<T>
) {
for (offset in expected.indices) {
val rotated = expected.rotated(offset)
val matchesAtRotation = run {
for ((i, v) in actual.withIndex()) {
if (rotated[i % rotated.size] != v)
return@run false
}
true
}
if (matchesAtRotation)
return
}
assertEquals(expected, actual, "Expected arrays to be cycle equivalent")
}
@Test
fun testUnfixableCycleNotBeingModified() {
assertEquals(
listOf(1, 2, 3, 4, 6, 1, 2, 3, 4, 6),
GChainReconciliation.reconcileCycles(
listOf(1, 2, 3, 4, 6, 1, 2, 3, 4, 6),
listOf(2, 3, 4, 5, 1, 2, 3, 4, 5, 1)
)
)
}
@Test
fun testMultipleIndependentHoles() {
assertEqualCycles(
listOf(1, 2, 3, 4, 5, 6),
GChainReconciliation.reconcileCycles(
listOf(1, 3, 4, 5, 6, 1, 3, 4, 5, 6),
listOf(2, 3, 4, 5, 1, 2, 3, 4, 5, 1)
)
)
}
@Test
fun testBigHole() {
assertEqualCycles(
listOf(1, 2, 3, 4, 5, 6),
GChainReconciliation.reconcileCycles(
listOf(1, 4, 5, 6, 1, 4, 5, 6),
listOf(2, 3, 4, 5, 1, 2, 3, 4, 5, 1)
)
)
}
@Test
fun testOneMissingBeingDetected() {
assertEqualCycles(
listOf(1, 2, 3, 4, 5, 6),
GChainReconciliation.reconcileCycles(
listOf(1, 2, 3, 4, 5, 6, 1, 2, 3, 4, 5, 6),
listOf(2, 3, 4, 5, 1, 2, 3, 4, 5, 1)
)
)
}
}