New invsee implementation
This commit is contained in:
parent
63e3cc8e12
commit
ec0e1b9849
@ -44,7 +44,7 @@ import mineplex.game.clans.clans.commands.*;
|
||||
import mineplex.game.clans.clans.data.PlayerClan;
|
||||
import mineplex.game.clans.clans.event.ClansPlayerDeathEvent;
|
||||
import mineplex.game.clans.clans.gui.ClanShop;
|
||||
import mineplex.game.clans.clans.invsee.Invsee;
|
||||
import mineplex.game.clans.clans.invsee.InvseeManager;
|
||||
import mineplex.game.clans.clans.loot.LootManager;
|
||||
import mineplex.game.clans.clans.map.ItemMapManager;
|
||||
import mineplex.game.clans.clans.nameblacklist.ClansBlacklist;
|
||||
@ -260,7 +260,7 @@ public class ClansManager extends MiniClientPlugin<ClientClan>implements IRelati
|
||||
new TntGeneratorManager(plugin, this);
|
||||
new SupplyDropManager(plugin, this);
|
||||
|
||||
new Invsee(this);
|
||||
new InvseeManager(this);
|
||||
|
||||
_explosion = new Explosion(plugin, blockRestore);
|
||||
_warPointEvasion = new WarPointEvasion(plugin);
|
||||
|
@ -1,23 +0,0 @@
|
||||
package mineplex.game.clans.clans.invsee;
|
||||
|
||||
import mineplex.core.MiniPlugin;
|
||||
import mineplex.game.clans.clans.ClansManager;
|
||||
import mineplex.game.clans.clans.invsee.commands.InvseeCommand;
|
||||
|
||||
public class Invsee extends MiniPlugin
|
||||
{
|
||||
private ClansManager _clansManager;
|
||||
|
||||
public Invsee(ClansManager clansManager)
|
||||
{
|
||||
super("Inventory Viewer", clansManager.getPlugin());
|
||||
|
||||
_clansManager = clansManager;
|
||||
}
|
||||
|
||||
public void addCommands()
|
||||
{
|
||||
addCommand(new InvseeCommand(this));
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
package mineplex.game.clans.clans.invsee;
|
||||
|
||||
import mineplex.core.MiniPlugin;
|
||||
import mineplex.game.clans.clans.ClansManager;
|
||||
import mineplex.game.clans.clans.invsee.commands.InvseeCommand;
|
||||
import mineplex.game.clans.clans.invsee.ui.InvseeInventory;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public class InvseeManager extends MiniPlugin
|
||||
{
|
||||
private Map<UUID, InvseeInventory> viewing = new HashMap<>();
|
||||
|
||||
public InvseeManager(ClansManager manager)
|
||||
{
|
||||
super("Invsee Manager", manager.getPlugin());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCommands()
|
||||
{
|
||||
addCommand(new InvseeCommand(this));
|
||||
}
|
||||
|
||||
public void doInvsee(OfflinePlayer target, Player requester)
|
||||
{
|
||||
InvseeInventory invseeInventory = viewing.computeIfAbsent(target.getUniqueId(), key -> new InvseeInventory(this, target));
|
||||
invseeInventory.addAndShowViewer(requester);
|
||||
}
|
||||
|
||||
public void close(UUID target)
|
||||
{
|
||||
InvseeInventory invseeInventory = viewing.remove(target);
|
||||
if (invseeInventory == null)
|
||||
{
|
||||
log("Expected non-null inventory when closing " + target);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,50 +1,83 @@
|
||||
package mineplex.game.clans.clans.invsee.commands;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import mineplex.core.command.CommandBase;
|
||||
import mineplex.core.common.Rank;
|
||||
import mineplex.core.common.util.F;
|
||||
import mineplex.core.common.util.UtilPlayer;
|
||||
import mineplex.game.clans.clans.invsee.Invsee;
|
||||
import mineplex.game.clans.clans.invsee.InvseeManager;
|
||||
import mineplex.game.clans.clans.invsee.ui.InvseeInventory;
|
||||
import net.minecraft.server.v1_8_R3.MinecraftServer;
|
||||
import net.minecraft.server.v1_8_R3.NBTTagCompound;
|
||||
import net.minecraft.server.v1_8_R3.WorldNBTStorage;
|
||||
import net.minecraft.server.v1_8_R3.WorldServer;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.craftbukkit.v1_8_R3.CraftOfflinePlayer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
public class InvseeCommand extends CommandBase<Invsee>
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.UUID;
|
||||
|
||||
public class InvseeCommand extends CommandBase<InvseeManager>
|
||||
{
|
||||
public InvseeCommand(Invsee plugin)
|
||||
public InvseeCommand(InvseeManager plugin)
|
||||
{
|
||||
super(plugin, Rank.ADMIN, "invsee");
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@Override
|
||||
public void Execute(Player caller, String[] args)
|
||||
{
|
||||
if (args.length == 0)
|
||||
{
|
||||
UtilPlayer.message(caller, F.help("/invsee <Player>", "View a player's inventory", Rank.ADMIN));
|
||||
UtilPlayer.message(caller, F.help("/invsee <playername/playeruuid>", "View a player's inventory", Rank.ADMIN));
|
||||
return;
|
||||
}
|
||||
else
|
||||
UUID uuid = null;
|
||||
try
|
||||
{
|
||||
String name = args[0];
|
||||
uuid = UUID.fromString(args[0]);
|
||||
}
|
||||
catch (IllegalArgumentException failed)
|
||||
{
|
||||
}
|
||||
|
||||
OfflinePlayer player = Bukkit.getServer().getPlayer(name);
|
||||
|
||||
if (player == null)
|
||||
OfflinePlayer exactPlayer = Bukkit.getServer().getPlayerExact(args[0]);
|
||||
if (exactPlayer == null)
|
||||
{
|
||||
if (uuid == null)
|
||||
{
|
||||
player = Bukkit.getServer().getOfflinePlayer(name);
|
||||
// We don't want to open the wrong OfflinePlayer's inventory, so if we can't fetch the UUID then abort
|
||||
GameProfile gameProfile = MinecraftServer.getServer().getUserCache().getProfile(args[0]);
|
||||
if (gameProfile == null)
|
||||
{
|
||||
UtilPlayer.message(caller, F.main("Invsee", "Player is offline and we could not find the UUID. Aborting"));
|
||||
return;
|
||||
}
|
||||
uuid = gameProfile.getId();
|
||||
}
|
||||
|
||||
if (player == null)
|
||||
if (uuid == null)
|
||||
{
|
||||
UtilPlayer.message(caller, F.main("Clans", "Specified player is neither online nor offline. Perhaps they changed their name?"));
|
||||
UtilPlayer.message(caller, F.main("Invsee", "Something has gone very wrong. Please report the username/uuid you tried to look up"));
|
||||
return;
|
||||
}
|
||||
|
||||
new InvseeInventory(player).ShowTo(caller);
|
||||
// We need to check if we actually have data on this player
|
||||
NBTTagCompound compound = ((WorldNBTStorage) MinecraftServer.getServer().worlds.get(0).getDataManager()).getPlayerData(uuid.toString());
|
||||
if (compound == null)
|
||||
{
|
||||
UtilPlayer.message(caller, F.main("Invsee", "The player exists, but has never joined this server. No inventory to show"));
|
||||
return;
|
||||
}
|
||||
exactPlayer = Bukkit.getServer().getOfflinePlayer(uuid);
|
||||
}
|
||||
if (exactPlayer == null)
|
||||
{
|
||||
UtilPlayer.message(caller, F.main("Invsee", "Could not load offline player data. Does the player exist?"));
|
||||
return;
|
||||
}
|
||||
|
||||
Plugin.doInvsee(exactPlayer, caller);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -1,51 +1,233 @@
|
||||
package mineplex.game.clans.clans.invsee.ui;
|
||||
|
||||
import mineplex.core.common.util.*;
|
||||
import mineplex.core.itemstack.ItemStackFactory;
|
||||
import mineplex.game.clans.clans.ClansManager;
|
||||
import mineplex.game.clans.clans.invsee.InvseeManager;
|
||||
import net.minecraft.server.v1_8_R3.*;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.craftbukkit.v1_8_R3.entity.CraftPlayer;
|
||||
import org.bukkit.craftbukkit.v1_8_R3.inventory.CraftInventory;
|
||||
import org.bukkit.craftbukkit.v1_8_R3.inventory.CraftInventoryCrafting;
|
||||
import org.bukkit.craftbukkit.v1_8_R3.inventory.CraftInventoryPlayer;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryCloseEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
import mineplex.core.common.util.C;
|
||||
import mineplex.core.common.util.UtilCollections;
|
||||
import mineplex.core.common.util.UtilServer;
|
||||
import mineplex.core.updater.UpdateType;
|
||||
import mineplex.core.updater.event.UpdateEvent;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.*;
|
||||
|
||||
public class InvseeInventory implements Listener
|
||||
{
|
||||
private OfflinePlayer _player;
|
||||
private final InvseeManager invseeManager;
|
||||
private final UUID uuid;
|
||||
|
||||
// This is the current player. It will switch when the player joins/quits
|
||||
private OfflinePlayer targetPlayer;
|
||||
|
||||
private Inventory _inventory;
|
||||
|
||||
private boolean _online;
|
||||
private net.minecraft.server.v1_8_R3.PlayerInventory playerInventory;
|
||||
|
||||
private Player _admin;
|
||||
private List<Player> viewers = new ArrayList<>();
|
||||
private boolean dontUpdate = false;
|
||||
|
||||
public InvseeInventory(OfflinePlayer player)
|
||||
public InvseeInventory(InvseeManager manager, OfflinePlayer player)
|
||||
{
|
||||
_online = (_player = player) instanceof Player;
|
||||
invseeManager = manager;
|
||||
uuid = player.getUniqueId();
|
||||
targetPlayer = player;
|
||||
updateInventory();
|
||||
|
||||
_inventory = UtilServer.getServer().createInventory(null, 54, player.getName() + " " + (_online ? C.cGreen + "ONLINE" : C.cRed + "OFFLINE"));
|
||||
_inventory = UtilServer.getServer().createInventory(null, 6 * 9, player.getName());
|
||||
|
||||
for (int index = 38; index < 45; index++)
|
||||
{
|
||||
_inventory.setItem(index, ItemStackFactory.Instance.CreateStack(Material.BARRIER, (byte) 0, 1, C.Bold));
|
||||
}
|
||||
|
||||
UtilServer.RegisterEvents(this);
|
||||
}
|
||||
|
||||
public void ShowTo(Player admin)
|
||||
/*
|
||||
* Add the player to the list of viewers and open the inventory for him
|
||||
*/
|
||||
public void addAndShowViewer(Player requester)
|
||||
{
|
||||
_admin = admin;
|
||||
admin.openInventory(_inventory);
|
||||
viewers.add(requester);
|
||||
requester.openInventory(_inventory);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void quit(PlayerQuitEvent event)
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void on(PlayerJoinEvent event)
|
||||
{
|
||||
if (_online && event.getPlayer().equals(_player))
|
||||
if (uuid.equals(event.getPlayer().getUniqueId()))
|
||||
{
|
||||
_admin.closeInventory();
|
||||
targetPlayer = event.getPlayer();
|
||||
updateInventory();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void on(PlayerQuitEvent event)
|
||||
{
|
||||
// If a viewer quit, this will clean it up
|
||||
viewers.remove(event.getPlayer());
|
||||
if (viewers.size() == 0)
|
||||
{
|
||||
invseeManager.close(uuid);
|
||||
}
|
||||
else
|
||||
{
|
||||
// This should always work
|
||||
targetPlayer = Bukkit.getOfflinePlayer(uuid);
|
||||
updateInventory();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void on(InventoryCloseEvent event)
|
||||
{
|
||||
if (_inventory.equals(event.getInventory()))
|
||||
{
|
||||
viewers.remove(event.getPlayer());
|
||||
if (viewers.size() == 0)
|
||||
{
|
||||
invseeManager.close(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void on(InventoryClickEvent event)
|
||||
{
|
||||
if (_inventory.equals(event.getClickedInventory()))
|
||||
{
|
||||
if (event.getCurrentItem() != null && event.getCurrentItem().getType() == Material.BARRIER
|
||||
&& event.getCurrentItem().getItemMeta() != null
|
||||
&& event.getCurrentItem().getItemMeta().getDisplayName().equals(C.Bold))
|
||||
{
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (MAPPING_INVENTORY_REVERSE.containsKey(event.getRawSlot()))
|
||||
{
|
||||
dontUpdate = true;
|
||||
ClansManager.getInstance().runSync(() ->
|
||||
{
|
||||
IInventory iInventoryThis = ((CraftInventory) _inventory).getInventory();
|
||||
playerInventory.setItem(MAPPING_INVENTORY_REVERSE.get(event.getRawSlot()), iInventoryThis.getItem(event.getRawSlot()));
|
||||
saveInventory();
|
||||
dontUpdate = false;
|
||||
});
|
||||
}
|
||||
else if (MAPPING_CRAFTING_REVERSE.containsKey(event.getRawSlot()))
|
||||
{
|
||||
if (targetPlayer.isOnline())
|
||||
{
|
||||
dontUpdate = true;
|
||||
ClansManager.getInstance().runSync(() ->
|
||||
{
|
||||
IInventory iInventoryThis = ((CraftInventory) _inventory).getInventory();
|
||||
EntityPlayer entityPlayer = ((CraftPlayer) targetPlayer).getHandle();
|
||||
ContainerPlayer containerPlayer = (ContainerPlayer) entityPlayer.defaultContainer;
|
||||
containerPlayer.craftInventory.setItem(MAPPING_CRAFTING_REVERSE.get(event.getRawSlot()), iInventoryThis.getItem(event.getRawSlot()));
|
||||
dontUpdate = false;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
else if (event.getRawSlot() == 49)
|
||||
{
|
||||
if (targetPlayer.isOnline())
|
||||
{
|
||||
dontUpdate = true;
|
||||
ClansManager.getInstance().runSync(() ->
|
||||
{
|
||||
IInventory iInventoryThis = ((CraftInventory) _inventory).getInventory();
|
||||
playerInventory.setCarried(iInventoryThis.getItem(49));
|
||||
saveInventory();
|
||||
((Player) targetPlayer).updateInventory();
|
||||
dontUpdate = false;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Updates the inventory instance
|
||||
*/
|
||||
private void updateInventory()
|
||||
{
|
||||
if (targetPlayer.isOnline())
|
||||
{
|
||||
playerInventory = ((CraftPlayer) targetPlayer).getHandle().inventory;
|
||||
}
|
||||
else
|
||||
{
|
||||
NBTTagCompound compound = ((WorldNBTStorage) MinecraftServer.getServer().worlds.get(0).getDataManager()).getPlayerData(uuid.toString());
|
||||
// Should not matter if null
|
||||
playerInventory = new PlayerInventory(null);
|
||||
if (compound.hasKeyOfType("Inventory", 9))
|
||||
{
|
||||
playerInventory.b(compound.getList("Inventory", 10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void saveInventory()
|
||||
{
|
||||
if (!targetPlayer.isOnline())
|
||||
{
|
||||
try
|
||||
{
|
||||
WorldNBTStorage worldNBTStorage = ((WorldNBTStorage) MinecraftServer.getServer().worlds.get(0).getDataManager());
|
||||
NBTTagCompound compound = worldNBTStorage.getPlayerData(uuid.toString());
|
||||
compound.set("Inventory", new NBTTagList());
|
||||
playerInventory.a(compound.getList("Inventory", 10));
|
||||
File file = new File(worldNBTStorage.getPlayerDir(), targetPlayer.getUniqueId().toString() + ".dat.tmp");
|
||||
File file1 = new File(worldNBTStorage.getPlayerDir(), targetPlayer.getUniqueId().toString() + ".dat");
|
||||
NBTCompressedStreamTools.a(compound, new FileOutputStream(file));
|
||||
if (file1.exists())
|
||||
{
|
||||
file1.delete();
|
||||
}
|
||||
|
||||
file.renameTo(file1);
|
||||
}
|
||||
catch (Exception var5)
|
||||
{
|
||||
invseeManager.log("Failed to save player inventory for " + targetPlayer.getName());
|
||||
for (Player player : viewers)
|
||||
{
|
||||
UtilPlayer.message(player, F.main("Invsee", "Could not save inventory for " + targetPlayer.getName()));
|
||||
}
|
||||
var5.printStackTrace(System.out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,44 +238,71 @@ public class InvseeInventory implements Listener
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_online)
|
||||
if (dontUpdate)
|
||||
{
|
||||
if (!UtilCollections.equal(_inventory.getContents(), ((Player) _player).getInventory().getContents()))
|
||||
return;
|
||||
}
|
||||
|
||||
IInventory iInventoryThis = ((CraftInventory) _inventory).getInventory();
|
||||
|
||||
// Update items on hotbar
|
||||
for (int otherSlot = 0; otherSlot < 9; otherSlot++)
|
||||
{
|
||||
iInventoryThis.setItem(MAPPING_INVENTORY.get(otherSlot), playerInventory.getItem(otherSlot));
|
||||
}
|
||||
// Update main inventory
|
||||
for (int otherSlot = 9; otherSlot < 36; otherSlot++)
|
||||
{
|
||||
iInventoryThis.setItem(MAPPING_INVENTORY.get(otherSlot), playerInventory.getItem(otherSlot));
|
||||
}
|
||||
// Update armor
|
||||
for (int otherSlot = 36; otherSlot < 40; otherSlot++)
|
||||
{
|
||||
iInventoryThis.setItem(MAPPING_INVENTORY.get(otherSlot), playerInventory.getItem(otherSlot));
|
||||
}
|
||||
|
||||
if (targetPlayer.isOnline())
|
||||
{
|
||||
ContainerPlayer containerPlayer = (ContainerPlayer) ((CraftPlayer) targetPlayer).getHandle().defaultContainer;
|
||||
for (int craftingIndex = 0; craftingIndex < 4; craftingIndex++)
|
||||
{
|
||||
_inventory.setContents(((Player) _player).getInventory().getContents());
|
||||
iInventoryThis.setItem(MAPPING_CRAFTING.get(craftingIndex), containerPlayer.craftInventory.getItem(craftingIndex));
|
||||
}
|
||||
}
|
||||
iInventoryThis.setItem(49, playerInventory.getCarried());
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void inventoryClick(InventoryClickEvent event)
|
||||
// Maps slot indices of player inventories to slot indices of double chests
|
||||
private static final Map<Integer, Integer> MAPPING_INVENTORY = new HashMap<>();
|
||||
private static final Map<Integer, Integer> MAPPING_INVENTORY_REVERSE = new HashMap<>();
|
||||
private static final Map<Integer, Integer> MAPPING_CRAFTING = new HashMap<>();
|
||||
private static final Map<Integer, Integer> MAPPING_CRAFTING_REVERSE = new HashMap<>();
|
||||
|
||||
static
|
||||
{
|
||||
if (event.getClickedInventory().equals(((Player) _player).getInventory()))
|
||||
int[] inventoryMapping = new int[]
|
||||
{
|
||||
27, 28, 29, 30, 31, 32, 33, 34, 35, //Hotbar
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, // Top row inventory
|
||||
9, 10, 11, 12, 13, 14, 15, 16, 17, //Second row inventory
|
||||
18, 19, 20, 21, 22, 23, 24, 25, 26, //Third row inventory
|
||||
53, 52, 51, 50 //Armor
|
||||
};
|
||||
int[] craftingMapping = new int[]
|
||||
{
|
||||
36, 37, //Top crafting
|
||||
45, 46 //Bottom crafting
|
||||
};
|
||||
for (int i = 0; i < inventoryMapping.length; i++)
|
||||
{
|
||||
_inventory.setContents(((Player) _player).getInventory().getContents());
|
||||
MAPPING_INVENTORY.put(i, inventoryMapping[i]);
|
||||
MAPPING_INVENTORY_REVERSE.put(inventoryMapping[i], i);
|
||||
}
|
||||
else if (event.getClickedInventory().equals(_inventory))
|
||||
{
|
||||
if (_online)
|
||||
{
|
||||
((Player) _player).getInventory().setContents(_inventory.getContents());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void closeInventory(InventoryCloseEvent event)
|
||||
{
|
||||
if (event.getInventory().equals(_inventory))
|
||||
for (int i = 0; i < craftingMapping.length; i++)
|
||||
{
|
||||
UtilServer.Unregister(this);
|
||||
|
||||
if (!_online)
|
||||
{
|
||||
// save offline inv
|
||||
}
|
||||
MAPPING_CRAFTING.put(i, craftingMapping[i]);
|
||||
MAPPING_CRAFTING_REVERSE.put(craftingMapping[i], i);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -132,7 +132,7 @@ public class SiegeWeaponRepository extends MinecraftRepository
|
||||
|
||||
public void updateWeapon(SiegeWeaponToken token)
|
||||
{
|
||||
System.out.println("Siege Repo> Updating weapon " + token.UniqueId);
|
||||
// System.out.println("Siege Repo> Updating weapon " + token.UniqueId);
|
||||
|
||||
_siegeManager.runAsync(() ->
|
||||
executeUpdate(UPDATE_WEAPON,
|
||||
|
Loading…
Reference in New Issue
Block a user