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