diff --git a/contrib/assets/backpack/backpack.png b/contrib/assets/backpack/backpack.png new file mode 100644 index 000000000..4e4e57092 Binary files /dev/null and b/contrib/assets/backpack/backpack.png differ diff --git a/contrib/assets/pouch/pouch.png b/contrib/assets/pouch/pouch.png new file mode 100644 index 000000000..092a27f31 Binary files /dev/null and b/contrib/assets/pouch/pouch.png differ diff --git a/vane-core/src/main/java/org/oddlama/vane/core/config/recipes/RecipeList.java b/vane-core/src/main/java/org/oddlama/vane/core/config/recipes/RecipeList.java index fc39bace2..22552c914 100644 --- a/vane-core/src/main/java/org/oddlama/vane/core/config/recipes/RecipeList.java +++ b/vane-core/src/main/java/org/oddlama/vane/core/config/recipes/RecipeList.java @@ -11,6 +11,11 @@ public class RecipeList implements ConfigDictSerializable { private List recipes = new ArrayList<>(); + public RecipeList() { } + public RecipeList(List recipes) { + this.recipes = recipes; + } + public List recipes() { return recipes; } diff --git a/vane-core/src/main/java/org/oddlama/vane/core/resourcepack/ResourcePackGenerator.java b/vane-core/src/main/java/org/oddlama/vane/core/resourcepack/ResourcePackGenerator.java index 3fe88556f..b13705efa 100644 --- a/vane-core/src/main/java/org/oddlama/vane/core/resourcepack/ResourcePackGenerator.java +++ b/vane-core/src/main/java/org/oddlama/vane/core/resourcepack/ResourcePackGenerator.java @@ -100,19 +100,28 @@ private void write_translations(final ZipOutputStream zip) throws IOException { private JSONObject create_item_model_handheld(NamespacedKey texture) { // Create model json - final var textures = new JSONObject(); - // FIXME: hardcoded fix for compasses. better rewrite RP generator + final var model = new JSONObject(); + + // FIXME: hardcoded fixes. better rewrite RP generator // and use static files for all items. just language should be generated. - if (texture.getNamespace().equals("minecraft") && texture.getKey().equals("compass")) { - textures.put("layer0", texture.getNamespace() + ":item/compass_16"); + if (texture.getNamespace().equals("minecraft") && texture.getKey().equals("dropper")) { + model.put("parent", "minecraft:block/dropper"); + } else if (texture.getNamespace().equals("minecraft") && texture.getKey().endsWith("shulker_box")) { + model.put("parent", "minecraft:item/template_shulker_box"); + final var textures = new JSONObject(); + textures.put("particle", "minecraft:block/" + texture.getKey()); + model.put("textures", textures); } else { - textures.put("layer0", texture.getNamespace() + ":item/" + texture.getKey()); + model.put("parent", "minecraft:item/handheld"); + final var textures = new JSONObject(); + if (texture.getNamespace().equals("minecraft") && texture.getKey().equals("compass")) { + textures.put("layer0", texture.getNamespace() + ":item/compass_16"); + } else { + textures.put("layer0", texture.getNamespace() + ":item/" + texture.getKey()); + } + model.put("textures", textures); } - final var model = new JSONObject(); - model.put("parent", "minecraft:item/handheld"); - model.put("textures", textures); - return model; } diff --git a/vane-core/src/main/java/org/oddlama/vane/util/BlockUtil.java b/vane-core/src/main/java/org/oddlama/vane/util/BlockUtil.java index badbafbb6..f53f1c7cb 100644 --- a/vane-core/src/main/java/org/oddlama/vane/util/BlockUtil.java +++ b/vane-core/src/main/java/org/oddlama/vane/util/BlockUtil.java @@ -388,6 +388,10 @@ public static RaytraceDominantFaceResult raytrace_dominant_face(final LivingEnti public static String texture_from_skull(final Skull skull) { final var profile = skull.getPlayerProfile(); + if (profile == null) { + return null; + } + for (final var property : profile.getProperties()) { if ("textures".equals(property.getName())) { return property.getValue(); diff --git a/vane-trifles/src/main/java/org/oddlama/vane/trifles/StorageGroup.java b/vane-trifles/src/main/java/org/oddlama/vane/trifles/StorageGroup.java new file mode 100644 index 000000000..4cbc00e9d --- /dev/null +++ b/vane-trifles/src/main/java/org/oddlama/vane/trifles/StorageGroup.java @@ -0,0 +1,211 @@ +package org.oddlama.vane.trifles; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.apache.commons.lang3.tuple.Pair; +import org.bukkit.Nameable; +import org.bukkit.block.Container; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BlockStateMeta; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.oddlama.vane.annotation.lang.LangMessage; +import org.oddlama.vane.core.Listener; +import org.oddlama.vane.core.lang.TranslatedMessage; +import org.oddlama.vane.core.module.Context; + +import net.kyori.adventure.text.Component; + +public class StorageGroup extends Listener { + private Map> open_block_state_inventories = Collections + .synchronizedMap(new HashMap>()); + + @LangMessage + public TranslatedMessage lang_open_stacked_item; + + public StorageGroup(Context context) { + super(context.group("storage", "Extensions to storage related stuff will be grouped under here.")); + } + + @SuppressWarnings("deprecation") + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void on_place_item_in_storage_inventory(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + // Only if no block state inventory is open, else we could delete items by accident + final var owner_and_item = open_block_state_inventories.get(event.getInventory()); + if (owner_and_item != null) { + return; + } + + // Put non-storage items in a right-clicked storage item + if (event.getClick() == ClickType.RIGHT && event.getAction() == InventoryAction.SWAP_WITH_CURSOR + && is_storage_item(event.getCurrentItem())) { + + // Allow putting in any items that are not a storage item, or storage items that have nothing in them. + if (!(is_storage_item(event.getCursor()) && event.getCursor().hasItemMeta())) { + final var custom_item = get_module().core.item_registry().get(event.getCurrentItem()); + + // Only if the clicked storage item is a custom item + if (custom_item != null) { + event.getCurrentItem().editMeta(BlockStateMeta.class, meta -> { + final var block_state = meta.getBlockState(); + if (block_state instanceof Container container) { + final var leftovers = container.getInventory().addItem(event.getCursor()); + if (leftovers.size() == 0) { + event.setCursor(null); + } else { + event.setCursor(leftovers.get(0)); + } + meta.setBlockState(block_state); + } + }); + } + } + + // right-clicking a storage item to swap is never "allowed". + event.setCancelled(true); + return; + } + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void on_inventory_click(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + final var owner_and_item = open_block_state_inventories.get(event.getInventory()); + if (owner_and_item == null || !owner_and_item.getLeft().equals(player.getUniqueId())) { + return; + } + + // Prevent putting non-empty storage items in other storage items + if (is_storage_item(event.getCurrentItem()) || (is_storage_item(event.getCursor()) && event.getCursor().hasItemMeta())) { + event.setCancelled(true); + return; + } + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void on_inventory_drag(InventoryDragEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + final var owner_and_item = open_block_state_inventories.get(event.getInventory()); + if (owner_and_item == null || !owner_and_item.getLeft().equals(player.getUniqueId())) { + return; + } + + // Prevent putting storage items in other storage items + for (final var item_stack : event.getNewItems().values()) { + if (is_storage_item(item_stack)) { + event.setCancelled(true); + return; + } + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void save_after_click(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + final var owner_and_item = open_block_state_inventories.get(event.getInventory()); + if (owner_and_item == null || !owner_and_item.getLeft().equals(player.getUniqueId())) { + return; + } + + update_storage_item(owner_and_item.getRight(), event.getInventory()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void save_after_drag(InventoryDragEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + final var owner_and_item = open_block_state_inventories.get(event.getInventory()); + if (owner_and_item == null || !owner_and_item.getLeft().equals(player.getUniqueId())) { + return; + } + + update_storage_item(owner_and_item.getRight(), event.getInventory()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void save_after_close(InventoryCloseEvent event) { + final var owner_and_item = open_block_state_inventories.get(event.getInventory()); + if (owner_and_item == null || !owner_and_item.getLeft().equals(event.getPlayer().getUniqueId())) { + return; + } + + update_storage_item(owner_and_item.getRight(), event.getInventory()); + open_block_state_inventories.remove(event.getInventory()); + } + + private boolean is_storage_item(@Nullable ItemStack item) { + // Any item that has a container block state as the meta is a container to us. + // If the item has no meta (i.e. is empty) it doesnt count. + return item != null + && item.getItemMeta() instanceof BlockStateMeta meta + && meta.getBlockState() instanceof Container; + } + + private void update_storage_item(@NotNull ItemStack item, @NotNull Inventory inventory) { + item.editMeta(BlockStateMeta.class, meta -> { + final var block_state = meta.getBlockState(); + if (block_state instanceof Container container) { + container.getInventory().setContents(inventory.getContents()); + meta.setBlockState(block_state); + } + }); + } + + public boolean open_block_state_inventory(@NotNull final Player player, @NotNull ItemStack item) { + // Require correct block state meta + if (!(item.getItemMeta() instanceof BlockStateMeta meta) + || !(meta.getBlockState() instanceof Container container)) { + return false; + } + + // Only if the stack size is 1. + if (item.getAmount() != 1) { + get_module().storage_group.lang_open_stacked_item.send_action_bar(player); + return false; + } + + // Transfer item name to block-state + Component name = null; + if (meta.getBlockState() instanceof Nameable nameable) { + name = meta.hasDisplayName() ? meta.displayName() : null; + nameable.customName(name); + } + + // Create transient inventory + final var transient_inventory = get_module().getServer().createInventory(player, + container.getInventory().getType(), name); + transient_inventory.setContents(container.getInventory().getContents()); + + // Open inventory + open_block_state_inventories.put(transient_inventory, Pair.of(player.getUniqueId(), item)); + player.openInventory(transient_inventory); + return true; + } +} diff --git a/vane-trifles/src/main/java/org/oddlama/vane/trifles/Trifles.java b/vane-trifles/src/main/java/org/oddlama/vane/trifles/Trifles.java index aeb899f5a..14f169671 100644 --- a/vane-trifles/src/main/java/org/oddlama/vane/trifles/Trifles.java +++ b/vane-trifles/src/main/java/org/oddlama/vane/trifles/Trifles.java @@ -12,9 +12,10 @@ public class Trifles extends Module { public final HashMap last_xp_bottle_consume_time = new HashMap<>(); public XpBottles xp_bottles; public ItemFinder item_finder; + public StorageGroup storage_group; public Trifles() { - var fast_walking_group = new FastWalkingGroup(this); + final var fast_walking_group = new FastWalkingGroup(this); new FastWalkingListener(fast_walking_group); new DoubleDoorListener(this); new HarvestListener(this); @@ -37,5 +38,9 @@ public Trifles() { new org.oddlama.vane.trifles.items.Trowel(this); new org.oddlama.vane.trifles.items.NorthCompass(this); new org.oddlama.vane.trifles.items.SlimeBucket(this); + + storage_group = new StorageGroup(this); + new org.oddlama.vane.trifles.items.storage.Pouch(storage_group.get_context()); + new org.oddlama.vane.trifles.items.storage.Backpack(storage_group.get_context()); } } diff --git a/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/SlimeBucket.java b/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/SlimeBucket.java index 9bd4ec868..67a2ad6d8 100644 --- a/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/SlimeBucket.java +++ b/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/SlimeBucket.java @@ -20,7 +20,6 @@ import org.bukkit.event.player.PlayerInteractEntityEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerMoveEvent; -import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import org.oddlama.vane.annotation.item.VaneItem; import org.oddlama.vane.core.item.CustomItem; diff --git a/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/Trowel.java b/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/Trowel.java index 7046ab4d9..da9a76804 100644 --- a/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/Trowel.java +++ b/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/Trowel.java @@ -1,5 +1,6 @@ package org.oddlama.vane.trifles.items; +import static org.oddlama.vane.util.ItemUtil.damage_item; import static org.oddlama.vane.util.PlayerUtil.swing_arm; import java.util.ArrayList; @@ -231,6 +232,7 @@ public void on_player_interact_block(final PlayerInteractEvent event) { if (result.consumesAction()) { swing_arm(player, EquipmentSlot.HAND); + damage_item(player, item_in_hand, 1); if (sound_type != null) { nms_world.playSound(null, block_pos, sound_type.getPlaceSound(), SoundSource.BLOCKS, (sound_type.getVolume() + 1.0F) / 2.0F, sound_type.getPitch() * 0.8F); } diff --git a/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/storage/Backpack.java b/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/storage/Backpack.java new file mode 100644 index 000000000..eb003abe7 --- /dev/null +++ b/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/storage/Backpack.java @@ -0,0 +1,91 @@ +package org.oddlama.vane.trifles.items.storage; + +import static org.oddlama.vane.util.PlayerUtil.swing_arm; + +import java.util.ArrayList; +import java.util.EnumSet; + +import org.bukkit.DyeColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Sound; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.oddlama.vane.annotation.item.VaneItem; +import org.oddlama.vane.core.config.recipes.RecipeDefinition; +import org.oddlama.vane.core.config.recipes.RecipeList; +import org.oddlama.vane.core.config.recipes.SmithingRecipeDefinition; +import org.oddlama.vane.core.item.CustomItem; +import org.oddlama.vane.core.item.api.InhibitBehavior; +import org.oddlama.vane.core.module.Context; +import org.oddlama.vane.trifles.Trifles; +import org.oddlama.vane.util.MaterialUtil; + +@VaneItem(name = "backpack", base = Material.SHULKER_BOX, model_data = 0x760017 /* until 0x760027, inclusive */, version = 1) +public class Backpack extends CustomItem { + public Backpack(Context context) { + super(context); + } + + @Override + public RecipeList default_recipes() { + final var list = new ArrayList(); + list.add(new SmithingRecipeDefinition("from_shulker_box") + .base(Material.SHULKER_BOX) + .addition(Material.NETHERITE_INGOT) + .copy_nbt(true) + .result(key().toString())); + + for (final var color : DyeColor.values()) { + final var color_name = color.toString().toLowerCase(); + list.add(new SmithingRecipeDefinition("from_" + color_name + "_shulker_box") + .base(MaterialUtil.material_from(NamespacedKey.minecraft(color_name + "_shulker_box"))) + .addition(Material.NETHERITE_INGOT) + .copy_nbt(true) + // TODO custom model data + //.result(key().toString() + "[]")); + .result(key().toString())); + // TODO remove color how? + } + + return new RecipeList(list); + } + + // ignoreCancelled = false to catch right-click-air events + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = false) + public void on_player_right_click(final PlayerInteractEvent event) { + if (!event.hasItem() || event.useItemInHand() == Event.Result.DENY) { + return; + } + + // Any right click to open + if (event.getAction() != Action.RIGHT_CLICK_BLOCK && event.getAction() != Action.RIGHT_CLICK_AIR) { + return; + } + + // Assert this is a matching custom item + final var player = event.getPlayer(); + final var item = player.getEquipment().getItem(event.getHand()); + final var custom_item = get_module().core.item_registry().get(item); + if (!(custom_item instanceof Backpack backpack) || !backpack.enabled()) { + return; + } + + // Never use anything else (e.g. offhand) + event.setUseInteractedBlock(Event.Result.DENY); + event.setUseItemInHand(Event.Result.DENY); + + if (get_module().storage_group.open_block_state_inventory(player, item)) { + player.getWorld().playSound(player, Sound.ITEM_BUNDLE_DROP_CONTENTS, 1.0f, 1.2f); + swing_arm(player, event.getHand()); + } + } + + @Override + public EnumSet inhibitedBehaviors() { + return EnumSet.of(InhibitBehavior.USE_IN_VANILLA_RECIPE); + } +} diff --git a/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/storage/Pouch.java b/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/storage/Pouch.java new file mode 100644 index 000000000..b2d54a031 --- /dev/null +++ b/vane-trifles/src/main/java/org/oddlama/vane/trifles/items/storage/Pouch.java @@ -0,0 +1,71 @@ +package org.oddlama.vane.trifles.items.storage; + +import static org.oddlama.vane.util.PlayerUtil.swing_arm; + +import java.util.EnumSet; + +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.oddlama.vane.annotation.item.VaneItem; +import org.oddlama.vane.core.config.recipes.RecipeList; +import org.oddlama.vane.core.config.recipes.ShapedRecipeDefinition; +import org.oddlama.vane.core.item.CustomItem; +import org.oddlama.vane.core.item.api.InhibitBehavior; +import org.oddlama.vane.core.module.Context; +import org.oddlama.vane.trifles.Trifles; + +@VaneItem(name = "pouch", base = Material.DROPPER, model_data = 0x760016, version = 1) +public class Pouch extends CustomItem { + public Pouch(Context context) { + super(context); + } + + @Override + public RecipeList default_recipes() { + return RecipeList.of(new ShapedRecipeDefinition("generic") + .shape("sls", "l l", "lll") + .set_ingredient('s', Material.STRING) + .set_ingredient('l', Material.RABBIT_HIDE) + .result(key().toString())); + } + + // ignoreCancelled = false to catch right-click-air events + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = false) + public void on_player_right_click(final PlayerInteractEvent event) { + if (!event.hasItem() || event.useItemInHand() == Event.Result.DENY) { + return; + } + + // Any right click to open + if (event.getAction() != Action.RIGHT_CLICK_BLOCK && event.getAction() != Action.RIGHT_CLICK_AIR) { + return; + } + + // Assert this is a matching custom item + final var player = event.getPlayer(); + final var item = player.getEquipment().getItem(event.getHand()); + final var custom_item = get_module().core.item_registry().get(item); + if (!(custom_item instanceof Pouch pouch) || !pouch.enabled()) { + return; + } + + // Never use anything else (e.g. offhand) + event.setUseInteractedBlock(Event.Result.DENY); + event.setUseItemInHand(Event.Result.DENY); + + if (get_module().storage_group.open_block_state_inventory(player, item)) { + player.getWorld().playSound(player, Sound.ITEM_BUNDLE_DROP_CONTENTS, 1.0f, 1.2f); + swing_arm(player, event.getHand()); + } + } + + @Override + public EnumSet inhibitedBehaviors() { + return EnumSet.of(InhibitBehavior.USE_IN_VANILLA_RECIPE); + } +} diff --git a/vane-trifles/src/main/resources/items/backpack.png b/vane-trifles/src/main/resources/items/backpack.png new file mode 120000 index 000000000..8e2a44fe0 --- /dev/null +++ b/vane-trifles/src/main/resources/items/backpack.png @@ -0,0 +1 @@ +../../../../../contrib/assets/backpack/backpack.png \ No newline at end of file diff --git a/vane-trifles/src/main/resources/items/pouch.png b/vane-trifles/src/main/resources/items/pouch.png new file mode 120000 index 000000000..84224f1a9 --- /dev/null +++ b/vane-trifles/src/main/resources/items/pouch.png @@ -0,0 +1 @@ +../../../../../contrib/assets/pouch/pouch.png \ No newline at end of file diff --git a/vane-trifles/src/main/resources/lang-de.yml b/vane-trifles/src/main/resources/lang-de.yml index 7d1251cc2..a5d4e5ad7 100644 --- a/vane-trifles/src/main/resources/lang-de.yml +++ b/vane-trifles/src/main/resources/lang-de.yml @@ -33,6 +33,13 @@ command_setspawn: description: "Setzt den globalen Spawn auf deine aktuelle Position." help: "Setzt den globalen Spawn auf deine aktuelle Position." +storage: + open_stacked_item: "Nur ein einzelnes Item kann geöffnet werden." + item_pouch: + name: "Beutel" + item_backpack: + name: "Rucksack" + xp_bottles: item_small_xp_bottle: name: "Kleine Erfahrungsflasche" diff --git a/vane-trifles/src/main/resources/lang-en.yml b/vane-trifles/src/main/resources/lang-en.yml index 8b84a5f49..1a49e2c28 100644 --- a/vane-trifles/src/main/resources/lang-en.yml +++ b/vane-trifles/src/main/resources/lang-en.yml @@ -33,6 +33,15 @@ command_setspawn: description: "Sets the global spawn location to your current position." help: "Sets the global spawn location to your current position." +storage: + open_stacked_item: "You need to unstack these items to open them." + item_pouch: + # The display name of this item variant. + name: "Pouch" + item_backpack: + # The display name of this item variant. + name: "Backpack" + xp_bottles: item_small_xp_bottle: # The display name of this item variant. diff --git a/vane-trifles/src/main/resources/lang-fr-fr.yml b/vane-trifles/src/main/resources/lang-fr-fr.yml index dcbe6bd5b..5eb067ab0 100644 --- a/vane-trifles/src/main/resources/lang-fr-fr.yml +++ b/vane-trifles/src/main/resources/lang-fr-fr.yml @@ -34,6 +34,14 @@ command_setspawn: description: "Change la position du point d'apparition global vers votre position actuelle." help: "Change la position du point d'apparition global vers votre position actuelle." +storage: + # FIXME: missing translation + open_stacked_item: "You need to unstack these items to open them." + item_pouch: + name: "Pouch" + item_backpack: + name: "Backpack" + xp_bottles: item_small_xp_bottle: name: "Petite fiole d'Expérience" diff --git a/vane-trifles/src/main/resources/lang-ru.yml b/vane-trifles/src/main/resources/lang-ru.yml index 9afed4568..8d3e5776a 100644 --- a/vane-trifles/src/main/resources/lang-ru.yml +++ b/vane-trifles/src/main/resources/lang-ru.yml @@ -34,6 +34,14 @@ command_setspawn: description: "Устанавливает глобальный спавнпоинт на текущую позицию." help: "Устанавливает глобальный спавнпоинт на текущую позицию." +storage: + # FIXME: missing translation + open_stacked_item: "You need to unstack these items to open them." + item_pouch: + name: "Pouch" + item_backpack: + name: "Backpack" + xp_bottles: item_small_xp_bottle: name: "Маленькая бутылка опыта"