diff options
author | Linnea Gräf <nea@nea.moe> | 2025-05-07 23:09:10 +0200 |
---|---|---|
committer | Linnea Gräf <nea@nea.moe> | 2025-05-07 23:09:10 +0200 |
commit | 63669bc28be11adbf55c8d49bb747bb22124be86 (patch) | |
tree | d77c8e5a7e32985ae1402eba16544a49d3c163e9 | |
parent | 38fd61fdcc70f75f5b8b5eb39e21c34aaf5ceb90 (diff) | |
download | Firmament-63669bc28be11adbf55c8d49bb747bb22124be86.tar.gz Firmament-63669bc28be11adbf55c8d49bb747bb22124be86.tar.bz2 Firmament-63669bc28be11adbf55c8d49bb747bb22124be86.zip |
feat: Add more complex entity equipment scraper
-rw-r--r-- | src/main/kotlin/events/EntityUpdateEvent.kt | 24 | ||||
-rw-r--r-- | src/main/kotlin/events/PlayerInventoryUpdate.kt | 19 | ||||
-rw-r--r-- | src/main/kotlin/features/debug/AnimatedClothingScanner.kt | 183 | ||||
-rw-r--r-- | src/main/kotlin/util/math/GChainReconciliation.kt | 102 | ||||
-rw-r--r-- | src/test/kotlin/util/math/GChainReconciliationTest.kt | 75 |
5 files changed, 348 insertions, 55 deletions
diff --git a/src/main/kotlin/events/EntityUpdateEvent.kt b/src/main/kotlin/events/EntityUpdateEvent.kt index 27a90f9..fec2fa5 100644 --- a/src/main/kotlin/events/EntityUpdateEvent.kt +++ b/src/main/kotlin/events/EntityUpdateEvent.kt @@ -7,6 +7,8 @@ import net.minecraft.entity.LivingEntity import net.minecraft.entity.data.DataTracker import net.minecraft.item.ItemStack 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. @@ -15,7 +17,27 @@ import net.minecraft.network.packet.s2c.play.EntityAttributesS2CPacket * *after* the values have been applied to the entity. */ 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 diff --git a/src/main/kotlin/events/PlayerInventoryUpdate.kt b/src/main/kotlin/events/PlayerInventoryUpdate.kt index 6e8203a..88439a9 100644 --- a/src/main/kotlin/events/PlayerInventoryUpdate.kt +++ b/src/main/kotlin/events/PlayerInventoryUpdate.kt @@ -1,11 +1,22 @@ - package moe.nea.firmament.events import net.minecraft.item.ItemStack sealed class PlayerInventoryUpdate : FirmamentEvent() { - companion object : FirmamentEventBus<PlayerInventoryUpdate>() - data class Single(val slot: Int, val stack: ItemStack) : PlayerInventoryUpdate() - data class Multi(val contents: List<ItemStack>) : PlayerInventoryUpdate() + companion object : FirmamentEventBus<PlayerInventoryUpdate>() + data class Single(val slot: Int, val stack: 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? } diff --git a/src/main/kotlin/features/debug/AnimatedClothingScanner.kt b/src/main/kotlin/features/debug/AnimatedClothingScanner.kt index 47da7d6..9f9f135 100644 --- a/src/main/kotlin/features/debug/AnimatedClothingScanner.kt +++ b/src/main/kotlin/features/debug/AnimatedClothingScanner.kt @@ -2,13 +2,12 @@ package moe.nea.firmament.features.debug import net.minecraft.command.argument.RegistryKeyArgumentType import net.minecraft.component.ComponentType -import net.minecraft.component.DataComponentTypes import net.minecraft.entity.Entity +import net.minecraft.entity.decoration.ArmorStandEntity import net.minecraft.item.ItemStack import net.minecraft.nbt.NbtElement import net.minecraft.nbt.NbtOps import net.minecraft.registry.RegistryKeys -import net.minecraft.util.Identifier import moe.nea.firmament.annotations.Subscribe import moe.nea.firmament.commands.get 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.events.CommandEvent import moe.nea.firmament.events.EntityUpdateEvent +import moe.nea.firmament.events.WorldReadyEvent import moe.nea.firmament.util.ClipboardUtils 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.skyBlockId import moe.nea.firmament.util.tr object AnimatedClothingScanner { - data class SubjectOfFashionTheft<T>( - val observedEntity: Entity, + data class LensOfFashionTheft<T>( val prism: NbtPrism, 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) @Subscribe fun onUpdate(event: EntityUpdateEvent) { val s = subject ?: return - if (event.entity != s.observedEntity) return + if (event.entity != s) return + val l = lens ?: return if (event is EntityUpdateEvent.EquipmentUpdate) { - val lines = mutableListOf<String>() event.newEquipment.forEach { - val formatted = (s.observe(it.second)).joinToString() - lines.add(formatted) - MC.sendChat( - 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 formatted = (l.observe(it.second)).joinToString() + history.add(formatted) + // TODO: add a slot filter } } } - val EXPORT_WATERMARK = "[CLOTHES EXPORT]" + fun reduceHistory(reducer: (List<String>, List<String>) -> List<String>): List<String> { + return metaHistory.fold(history, reducer).shortenCycle() + } @Subscribe fun onSubCommand(event: CommandEvent.SubCommand) { event.subcommand("dev") { thenLiteral("stealthisfit") { - thenArgument( - "component", - RegistryKeyArgumentType.registryKey(RegistryKeys.DATA_COMPONENT_TYPE) - ) { component -> - thenArgument("path", NbtPrism.Argument) { path -> + 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 { - subject = - 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), - MC.unsafeGetRegistryEntry(get(component))!!, - ) - } else null - + val history = reduceHistory { a, b -> + (a.toMutableSet() + b).toList() + } + copyHistory(history) MC.sendChat( - subject?.let { + 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( + "component", + RegistryKeyArgumentType.registryKey(RegistryKeys.DATA_COMPONENT_TYPE) + ) { component -> + thenArgument("path", NbtPrism.Argument) { path -> + thenExecute { + lens = LensOfFashionTheft( + get(path), + MC.unsafeGetRegistryEntry(get(component))!!, + ) + source.sendFeedback( tr( - "firmament.fitstealer.targeted", - "Observing the equipment of ${it.observedEntity.name}." + "firmament.fitstealer.lensset", + "Analyzing path ${get(path)} for component ${get(component).value}" ) - } ?: tr("firmament.fitstealer.targetlost", "No longer logging equipment."), - ) + ) + } } } } } } } + + 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( + subject?.let { + tr( + "firmament.fitstealer.targeted", + "Observing the equipment of ${it.name}." + ) + } ?: tr("firmament.fitstealer.targetlost", "No longer logging equipment."), + ) + } } diff --git a/src/main/kotlin/util/math/GChainReconciliation.kt b/src/main/kotlin/util/math/GChainReconciliation.kt new file mode 100644 index 0000000..37998d5 --- /dev/null +++ b/src/main/kotlin/util/math/GChainReconciliation.kt @@ -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 + } + +} diff --git a/src/test/kotlin/util/math/GChainReconciliationTest.kt b/src/test/kotlin/util/math/GChainReconciliationTest.kt new file mode 100644 index 0000000..502bd9e --- /dev/null +++ b/src/test/kotlin/util/math/GChainReconciliationTest.kt @@ -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) + ) + ) + } +} |