Skip to main content

steel_core/command/builtins/
invsee.rs

1use std::{
2    array,
3    ops::Range,
4    sync::{Arc, Weak},
5};
6
7use steel_registry::vanilla_menu_types;
8use steel_utils::Identifier;
9use text_components::TextComponent;
10
11use super::super::{
12    brigadier::{CommandNodeBuilder, CommandSyntaxError},
13    execution::{
14        CommandPermissionSource, CommandSource, SteelArgumentType, SteelCommandRuntime, argument,
15        literal,
16    },
17    registration::{CommandRegistration, CommandRegistrationError},
18};
19use crate::entity::Entity;
20use crate::inventory::menu::Menu;
21use crate::inventory::prelude::*;
22use crate::inventory::slots::CraftingHandler;
23use crate::permission::{PermissionExpr, PermissionKey, PermissionKeyError};
24use crate::player::player_inventory::{PlayerInventory, armor_equipment};
25use crate::player::{Player, connection::NetworkConnection};
26
27const INVSEE_PERMISSION: &str = "steel.command.invsee";
28const MODIFY_PERMISSION: &str = "steel.command.invsee.modify";
29
30pub(super) fn registration() -> Result<CommandRegistration<CommandSource>, CommandRegistrationError>
31{
32    let id = Identifier::from_steel("invsee");
33    let (access_permission, modify_permission) = invsee_permissions().map_err(|source| {
34        CommandRegistrationError::InvalidExplicitPermission {
35            id: id.clone(),
36            source,
37        }
38    })?;
39    Ok(
40        CommandRegistration::new(id, move |_| command(modify_permission))
41            .permission(access_permission),
42    )
43}
44
45fn invsee_permissions() -> Result<(PermissionExpr, PermissionExpr), PermissionKeyError> {
46    let access = PermissionExpr::key(PermissionKey::parse(INVSEE_PERMISSION)?);
47    let modify = PermissionExpr::key(PermissionKey::parse(MODIFY_PERMISSION)?);
48    Ok((PermissionExpr::Any(vec![access, modify.clone()]), modify))
49}
50
51fn command(
52    modify_permission: PermissionExpr,
53) -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
54    literal("invsee").then(
55        argument("target", SteelArgumentType::player()).executes(move |ctx| {
56            let target = ctx.player("target")?;
57            let Some(source) = ctx.source().player() else {
58                return Err(CommandSyntaxError::dynamic(TextComponent::const_plain(
59                    "you cannot use this command from the console",
60                )));
61            };
62            ensure_same_domain(source, &target)?;
63            // Command permissions belong to the initiating authorization even
64            // when `/execute as` changes which player receives the menu. Capture
65            // the resulting mode once when the menu opens.
66            let modify = ctx.source().has_permission(&modify_permission);
67            let opener = Arc::clone(source);
68            let menu_source = Arc::clone(source);
69            opener.open_menu(target.display_name(), move |context| {
70                invsee(context.container_id, &menu_source, &target, modify)
71            });
72            Ok(1)
73        }),
74    )
75}
76
77fn ensure_same_domain(source: &Player, target: &Player) -> Result<(), CommandSyntaxError> {
78    if source.is_domain_switching() || target.is_domain_switching() {
79        return Err(CommandSyntaxError::dynamic(
80            "Invsee is unavailable while a player is switching domains",
81        ));
82    }
83    let source_world = source.get_world();
84    let target_world = target.get_world();
85    if source_world.domain() == target_world.domain() {
86        return Ok(());
87    }
88    Err(CommandSyntaxError::dynamic(
89        "Invsee cannot open inventories across Steel domains",
90    ))
91}
92
93fn invsee(container_id: u8, source: &Arc<Player>, target: &Arc<Player>, modify: bool) -> Menu {
94    let mut b = MenuBuilder::new(&vanilla_menu_types::GENERIC_9X5, container_id);
95
96    let kind = if modify {
97        SectionKind::Normal
98    } else {
99        SectionKind::Display
100    };
101
102    let target_inventory = b.player_inventory_with(&target.inventory, &kind);
103
104    let armor_kind = if modify {
105        SectionKind::restricted(|index, item| item.is_equippable_in_slot(armor_equipment(index)))
106    } else {
107        SectionKind::Display
108    };
109    let armor = b.section_at(
110        &target.inventory,
111        PlayerInventory::ARMOR_TOP_DOWN,
112        armor_kind,
113    );
114    let offhand = b.section_at(&target.inventory, [PlayerInventory::SLOT_OFFHAND], kind);
115
116    let crafting_handler = target.inventory_crafting_handler();
117    let crafting_container = crafting_handler.crafting_container();
118    let crafting = if modify {
119        b.section_all_with(crafting_container, SectionKind::take_only())
120    } else {
121        b.section_all_with(crafting_container, SectionKind::Display)
122    };
123    b.register_container(crafting_handler.result_container());
124
125    let target_slots = 0..b.slot_count();
126    let viewer = b.player_inventory(&source.inventory);
127
128    if modify {
129        let inventories_alias = Arc::ptr_eq(&source.inventory, &target.inventory);
130        if !inventories_alias {
131            b.route(
132                target_inventory.all(),
133                viewer.all(),
134                FillDirection::Backward,
135            );
136            b.route(
137                viewer.all(),
138                [target_inventory.all(), armor, offhand],
139                FillDirection::Forward,
140            );
141        }
142        b.route(
143            [armor, offhand, crafting],
144            viewer.all(),
145            FillDirection::Backward,
146        );
147    }
148
149    b.build(InvseeMenuKind {
150        target: Arc::downgrade(target),
151        target_inventory_id: ContainerId::from_arc(&target.inventory),
152        domain: target.get_world().domain().into(),
153        modify,
154        target_slots,
155        crafting,
156        crafting_handler,
157        inventory_before_click: None,
158    })
159}
160
161struct InvseeMenuKind {
162    target: Weak<Player>,
163    target_inventory_id: ContainerId,
164    domain: Box<str>,
165    modify: bool,
166    target_slots: Range<usize>,
167    crafting: Section,
168    crafting_handler: CraftingHandler,
169    inventory_before_click: Option<[ItemStack; PlayerInventory::SLOT_OFFHAND + 1]>,
170}
171
172// SAFETY: This Steel-owned key uniquely identifies the concrete menu kind
173// within the process.
174unsafe impl steel_utils::DowncastType for InvseeMenuKind {
175    const TYPE_KEY: steel_utils::DowncastTypeKey =
176        steel_utils::DowncastTypeKey::new("steel:menu/invsee");
177}
178
179impl MenuKind for InvseeMenuKind {
180    fn on_drag(
181        &mut self,
182        _behavior: &mut MenuBehavior,
183        guard: &mut ContainerLockGuard,
184        _action: QuickCraft,
185        _player: &Player,
186    ) -> ClickOutcome {
187        self.snapshot_inventory_before_click(guard);
188        ClickOutcome::Fallthrough
189    }
190
191    fn on_open(
192        &mut self,
193        _behavior: &mut MenuBehavior,
194        guard: &mut ContainerLockGuard,
195        _player: &Player,
196    ) {
197        self.crafting_handler.update_result(guard);
198    }
199
200    fn slots_changed(
201        &mut self,
202        _behavior: &mut MenuBehavior,
203        guard: &mut ContainerLockGuard,
204        _player: &Player,
205    ) {
206        self.crafting_handler.update_result(guard);
207        self.queue_changed_target_inventory(guard);
208    }
209
210    fn on_slot_clicked(
211        &mut self,
212        _behavior: &mut MenuBehavior,
213        guard: &mut ContainerLockGuard,
214        click: Click,
215        _player: &Player,
216    ) -> ClickOutcome {
217        let Some(slot) = click.slot() else {
218            return ClickOutcome::Fallthrough;
219        };
220        self.snapshot_inventory_before_click(guard);
221        if (!self.modify && self.target_slots.contains(&slot))
222            || (self.crafting.contains(slot) && matches!(click, Click::Clone { .. }))
223        {
224            ClickOutcome::Consume
225        } else {
226            ClickOutcome::Fallthrough
227        }
228    }
229
230    fn can_drag_to(&self, slot_index: usize) -> bool {
231        if self.modify {
232            !self.crafting.contains(slot_index)
233        } else {
234            !self.target_slots.contains(&slot_index)
235        }
236    }
237
238    fn can_take_item_for_pick_all(&self, _carried: &ItemStack, slot_index: usize) -> bool {
239        self.modify || !self.target_slots.contains(&slot_index)
240    }
241
242    fn still_valid(&self, _behavior: &MenuBehavior, player: &Player) -> bool {
243        let Some(target) = self.target.upgrade() else {
244            return false;
245        };
246        let player_world = player.get_world();
247        let target_world = target.get_world();
248        !player.is_domain_switching()
249            && !target.connection.closed()
250            && !target.is_domain_switching()
251            && player_world.domain() == self.domain.as_ref()
252            && target_world.domain() == self.domain.as_ref()
253    }
254}
255
256impl InvseeMenuKind {
257    fn snapshot_inventory_before_click(&mut self, guard: &ContainerLockGuard) {
258        if !self.modify {
259            return;
260        }
261        let Some(inventory) = guard.get(self.target_inventory_id) else {
262            unreachable!("invsee always locks the target inventory");
263        };
264        self.inventory_before_click = Some(array::from_fn(|slot| inventory.get_item(slot).clone()));
265    }
266
267    fn queue_changed_target_inventory(&mut self, guard: &ContainerLockGuard) {
268        let Some(previous) = self.inventory_before_click.take() else {
269            return;
270        };
271        let Some(inventory) = guard.get(self.target_inventory_id) else {
272            unreachable!("invsee always locks the target inventory");
273        };
274        let changed_slots = previous
275            .iter()
276            .enumerate()
277            .filter_map(|(slot, previous)| {
278                let current = inventory.get_item(slot);
279                (!ItemStack::matches(previous, current)).then_some(slot)
280            })
281            .collect::<Vec<_>>();
282        if changed_slots.is_empty() {
283            return;
284        }
285        let Some(target) = self.target.upgrade() else {
286            return;
287        };
288        target.request_inventory_resync(changed_slots);
289    }
290}
291
292#[cfg(test)]
293mod tests;