greedy stack transfers
This commit is contained in:
@@ -238,7 +238,7 @@ namespace UIFixes
|
|||||||
{
|
{
|
||||||
if (!allOrNothing || InteractionCount(EItemInfoButton.Equip, itemUiContext) == Count)
|
if (!allOrNothing || InteractionCount(EItemInfoButton.Equip, itemUiContext) == Count)
|
||||||
{
|
{
|
||||||
var taskSerializer = itemUiContext.GetOrAddComponent<ItemContextTaskSerializer>();
|
var taskSerializer = itemUiContext.gameObject.AddComponent<ItemContextTaskSerializer>();
|
||||||
taskSerializer.Initialize(SortedItemContexts(), itemContext => itemUiContext.QuickEquip(itemContext.Item));
|
taskSerializer.Initialize(SortedItemContexts(), itemContext => itemUiContext.QuickEquip(itemContext.Item));
|
||||||
itemUiContext.Tooltip?.Close();
|
itemUiContext.Tooltip?.Close();
|
||||||
}
|
}
|
||||||
@@ -248,7 +248,7 @@ namespace UIFixes
|
|||||||
{
|
{
|
||||||
if (!allOrNothing || InteractionCount(EItemInfoButton.Unequip, itemUiContext) == Count)
|
if (!allOrNothing || InteractionCount(EItemInfoButton.Unequip, itemUiContext) == Count)
|
||||||
{
|
{
|
||||||
var taskSerializer = itemUiContext.GetOrAddComponent<ItemContextTaskSerializer>();
|
var taskSerializer = itemUiContext.gameObject.AddComponent<ItemContextTaskSerializer>();
|
||||||
taskSerializer.Initialize(SortedItemContexts(), itemContext => itemUiContext.Uninstall(itemContext.GClass2813_0));
|
taskSerializer.Initialize(SortedItemContexts(), itemContext => itemUiContext.Uninstall(itemContext.GClass2813_0));
|
||||||
itemUiContext.Tooltip?.Close();
|
itemUiContext.Tooltip?.Close();
|
||||||
}
|
}
|
||||||
@@ -260,7 +260,7 @@ namespace UIFixes
|
|||||||
if (!allOrNothing || InteractionCount(EItemInfoButton.UnloadAmmo, itemUiContext) == Count)
|
if (!allOrNothing || InteractionCount(EItemInfoButton.UnloadAmmo, itemUiContext) == Count)
|
||||||
{
|
{
|
||||||
// Call Initialize() before setting UnloadSerializer so that the initial synchronous call to StopProcesses()->StopUnloading() doesn't immediately cancel this
|
// Call Initialize() before setting UnloadSerializer so that the initial synchronous call to StopProcesses()->StopUnloading() doesn't immediately cancel this
|
||||||
var taskSerializer = itemUiContext.GetOrAddComponent<ItemContextTaskSerializer>();
|
var taskSerializer = itemUiContext.gameObject.AddComponent<ItemContextTaskSerializer>();
|
||||||
taskSerializer.Initialize(SortedItemContexts(), itemContext => itemUiContext.UnloadAmmo(itemContext.Item));
|
taskSerializer.Initialize(SortedItemContexts(), itemContext => itemUiContext.UnloadAmmo(itemContext.Item));
|
||||||
|
|
||||||
UnloadSerializer = taskSerializer;
|
UnloadSerializer = taskSerializer;
|
||||||
@@ -391,6 +391,15 @@ namespace UIFixes
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<ItemContextClass> RepeatUntilEmpty(this ItemContextClass itemContext)
|
||||||
|
{
|
||||||
|
while (itemContext.Item.StackObjectsCount > 0)
|
||||||
|
{
|
||||||
|
yield return itemContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -11,6 +11,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.EventSystems;
|
using UnityEngine.EventSystems;
|
||||||
@@ -103,7 +104,7 @@ namespace UIFixes
|
|||||||
__instance.TransferItemsScreen.GetOrAddComponent<DrawMultiSelect>();
|
__instance.TransferItemsScreen.GetOrAddComponent<DrawMultiSelect>();
|
||||||
__instance.ScavengerInventoryScreen.GetOrAddComponent<DrawMultiSelect>();
|
__instance.ScavengerInventoryScreen.GetOrAddComponent<DrawMultiSelect>();
|
||||||
|
|
||||||
void ToggleDebug()
|
static void ToggleDebug()
|
||||||
{
|
{
|
||||||
if (Settings.ShowMultiSelectDebug.Value)
|
if (Settings.ShowMultiSelectDebug.Value)
|
||||||
{
|
{
|
||||||
@@ -475,13 +476,54 @@ namespace UIFixes
|
|||||||
|
|
||||||
Item targetItem = __instance.method_8(targetItemContext);
|
Item targetItem = __instance.method_8(targetItemContext);
|
||||||
DisableMerge = targetItem == null;
|
DisableMerge = targetItem == null;
|
||||||
bool showHighlights = targetItem == null;
|
bool isGridPlacement = targetItem == null;
|
||||||
|
|
||||||
Stack<GStruct413> operations = new();
|
Stack<GStruct413> operations = new();
|
||||||
foreach (ItemContextClass selectedItemContext in MultiSelect.SortedItemContexts(itemContext))
|
foreach (ItemContextClass selectedItemContext in MultiSelect.SortedItemContexts(itemContext))
|
||||||
|
{
|
||||||
|
if (Settings.GreedyStackMove.Value && !isGridPlacement && selectedItemContext.Item.StackObjectsCount > 1)
|
||||||
|
{
|
||||||
|
int stackCount = int.MaxValue;
|
||||||
|
bool failed = false;
|
||||||
|
while (selectedItemContext.Item.StackObjectsCount > 0)
|
||||||
|
{
|
||||||
|
if (selectedItemContext.Item.StackObjectsCount >= stackCount)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
stackCount = selectedItemContext.Item.StackObjectsCount;
|
||||||
|
operation = wrappedInstance.TraderController.ExecutePossibleAction(selectedItemContext, targetItem, false /* splitting */, false /* simulate */);
|
||||||
|
if (__result = operation.Succeeded)
|
||||||
|
{
|
||||||
|
operations.Push(operation);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (operation.Error is GClass3292 noRoomError)
|
||||||
|
{
|
||||||
|
// Wrap this error to display it
|
||||||
|
operation = new(new DisplayableErrorWrapper(noRoomError));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to double-break
|
||||||
|
failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (isGridPlacement)
|
||||||
{
|
{
|
||||||
FindOrigin = GetTargetGridAddress(itemContext, selectedItemContext, hoveredAddress);
|
FindOrigin = GetTargetGridAddress(itemContext, selectedItemContext, hoveredAddress);
|
||||||
FindVerticalFirst = selectedItemContext.ItemRotation == ItemRotation.Vertical;
|
FindVerticalFirst = selectedItemContext.ItemRotation == ItemRotation.Vertical;
|
||||||
|
}
|
||||||
|
|
||||||
if (targetItem is SortingTableClass)
|
if (targetItem is SortingTableClass)
|
||||||
{
|
{
|
||||||
@@ -500,7 +542,7 @@ namespace UIFixes
|
|||||||
if (__result = operation.Succeeded)
|
if (__result = operation.Succeeded)
|
||||||
{
|
{
|
||||||
operations.Push(operation);
|
operations.Push(operation);
|
||||||
if (targetItem != null && showHighlights) // targetItem was originally null so this is the rest of the items
|
if (targetItem != null && isGridPlacement) // targetItem was originally null so this is the rest of the items
|
||||||
{
|
{
|
||||||
ShowPreview(__instance, selectedItemContext, operation);
|
ShowPreview(__instance, selectedItemContext, operation);
|
||||||
}
|
}
|
||||||
@@ -510,7 +552,7 @@ namespace UIFixes
|
|||||||
// Moving item to the same place, cool, not a problem
|
// Moving item to the same place, cool, not a problem
|
||||||
__result = true;
|
__result = true;
|
||||||
operation = default;
|
operation = default;
|
||||||
if (showHighlights && selectedItemContext.Item.Parent is GClass2769 gridAddress)
|
if (isGridPlacement && selectedItemContext.Item.Parent is GClass2769 gridAddress)
|
||||||
{
|
{
|
||||||
ShowPreview(__instance, selectedItemContext, gridAddress, R.GridView.ValidMoveColor);
|
ShowPreview(__instance, selectedItemContext, gridAddress, R.GridView.ValidMoveColor);
|
||||||
}
|
}
|
||||||
@@ -525,6 +567,7 @@ namespace UIFixes
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set this after the first one
|
// Set this after the first one
|
||||||
targetItem ??= __instance.Grid.ParentItem;
|
targetItem ??= __instance.Grid.ParentItem;
|
||||||
@@ -603,7 +646,7 @@ namespace UIFixes
|
|||||||
targetItemContext = new GClass2817(__instance.Grid.ParentItem, EItemViewType.Empty);
|
targetItemContext = new GClass2817(__instance.Grid.ParentItem, EItemViewType.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
var serializer = __instance.GetOrAddComponent<ItemContextTaskSerializer>();
|
var serializer = __instance.gameObject.AddComponent<ItemContextTaskSerializer>();
|
||||||
__result = serializer.Initialize(MultiSelect.SortedItemContexts(itemContext), ic =>
|
__result = serializer.Initialize(MultiSelect.SortedItemContexts(itemContext), ic =>
|
||||||
{
|
{
|
||||||
FindOrigin = GetTargetGridAddress(itemContext, ic, hoveredAddress);
|
FindOrigin = GetTargetGridAddress(itemContext, ic, hoveredAddress);
|
||||||
@@ -665,7 +708,7 @@ namespace UIFixes
|
|||||||
|
|
||||||
// Multiselect always disables "Transfer", which is a partial merge
|
// Multiselect always disables "Transfer", which is a partial merge
|
||||||
// It leaves things behind and that's not intuitive when multi-selecting
|
// It leaves things behind and that's not intuitive when multi-selecting
|
||||||
order &= ~PartialMerge;
|
// order &= ~PartialMerge;
|
||||||
|
|
||||||
if (DisableMerge)
|
if (DisableMerge)
|
||||||
{
|
{
|
||||||
@@ -773,6 +816,8 @@ namespace UIFixes
|
|||||||
|
|
||||||
Stack<GStruct413> operations = new();
|
Stack<GStruct413> operations = new();
|
||||||
foreach (ItemContextClass itemContext in MultiSelect.SortedItemContexts())
|
foreach (ItemContextClass itemContext in MultiSelect.SortedItemContexts())
|
||||||
|
{
|
||||||
|
if (!Settings.GreedyStackMove.Value || itemContext.Item.StackObjectsCount <= 1)
|
||||||
{
|
{
|
||||||
__result = itemContext.CanAccept(__instance.Slot, __instance.ParentItemContext, ___InventoryController, out operation, false /* simulate */);
|
__result = itemContext.CanAccept(__instance.Slot, __instance.ParentItemContext, ___InventoryController, out operation, false /* simulate */);
|
||||||
if (operation.Succeeded)
|
if (operation.Succeeded)
|
||||||
@@ -789,6 +834,38 @@ namespace UIFixes
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int stackCount = int.MaxValue;
|
||||||
|
bool failed = false;
|
||||||
|
while (itemContext.Item.StackObjectsCount > 0)
|
||||||
|
{
|
||||||
|
if (itemContext.Item.StackObjectsCount >= stackCount)
|
||||||
|
{
|
||||||
|
// The whole stack moved or nothing happened, it's done
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
stackCount = itemContext.Item.StackObjectsCount;
|
||||||
|
__result = itemContext.CanAccept(__instance.Slot, __instance.ParentItemContext, ___InventoryController, out operation, false /* simulate */);
|
||||||
|
if (operation.Succeeded)
|
||||||
|
{
|
||||||
|
operations.Push(operation);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Need to double-break
|
||||||
|
failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Didn't simulate so now undo
|
// Didn't simulate so now undo
|
||||||
while (operations.Any())
|
while (operations.Any())
|
||||||
@@ -818,7 +895,7 @@ namespace UIFixes
|
|||||||
|
|
||||||
InPatch = true;
|
InPatch = true;
|
||||||
|
|
||||||
var serializer = __instance.GetOrAddComponent<ItemContextTaskSerializer>();
|
var serializer = __instance.gameObject.AddComponent<ItemContextTaskSerializer>();
|
||||||
__result = serializer.Initialize(MultiSelect.SortedItemContexts(), itemContext => __instance.AcceptItem(itemContext, targetItemContext));
|
__result = serializer.Initialize(MultiSelect.SortedItemContexts(), itemContext => __instance.AcceptItem(itemContext, targetItemContext));
|
||||||
|
|
||||||
__result.ContinueWith(_ => { InPatch = false; });
|
__result.ContinueWith(_ => { InPatch = false; });
|
||||||
|
87
Patches/StackMoveGreedyPatches.cs
Normal file
87
Patches/StackMoveGreedyPatches.cs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
using Aki.Reflection.Patching;
|
||||||
|
using EFT.UI.DragAndDrop;
|
||||||
|
using HarmonyLib;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace UIFixes
|
||||||
|
{
|
||||||
|
public static class StackMoveGreedyPatches
|
||||||
|
{
|
||||||
|
private static bool InPatch = false;
|
||||||
|
|
||||||
|
public static void Enable()
|
||||||
|
{
|
||||||
|
new GridViewPatch().Enable();
|
||||||
|
new SlotViewPatch().Enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GridViewPatch : ModulePatch
|
||||||
|
{
|
||||||
|
protected override MethodBase GetTargetMethod()
|
||||||
|
{
|
||||||
|
return AccessTools.Method(typeof(GridView), nameof(GridView.AcceptItem));
|
||||||
|
}
|
||||||
|
|
||||||
|
[PatchPrefix]
|
||||||
|
[HarmonyPriority(Priority.LowerThanNormal)]
|
||||||
|
public static bool Prefix(GridView __instance, ItemContextClass itemContext, ItemContextAbstractClass targetItemContext, ref Task __result)
|
||||||
|
{
|
||||||
|
return AcceptStackable(__instance, itemContext, targetItemContext, ref __result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SlotViewPatch : ModulePatch
|
||||||
|
{
|
||||||
|
protected override MethodBase GetTargetMethod()
|
||||||
|
{
|
||||||
|
return AccessTools.Method(typeof(SlotView), nameof(SlotView.AcceptItem));
|
||||||
|
}
|
||||||
|
|
||||||
|
[PatchPrefix]
|
||||||
|
[HarmonyPriority(Priority.LowerThanNormal)]
|
||||||
|
public static bool Prefix(SlotView __instance, ItemContextClass itemContext, ItemContextAbstractClass targetItemContext, ref Task __result)
|
||||||
|
{
|
||||||
|
return AcceptStackable(__instance, itemContext, targetItemContext, ref __result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool AcceptStackable<T>(T __instance, ItemContextClass itemContext, ItemContextAbstractClass targetItemContext, ref Task __result) where T : MonoBehaviour, IContainer
|
||||||
|
{
|
||||||
|
if (!Settings.GreedyStackMove.Value || InPatch || itemContext.Item.StackObjectsCount <= 1 || targetItemContext == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
InPatch = true;
|
||||||
|
|
||||||
|
int stackCount = int.MaxValue;
|
||||||
|
var serializer = __instance.gameObject.AddComponent<ItemContextTaskSerializer>();
|
||||||
|
__result = serializer.Initialize(itemContext.RepeatUntilEmpty(), ic =>
|
||||||
|
{
|
||||||
|
if (ic.Item.StackObjectsCount >= stackCount)
|
||||||
|
{
|
||||||
|
// Nothing happened, bail out
|
||||||
|
return Task.FromCanceled(new CancellationToken(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
stackCount = ic.Item.StackObjectsCount;
|
||||||
|
return __instance.AcceptItem(ic, targetItemContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
// This won't block the first action from swapping, but will prevent follow up swaps
|
||||||
|
SwapPatches.BlockSwaps = true;
|
||||||
|
|
||||||
|
__result.ContinueWith(_ =>
|
||||||
|
{
|
||||||
|
InPatch = false;
|
||||||
|
SwapPatches.BlockSwaps = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -30,6 +30,8 @@ namespace UIFixes
|
|||||||
|
|
||||||
private static readonly EOwnerType[] BannedOwnerTypes = [EOwnerType.Mail, EOwnerType.Trader];
|
private static readonly EOwnerType[] BannedOwnerTypes = [EOwnerType.Mail, EOwnerType.Trader];
|
||||||
|
|
||||||
|
public static bool BlockSwaps = false;
|
||||||
|
|
||||||
public static void Enable()
|
public static void Enable()
|
||||||
{
|
{
|
||||||
new DetectSwapSourceContainerPatch().Enable();
|
new DetectSwapSourceContainerPatch().Enable();
|
||||||
@@ -57,6 +59,11 @@ namespace UIFixes
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (BlockSwaps)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var wrappedOperation = new R.GridViewCanAcceptOperation(operation);
|
var wrappedOperation = new R.GridViewCanAcceptOperation(operation);
|
||||||
|
|
||||||
if (InHighlight || itemContext == null || targetItemContext == null || wrappedOperation.Succeeded)
|
if (InHighlight || itemContext == null || targetItemContext == null || wrappedOperation.Succeeded)
|
||||||
|
@@ -51,6 +51,7 @@ namespace UIFixes
|
|||||||
LoadAmmoInRaidPatches.Enable();
|
LoadAmmoInRaidPatches.Enable();
|
||||||
MultiSelectPatches.Enable();
|
MultiSelectPatches.Enable();
|
||||||
new FixUnloadLastBulletPatch().Enable();
|
new FixUnloadLastBulletPatch().Enable();
|
||||||
|
StackMoveGreedyPatches.Enable();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool InRaid()
|
public static bool InRaid()
|
||||||
|
10
Settings.cs
10
Settings.cs
@@ -72,6 +72,7 @@ namespace UIFixes
|
|||||||
public static ConfigEntry<bool> SwapItems { get; set; }
|
public static ConfigEntry<bool> SwapItems { get; set; }
|
||||||
public static ConfigEntry<bool> SwapImpossibleContainers { get; set; }
|
public static ConfigEntry<bool> SwapImpossibleContainers { get; set; }
|
||||||
public static ConfigEntry<bool> SynchronizeStashScrolling { get; set; }
|
public static ConfigEntry<bool> SynchronizeStashScrolling { get; set; }
|
||||||
|
public static ConfigEntry<bool> GreedyStackMove { get; set; }
|
||||||
public static ConfigEntry<bool> MergeFIRMoney { get; set; }
|
public static ConfigEntry<bool> MergeFIRMoney { get; set; }
|
||||||
public static ConfigEntry<bool> MergeFIRAmmo { get; set; }
|
public static ConfigEntry<bool> MergeFIRAmmo { get; set; }
|
||||||
public static ConfigEntry<bool> MergeFIROther { get; set; }
|
public static ConfigEntry<bool> MergeFIROther { get; set; }
|
||||||
@@ -359,6 +360,15 @@ namespace UIFixes
|
|||||||
null,
|
null,
|
||||||
new ConfigurationManagerAttributes { })));
|
new ConfigurationManagerAttributes { })));
|
||||||
|
|
||||||
|
configEntries.Add(GreedyStackMove = config.Bind(
|
||||||
|
InventorySection,
|
||||||
|
"Always Move Entire Stacks",
|
||||||
|
false,
|
||||||
|
new ConfigDescription(
|
||||||
|
"When moving into a container that contains a partial stack, this will top up that stack and try to move the remainder into an open spot (or another stack), instead of leaving it behind.",
|
||||||
|
null,
|
||||||
|
new ConfigurationManagerAttributes { })));
|
||||||
|
|
||||||
configEntries.Add(MergeFIRMoney = config.Bind(
|
configEntries.Add(MergeFIRMoney = config.Bind(
|
||||||
InventorySection,
|
InventorySection,
|
||||||
"Autostack Money with FiR Money",
|
"Autostack Money with FiR Money",
|
||||||
|
@@ -9,13 +9,13 @@ namespace UIFixes
|
|||||||
public class TaskSerializer<T> : MonoBehaviour
|
public class TaskSerializer<T> : MonoBehaviour
|
||||||
{
|
{
|
||||||
private Func<T, Task> func;
|
private Func<T, Task> func;
|
||||||
private Queue<T> items;
|
private IEnumerator<T> enumerator;
|
||||||
private Task currentTask;
|
private Task currentTask;
|
||||||
private TaskCompletionSource totalTask;
|
private TaskCompletionSource totalTask;
|
||||||
|
|
||||||
public Task Initialize(IEnumerable<T> items, Func<T, Task> func)
|
public Task Initialize(IEnumerable<T> items, Func<T, Task> func)
|
||||||
{
|
{
|
||||||
this.items = new(items);
|
this.enumerator = items.GetEnumerator();
|
||||||
this.func = func;
|
this.func = func;
|
||||||
|
|
||||||
currentTask = Task.CompletedTask;
|
currentTask = Task.CompletedTask;
|
||||||
@@ -44,9 +44,9 @@ namespace UIFixes
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items.Any())
|
if (enumerator.MoveNext())
|
||||||
{
|
{
|
||||||
currentTask = func(items.Dequeue());
|
currentTask = func(enumerator.Current);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
Reference in New Issue
Block a user