Skip to main content

steel_core/inventory/menu/kinds/
grindstone_menu.rs

1//! Grindstone menu.
2use crate::player::player_inventory::Prediction;
3use std::sync::Arc;
4
5use steel_registry::data_components::vanilla_components::MAX_DAMAGE;
6use steel_registry::vanilla_blocks;
7use steel_registry::{
8    REGISTRY, RegistryExt, TaggedRegistryExt,
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        Arc::clone(&input_container),
60        Arc::clone(&result_container),
61        pos,
62        Arc::clone(world),
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(0, GrindstoneKind::merge_items(first, second));
138            } else {
139                let item = if first.is_empty() { second } else { first };
140
141                if item
142                    .get_enchantments_for_crafting()
143                    .is_some_and(|e| !e.is_empty())
144                {
145                    result_container
146                        .set_item(0, GrindstoneKind::remove_non_curses_from(item.clone()));
147                } else {
148                    result_container.set_item(0, ItemStack::empty());
149                }
150            }
151        } else {
152            result_container.set_item(0, ItemStack::empty());
153        }
154    }
155
156    /// Merges two items and their enchants but gets rid of their non-curse enchants
157    #[must_use]
158    fn merge_items(first: &ItemStack, second: &ItemStack) -> ItemStack {
159        if !first.is(second.item()) {
160            return ItemStack::empty();
161        }
162
163        let durability = max(first.get_max_damage(), second.get_max_damage());
164        let remaining1 = first.get_max_damage() - first.get_damage_value();
165        let remaining2 = second.get_max_damage() - second.get_damage_value();
166        let remaining = remaining1 + remaining2 + durability * 5 / 100;
167        let mut count = 1;
168
169        if !first.is_damageable_item() {
170            count = 2;
171
172            if first.max_stack_size() < 2 || !ItemStack::matches(first, second) {
173                return ItemStack::empty();
174            }
175        }
176
177        let mut new_item = first.copy_with_count(count);
178        if new_item.is_damageable_item() {
179            new_item.set(MAX_DAMAGE, durability);
180            new_item.set_damage_value(max(durability - remaining, 0));
181        }
182
183        GrindstoneKind::merge_enchantments_from(&mut new_item, second);
184        GrindstoneKind::remove_non_curses_from(new_item)
185    }
186
187    /// Copies `source`'s enchantments onto `target`, keeping the higher level when
188    /// both carry the same one. Curses only transfer if `target` does not already
189    /// have them, so merging two cursed items cannot stack the curse level.
190    fn merge_enchantments_from(target: &mut ItemStack, source: &ItemStack) {
191        let Some(source_enchantments) = source.get_enchantments_for_crafting() else {
192            return;
193        };
194
195        for (id, level) in source_enchantments.iter() {
196            let is_curse = REGISTRY.enchantments.by_key(id).is_some_and(|enchantment| {
197                REGISTRY
198                    .enchantments
199                    .is_in_tag(enchantment, &EnchantmentTag::CURSE)
200            });
201
202            let target_level = target
203                .get_enchantments_for_crafting()
204                .map_or(0, |enchantments| enchantments.get_level(id));
205
206            if !is_curse || target_level == 0 {
207                target.upgrade_enchantment(id.clone(), *level);
208            }
209        }
210    }
211
212    /// Remove non-curse enchantments from items and returns them
213    #[must_use]
214    pub fn remove_non_curses_from(mut item: ItemStack) -> ItemStack {
215        let Some(enchantments) = item.get_enchantments_for_crafting() else {
216            return ItemStack::empty();
217        };
218
219        let mut new_enchantments = ItemEnchantments::empty();
220
221        enchantments
222            .iter()
223            .filter(|(id, _)| {
224                REGISTRY.enchantments.by_key(id).is_some_and(|enchantment| {
225                    REGISTRY
226                        .enchantments
227                        .is_in_tag(enchantment, &EnchantmentTag::CURSE)
228                })
229            })
230            .for_each(|(id, level)| new_enchantments.set(id.clone(), *level));
231
232        let mut repair_cost = 0;
233        for _ in 0..new_enchantments.len() {
234            repair_cost = AnvilKind::calculate_increased_repair_cost(repair_cost);
235        }
236
237        item.set(REPAIR_COST, repair_cost);
238        if item.is(&vanilla_items::ENCHANTED_BOOK) {
239            if new_enchantments.is_empty() {
240                // Clear before transmuting: BOOK has no default to sanitize against.
241                item.set(STORED_ENCHANTMENTS, new_enchantments);
242                item.set_item(&vanilla_items::BOOK.key);
243                return item;
244            }
245            item.set(STORED_ENCHANTMENTS, new_enchantments);
246        } else {
247            item.set(ENCHANTMENTS, new_enchantments);
248        }
249
250        item
251    }
252}
253
254impl MenuKind for GrindstoneKind {
255    /// Returns true while the original grindstone remains in range.
256    fn still_valid(&self, _behavior: &MenuBehavior, player: &Player) -> bool {
257        still_valid_block(
258            &self.world,
259            self.block_pos,
260            &vanilla_blocks::GRINDSTONE,
261            player,
262        )
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, Prediction::Predicted);
328        }
329        Some(clicked)
330    }
331}