forked from OpenWF/SpaceNinjaServer
		
	feat: classic lich vanquish inbox mesage (#2074)
Closes #1897 Reviewed-on: OpenWF/SpaceNinjaServer#2074 Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com> Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									daf721f7cd
								
							
						
					
					
						commit
						52c8802d57
					
				@ -7,7 +7,7 @@ import {
 | 
			
		||||
    getNemesisPasscodeModTypes,
 | 
			
		||||
    getWeaponsForManifest,
 | 
			
		||||
    IKnifeResponse,
 | 
			
		||||
    showdownNodes
 | 
			
		||||
    nemesisFactionInfos
 | 
			
		||||
} from "@/src/helpers/nemesisHelpers";
 | 
			
		||||
import { getJSONfromString } from "@/src/helpers/stringHelpers";
 | 
			
		||||
import { Loadout } from "@/src/models/inventoryModels/loadoutModel";
 | 
			
		||||
@ -223,7 +223,7 @@ export const nemesisController: RequestHandler = async (req, res) => {
 | 
			
		||||
 | 
			
		||||
        inventory.Nemesis!.InfNodes = [
 | 
			
		||||
            {
 | 
			
		||||
                Node: showdownNodes[inventory.Nemesis!.Faction],
 | 
			
		||||
                Node: nemesisFactionInfos[inventory.Nemesis!.Faction].showdownNode,
 | 
			
		||||
                Influence: 1
 | 
			
		||||
            }
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import { ExportRegions, ExportWarframes } from "warframe-public-export-plus";
 | 
			
		||||
import { IInfNode, ITypeCount, TNemesisFaction } from "@/src/types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import { IInfNode, TNemesisFaction } from "@/src/types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import { getRewardAtPercentage, SRng } from "@/src/services/rngService";
 | 
			
		||||
import { TInventoryDatabaseDocument } from "../models/inventoryModels/inventoryModel";
 | 
			
		||||
import { logger } from "../utils/logger";
 | 
			
		||||
@ -7,13 +7,51 @@ import { IOid } from "../types/commonTypes";
 | 
			
		||||
import { Types } from "mongoose";
 | 
			
		||||
import { addMods, generateRewardSeed } from "../services/inventoryService";
 | 
			
		||||
import { isArchwingMission } from "../services/worldStateService";
 | 
			
		||||
import { fromStoreItem, toStoreItem } from "../services/itemDataService";
 | 
			
		||||
import { createMessage } from "../services/inboxService";
 | 
			
		||||
import { version_compare } from "./inventoryHelpers";
 | 
			
		||||
 | 
			
		||||
export interface INemesisFactionInfo {
 | 
			
		||||
    systemIndexes: number[];
 | 
			
		||||
    showdownNode: string;
 | 
			
		||||
    ephemeraChance: number;
 | 
			
		||||
    firstKillReward: string;
 | 
			
		||||
    firstConvertReward: string;
 | 
			
		||||
    messageTitle: string;
 | 
			
		||||
    messageBody: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const nemesisFactionInfos: Record<TNemesisFaction, INemesisFactionInfo> = {
 | 
			
		||||
    FC_GRINEER: {
 | 
			
		||||
        systemIndexes: [2, 3, 9, 11, 18],
 | 
			
		||||
        showdownNode: "CrewBattleNode557",
 | 
			
		||||
        ephemeraChance: 0.05,
 | 
			
		||||
        firstKillReward: "/Lotus/StoreItems/Upgrades/Skins/Clan/LichKillerBadgeItem",
 | 
			
		||||
        firstConvertReward: "/Lotus/StoreItems/Upgrades/Skins/Sigils/KuvaLichSigil",
 | 
			
		||||
        messageTitle: "/Lotus/Language/Inbox/VanquishKuvaMsgTitle",
 | 
			
		||||
        messageBody: "/Lotus/Language/Inbox/VanquishLichMsgBody"
 | 
			
		||||
    },
 | 
			
		||||
    FC_CORPUS: {
 | 
			
		||||
        systemIndexes: [1, 15, 4, 7, 8],
 | 
			
		||||
        showdownNode: "CrewBattleNode558",
 | 
			
		||||
        ephemeraChance: 0.2,
 | 
			
		||||
        firstKillReward: "/Lotus/StoreItems/Upgrades/Skins/Clan/CorpusLichBadgeItem",
 | 
			
		||||
        firstConvertReward: "/Lotus/StoreItems/Upgrades/Skins/Sigils/CorpusLichSigil",
 | 
			
		||||
        messageTitle: "/Lotus/Language/Inbox/VanquishLawyerMsgTitle",
 | 
			
		||||
        messageBody: "/Lotus/Language/Inbox/VanquishLichMsgBody"
 | 
			
		||||
    },
 | 
			
		||||
    FC_INFESTATION: {
 | 
			
		||||
        systemIndexes: [23],
 | 
			
		||||
        showdownNode: "CrewBattleNode559",
 | 
			
		||||
        ephemeraChance: 0,
 | 
			
		||||
        firstKillReward: "/Lotus/StoreItems/Upgrades/Skins/Sigils/InfLichVanquishedSigil",
 | 
			
		||||
        firstConvertReward: "/Lotus/StoreItems/Upgrades/Skins/Sigils/InfLichConvertedSigil",
 | 
			
		||||
        messageTitle: "/Lotus/Language/Inbox/VanquishBandMsgTitle",
 | 
			
		||||
        messageBody: "/Lotus/Language/Inbox/VanquishBandMsgBody"
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getInfNodes = (faction: TNemesisFaction, rank: number): IInfNode[] => {
 | 
			
		||||
    const infNodes = [];
 | 
			
		||||
    const systemIndex = systemIndexes[faction][rank];
 | 
			
		||||
    const systemIndex = nemesisFactionInfos[faction].systemIndexes[rank];
 | 
			
		||||
    for (const [key, value] of Object.entries(ExportRegions)) {
 | 
			
		||||
        if (
 | 
			
		||||
            value.systemIndex === systemIndex &&
 | 
			
		||||
@ -35,24 +73,6 @@ export const getInfNodes = (faction: TNemesisFaction, rank: number): IInfNode[]
 | 
			
		||||
    return infNodes;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const systemIndexes: Record<TNemesisFaction, number[]> = {
 | 
			
		||||
    FC_GRINEER: [2, 3, 9, 11, 18],
 | 
			
		||||
    FC_CORPUS: [1, 15, 4, 7, 8],
 | 
			
		||||
    FC_INFESTATION: [23]
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const showdownNodes: Record<TNemesisFaction, string> = {
 | 
			
		||||
    FC_GRINEER: "CrewBattleNode557",
 | 
			
		||||
    FC_CORPUS: "CrewBattleNode558",
 | 
			
		||||
    FC_INFESTATION: "CrewBattleNode559"
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ephemeraProbabilities: Record<TNemesisFaction, number> = {
 | 
			
		||||
    FC_GRINEER: 0.05,
 | 
			
		||||
    FC_CORPUS: 0.2,
 | 
			
		||||
    FC_INFESTATION: 0
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type TInnateDamageTag =
 | 
			
		||||
    | "InnateElectricityDamage"
 | 
			
		||||
    | "InnateHeatDamage"
 | 
			
		||||
@ -311,11 +331,17 @@ export const getInnateDamageTag = (KillingSuit: string): TInnateDamageTag => {
 | 
			
		||||
    return ExportWarframes[KillingSuit].nemesisUpgradeTag!;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const petHeads = [
 | 
			
		||||
    "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadA",
 | 
			
		||||
    "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadB",
 | 
			
		||||
    "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadC"
 | 
			
		||||
] as const;
 | 
			
		||||
 | 
			
		||||
export interface INemesisProfile {
 | 
			
		||||
    innateDamageTag: TInnateDamageTag;
 | 
			
		||||
    innateDamageValue: number;
 | 
			
		||||
    ephemera?: string;
 | 
			
		||||
    petHead?: string;
 | 
			
		||||
    petHead?: (typeof petHeads)[number];
 | 
			
		||||
    petBody?: string;
 | 
			
		||||
    petLegs?: string;
 | 
			
		||||
    petTail?: string;
 | 
			
		||||
@ -337,16 +363,12 @@ export const generateNemesisProfile = (
 | 
			
		||||
        innateDamageTag: getInnateDamageTag(killingSuit),
 | 
			
		||||
        innateDamageValue: Math.trunc(value * 0x40000000) // TODO: For -1399275245665749231n, the value should be 75306944, but we're off by 59 with 75307003.
 | 
			
		||||
    };
 | 
			
		||||
    if (rng.randomFloat() <= ephemeraProbabilities[Faction] && Faction != "FC_INFESTATION") {
 | 
			
		||||
    if (rng.randomFloat() <= nemesisFactionInfos[Faction].ephemeraChance && Faction != "FC_INFESTATION") {
 | 
			
		||||
        profile.ephemera = ephmeraTypes[Faction][profile.innateDamageTag];
 | 
			
		||||
    }
 | 
			
		||||
    rng.randomFloat(); // something related to sentinel agent maybe
 | 
			
		||||
    if (Faction == "FC_CORPUS") {
 | 
			
		||||
        profile.petHead = rng.randomElement([
 | 
			
		||||
            "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadA",
 | 
			
		||||
            "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadB",
 | 
			
		||||
            "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadC"
 | 
			
		||||
        ])!;
 | 
			
		||||
        profile.petHead = rng.randomElement(petHeads)!;
 | 
			
		||||
        profile.petBody = rng.randomElement([
 | 
			
		||||
            "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartBodyA",
 | 
			
		||||
            "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartBodyB",
 | 
			
		||||
@ -422,52 +444,3 @@ export const getInfestedLichItemRewards = (fp: bigint): string[] => {
 | 
			
		||||
    const rotBReward = getRewardAtPercentage(infestedLichRotB, rng.randomFloat())!.type;
 | 
			
		||||
    return [rotAReward, rotBReward];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const sendCodaFinishedMessage = async (
 | 
			
		||||
    inventory: TInventoryDatabaseDocument,
 | 
			
		||||
    fp: bigint = generateRewardSeed(),
 | 
			
		||||
    name: string = "ZEKE_BEATWOMAN_TM.1999",
 | 
			
		||||
    killed: boolean = true
 | 
			
		||||
): Promise<void> => {
 | 
			
		||||
    const att: string[] = [];
 | 
			
		||||
 | 
			
		||||
    // First vanquish/convert gives a sigil
 | 
			
		||||
    const sigil = killed
 | 
			
		||||
        ? "/Lotus/Upgrades/Skins/Sigils/InfLichVanquishedSigil"
 | 
			
		||||
        : "/Lotus/Upgrades/Skins/Sigils/InfLichConvertedSigil";
 | 
			
		||||
    if (!inventory.WeaponSkins.find(x => x.ItemType == sigil)) {
 | 
			
		||||
        att.push(toStoreItem(sigil));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const [rotAReward, rotBReward] = getInfestedLichItemRewards(fp);
 | 
			
		||||
    att.push(fromStoreItem(rotAReward));
 | 
			
		||||
    att.push(fromStoreItem(rotBReward));
 | 
			
		||||
 | 
			
		||||
    let countedAtt: ITypeCount[] | undefined;
 | 
			
		||||
    if (killed) {
 | 
			
		||||
        countedAtt = [
 | 
			
		||||
            {
 | 
			
		||||
                ItemType: "/Lotus/Types/Items/MiscItems/CodaWeaponBucks",
 | 
			
		||||
                ItemCount: getKillTokenRewardCount(fp)
 | 
			
		||||
            }
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await createMessage(inventory.accountOwnerId, [
 | 
			
		||||
        {
 | 
			
		||||
            sndr: "/Lotus/Language/Bosses/Ordis",
 | 
			
		||||
            msg: "/Lotus/Language/Inbox/VanquishBandMsgBody",
 | 
			
		||||
            arg: [
 | 
			
		||||
                {
 | 
			
		||||
                    Key: "LICH_NAME",
 | 
			
		||||
                    Tag: name
 | 
			
		||||
                }
 | 
			
		||||
            ],
 | 
			
		||||
            att: att,
 | 
			
		||||
            countedAtt: countedAtt,
 | 
			
		||||
            sub: "/Lotus/Language/Inbox/VanquishBandMsgTitle",
 | 
			
		||||
            icon: "/Lotus/Interface/Icons/Npcs/Ordis.png",
 | 
			
		||||
            highPriority: true
 | 
			
		||||
        }
 | 
			
		||||
    ]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,7 @@ import {
 | 
			
		||||
import { IMissionInventoryUpdateRequest, IRewardInfo } from "../types/requestTypes";
 | 
			
		||||
import { logger } from "@/src/utils/logger";
 | 
			
		||||
import { IRngResult, SRng, getRandomElement, getRandomReward } from "@/src/services/rngService";
 | 
			
		||||
import { equipmentKeys, IMission, TEquipmentKey } from "@/src/types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import { equipmentKeys, IMission, ITypeCount, TEquipmentKey } from "@/src/types/inventoryTypes/inventoryTypes";
 | 
			
		||||
import {
 | 
			
		||||
    addBooster,
 | 
			
		||||
    addChallenges,
 | 
			
		||||
@ -44,7 +44,7 @@ import {
 | 
			
		||||
import { updateQuestKey } from "@/src/services/questService";
 | 
			
		||||
import { Types } from "mongoose";
 | 
			
		||||
import { IAffiliationMods, IInventoryChanges } from "@/src/types/purchaseTypes";
 | 
			
		||||
import { getLevelKeyRewards, toStoreItem } from "@/src/services/itemDataService";
 | 
			
		||||
import { fromStoreItem, getLevelKeyRewards, toStoreItem } from "@/src/services/itemDataService";
 | 
			
		||||
import { TInventoryDatabaseDocument } from "@/src/models/inventoryModels/inventoryModel";
 | 
			
		||||
import { getEntriesUnsafe } from "@/src/utils/ts-utils";
 | 
			
		||||
import { IEquipmentClient } from "@/src/types/inventoryTypes/commonInventoryTypes";
 | 
			
		||||
@ -58,10 +58,12 @@ import kuriaMessage100 from "@/static/fixed_responses/kuriaMessages/oneHundredPe
 | 
			
		||||
import conservationAnimals from "@/static/fixed_responses/conservationAnimals.json";
 | 
			
		||||
import {
 | 
			
		||||
    generateNemesisProfile,
 | 
			
		||||
    getInfestedLichItemRewards,
 | 
			
		||||
    getInfNodes,
 | 
			
		||||
    getKillTokenRewardCount,
 | 
			
		||||
    getNemesisPasscode,
 | 
			
		||||
    getWeaponsForManifest,
 | 
			
		||||
    sendCodaFinishedMessage
 | 
			
		||||
    nemesisFactionInfos
 | 
			
		||||
} from "@/src/helpers/nemesisHelpers";
 | 
			
		||||
import { Loadout } from "../models/inventoryModels/loadoutModel";
 | 
			
		||||
import { ILoadoutConfigDatabase } from "../types/saveLoadoutTypes";
 | 
			
		||||
@ -665,6 +667,9 @@ export const addMissionInventoryUpdates = async (
 | 
			
		||||
                        inventory.Nemesis.Faction,
 | 
			
		||||
                        inventory.Nemesis.KillingSuit
 | 
			
		||||
                    );
 | 
			
		||||
                    const nemesisFactionInfo = nemesisFactionInfos[inventory.Nemesis.Faction];
 | 
			
		||||
                    const att: string[] = [];
 | 
			
		||||
                    let countedAtt: ITypeCount[] | undefined;
 | 
			
		||||
 | 
			
		||||
                    if (value.killed) {
 | 
			
		||||
                        if (
 | 
			
		||||
@ -675,45 +680,78 @@ export const addMissionInventoryUpdates = async (
 | 
			
		||||
                                inventory.Nemesis.WeaponIdx
 | 
			
		||||
                            ];
 | 
			
		||||
                            giveNemesisWeaponRecipe(inventory, weaponType, value.nemesisName, value.weaponLoc, profile);
 | 
			
		||||
                            att.push(weaponType);
 | 
			
		||||
                        }
 | 
			
		||||
                        if (value.petLoc) {
 | 
			
		||||
                        //if (value.petLoc) {
 | 
			
		||||
                        if (profile.petHead) {
 | 
			
		||||
                            giveNemesisPetRecipe(inventory, value.nemesisName, profile);
 | 
			
		||||
                            att.push(
 | 
			
		||||
                                {
 | 
			
		||||
                                    "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadA":
 | 
			
		||||
                                        "/Lotus/Types/Recipes/ZanukaPet/ZanukaPetCompleteHeadABlueprint",
 | 
			
		||||
                                    "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadB":
 | 
			
		||||
                                        "/Lotus/Types/Recipes/ZanukaPet/ZanukaPetCompleteHeadBBlueprint",
 | 
			
		||||
                                    "/Lotus/Types/Friendly/Pets/ZanukaPets/ZanukaPetParts/ZanukaPetPartHeadC":
 | 
			
		||||
                                        "/Lotus/Types/Recipes/ZanukaPet/ZanukaPetCompleteHeadCBlueprint"
 | 
			
		||||
                                }[profile.petHead]
 | 
			
		||||
                            );
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    // "Players will receive a Lich's Ephemera regardless of whether they Vanquish or Convert them."
 | 
			
		||||
                    if (profile.ephemera) {
 | 
			
		||||
                        addSkin(inventory, profile.ephemera);
 | 
			
		||||
                        att.push(profile.ephemera);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    switch (inventory.Nemesis.Faction) {
 | 
			
		||||
                        case "FC_GRINEER":
 | 
			
		||||
                            addSkin(
 | 
			
		||||
                                inventory,
 | 
			
		||||
                                value.killed
 | 
			
		||||
                                    ? "/Lotus/Upgrades/Skins/Clan/LichKillerBadgeItem"
 | 
			
		||||
                                    : "/Lotus/Upgrades/Skins/Sigils/KuvaLichSigil"
 | 
			
		||||
                            );
 | 
			
		||||
                            break;
 | 
			
		||||
                    const skinRewardStoreItem = value.killed
 | 
			
		||||
                        ? nemesisFactionInfo.firstKillReward
 | 
			
		||||
                        : nemesisFactionInfo.firstConvertReward;
 | 
			
		||||
                    if (Object.keys(addSkin(inventory, fromStoreItem(skinRewardStoreItem))).length != 0) {
 | 
			
		||||
                        att.push(skinRewardStoreItem);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                        case "FC_CORPUS":
 | 
			
		||||
                            addSkin(
 | 
			
		||||
                                inventory,
 | 
			
		||||
                                value.killed
 | 
			
		||||
                                    ? "/Lotus/Upgrades/Skins/Clan/CorpusLichBadgeItem"
 | 
			
		||||
                                    : "/Lotus/Upgrades/Skins/Sigils/CorpusLichSigil"
 | 
			
		||||
                            );
 | 
			
		||||
                            break;
 | 
			
		||||
                    if (inventory.Nemesis.Faction == "FC_INFESTATION") {
 | 
			
		||||
                        const [rotARewardStoreItem, rotBRewardStoreItem] = getInfestedLichItemRewards(
 | 
			
		||||
                            inventory.Nemesis.fp
 | 
			
		||||
                        );
 | 
			
		||||
                        const rotAReward = fromStoreItem(rotARewardStoreItem);
 | 
			
		||||
                        const rotBReward = fromStoreItem(rotBRewardStoreItem);
 | 
			
		||||
                        await addItem(inventory, rotAReward);
 | 
			
		||||
                        await addItem(inventory, rotBReward);
 | 
			
		||||
                        att.push(rotAReward);
 | 
			
		||||
                        att.push(rotBReward);
 | 
			
		||||
 | 
			
		||||
                        case "FC_INFESTATION":
 | 
			
		||||
                            // TOVERIFY: Is the inbox message also sent when converting a lich? If not, how are the rewards given?
 | 
			
		||||
                            await sendCodaFinishedMessage(
 | 
			
		||||
                                inventory,
 | 
			
		||||
                                inventory.Nemesis.fp,
 | 
			
		||||
                                value.nemesisName,
 | 
			
		||||
                                value.killed
 | 
			
		||||
                            );
 | 
			
		||||
                            break;
 | 
			
		||||
                        if (value.killed) {
 | 
			
		||||
                            countedAtt = [
 | 
			
		||||
                                {
 | 
			
		||||
                                    ItemType: "/Lotus/Types/Items/MiscItems/CodaWeaponBucks",
 | 
			
		||||
                                    ItemCount: getKillTokenRewardCount(inventory.Nemesis.fp)
 | 
			
		||||
                                }
 | 
			
		||||
                            ];
 | 
			
		||||
                            addMiscItems(inventory, countedAtt);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (value.killed) {
 | 
			
		||||
                        await createMessage(inventory.accountOwnerId, [
 | 
			
		||||
                            {
 | 
			
		||||
                                sndr: "/Lotus/Language/Bosses/Ordis",
 | 
			
		||||
                                msg: nemesisFactionInfo.messageBody,
 | 
			
		||||
                                arg: [
 | 
			
		||||
                                    {
 | 
			
		||||
                                        Key: "LICH_NAME",
 | 
			
		||||
                                        Tag: value.nemesisName
 | 
			
		||||
                                    }
 | 
			
		||||
                                ],
 | 
			
		||||
                                att: att,
 | 
			
		||||
                                countedAtt: countedAtt,
 | 
			
		||||
                                attVisualOnly: true,
 | 
			
		||||
                                sub: nemesisFactionInfo.messageTitle,
 | 
			
		||||
                                icon: "/Lotus/Interface/Icons/Npcs/Ordis.png",
 | 
			
		||||
                                highPriority: true
 | 
			
		||||
                            }
 | 
			
		||||
                        ]);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    inventory.Nemesis = undefined;
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user