Skip to main content

steel_core/inventory/menu/kinds/
grindstone_menu.rs

1//! Grindstone menu.
2use std::sync::Arc;
3
4use steel_registry::data_components::vanilla_components::MAX_DAMAGE;
5use steel_registry::vanilla_blocks;
6use steel_registry::{
7    REGISTRY, RegistryExt, TaggedRegistryExt,
8    blocks::block_state_ext::BlockStateExt,
9    data_components::{
10        components::ItemEnchantments,
11        vanilla_components::{ENCHANTMENTS, REPAIR_COST, STORED_ENCHANTMENTS},
12    },
13    item_stack::ItemStack,
14    vanilla_enchantment_tags::EnchantmentTag,
15    vanilla_items, vanilla_menu_types,
16};
17use steel_utils::{
18    BlockPos,
19    locks::{IntoShared, Shared},
20};
21
22use crate::{
23    inventory::{
24        container::{ResultContainer, SimpleContainer},
25        menu::kinds::AnvilKind,
26        prelude::*,
27        slots::GrindstoneResultHandler,
28    },
29    player::player_inventory::PlayerInventory,
30    world::World,
31};
32
33use std::cmp::max;
34
35/// Builds the grindstone menu.
36#[must_use]
37pub fn grindstone(
38    inventory: Shared<PlayerInventory>,
39    container_id: u8,
40    pos: BlockPos,
41    world: &Arc<World>,
42) -> Menu {
43    let input_container = SimpleContainer::new(2).into_shared();
44
45    let result_container = ResultContainer::new().into_shared();
46
47    let mut builder = MenuBuilder::new(&vanilla_menu_types::GRINDSTONE, container_id);
48
49    let input = builder.section_all_with(
50        &input_container,
51        SectionKind::restricted(|_index, item_stack| {
52            item_stack.is_damageable_item()
53                || item_stack
54                    .get_enchantments_for_crafting()
55                    .is_some_and(|e| !e.is_empty())
56        }),
57    );
58    let result = builder.result_slot(GrindstoneResultHandler::new(
59        input_container.clone(),
60        result_container.clone(),
61        pos,
62        world.clone(),
63    ));
64
65    let player = builder.player_inventory(&inventory);
66
67    builder.route_with_remainder_policy(
68        result,
69        player.all(),
70        FillDirection::Backward,
71        FakeResultRemainderPolicy::Discard,
72    );
73    builder.route(input, player.all(), FillDirection::Forward);
74    builder.drain(input);
75
76    builder.build(GrindstoneKind {
77        input_container,
78        result_container,
79        block_pos: pos,
80        world: Arc::clone(world),
81        input,
82        player,
83    })
84}
85
86/// Per-menu grindstone state: the two inputs and the virtual result.
87pub struct GrindstoneKind {
88    /// Input container (two slots).
89    input_container: Shared<SimpleContainer>,
90    /// Result container (single virtual slot).
91    result_container: Shared<ResultContainer>,
92    block_pos: BlockPos,
93    world: Arc<World>,
94    input: Section,
95    player: PlayerInventorySections,
96}
97
98// SAFETY: This Steel-owned key uniquely identifies the concrete menu kind
99// within the process.
100unsafe impl steel_utils::DowncastType for GrindstoneKind {
101    const TYPE_KEY: steel_utils::DowncastTypeKey =
102        steel_utils::DowncastTypeKey::new("steel:menu/grindstone");
103}
104
105impl GrindstoneKind {
106    /// Builds the grindstone result from combining and renaming the two inputs.
107    ///
108    /// # Panics
109    /// Panics if the input container is not exactly two slots.
110    #[tracing::instrument(skip(self, _behavior, player, guard), level = "info", fields(player = %player.gameprofile.name))]
111    pub(crate) fn create_result(
112        &mut self,
113        _behavior: &mut MenuBehavior,
114        guard: &mut ContainerLockGuard,
115        player: &Player,
116    ) {
117        let Some([input_container, result_container]) = guard.get_disjoint_mut([
118            ContainerId::from_arc(&self.input_container),
119            ContainerId::from_arc(&self.result_container),
120        ]) else {
121            log::warn!("failed to lock input and/or result containers to create grindstone result");
122            return;
123        };
124
125        let [first, second] = input_container.items() else {
126            log::warn!("input_container in grindstone menu does not fit expected shape");
127            return;
128        };
129
130        if first.is_empty() && second.is_empty() {
131            result_container.set_item(0, ItemStack::empty());
132            return;
133        }
134
135        if first.count() <= 1 && second.count() <= 1 {
136            if !first.is_empty() && !second.is_empty() {
137                result_container.set_item(
138                    0,
139                    GrindstoneKind::merge_items(first.clone(), second.clone()),
140                );
141            } else {
142                let item = if first.is_empty() { second } else { first };
143
144                if item
145                    .get_enchantments_for_crafting()
146                    .is_some_and(|e| !e.is_empty())
147                {
148                    result_container
149                        .set_item(0, GrindstoneKind::remove_non_curses_from(item.clone()));
150                } else {
151                    result_container.set_item(0, ItemStack::empty());
152                }
153            }
154        } else {
155            result_container.set_item(0, ItemStack::empty());
156        }
157    }
158
159    /// Merges two items and their enchants but gets rid of their non-curse enchants
160    #[must_use]
161    fn merge_items(first: ItemStack, second: ItemStack) -> ItemStack {
162        if !first.is(second.item()) {
163            return ItemStack::empty();
164        }
165
166        let durability = max(first.get_max_damage(), second.get_max_damage());
167        let remaining1 = first.get_max_damage() - first.get_damage_value();
168        let remaining2 = second.get_max_damage() - second.get_damage_value();
169        let remaining = remaining1 + remaining2 + durability * 5 / 100;
170        let mut count = 1;
171
172        if !first.is_damageable_item() {
173            count = 2;
174
175            if first.max_stack_size() < 2 || !ItemStack::matches(&first, &second) {
176                return ItemStack::empty();
177            }
178        }
179
180        let mut new_item = first.copy_with_count(count);
181        if new_item.is_damageable_item() {
182            new_item.set(MAX_DAMAGE, durability);
183            new_item.set_damage_value(max(durability - remaining, 0));
184        }
185
186        GrindstoneKind::merge_enchantments_from(&mut new_item, &second);
187        GrindstoneKind::remove_non_curses_from(new_item)
188    }
189
190    /// Copies `source`'s enchantments onto `target`, keeping the higher level when
191    /// both carry the same one. Curses only transfer if `target` does not already
192    /// have them, so merging two cursed items cannot stack the curse level.
193    fn merge_enchantments_from(target: &mut ItemStack, source: &ItemStack) {
194        let Some(source_enchantments) = source.get_enchantments_for_crafting() else {
195            return;
196        };
197
198        for (id, level) in source_enchantments.iter() {
199            let is_curse = REGISTRY.enchantments.by_key(id).is_some_and(|enchantment| {
200                REGISTRY
201                    .enchantments
202                    .is_in_tag(enchantment, &EnchantmentTag::CURSE)
203            });
204
205            let target_level = target
206                .get_enchantments_for_crafting()
207                .map_or(0, |enchantments| enchantments.get_level(id));
208
209            if !is_curse || target_level == 0 {
210                target.upgrade_enchantment(id.clone(), *level);
211            }
212        }
213    }
214
215    /// Remove non-curse enchantments from items and returns them
216    #[must_use]
217    pub fn remove_non_curses_from(mut item: ItemStack) -> ItemStack {
218        let Some(enchantments) = item.get_enchantments_for_crafting() else {
219            return ItemStack::empty();
220        };
221
222        let mut new_enchantments = ItemEnchantments::empty();
223
224        enchantments
225            .iter()
226            .filter(|(id, _)| {
227                REGISTRY.enchantments.by_key(id).is_some_and(|enchantment| {
228                    REGISTRY
229                        .enchantments
230                        .is_in_tag(enchantment, &EnchantmentTag::CURSE)
231                })
232            })
233            .for_each(|(id, level)| new_enchantments.set(id.clone(), *level));
234
235        let mut repair_cost = 0;
236        for _ in 0..new_enchantments.len() {
237            repair_cost = AnvilKind::calculate_increased_repair_cost(repair_cost);
238        }
239
240        item.set(REPAIR_COST, repair_cost);
241        if item.is(&vanilla_items::ENCHANTED_BOOK) {
242            if new_enchantments.is_empty() {
243                // Clear before transmuting: BOOK has no default to sanitize against.
244                item.set(STORED_ENCHANTMENTS, new_enchantments);
245                item.set_item(&vanilla_items::BOOK.key);
246                return item;
247            }
248            item.set(STORED_ENCHANTMENTS, new_enchantments);
249        } else {
250            item.set(ENCHANTMENTS, new_enchantments);
251        }
252
253        item
254    }
255}
256
257impl MenuKind for GrindstoneKind {
258    /// Returns true while the original grindstone remains in range.
259    fn still_valid(&self, _behavior: &MenuBehavior, player: &Player) -> bool {
260        let state = self.world.get_block_state(self.block_pos);
261        state.get_block() == &vanilla_blocks::GRINDSTONE
262            && player.is_within_block_interaction_range_with_buffer(self.block_pos, 4.0)
263    }
264
265    fn slots_changed(
266        &mut self,
267        behavior: &mut MenuBehavior,
268        guard: &mut ContainerLockGuard,
269        player: &Player,
270    ) {
271        self.create_result(behavior, guard, player);
272    }
273
274    /// Clears the virtual result on close. Inputs are drained by [`Menu::removed`].
275    fn removed(&mut self, _behavior: &mut MenuBehavior, _player: &Player) {
276        self.result_container.lock().set_item(0, ItemStack::empty());
277    }
278
279    fn quick_move(
280        &mut self,
281        behavior: &mut MenuBehavior,
282        guard: &mut ContainerLockGuard,
283        slot_index: usize,
284        player: &Player,
285    ) -> Option<ItemStack> {
286        if !self.player.all().contains(slot_index) {
287            return None;
288        }
289
290        let clicked = behavior.slots()[slot_index].get_item(guard).clone();
291        if clicked.is_empty() {
292            return Some(ItemStack::empty());
293        }
294
295        let Some(inputs) = guard.get(ContainerId::from_arc(&self.input_container)) else {
296            log::warn!("input container not locked while quick-moving in the grindstone menu");
297            return Some(ItemStack::empty());
298        };
299        let both_inputs_filled = !inputs.get_item(0).is_empty() && !inputs.get_item(1).is_empty();
300
301        let mut remaining = clicked.clone();
302        let destination = if !both_inputs_filled {
303            self.input
304        } else if self.player.main().contains(slot_index) {
305            self.player.hotbar()
306        } else {
307            self.player.main()
308        };
309
310        let moved = behavior.move_item_stack_to(
311            guard,
312            slot_index,
313            &mut remaining,
314            destination.start(),
315            destination.end(),
316            FillDirection::Forward,
317        );
318        if !moved {
319            return Some(ItemStack::empty());
320        }
321
322        behavior.update_quick_move_source(guard, slot_index, &remaining, &clicked);
323        if remaining.count() == clicked.count() {
324            return Some(ItemStack::empty());
325        }
326        if let Some(remainder) = behavior.slots()[slot_index].on_take(guard, &remaining, player) {
327            player.add_item_or_drop_with_guard(guard, remainder);
328        }
329        Some(clicked)
330    }
331}