1+ /*
2+ * Copyright 2026 Lambda
3+ *
4+ * This program is free software: you can redistribute it and/or modify
5+ * it under the terms of the GNU General Public License as published by
6+ * the Free Software Foundation, either version 3 of the License, or
7+ * (at your option) any later version.
8+ *
9+ * This program is distributed in the hope that it will be useful,
10+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+ * GNU General Public License for more details.
13+ *
14+ * You should have received a copy of the GNU General Public License
15+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
16+ */
17+
18+ package com.lambda.module.modules.player
19+
20+ import com.lambda.config.AutomationConfig.Companion.setDefaultAutomationConfig
21+ import com.lambda.config.applyEdits
22+ import com.lambda.config.settings.complex.Bind
23+ import com.lambda.config.settings.complex.KeybindSetting.Companion.onPress
24+ import com.lambda.context.SafeContext
25+ import com.lambda.event.events.PacketEvent
26+ import com.lambda.event.events.TickEvent
27+ import com.lambda.event.listener.SafeListener.Companion.listen
28+ import com.lambda.interaction.construction.blueprint.Blueprint.Companion.toStructure
29+ import com.lambda.interaction.construction.blueprint.StaticBlueprint.Companion.toBlueprint
30+ import com.lambda.interaction.construction.verify.TargetState
31+ import com.lambda.interaction.managers.rotating.IRotationRequest.Companion.rotationRequest
32+ import com.lambda.interaction.managers.rotating.visibilty.lookAtEntity
33+ import com.lambda.module.Module
34+ import com.lambda.module.tag.ModuleTag
35+ import com.lambda.sound.SoundManager.playSound
36+ import com.lambda.task.RootTask.run
37+ import com.lambda.task.Task
38+ import com.lambda.task.tasks.BuildTask.Companion.build
39+ import com.lambda.threading.runSafeAutomated
40+ import com.lambda.util.BlockUtils.blockState
41+ import com.lambda.util.BlockUtils.isEmpty
42+ import com.lambda.util.Communication.info
43+ import com.lambda.util.Communication.logError
44+ import com.lambda.util.EnchantmentUtils.forEachEnchantment
45+ import com.lambda.util.NamedEnum
46+ import com.lambda.util.world.closestEntity
47+ import net.minecraft.block.Blocks
48+ import net.minecraft.component.DataComponentTypes
49+ import net.minecraft.component.type.ItemEnchantmentsComponent
50+ import net.minecraft.enchantment.Enchantment
51+ import net.minecraft.entity.passive.VillagerEntity
52+ import net.minecraft.item.ItemStack
53+ import net.minecraft.item.Items
54+ import net.minecraft.network.packet.s2c.play.SetTradeOffersS2CPacket
55+ import net.minecraft.registry.RegistryKeys
56+ import net.minecraft.sound.SoundEvents
57+ import net.minecraft.util.Hand
58+ import net.minecraft.util.hit.EntityHitResult
59+ import net.minecraft.util.math.BlockPos
60+
61+
62+ object AutoVillagerCycle : Module(
63+ name = " AutoVillagerCycle" ,
64+ description = " Automatically cycles librarian villagers with lecterns until a desired enchanted book is found" ,
65+ tag = ModuleTag .PLAYER
66+ ) {
67+ private enum class Group (override val displayName : String ) : NamedEnum {
68+ General (" General" ),
69+ Enchantments (" Enchantments" )
70+ }
71+
72+ private val allEnchantments = ArrayList <String >()
73+
74+ private val lecternPos by setting(" Lectern Pos" , BlockPos .ORIGIN , " Position where the lectern should be placed/broken" ).group(Group .General )
75+ private val logFoundBooks by setting(" Log Found Books" , true , " Log all enchanted books found during cycling" ).group(Group .General )
76+ private val interactDelay by setting(" Interact Delay" , 20 , 1 .. 40 , 1 , " Ticks to wait before interacting with the villager" , " ticks" ).group(Group .General )
77+ private val breakDelay by setting(" Break Delay" , 5 , 1 .. 20 , 1 , " Ticks to wait after breaking the lectern" , " ticks" ).group(Group .General )
78+ private val searchRange by setting(" Search Range" , 5.0 , 1.0 .. 10.0 , 0.5 , " Range to search for nearby villagers" , " blocks" ).group(Group .General )
79+ private val startCyclingBind by setting(" Start Cycling" , Bind .EMPTY , " Press to start/stop cycling" ).group(Group .General )
80+ .onPress {
81+ if (cycleState != CycleState .Idle ) {
82+ info(" Stopped villager cycling." )
83+ switchState(CycleState .Idle )
84+ } else {
85+ info(" Started villager cycling." )
86+ buildTask?.cancel()
87+ buildTask = null
88+ switchState(CycleState .PlaceLectern )
89+ }
90+ }
91+ private val desiredEnchantments by setting(" Desired Enchantments" , emptySet(), allEnchantments).group(Group .Enchantments )
92+ private val minLevel by setting(" Min Level" , 1 , 1 .. 5 , 1 , " Minimum enchantment level to look for" ).group(Group .Enchantments )
93+
94+ private var cycleState = CycleState .Idle
95+ private var tickCounter = 0
96+
97+ private var buildTask: Task <* >? = null
98+
99+ init {
100+ setDefaultAutomationConfig() {
101+ applyEdits {
102+ hideAllGroupsExcept(rotationConfig, inventoryConfig, breakConfig, interactConfig, buildConfig)
103+ }
104+ }
105+
106+ onEnable {
107+ allEnchantments.clear()
108+ allEnchantments.addAll(getEnchantmentList())
109+ cycleState = CycleState .Idle
110+ tickCounter = 0
111+ }
112+
113+ onDisable {
114+ cycleState = CycleState .Idle
115+ tickCounter = 0
116+ buildTask?.cancel()
117+ buildTask = null
118+ }
119+
120+ listen<TickEvent .Pre > {
121+ tickCounter++
122+
123+ if (allEnchantments.isEmpty()) {
124+ allEnchantments.addAll(getEnchantmentList()) // Have to load enchantments after we loaded into a world
125+ }
126+
127+ when (cycleState) {
128+ CycleState .Idle -> {}
129+ CycleState .PlaceLectern -> handlePlaceLectern()
130+ CycleState .WaitLectern -> {}
131+ CycleState .OpenVillager -> handleOpenVillager()
132+ CycleState .BreakLectern -> handleBreakLectern()
133+ CycleState .WaitBreak -> {}
134+ }
135+ }
136+
137+ listen<PacketEvent .Receive .Pre > { event ->
138+ if (event.packet !is SetTradeOffersS2CPacket ) return @listen
139+ if (cycleState != CycleState .OpenVillager ) return @listen
140+
141+ val tradeOfferPacket = event.packet
142+ val trades = tradeOfferPacket.offers
143+ if (trades.isEmpty()) {
144+ logError(" Villager has no trades!" )
145+ switchState(CycleState .Idle )
146+ return @listen
147+ }
148+
149+ var bookFound = false
150+ for (offer in trades) {
151+ if (offer.isDisabled) continue
152+
153+ val sellItem = offer.sellItem
154+ if (sellItem.item != Items .ENCHANTED_BOOK ) continue
155+
156+ if (logFoundBooks) {
157+ val storedEnchantments = sellItem.get(DataComponentTypes .STORED_ENCHANTMENTS )
158+ val foundEnchantments = mutableListOf<String >()
159+ for (entry in storedEnchantments?.enchantmentEntries ? : emptyList()) {
160+ foundEnchantments.add(entry.key.value().description().string)
161+ }
162+ if (foundEnchantments.isNotEmpty()) {
163+ bookFound = true
164+ info(" Found book(s): ${foundEnchantments.joinToString(" , " )} " )
165+ }
166+ }
167+
168+ findDesiredEnchantment(sellItem)?.let {
169+ info(" Found desired enchantment: ${it.description().string} !" )
170+ playSound(SoundEvents .ENTITY_EXPERIENCE_ORB_PICKUP )
171+ switchState(CycleState .Idle )
172+ return @listen
173+ }
174+ }
175+ if (! bookFound && logFoundBooks) {
176+ info(" No books found" )
177+ }
178+
179+ // No desired enchantment found, break lectern and try again
180+ tickCounter = 0
181+ switchState(CycleState .BreakLectern )
182+ }
183+ }
184+
185+ private fun SafeContext.getEnchantmentList (): MutableList <String > {
186+ val enchantments = ArrayList <String >()
187+ for (foo in world.registryManager.getOrThrow(RegistryKeys .ENCHANTMENT )) {
188+ enchantments.add(foo.description.string)
189+ }
190+ return enchantments
191+ }
192+
193+ private fun SafeContext.handlePlaceLectern () {
194+ player.closeHandledScreen()
195+
196+ if (desiredEnchantments.isEmpty()) {
197+ logError(" No desired enchantments set!" )
198+ switchState(CycleState .Idle )
199+ return
200+ }
201+
202+ if (lecternPos == BlockPos .ORIGIN ) {
203+ logError(" Lectern position is not set!" )
204+ switchState(CycleState .Idle )
205+ return
206+ }
207+
208+ val state = blockState(lecternPos)
209+
210+ if (! state.isEmpty) {
211+ if (state.isOf(Blocks .LECTERN )) {
212+ switchState(CycleState .OpenVillager )
213+ return
214+ }
215+ logError(" Block at lectern position is not air or a lectern!" )
216+ switchState(CycleState .Idle )
217+ return
218+ }
219+
220+ runSafeAutomated {
221+ buildTask = lecternPos.toStructure(TargetState .Block (Blocks .LECTERN ))
222+ .toBlueprint()
223+ .build(finishOnDone = true )
224+ .finally {
225+ switchState(CycleState .OpenVillager )
226+ }
227+ .run ()
228+ }
229+ switchState(CycleState .WaitLectern )
230+ }
231+
232+ private fun SafeContext.handleOpenVillager () {
233+ if (tickCounter < interactDelay) return
234+
235+ if (buildTask?.state == Task .State .Running ) {
236+ return
237+ }
238+
239+ // Verify lectern is still present
240+ val state = blockState(lecternPos)
241+ if (state.isEmpty) {
242+ tickCounter = 0
243+ switchState(CycleState .PlaceLectern )
244+ return
245+ }
246+ if (! state.isOf(Blocks .LECTERN )) {
247+ logError(" Block at lectern position is not a lectern!" )
248+ switchState(CycleState .Idle )
249+ return
250+ }
251+
252+ val villager = closestEntity<VillagerEntity >(searchRange)
253+ if (villager == null ) {
254+ logError(" No villager found nearby!" )
255+ switchState(CycleState .Idle )
256+ return
257+ }
258+
259+ runSafeAutomated {
260+ lookAtEntity(villager)?.let {
261+ val done = rotationRequest {
262+ rotation(it.rotation)
263+ }.submit().done
264+ if (done) {
265+ interaction.interactEntityAtLocation(player, villager, it.hit as EntityHitResult ? , Hand .MAIN_HAND )
266+ interaction.interactEntity(player, villager, Hand .MAIN_HAND )
267+ player.swingHand(Hand .MAIN_HAND )
268+ tickCounter = 0
269+ }
270+ }
271+ }
272+ }
273+
274+ private fun SafeContext.handleBreakLectern () {
275+ if (player.currentScreenHandler != player.playerScreenHandler) {
276+ player.closeHandledScreen()
277+ }
278+
279+ if (tickCounter < breakDelay) return
280+
281+ val state = blockState(lecternPos)
282+
283+ if (! state.isEmpty) {
284+ buildTask = runSafeAutomated {
285+ lecternPos.toStructure(TargetState .Empty )
286+ .build(finishOnDone = true )
287+ .finally {
288+ switchState(CycleState .PlaceLectern )
289+ }
290+ .run ()
291+ }
292+ switchState(CycleState .WaitBreak )
293+ return
294+ }
295+ switchState(CycleState .PlaceLectern )
296+ }
297+
298+ private fun findDesiredEnchantment (itemStack : ItemStack ): Enchantment ? {
299+ if (desiredEnchantments.isEmpty()) return null
300+
301+ val enchantments = itemStack.get(DataComponentTypes .STORED_ENCHANTMENTS ) ? : return null
302+ enchantments.enchantmentEntries.forEach { (entry, level) ->
303+ val enchantmentName = entry.value().description().string
304+ if (desiredEnchantments.any { it.equals(enchantmentName, ignoreCase = true ) && level >= minLevel }) {
305+ return entry.value()
306+ }
307+ }
308+ return null
309+ }
310+
311+ private fun switchState (newState : CycleState ) {
312+ if (cycleState != newState) {
313+ tickCounter = 0
314+ }
315+ cycleState = newState
316+ }
317+
318+ private enum class CycleState {
319+ Idle ,
320+ PlaceLectern ,
321+ OpenVillager ,
322+ WaitLectern ,
323+ BreakLectern ,
324+ WaitBreak
325+ }
326+ }
0 commit comments