Skip to main content

steel_core/inventory/menu/kinds/
enchantment_menu.rs

1//! Enchanting table menu.
2
3use std::sync::Arc;
4
5use steel_protocol::packets::game::SoundSource;
6use steel_registry::{
7    REGISTRY, RegistryEntry, TaggedRegistryExt, item_stack::ItemStack, sound_events,
8    vanilla_blocks, vanilla_custom_stats, vanilla_enchantment_tags::EnchantmentTag, vanilla_items,
9    vanilla_menu_types,
10};
11use steel_utils::random::Random as _;
12use steel_utils::random::legacy_random::LegacyRandom;
13use steel_utils::{BlockPos, DowncastType, DowncastTypeKey, locks::Shared};
14
15use crate::behavior::blocks::EnchantingTableBlock;
16use crate::enchantment_helper::{EnchantmentInstance, get_enchantment_cost, select_enchantment};
17use crate::inventory::prelude::*;
18use crate::inventory::slots::EnchantItemSlot;
19use crate::player::player_inventory::{PlayerInventory, Prediction};
20use crate::world::World;
21
22const OFFER_COUNT: usize = 3;
23/// Index of the item to enchant within `enchant_slots`.
24const ITEM_SLOT: usize = 0;
25/// Index of the lapis lazuli within `enchant_slots`.
26const LAPIS_SLOT: usize = 1;
27/// Combined item and lapis slot count.
28const TABLE_SLOT_COUNT: usize = 2;
29
30const ENCHANT_SOUND_MIN_PITCH: f32 = 0.9;
31const ENCHANT_SOUND_PITCH_RANGE: f32 = 0.1;
32
33/// Builds the enchanting table menu for the table at `pos`.
34#[must_use]
35pub fn enchantment(
36    inventory: Shared<PlayerInventory>,
37    container_id: u8,
38    pos: BlockPos,
39    world: &Arc<World>,
40    enchantment_seed: i32,
41) -> Menu {
42    let enchant_slots = SimpleContainer::new(TABLE_SLOT_COUNT).into_shared();
43    let enchant_slots_ref = ContainerRef::from(Arc::clone(&enchant_slots));
44
45    let mut builder = MenuBuilder::new(&vanilla_menu_types::ENCHANTMENT, container_id);
46    let item = builder.section_at(
47        enchant_slots_ref.clone(),
48        [ITEM_SLOT],
49        SectionKind::custom(|container, index| {
50            Box::new(EnchantItemSlot::new(container.clone(), index))
51        }),
52    );
53    let lapis = builder.section_at(
54        enchant_slots_ref,
55        [LAPIS_SLOT],
56        SectionKind::restricted(|_, stack| stack.is(&vanilla_items::LAPIS_LAZULI)),
57    );
58    let player_slots = builder.player_inventory(&inventory);
59
60    let cost_slots = [
61        builder.data_slot(0),
62        builder.data_slot(0),
63        builder.data_slot(0),
64    ];
65    let seed_slot = builder.data_slot(EnchantmentKind::client_value(enchantment_seed));
66    let enchant_clue_slots = [
67        builder.data_slot(-1),
68        builder.data_slot(-1),
69        builder.data_slot(-1),
70    ];
71    let level_clue_slots = [
72        builder.data_slot(-1),
73        builder.data_slot(-1),
74        builder.data_slot(-1),
75    ];
76
77    builder.drain([item, lapis]);
78
79    builder.build(EnchantmentKind {
80        enchant_slots,
81        block_pos: pos,
82        world: Arc::clone(world),
83        item,
84        lapis,
85        player_slots,
86        enchantment_seed,
87        table_changes_seen: 0,
88        costs: [0; OFFER_COUNT],
89        enchant_clue: [None; OFFER_COUNT],
90        level_clue: [None; OFFER_COUNT],
91        cost_slots,
92        seed_slot,
93        enchant_clue_slots,
94        level_clue_slots,
95    })
96}
97
98/// Per-menu enchanting table state: the two table slots, the seeded offers, and
99/// their client mirrors.
100pub struct EnchantmentKind {
101    /// The table's own two slots, indexed by [`ITEM_SLOT`] and [`LAPIS_SLOT`].
102    enchant_slots: Shared<SimpleContainer>,
103    block_pos: BlockPos,
104    world: Arc<World>,
105    item: Section,
106    lapis: Section,
107    player_slots: PlayerInventorySections,
108    enchantment_seed: i32,
109    /// `enchant_slots.times_changed()` when the offers were last computed.
110    table_changes_seen: u32,
111    costs: [i32; OFFER_COUNT],
112    /// Registry id of the shown enchantment per offer
113    enchant_clue: [Option<i32>; OFFER_COUNT],
114    /// Level of the shown enchantment per offer
115    level_clue: [Option<i32>; OFFER_COUNT],
116    cost_slots: [DataSlot; OFFER_COUNT],
117    seed_slot: DataSlot,
118    enchant_clue_slots: [DataSlot; OFFER_COUNT],
119    level_clue_slots: [DataSlot; OFFER_COUNT],
120}
121
122// SAFETY: This Steel-owned key uniquely identifies the concrete menu kind
123// within the process.
124unsafe impl DowncastType for EnchantmentKind {
125    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:menu/enchantment");
126}
127
128impl EnchantmentKind {
129    /// Data slots travel as protocol shorts; vanilla sends the low 16 bits of each int.
130    const fn client_value(value: i32) -> i16 {
131        let [low, high, _, _] = value.to_le_bytes();
132        i16::from_le_bytes([low, high])
133    }
134
135    fn seeded_random(seed: i32) -> LegacyRandom {
136        LegacyRandom::from_seed(i64::from(seed).cast_unsigned())
137    }
138
139    fn sync_data(&self, behavior: &mut MenuBehavior) {
140        for offer in 0..OFFER_COUNT {
141            self.cost_slots[offer].set(behavior, Self::client_value(self.costs[offer]));
142            self.enchant_clue_slots[offer].set(
143                behavior,
144                self.enchant_clue[offer].map_or(-1, Self::client_value),
145            );
146            self.level_clue_slots[offer].set(
147                behavior,
148                self.level_clue[offer].map_or(-1, Self::client_value),
149            );
150        }
151        self.seed_slot
152            .set(behavior, Self::client_value(self.enchantment_seed));
153    }
154
155    const fn clear_offers(&mut self) {
156        self.costs = [0; OFFER_COUNT];
157        self.enchant_clue = [None; OFFER_COUNT];
158        self.level_clue = [None; OFFER_COUNT];
159    }
160
161    fn count_bookshelves(&self) -> i32 {
162        EnchantingTableBlock::BOOKSHELF_OFFSETS
163            .iter()
164            .filter(|offset| {
165                EnchantingTableBlock::is_valid_book_shelf(&self.world, self.block_pos, **offset)
166            })
167            .count() as i32
168    }
169
170    /// Selects an offer using `seed + slot`; books discard one result if several are rolled.
171    fn enchantment_list(
172        &self,
173        random: &mut LegacyRandom,
174        stack: &ItemStack,
175        slot: usize,
176        enchantment_cost: i32,
177    ) -> Vec<EnchantmentInstance> {
178        random.set_seed(i64::from(self.enchantment_seed.wrapping_add(slot as i32)));
179        let mut list = select_enchantment(
180            random,
181            stack,
182            enchantment_cost,
183            REGISTRY
184                .enchantments
185                .iter_tag(&EnchantmentTag::IN_ENCHANTING_TABLE),
186        );
187        if stack.is(&vanilla_items::BOOK) && list.len() > 1 {
188            let removed = random.next_i32_bounded(list.len() as i32) as usize;
189            list.remove(removed);
190        }
191        list
192    }
193
194    /// `enchant_slots.times_changed()` as seen through `guard`.
195    fn table_changes(&self, guard: &ContainerLockGuard) -> Option<u32> {
196        guard
197            .get_typed::<SimpleContainer>(ContainerId::from_arc(&self.enchant_slots))
198            .map(SimpleContainer::times_changed)
199    }
200
201    /// Rerolls the three offers for the table item
202    fn update_offers(&mut self, behavior: &mut MenuBehavior, guard: &mut ContainerLockGuard) {
203        if let Some(changes) = self.table_changes(guard) {
204            self.table_changes_seen = changes;
205        }
206        let stack = behavior.slots()[self.item.start()].get_item(guard).clone();
207        if stack.is_empty() || !stack.is_enchantable() {
208            self.clear_offers();
209            self.sync_data(behavior);
210            return;
211        }
212
213        let bookcases = guard.run_unlocked(|| self.count_bookshelves());
214        let mut random = Self::seeded_random(self.enchantment_seed);
215        for offer in 0..OFFER_COUNT {
216            self.costs[offer] = get_enchantment_cost(&mut random, offer, bookcases, &stack);
217            self.enchant_clue[offer] = None;
218            self.level_clue[offer] = None;
219            if self.costs[offer] < offer as i32 + 1 {
220                self.costs[offer] = 0;
221            }
222        }
223        for offer in 0..OFFER_COUNT {
224            if self.costs[offer] <= 0 {
225                continue;
226            }
227            let list = self.enchantment_list(&mut random, &stack, offer, self.costs[offer]);
228            if list.is_empty() {
229                continue;
230            }
231            let clue = list[random.next_i32_bounded(list.len() as i32) as usize];
232            self.enchant_clue[offer] = Some(clue.enchantment.id() as i32);
233            self.level_clue[offer] = Some(clue.level as i32);
234        }
235        self.sync_data(behavior);
236    }
237}
238
239impl MenuKind for EnchantmentKind {
240    fn still_valid(&self, _behavior: &MenuBehavior, player: &Player) -> bool {
241        still_valid_block(
242            &self.world,
243            self.block_pos,
244            &vanilla_blocks::ENCHANTING_TABLE,
245            player,
246        )
247    }
248
249    /// Rerolls offers only when the table slots change, ignoring player-inventory clicks.
250    fn slots_changed(
251        &mut self,
252        behavior: &mut MenuBehavior,
253        guard: &mut ContainerLockGuard,
254        _player: &Player,
255    ) {
256        if self.table_changes(guard) == Some(self.table_changes_seen) {
257            return;
258        }
259        self.update_offers(behavior, guard);
260    }
261
262    /// Applies the selected offer when the player can pay.
263    fn click_menu_button(
264        &mut self,
265        behavior: &mut MenuBehavior,
266        button_id: i32,
267        player: &Player,
268    ) -> bool {
269        let Some(offer) = usize::try_from(button_id)
270            .ok()
271            .filter(|offer| *offer < OFFER_COUNT)
272        else {
273            log::debug!(
274                "{} pressed invalid button id: {button_id}",
275                player.gameprofile.name()
276            );
277            return false;
278        };
279        let enchantment_cost = offer as i32 + 1;
280        let has_infinite_materials = player.has_infinite_materials();
281
282        let mut guard = ContainerLockGuard::lock_all(&[ContainerRef::from(&self.enchant_slots)]);
283        let container_id = ContainerId::from_arc(&self.enchant_slots);
284        let Some(container) = guard.get(container_id) else {
285            return false;
286        };
287        let (stack, mut currency) = (
288            container.get_item(ITEM_SLOT).clone(),
289            container.get_item(LAPIS_SLOT).clone(),
290        );
291
292        if (currency.is_empty() || currency.count() < enchantment_cost) && !has_infinite_materials {
293            return false;
294        }
295        let level = player.experience.lock().level();
296        if self.costs[offer] <= 0
297            || stack.is_empty()
298            || ((level < enchantment_cost || level < self.costs[offer]) && !has_infinite_materials)
299        {
300            return false;
301        }
302
303        let mut random = Self::seeded_random(self.enchantment_seed);
304        let new_enchantments = self.enchantment_list(&mut random, &stack, offer, self.costs[offer]);
305        if new_enchantments.is_empty() {
306            return true;
307        }
308
309        {
310            let mut experience = player.experience.lock();
311            experience.on_enchantment_performed(enchantment_cost, rand::random());
312            self.enchantment_seed = experience.enchantment_seed();
313        }
314
315        // Unlike vanilla transmuteCopy, sanitizing only drops patches equal to the new
316        // prototype's defaults. Custom patches can therefore survive the conversion.
317        let mut enchanted = if stack.is(&vanilla_items::BOOK) {
318            ItemStack::with_count_and_patch(
319                &vanilla_items::ENCHANTED_BOOK,
320                stack.count(),
321                stack.components_patch().clone(),
322            )
323        } else {
324            stack
325        };
326        for instance in &new_enchantments {
327            enchanted.upgrade_enchantment(instance.enchantment.key.clone(), instance.level);
328        }
329        currency.consume(enchantment_cost, has_infinite_materials);
330
331        let Some(container) = guard.get_mut(container_id) else {
332            return false;
333        };
334        container.set_item(ITEM_SLOT, enchanted);
335        container.set_item(LAPIS_SLOT, currency);
336        container.set_changed();
337
338        player.award_custom_stat(&vanilla_custom_stats::ENCHANT_ITEM);
339        // TODO: Trigger CriteriaTriggers.ENCHANTED_ITEM once Steel has shared advancement foundations.
340
341        self.update_offers(behavior, &mut guard);
342        drop(guard);
343
344        self.world.play_sound(
345            &sound_events::BLOCK_ENCHANTMENT_TABLE_USE,
346            SoundSource::Blocks,
347            self.block_pos,
348            1.0,
349            rand::random::<f32>() * ENCHANT_SOUND_PITCH_RANGE + ENCHANT_SOUND_MIN_PITCH,
350            None,
351        );
352        true
353    }
354
355    /// Moves table items back to inventory, lapis to its slot, and one other item
356    /// into the empty item slot.
357    fn quick_move(
358        &mut self,
359        behavior: &mut MenuBehavior,
360        guard: &mut ContainerLockGuard,
361        slot_index: usize,
362        player: &Player,
363    ) -> Option<ItemStack> {
364        if slot_index >= behavior.slots().len() {
365            return Some(ItemStack::empty());
366        }
367        let clicked = behavior.slots()[slot_index].get_item(guard).clone();
368        if clicked.is_empty() {
369            return Some(ItemStack::empty());
370        }
371        let mut remaining = clicked.clone();
372
373        let moved = if self.item.contains(slot_index) || self.lapis.contains(slot_index) {
374            behavior.move_item_stack_to(
375                guard,
376                slot_index,
377                &mut remaining,
378                self.player_slots.all().start(),
379                self.player_slots.all().end(),
380                FillDirection::Backward,
381            )
382        } else if clicked.is(&vanilla_items::LAPIS_LAZULI) {
383            behavior.move_item_stack_to(
384                guard,
385                slot_index,
386                &mut remaining,
387                self.lapis.start(),
388                self.lapis.end(),
389                FillDirection::Backward,
390            )
391        } else {
392            let item_slot = &behavior.slots()[self.item.start()];
393            if item_slot.has_item(guard) || !item_slot.may_place(&clicked) {
394                return Some(ItemStack::empty());
395            }
396            let single = remaining.split(1);
397            item_slot.set_by_player(guard, single, &ItemStack::empty());
398            true
399        };
400
401        if !moved {
402            return Some(ItemStack::empty());
403        }
404        behavior.update_quick_move_source(guard, slot_index, &remaining, &clicked);
405        if remaining.count() == clicked.count() {
406            return Some(ItemStack::empty());
407        }
408        if let Some(remainder) = behavior.slots()[slot_index].on_take(guard, &remaining, player) {
409            player.add_item_or_drop_with_guard(guard, remainder, Prediction::Predicted);
410        }
411        Some(clicked)
412    }
413}
414
415#[cfg(test)]
416mod tests;