diff --git a/src/app/(frontend)/locker/actions.ts b/src/app/(frontend)/locker/actions.ts index a6817d3..c774341 100644 --- a/src/app/(frontend)/locker/actions.ts +++ b/src/app/(frontend)/locker/actions.ts @@ -7,20 +7,26 @@ import hasRoles from "@/utils/access-control/hasRoles"; import { emitGameEvent } from "@/utils/event-log/emit"; import { EventTypes } from "@/utils/event-log/eventTypes"; import { + ATTACHMENT_CATEGORIES, autoArrangeLockerItems, ensureLockerStorage, findLockerEmptySpot, getLockerGridDimensions, getLockerItemFootprint, getLoadoutSlot, + isAttachmentCompatible, isLockerPlacementValid, + isSkinAsset, lockerIsRotatable, lockerIsStackable, lockerMaxStack, + resolveAssetId, + skinAppliesTo, + toLockerGridItems, type LockerGridItem, } from "@/lib/locker"; -interface ActionResult { +export interface ActionResult { success: boolean; error?: string; data?: T; @@ -44,29 +50,6 @@ function isLockerManager(user: User): boolean { return hasRoles(["admin", "developer"], user); } -function toGridItems(items: LockerStorage["items"]): LockerGridItem[] { - return (items ?? []) - .filter((e) => e.asset && typeof e.asset === "object" && "id" in e.asset) - .map((e) => { - const asset = e.asset as Asset; - return { - id: e.id ?? `${asset.id}-${e.gridX}-${e.gridY}`, - assetId: asset.id, - quantity: e.quantity, - gridX: e.gridX ?? 0, - gridY: e.gridY ?? 0, - rotated: e.rotated ?? false, - asset, - }; - }); -} - -function resolveAssetId(asset: NonNullable[number]["asset"]): number | null { - if (asset == null) return null; - if (typeof asset === "object" && "id" in asset) return asset.id as number; - return asset as number; -} - async function getLockerWithItems( payload: Awaited>, userId: number, @@ -147,7 +130,7 @@ export async function addItemToLocker( const grid = await getLockerGridDimensions(payload); const existing = [...(locker.items ?? [])]; - const gridItems = toGridItems(existing); + const gridItems = toLockerGridItems(existing); const stackable = lockerIsStackable(asset); const maxStack = lockerMaxStack(asset); const fp = getLockerItemFootprint(asset, false); @@ -290,7 +273,7 @@ export async function moveLockerItem( return { success: false, error: "Placement is outside grid bounds." }; } - const gridItems = toGridItems(items); + const gridItems = toLockerGridItems(items); if (!isLockerPlacementValid(gridItems, entryId, gridX, gridY, fp.width, fp.height, grid.width, grid.height)) { return { success: false, error: "Cannot move here — overlaps with another item." }; } @@ -347,7 +330,7 @@ export async function rotateLockerItem(entryId: string): Promise { return { success: false, error: "Rotation would push the item out of bounds." }; } - const gridItems = toGridItems(items); + const gridItems = toLockerGridItems(items); if (!isLockerPlacementValid(gridItems, entryId, entry.gridX, entry.gridY, fp.width, fp.height, grid.width, grid.height)) { return { success: false, error: "Rotation would overlap with another item." }; } @@ -464,7 +447,7 @@ export async function splitLockerStack(entryId: string, splitAmount: number): Pr if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." }; if (!lockerIsStackable(asset)) return { success: false, error: "This asset cannot be stacked." }; - const gridItems = toGridItems(items); + const gridItems = toLockerGridItems(items); const fp = getLockerItemFootprint(asset, entry.rotated ?? false); const spot = findLockerEmptySpot(gridItems, fp.width, fp.height, grid.width, grid.height); if (!spot) return { success: false, error: "No empty spot available for the split stack." }; @@ -511,7 +494,7 @@ export async function autoArrangeLocker(): Promise { const grid = await getLockerGridDimensions(payload); const items = [...(locker.items ?? [])]; - const rearranged = autoArrangeLockerItems(toGridItems(items), grid.width, grid.height); + const rearranged = autoArrangeLockerItems(toLockerGridItems(items), grid.width, grid.height); const updatedItems = items.map((entry) => { const match = rearranged.find((r) => r.id === entry.id); @@ -733,3 +716,571 @@ async function getOwnedLoadout( if (ownerId !== user.id && !isLockerManager(user)) return null; return loadout; } + +// --- Wardrobe / equipped loadout --- + +export async function equipLoadout(loadoutId: number, equipped = true): Promise { + try { + const { payload, user } = await authenticate(); + const loadout = await getOwnedLoadout(payload, user, loadoutId, 0); + if (!loadout) return { success: false, error: "Loadout not found." }; + + const ownedRes = (await payload.find({ + collection: "loadouts", + where: { ownerUser: { equals: user.id } }, + limit: 100, + depth: 0, + overrideAccess: true, + })) as unknown as { docs: Loadout[] }; + + for (const doc of ownedRes.docs) { + const isEquipped = doc.equipped === true; + if (doc.id === loadoutId) { + if (isEquipped !== equipped) { + await payload.update({ + collection: "loadouts", + id: doc.id, + data: { equipped }, + overrideAccess: true, + depth: 0, + }); + } + } else if (isEquipped && equipped) { + await payload.update({ + collection: "loadouts", + id: doc.id, + data: { equipped: false }, + overrideAccess: true, + depth: 0, + }); + } + } + + await emitGameEvent(payload, { + type: EventTypes.LoadoutSetActive, + message: equipped + ? `Set "${loadout.name}" as the equipped loadout` + : `Un-equipped "${loadout.name}"`, + actor: user.id, + targetCollection: "loadouts", + targetId: loadoutId, + data: { loadoutName: loadout.name, active: equipped }, + }); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} + +// --- Equipment customization (attachments & skins) --- + +export async function setItemAttachment( + entryId: string, + assetId: number, +): Promise { + try { + const { payload, user } = await authenticate(); + const locker = await getLockerWithItems(payload, user.id as number); + const items = [...(locker.items ?? [])]; + + const targetIdx = items.findIndex((e) => e.id === entryId); + if (targetIdx < 0) return { success: false, error: "Item not found in locker." }; + + const target = items[targetIdx]; + const weapon = target.asset as Asset; + if (!weapon || typeof weapon === "number" || weapon.assetType !== "weapon") { + return { success: false, error: "Attachments can only be installed on weapons." }; + } + + const attachment = await getAssetOrThrow(payload, assetId); + if (!isAttachmentCompatible(weapon, attachment)) { + return { + success: false, + error: `"${attachment.name}" is not compatible with ${weapon.name}.`, + }; + } + + const already = (target.equipment?.attachments ?? []).some( + (a) => resolveAssetId(a.asset) === assetId, + ); + if (already) return { success: false, error: "This attachment is already installed." }; + + const srcIdx = items.findIndex( + (e) => e.id !== entryId && resolveAssetId(e.asset) === assetId && e.quantity >= 1, + ); + if (srcIdx < 0) { + return { success: false, error: `You do not have ${attachment.name} in your locker.` }; + } + + const src = items[srcIdx]; + const updatedItems = [...items]; + if (src.quantity > 1) { + updatedItems[srcIdx] = { ...src, quantity: src.quantity - 1 }; + } else { + updatedItems.splice(srcIdx, 1); + } + + const tIdx = updatedItems.findIndex((e) => e.id === entryId); + const currentAttachments = target.equipment?.attachments ?? []; + updatedItems[tIdx] = { + ...updatedItems[tIdx], + equipment: { + ...(updatedItems[tIdx].equipment ?? {}), + attachments: [...currentAttachments, { asset: assetId }], + }, + }; + + await payload.update({ + collection: "locker-storages", + id: locker.id, + data: { items: updatedItems }, + overrideAccess: true, + depth: 0, + }); + + await emitGameEvent(payload, { + type: EventTypes.LockerItemAttach, + message: `Installed ${attachment.name} on ${weapon.name}`, + actor: user.id, + targetCollection: "locker-storages", + targetId: locker.id, + data: { + entryId, + weaponId: weapon.id, + weaponName: weapon.name, + attachmentId: assetId, + attachmentName: attachment.name, + }, + }); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} + +export async function removeItemAttachment( + entryId: string, + attachmentId: number, +): Promise { + try { + const { payload, user } = await authenticate(); + const locker = await getLockerWithItems(payload, user.id as number); + const grid = await getLockerGridDimensions(payload); + const items = [...(locker.items ?? [])]; + + const targetIdx = items.findIndex((e) => e.id === entryId); + if (targetIdx < 0) return { success: false, error: "Item not found in locker." }; + + const target = items[targetIdx]; + const weapon = target.asset as Asset; + if (!weapon || typeof weapon === "number") return { success: false, error: "Invalid asset." }; + + const attachments = target.equipment?.attachments ?? []; + const attIdx = attachments.findIndex((a) => resolveAssetId(a.asset) === attachmentId); + if (attIdx < 0) return { success: false, error: "Attachment not installed on this item." }; + + const attachment = await getAssetOrThrow(payload, attachmentId); + const remainingAttachments = attachments.filter((_, i) => i !== attIdx); + + const gridItems = toLockerGridItems(items); + const fp = getLockerItemFootprint(attachment, false); + const spot = findLockerEmptySpot(gridItems, fp.width, fp.height, grid.width, grid.height); + if (!spot) { + return { success: false, error: `No space in locker to return ${attachment.name}.` }; + } + + const updatedItems = [...items]; + updatedItems[targetIdx] = { + ...target, + equipment: { ...(target.equipment ?? {}), attachments: remainingAttachments }, + }; + updatedItems.push({ + asset: attachmentId, + quantity: 1, + gridX: spot.x, + gridY: spot.y, + rotated: false, + }); + + await payload.update({ + collection: "locker-storages", + id: locker.id, + data: { items: updatedItems }, + overrideAccess: true, + depth: 0, + }); + + await emitGameEvent(payload, { + type: EventTypes.LockerItemDetach, + message: `Removed ${attachment.name} from ${weapon.name}`, + actor: user.id, + targetCollection: "locker-storages", + targetId: locker.id, + data: { + entryId, + weaponId: weapon.id, + weaponName: weapon.name, + attachmentId, + attachmentName: attachment.name, + }, + }); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} + +export async function setItemSkin(entryId: string, assetId: number): Promise { + try { + const { payload, user } = await authenticate(); + const locker = await getLockerWithItems(payload, user.id as number); + const grid = await getLockerGridDimensions(payload); + let items = [...(locker.items ?? [])]; + + const targetIdx = items.findIndex((e) => e.id === entryId); + if (targetIdx < 0) return { success: false, error: "Item not found in locker." }; + + const target = items[targetIdx]; + const equipment = target.asset as Asset; + if (!equipment || typeof equipment === "number") return { success: false, error: "Invalid asset." }; + + const skin = await getAssetOrThrow(payload, assetId); + if (!isSkinAsset(skin)) return { success: false, error: `"${skin.name}" is not a skin.` }; + if (!skinAppliesTo(skin, equipment)) { + return { success: false, error: `"${skin.name}" cannot be applied to ${equipment.name}.` }; + } + + const currentSkinId = resolveAssetId(target.equipment?.skin); + if (currentSkinId != null && currentSkinId !== assetId) { + const oldSkin = await getAssetOrThrow(payload, currentSkinId).catch(() => null); + if (oldSkin) { + const fp = getLockerItemFootprint(oldSkin, false); + const spot = findLockerEmptySpot( + toLockerGridItems(items), + fp.width, + fp.height, + grid.width, + grid.height, + ); + if (!spot) { + return { + success: false, + error: "No space in locker to return the currently applied skin.", + }; + } + items = [ + ...items, + { asset: currentSkinId, quantity: 1, gridX: spot.x, gridY: spot.y, rotated: false }, + ]; + } + } + + const srcIdx = items.findIndex( + (e) => e.id !== entryId && resolveAssetId(e.asset) === assetId && e.quantity >= 1, + ); + if (srcIdx < 0) { + return { success: false, error: `You do not have ${skin.name} in your locker.` }; + } + + const src = items[srcIdx]; + const updatedItems = [...items]; + if (src.quantity > 1) { + updatedItems[srcIdx] = { ...src, quantity: src.quantity - 1 }; + } else { + updatedItems.splice(srcIdx, 1); + } + + const tIdx = updatedItems.findIndex((e) => e.id === entryId); + updatedItems[tIdx] = { + ...updatedItems[tIdx], + equipment: { ...(updatedItems[tIdx].equipment ?? {}), skin: assetId }, + }; + + await payload.update({ + collection: "locker-storages", + id: locker.id, + data: { items: updatedItems }, + overrideAccess: true, + depth: 0, + }); + + await emitGameEvent(payload, { + type: EventTypes.LockerItemSkin, + message: `Applied ${skin.name} to ${equipment.name}`, + actor: user.id, + targetCollection: "locker-storages", + targetId: locker.id, + data: { + entryId, + equipmentId: equipment.id, + equipmentName: equipment.name, + skinId: assetId, + skinName: skin.name, + }, + }); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} + +export async function removeItemSkin(entryId: string): Promise { + try { + const { payload, user } = await authenticate(); + const locker = await getLockerWithItems(payload, user.id as number); + const grid = await getLockerGridDimensions(payload); + const items = [...(locker.items ?? [])]; + + const targetIdx = items.findIndex((e) => e.id === entryId); + if (targetIdx < 0) return { success: false, error: "Item not found in locker." }; + + const target = items[targetIdx]; + const skinId = resolveAssetId(target.equipment?.skin); + if (skinId == null) return { success: false, error: "No skin applied to this item." }; + + const skin = await getAssetOrThrow(payload, skinId); + const fp = getLockerItemFootprint(skin, false); + const spot = findLockerEmptySpot(toLockerGridItems(items), fp.width, fp.height, grid.width, grid.height); + if (!spot) return { success: false, error: `No space in locker to return ${skin.name}.` }; + + const updatedItems = [...items]; + updatedItems[targetIdx] = { + ...target, + equipment: { ...(target.equipment ?? {}), skin: null }, + }; + updatedItems.push({ asset: skinId, quantity: 1, gridX: spot.x, gridY: spot.y, rotated: false }); + + await payload.update({ + collection: "locker-storages", + id: locker.id, + data: { items: updatedItems }, + overrideAccess: true, + depth: 0, + }); + + await emitGameEvent(payload, { + type: EventTypes.LockerItemSkinClear, + message: `Removed ${skin.name} from ${(target.asset as Asset).name ?? "item"}`, + actor: user.id, + targetCollection: "locker-storages", + targetId: locker.id, + data: { entryId, skinId, skinName: skin.name }, + }); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} + +export async function renameLockerItem( + entryId: string, + customName: string, +): Promise { + try { + const { payload, user } = await authenticate(); + const locker = await getLockerWithItems(payload, user.id as number); + const items = [...(locker.items ?? [])]; + + const idx = items.findIndex((e) => e.id === entryId); + if (idx < 0) return { success: false, error: "Item not found in locker." }; + + const entry = items[idx]; + const asset = entry.asset as Asset; + if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." }; + + const name = customName.trim(); + items[idx] = { ...entry, equipment: { ...(entry.equipment ?? {}), customName: name || null } }; + + await payload.update({ + collection: "locker-storages", + id: locker.id, + data: { items }, + overrideAccess: true, + depth: 0, + }); + + await emitGameEvent(payload, { + type: EventTypes.LockerItemRename, + message: name + ? `Renamed ${asset.name} to "${name}"` + : `Cleared custom name on ${asset.name}`, + actor: user.id, + targetCollection: "locker-storages", + targetId: locker.id, + data: { entryId, assetId: asset.id, assetName: asset.name, customName: name || null }, + }); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} + +export async function attachItemFromGrid( + weaponEntryId: string, + attachmentEntryId: string, +): Promise { + try { + const { payload, user } = await authenticate(); + const locker = await getLockerWithItems(payload, user.id as number); + const grid = await getLockerGridDimensions(payload); + const items = [...(locker.items ?? [])]; + + const weaponIdx = items.findIndex((e) => e.id === weaponEntryId); + const attIdx = items.findIndex((e) => e.id === attachmentEntryId); + if (weaponIdx < 0) return { success: false, error: "Weapon not found in locker." }; + if (attIdx < 0) return { success: false, error: "Item not found in locker." }; + + const weapon = items[weaponIdx].asset as Asset; + if (!weapon || typeof weapon === "number" || weapon.assetType !== "weapon") { + return { success: false, error: "Attachments can only be installed on weapons." }; + } + + const attachment = items[attIdx].asset as Asset; + if (!attachment || typeof attachment === "number") { + return { success: false, error: "Invalid item." }; + } + + const category = ATTACHMENT_CATEGORIES.find((c) => c.assetType === attachment.assetType); + if (!category) { + return { success: false, error: "This item type cannot be attached to a weapon." }; + } + + if (!isAttachmentCompatible(weapon, attachment)) { + return { + success: false, + error: `"${attachment.name}" is not compatible with ${weapon.name}.`, + }; + } + + const already = (items[weaponIdx].equipment?.attachments ?? []).some( + (a) => resolveAssetId(a.asset) === attachment.id, + ); + if (already) { + return { success: false, error: "This attachment is already installed on this weapon." }; + } + + let working = [...items]; + const dragged = working[attIdx]; + if (dragged.quantity > 1) { + working[attIdx] = { ...dragged, quantity: dragged.quantity - 1 }; + } else { + working.splice(attIdx, 1); + } + + let existingAsset: Asset | null = null; + for (const a of items[weaponIdx].equipment?.attachments ?? []) { + const id = resolveAssetId(a.asset); + if (id == null) continue; + const existing = await getAssetOrThrow(payload, id); + if (existing.assetType === category.assetType) { + existingAsset = existing; + break; + } + } + + if (existingAsset) { + const fp = getLockerItemFootprint(existingAsset, false); + const spot = findLockerEmptySpot( + toLockerGridItems(working), + fp.width, + fp.height, + grid.width, + grid.height, + ); + if (!spot) { + return { success: false, error: `No space in locker to return ${existingAsset.name}.` }; + } + working.push({ + asset: existingAsset.id, + quantity: 1, + gridX: spot.x, + gridY: spot.y, + rotated: false, + }); + } + + const wIdx = working.findIndex((e) => e.id === weaponEntryId); + if (wIdx < 0) return { success: false, error: "Weapon not found in locker." }; + const kept = (working[wIdx].equipment?.attachments ?? []).filter( + (a) => resolveAssetId(a.asset) !== existingAsset?.id, + ); + working[wIdx] = { + ...working[wIdx], + equipment: { + ...(working[wIdx].equipment ?? {}), + attachments: [...kept, { asset: attachment.id }], + }, + }; + + await payload.update({ + collection: "locker-storages", + id: locker.id, + data: { items: working }, + overrideAccess: true, + depth: 0, + }); + + if (existingAsset) { + await emitGameEvent(payload, { + type: EventTypes.LockerItemDetach, + message: `Removed ${existingAsset.name} from ${weapon.name}`, + actor: user.id, + targetCollection: "locker-storages", + targetId: locker.id, + data: { + entryId: weaponEntryId, + weaponId: weapon.id, + weaponName: weapon.name, + attachmentId: existingAsset.id, + attachmentName: existingAsset.name, + }, + }); + } + + await emitGameEvent(payload, { + type: EventTypes.LockerItemAttach, + message: `Installed ${attachment.name} on ${weapon.name}`, + actor: user.id, + targetCollection: "locker-storages", + targetId: locker.id, + data: { + entryId: weaponEntryId, + weaponId: weapon.id, + weaponName: weapon.name, + attachmentId: attachment.id, + attachmentName: attachment.name, + }, + }); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} diff --git a/src/app/(frontend)/locker/page.tsx b/src/app/(frontend)/locker/page.tsx index 4748405..d80a54d 100644 --- a/src/app/(frontend)/locker/page.tsx +++ b/src/app/(frontend)/locker/page.tsx @@ -70,6 +70,7 @@ export default async function LockerPage() { loadouts={loadouts} assetsCatalog={assetsCatalog} isManager={isManager} + userName={user?.username ?? "Member"} /> ); diff --git a/src/collections/locker/Loadouts.ts b/src/collections/locker/Loadouts.ts index 4afd32d..34fbba7 100644 --- a/src/collections/locker/Loadouts.ts +++ b/src/collections/locker/Loadouts.ts @@ -52,6 +52,15 @@ export const Loadouts: CollectionConfig = { description: "The operator this loadout belongs to.", }, }, + { + name: "equipped", + type: "checkbox", + defaultValue: false, + admin: { + description: + "Whether this is the member's currently equipped loadout. Only one loadout per member can be equipped; the wardrobe reflects the equipped loadout.", + }, + }, { name: "slots", type: "group", diff --git a/src/collections/locker/LockerStorages.ts b/src/collections/locker/LockerStorages.ts index 4b98cde..bdbcfcf 100644 --- a/src/collections/locker/LockerStorages.ts +++ b/src/collections/locker/LockerStorages.ts @@ -89,6 +89,55 @@ export const LockerStorages: CollectionConfig = { description: "Whether this item is rotated 90 degrees (swaps width and height).", }, }, + { + name: "equipment", + type: "group", + label: "Equipment Customization", + admin: { + description: + "Per-instance customization for this item: installed attachments, applied skin, and a custom name. Managed from the member locker UI.", + }, + fields: [ + { + name: "attachments", + type: "array", + label: "Installed Attachments", + admin: { + description: + "Attachments installed on this item (e.g. optics, muzzles, bipods on a weapon). Installing consumes the attachment from the locker grid.", + }, + fields: [ + { + name: "asset", + type: "relationship", + relationTo: "assets", + required: true, + }, + ], + }, + { + name: "skin", + type: "relationship", + relationTo: "assets", + admin: { + description: + "Skin applied to this item. Applying consumes the skin from the locker grid.", + }, + filterOptions: { + assetType: { + equals: "skin", + }, + }, + }, + { + name: "customName", + type: "text", + admin: { + description: "Optional custom name for this item (e.g. \"Frank's Karma\").", + }, + }, + ], + }, ], }, { diff --git a/src/collections/logistics/Assets.ts b/src/collections/logistics/Assets.ts index 9d8b3d3..5091a22 100644 --- a/src/collections/logistics/Assets.ts +++ b/src/collections/logistics/Assets.ts @@ -119,6 +119,10 @@ export const Assets: CollectionConfig = { label: "Explosive", value: "explosive", }, + { + label: "Skin", + value: "skin", + }, { label: "Miscellaneous", value: "miscellaneous", @@ -232,6 +236,32 @@ export const Assets: CollectionConfig = { }, ], }, + { + name: "Skin Configuration", + type: "group", + admin: { + condition: (data, siblingData) => siblingData.assetType === "skin", + }, + fields: [ + { + name: "appliesToAssetTypes", + type: "select", + hasMany: true, + label: "Applies To", + options: [ + { label: "Weapon", value: "weapon" }, + { label: "Uniform", value: "uniform" }, + { label: "Vest", value: "vest" }, + { label: "Backpack", value: "backpack" }, + { label: "Helmet", value: "helmet" }, + ], + admin: { + description: + "Which equipment types this skin can be applied to. Leave empty to allow on any equipment.", + }, + }, + ], + }, { name: "Magazine Configuration", type: "group", diff --git a/src/components/frontend/locker/EquipmentEditorDialog.tsx b/src/components/frontend/locker/EquipmentEditorDialog.tsx new file mode 100644 index 0000000..fee2e87 --- /dev/null +++ b/src/components/frontend/locker/EquipmentEditorDialog.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { Asset } from "@/payload-types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + getAssetTypeLabel, + getAttachmentOptionsForItem, + getSkinOptionsForItem, + resolveAssetId, + type LockerGridItem, +} from "@/lib/locker"; +import { getAssetIcon } from "./LockerGrid"; +import { + removeItemAttachment, + removeItemSkin, + renameLockerItem, + setItemAttachment, + setItemSkin, +} from "@/app/(frontend)/locker/actions"; + +interface EquipmentEditorDialogProps { + item: LockerGridItem | null; + items: LockerGridItem[]; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EquipmentEditorDialog({ + item, + items, + open, + onOpenChange, +}: EquipmentEditorDialogProps) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [customName, setCustomName] = useState(""); + + useEffect(() => { + setCustomName(item?.equipment?.customName ?? ""); + setError(null); + }, [item, open]); + + const resolvedItem = useMemo( + () => (item ? items.find((i) => i.id === item.id) ?? item : null), + [item, items], + ); + + const attachmentOptions = useMemo( + () => (resolvedItem ? getAttachmentOptionsForItem(resolvedItem.asset, items) : []), + [resolvedItem, items], + ); + + const installedAssets = useMemo(() => { + const list: Asset[] = []; + for (const a of resolvedItem?.equipment?.attachments ?? []) { + const resolved = resolveAssetId(a.asset); + if (resolved == null) continue; + const found = items.find((i) => i.assetId === resolved)?.asset; + if (found) list.push(found); + else if (typeof a.asset === "object" && "id" in a.asset) list.push(a.asset as Asset); + } + return list; + }, [resolvedItem, items]); + + const installedSkinId = resolveAssetId(resolvedItem?.equipment?.skin); + const installedSkin = useMemo(() => { + if (installedSkinId == null) return null; + return items.find((i) => i.assetId === installedSkinId)?.asset ?? null; + }, [items, installedSkinId]); + + const skinOptions = useMemo( + () => (resolvedItem ? getSkinOptionsForItem(resolvedItem.asset, items) : []), + [resolvedItem, items], + ); + + const mergedSkinOptions = useMemo(() => { + if (!installedSkin) return skinOptions; + return skinOptions.some((o) => o.assetId === installedSkin.id) + ? skinOptions + : [{ assetId: installedSkin.id, quantity: 0, asset: installedSkin }, ...skinOptions]; + }, [skinOptions, installedSkin]); + + if (!resolvedItem) return null; + const activeItem = resolvedItem; + + async function handleCategoryPick( + category: { name: string; label: string; assetType: Asset["assetType"] }, + value: string, + ) { + setBusy(true); + setError(null); + const installed = installedAssets.find((a) => a.assetType === category.assetType); + const assetId = value === "none" ? null : Number(value); + + if (assetId == null) { + if (installed) { + const removed = await removeItemAttachment(activeItem.id, installed.id); + if (!removed.success) setError(removed.error ?? "Failed to remove attachment."); + else router.refresh(); + } + setBusy(false); + return; + } + + if (installed && installed.id !== assetId) { + const removed = await removeItemAttachment(activeItem.id, installed.id); + if (!removed.success) { + setError(removed.error ?? "Failed to replace current attachment."); + setBusy(false); + return; + } + } + + const res = await setItemAttachment(activeItem.id, assetId); + if (!res.success) setError(res.error ?? "Failed to install attachment."); + else router.refresh(); + setBusy(false); + } + + async function handleSkinPick(value: string) { + setBusy(true); + setError(null); + const assetId = value === "none" ? null : Number(value); + const res = + assetId == null + ? await removeItemSkin(activeItem.id) + : await setItemSkin(activeItem.id, assetId); + if (!res.success) setError(res.error ?? "Failed to update skin."); + else router.refresh(); + setBusy(false); + } + + async function handleRename() { + setBusy(true); + setError(null); + const res = await renameLockerItem(activeItem.id, customName); + if (!res.success) setError(res.error ?? "Failed to rename item."); + else router.refresh(); + setBusy(false); + } + + const displayName = customName.trim() || activeItem.asset.name; + + return ( + + + + Customize {displayName} + + Install attachments, apply a skin, and give your {getAssetTypeLabel(activeItem.asset.assetType).toLowerCase()} a name. + Installing consumes the item from your locker grid; removing returns it. + + + +
+
+ {getAssetIcon(activeItem.asset.assetType, "size-6")} +
+
{activeItem.asset.name}
+
+ {getAssetTypeLabel(activeItem.asset.assetType)} · {activeItem.quantity.toLocaleString()}× +
+
+
+ + {attachmentOptions.length > 0 && ( +
+ + {attachmentOptions.map(({ category, options }) => { + const installed = installedAssets.find((a) => a.assetType === category.assetType); + const categoryOptions = + installed && !options.some((o) => o.assetId === installed.id) + ? [{ assetId: installed.id, quantity: 0, asset: installed }, ...options] + : options; + return ( +
+ {category.label} + +
+ ); + })} +

+ Compatible with this weapon based on its catalog entry. Replacing an attachment returns + the old one to your locker. +

+
+ )} + +
+ +
+ Applied + +
+ {installedSkin && ( +

+ Currently applied: {installedSkin.name}. Choosing another skin returns the old one to your locker. +

+ )} +
+ +
+ +
+ setCustomName(e.target.value)} + placeholder="e.g., Frank's Karma" + maxLength={60} + /> + +
+ {customName.trim() && ( +

+ Will display as "{customName.trim()}" in your locker and wardrobe. +

+ )} +
+ + {error &&

{error}

} +
+ + + + +
+
+ ); +} diff --git a/src/components/frontend/locker/LoadoutCard.tsx b/src/components/frontend/locker/LoadoutCard.tsx index bf31180..0dcd11e 100644 --- a/src/components/frontend/locker/LoadoutCard.tsx +++ b/src/components/frontend/locker/LoadoutCard.tsx @@ -1,10 +1,10 @@ "use client"; -import { PencilIcon, TrashIcon } from "lucide-react"; +import { CheckCircle2Icon, PencilIcon, TrashIcon } from "lucide-react"; import type { Asset, Loadout } from "@/payload-types"; import { Button } from "@/components/ui/button"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; -import { getAssetTypeLabel, LOADOUT_SLOTS, type LoadoutSlots } from "@/lib/locker"; +import { getAssetTypeLabel, LOADOUT_SLOTS, type LockerGridItem, type LoadoutSlots } from "@/lib/locker"; function resolveSlotValue(value: LoadoutSlots[keyof LoadoutSlots]): Asset | null { if (value == null) return null; @@ -12,13 +12,20 @@ function resolveSlotValue(value: LoadoutSlots[keyof LoadoutSlots]): Asset | null return null; } -interface LoadoutCardProps { - loadout: Loadout; - onEdit: () => void; - onDelete: () => void; +function slotDisplayName(asset: Asset, ownedItems: LockerGridItem[]): string { + const custom = ownedItems.find((i) => i.assetId === asset.id)?.equipment?.customName?.trim(); + return custom || asset.name; } -export function LoadoutCard({ loadout, onEdit, onDelete }: LoadoutCardProps) { +interface LoadoutCardProps { + loadout: Loadout; + ownedItems: LockerGridItem[]; + onEdit: () => void; + onDelete: () => void; + onEquip: () => void; +} + +export function LoadoutCard({ loadout, ownedItems, onEdit, onDelete, onEquip }: LoadoutCardProps) { const slots = (loadout.slots ?? {}) as LoadoutSlots; const filled = LOADOUT_SLOTS.filter((slot) => { const v = slots[slot.name]; @@ -28,11 +35,26 @@ export function LoadoutCard({ loadout, onEdit, onDelete }: LoadoutCardProps) { return ( - {loadout.name} + + {loadout.name} + {loadout.equipped && ( + + + Equipped + + )} + {loadout.description && ( {loadout.description} )}
+ + )} {lockerIsRotatable(asset) && ( + ); + })} +
+ + ); +} diff --git a/src/components/frontend/locker/WardrobePanel.tsx b/src/components/frontend/locker/WardrobePanel.tsx new file mode 100644 index 0000000..b1aaa71 --- /dev/null +++ b/src/components/frontend/locker/WardrobePanel.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import type { Asset, Loadout } from "@/payload-types"; +import { Button } from "@/components/ui/button"; +import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Wardrobe } from "./Wardrobe"; +import { WardrobeSlotPicker } from "./WardrobeSlotPicker"; +import { + WARDROBE_SLOT_LAYOUT, + getAssetTypeLabel, + getWardrobeSlot, + getLoadoutSlot, + type LoadoutSlots, + type LockerGridItem, +} from "@/lib/locker"; +import { equipLoadout, setLoadoutSlot } from "@/app/(frontend)/locker/actions"; +import { getAssetIcon } from "./LockerGrid"; + +interface WardrobePanelProps { + userName: string; + loadouts: Loadout[]; + equippedLoadout: Loadout | null; + items: LockerGridItem[]; +} + +function resolveSlotAsset( + value: LoadoutSlots[keyof LoadoutSlots], + items: LockerGridItem[], +): Asset | null { + if (value == null) return null; + if (typeof value === "object" && "id" in value) return value as Asset; + const id = value as number; + return items.find((i) => i.assetId === id)?.asset ?? null; +} + +function resolveSlotDisplayName(asset: Asset, items: LockerGridItem[]): string { + const custom = items.find((i) => i.assetId === asset.id)?.equipment?.customName?.trim(); + return custom || asset.name; +} + +export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: WardrobePanelProps) { + const router = useRouter(); + const [pickerSlot, setPickerSlot] = useState(null); + const [busy, setBusy] = useState(false); + + const slots = (equippedLoadout?.slots ?? {}) as LoadoutSlots; + + async function handleEquip(loadoutId: number) { + setBusy(true); + const res = await equipLoadout(loadoutId); + setBusy(false); + if (res.success) router.refresh(); + } + + async function handlePick(slotName: string, assetId: number | null) { + if (!equippedLoadout) return; + setBusy(true); + const res = await setLoadoutSlot(equippedLoadout.id, slotName, assetId); + setBusy(false); + if (res.success) { + setPickerSlot(null); + router.refresh(); + } + } + + const pickerLayout = pickerSlot ? getWardrobeSlot(pickerSlot) : null; + const pickerConfig = pickerSlot ? getLoadoutSlot(pickerSlot) : null; + const pickerValue = pickerSlot ? slots[pickerSlot as keyof LoadoutSlots] ?? null : null; + + const filled = equippedLoadout + ? WARDROBE_SLOT_LAYOUT.filter((layout) => slots[layout.name] != null) + : []; + + return ( +
+ + + Character + + + setPickerSlot(name)} + /> +

+ {equippedLoadout + ? "Click a highlighted point to equip gear from your locker." + : "Equip a loadout to start dressing your character."} +

+
+
+ +
+ + + Equipped Loadout + + + {loadouts.length === 0 ? ( +

+ You have no loadouts yet. Head to the Loadouts tab to create one, then come back + here to equip it. +

+ ) : ( +
+
+ + {equippedLoadout && ( + + )} +
+ + {!equippedLoadout && ( +

+ Choose a loadout above — it becomes the active loadout that the wardrobe reflects. +

+ )} +
+ )} +
+
+ + + + Current Equipment + + + {filled.length === 0 ? ( +

+ Nothing equipped. Select a loadout above and click its equipment slots. +

+ ) : ( +
+ {filled.map((layout) => { + const asset = resolveSlotAsset(slots[layout.name], items); + if (!asset) return null; + return ( + + ); + })} +
+ )} +
+
+ +

+ {userName}, you have {items.length.toLocaleString()} distinct items in your locker. +

+
+ + {pickerLayout && pickerConfig && ( + { + if (!open) setPickerSlot(null); + }} + slot={pickerConfig} + items={items} + currentValue={pickerValue} + busy={busy} + onPick={(assetId) => handlePick(pickerLayout.name, assetId)} + /> + )} +
+ ); +} diff --git a/src/components/frontend/locker/WardrobeSlotPicker.tsx b/src/components/frontend/locker/WardrobeSlotPicker.tsx new file mode 100644 index 0000000..f97402b --- /dev/null +++ b/src/components/frontend/locker/WardrobeSlotPicker.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useMemo } from "react"; +import type { Asset } from "@/payload-types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { getAssetTypeLabel, type LoadoutSlotConfig, type LockerGridItem } from "@/lib/locker"; +import { getAssetIcon } from "./LockerGrid"; +import { cn } from "@/lib/utils"; + +interface WardrobeSlotPickerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + slot: LoadoutSlotConfig; + items: LockerGridItem[]; + currentValue: number | Asset | null; + busy: boolean; + onPick: (assetId: number | null) => void; +} + +function resolveCurrentId(value: number | Asset | null): number | null { + if (value == null) return null; + if (typeof value === "object" && "id" in value) return value.id; + return value; +} + +export function WardrobeSlotPicker({ + open, + onOpenChange, + slot, + items, + currentValue, + busy, + onPick, +}: WardrobeSlotPickerProps) { + const options = useMemo(() => { + const types = new Set(slot.assetTypes); + const seen = new Set(); + return items.filter((entry) => { + if (seen.has(entry.assetId)) return false; + if (!types.has(entry.asset.assetType)) return false; + seen.add(entry.assetId); + return true; + }); + }, [items, slot.assetTypes]); + + const currentId = resolveCurrentId(currentValue); + + return ( + + + + {slot.label} Slot + + Choose an item from your locker to equip in this slot, or clear it. + + + +
+ {options.length === 0 && ( +

+ No matching items in your locker for this slot. +

+ )} + {options.map((entry) => { + const selected = entry.assetId === currentId; + return ( + + ); + })} +
+ + + + {currentId != null && ( + + )} + +
+
+ ); +} diff --git a/src/lib/locker/index.ts b/src/lib/locker/index.ts index ce1a810..97aa771 100644 --- a/src/lib/locker/index.ts +++ b/src/lib/locker/index.ts @@ -2,6 +2,16 @@ import type { Payload } from "payload"; import type { Asset, LockerStorage } from "@/payload-types"; import type { GridDimensions } from "@/lib/logistics"; +/** + * Per-instance customization stored on a locker grid entry: installed + * attachments, applied skin, and an optional custom name. + */ +export type LockerItemEquipment = { + attachments?: { asset: number | Asset; id?: string | null }[] | null; + skin?: number | Asset | null; + customName?: string | null; +} | null; + /** * A single entry in a user's locker grid. Mirrors the shape of the * `items` array on the locker-storages collection, but with the asset @@ -15,8 +25,35 @@ export type LockerGridItem = { gridY: number; rotated: boolean; asset: Asset; + equipment?: LockerItemEquipment; }; +/** Normalize raw locker `items` into populated grid items. */ +export function toLockerGridItems(items: LockerStorage["items"]): LockerGridItem[] { + return (items ?? []) + .filter((e) => e.asset && typeof e.asset === "object" && "id" in e.asset) + .map((e) => { + const asset = e.asset as Asset; + return { + id: e.id ?? `${asset.id}-${e.gridX}-${e.gridY}`, + assetId: asset.id, + quantity: e.quantity, + gridX: e.gridX ?? 0, + gridY: e.gridY ?? 0, + rotated: e.rotated ?? false, + asset, + equipment: e.equipment ?? null, + }; + }); +} + +/** Resolve the numeric id from a relationship value (id or populated doc). */ +export function resolveAssetId(value: number | Asset | null | undefined): number | null { + if (value == null) return null; + if (typeof value === "object" && "id" in value) return value.id; + return value; +} + /** Equipment slot definitions for user loadouts. */ export type LoadoutSlotConfig = { name: string; @@ -82,6 +119,8 @@ export function getAssetTypeLabel(assetType: Asset["assetType"]): string { return "Tool"; case "explosive": return "Explosive"; + case "skin": + return "Skin"; case "miscellaneous": return "Miscellaneous"; default: @@ -105,6 +144,7 @@ const ASSET_TYPE_COLORS: Record = { medical: "oklch(0.45 0.14 20)", tool: "oklch(0.45 0.1 80)", explosive: "oklch(0.45 0.16 55)", + skin: "oklch(0.55 0.12 320)", miscellaneous: "oklch(0.42 0 0)", }; @@ -318,3 +358,121 @@ export function findLockerMergeTarget( } return null; } + +// --- Wardrobe --- + +export type WardrobeSlotIcon = + | "head" + | "eye" + | "face" + | "chest" + | "torso" + | "back" + | "weapon" + | "sidearm" + | "heavy"; + +export type WardrobeSlotLayout = { + name: LoadoutSlotName; + label: string; + /** Hotspot position as percentages of the wardrobe container (0-100). */ + x: number; + y: number; + /** Which body zone this slot highlights. */ + zone: string; + icon: WardrobeSlotIcon; +}; + +/** Positioning of each equipment slot on the wardrobe figure. */ +export const WARDROBE_SLOT_LAYOUT: WardrobeSlotLayout[] = [ + { name: "helmet", label: "Helmet", x: 57, y: 10, zone: "helmet", icon: "head" }, + { name: "nvg", label: "NVG", x: 46, y: 8, zone: "nvg", icon: "eye" }, + { name: "facewear", label: "Facewear", x: 52, y: 15, zone: "facewear", icon: "face" }, + { name: "radio", label: "Radio", x: 47, y: 25, zone: "radio", icon: "chest" }, + { name: "vest", label: "Vest", x: 53, y: 32, zone: "vest", icon: "chest" }, + { name: "uniform", label: "Uniform", x: 53, y: 46, zone: "uniform", icon: "torso" }, + { name: "backpack", label: "Backpack", x: 68, y: 36, zone: "backpack", icon: "back" }, + { name: "primary", label: "Primary", x: 30, y: 19, zone: "primary", icon: "weapon" }, + { name: "optic", label: "Optic", x: 37, y: 18, zone: "primary", icon: "weapon" }, + { name: "muzzle", label: "Muzzle", x: 25, y: 6, zone: "primary", icon: "weapon" }, + { name: "bipod", label: "Bipod", x: 24, y: 12, zone: "primary", icon: "weapon" }, + { name: "launcher", label: "Launcher", x: 68, y: 22, zone: "launcher", icon: "heavy" }, + { name: "secondary", label: "Secondary", x: 35, y: 56, zone: "secondary", icon: "sidearm" }, +]; + +export function getWardrobeSlot(name: string): WardrobeSlotLayout | null { + return WARDROBE_SLOT_LAYOUT.find((s) => s.name === name) ?? null; +} + +// --- Equipment customization (attachments & skins) --- + +export const ATTACHMENT_CATEGORIES: { + name: string; + label: string; + assetType: Asset["assetType"]; +}[] = [ + { name: "optic", label: "Optic", assetType: "optic" }, + { name: "muzzle", label: "Muzzle", assetType: "muzzle" }, + { name: "bipod", label: "Bipod", assetType: "bipod" }, +]; + +export const ATTACHMENT_ASSET_TYPES = new Set(ATTACHMENT_CATEGORIES.map((c) => c.assetType)); + +/** Explicit compatible attachment ids from the weapon's catalog entry (empty = any). */ +export function getCompatibleAttachmentIds(weapon: Asset): Set { + const list = weapon["Weapon Configuration"]?.compatibleAttachments; + if (!list || list.length === 0) return new Set(); + const ids = new Set(); + for (const ref of list) { + const id = resolveAssetId(ref); + if (id != null) ids.add(id); + } + return ids; +} + +export function isAttachmentCompatible(weapon: Asset, attachment: Asset): boolean { + if (!ATTACHMENT_ASSET_TYPES.has(attachment.assetType)) return false; + const allowed = getCompatibleAttachmentIds(weapon); + if (allowed.size > 0 && !allowed.has(attachment.id)) return false; + return true; +} + +/** Categorised attachment candidates from the locker for a given weapon. */ +export function getAttachmentOptionsForItem( + weapon: Asset, + items: LockerGridItem[], +): { category: (typeof ATTACHMENT_CATEGORIES)[number]; options: LockerGridItem[] }[] { + const allowed = getCompatibleAttachmentIds(weapon); + return ATTACHMENT_CATEGORIES.map((category) => ({ + category, + options: items.filter((entry) => { + if (entry.assetId === weapon.id) return false; + if (entry.asset.assetType !== category.assetType) return false; + if (allowed.size > 0 && !allowed.has(entry.assetId)) return false; + return true; + }), + })); +} + +export function isSkinAsset(asset: Asset): boolean { + return asset.assetType === "skin"; +} + +/** True if a skin can be applied to the given equipment asset. */ +export function skinAppliesTo(skin: Asset, equipment: Asset): boolean { + const applies = skin["Skin Configuration"]?.appliesToAssetTypes; + if (!applies || applies.length === 0) return true; + return (applies as readonly string[]).includes(equipment.assetType); +} + +/** Skin candidates from the locker that fit the given equipment asset. */ +export function getSkinOptionsForItem( + equipment: Asset, + items: LockerGridItem[], +): LockerGridItem[] { + return items.filter((entry) => { + if (entry.assetId === equipment.id) return false; + if (!isSkinAsset(entry.asset)) return false; + return skinAppliesTo(entry.asset, equipment); + }); +} diff --git a/src/payload-types.ts b/src/payload-types.ts index 9f3ab0f..d56de89 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -677,6 +677,7 @@ export interface Asset { | 'medical' | 'tool' | 'explosive' + | 'skin' | 'miscellaneous'; 'Weapon Configuration'?: { slotType?: ('primary' | 'secondary' | 'launcher') | null; @@ -694,6 +695,12 @@ export interface Asset { capacityKg?: number | null; weightKg?: number | null; }; + 'Skin Configuration'?: { + /** + * Which equipment types this skin can be applied to. Leave empty to allow on any equipment. + */ + appliesToAssetTypes?: ('weapon' | 'uniform' | 'vest' | 'backpack' | 'helmet')[] | null; + }; 'Magazine Configuration'?: { roundCapacity?: number | null; ammoType?: string | null; @@ -1796,6 +1803,28 @@ export interface LockerStorage { * Whether this item is rotated 90 degrees (swaps width and height). */ rotated?: boolean | null; + /** + * Per-instance customization for this item: installed attachments, applied skin, and a custom name. Managed from the member locker UI. + */ + equipment?: { + /** + * Attachments installed on this item (e.g. optics, muzzles, bipods on a weapon). Installing consumes the attachment from the locker grid. + */ + attachments?: + | { + asset: number | Asset; + id?: string | null; + }[] + | null; + /** + * Skin applied to this item. Applying consumes the skin from the locker grid. + */ + skin?: (number | null) | Asset; + /** + * Optional custom name for this item (e.g. "Frank's Karma"). + */ + customName?: string | null; + }; id?: string | null; }[] | null; @@ -1824,6 +1853,10 @@ export interface Loadout { * The operator this loadout belongs to. */ ownerUser: number | User; + /** + * Whether this is the member's currently equipped loadout. Only one loadout per member can be equipped; the wardrobe reflects the equipped loadout. + */ + equipped?: boolean | null; /** * Assign assets from your locker to each equipment slot. */ @@ -2577,6 +2610,11 @@ export interface AssetsSelect { capacityKg?: T; weightKg?: T; }; + 'Skin Configuration'?: + | T + | { + appliesToAssetTypes?: T; + }; 'Magazine Configuration'?: | T | { @@ -2921,6 +2959,18 @@ export interface LockerStoragesSelect { gridX?: T; gridY?: T; rotated?: T; + equipment?: + | T + | { + attachments?: + | T + | { + asset?: T; + id?: T; + }; + skin?: T; + customName?: T; + }; id?: T; }; itemCount?: T; @@ -2935,6 +2985,7 @@ export interface LoadoutsSelect { name?: T; description?: T; ownerUser?: T; + equipped?: T; slots?: | T | { diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index 72df021..e705076 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -32,12 +32,18 @@ export const EventTypes = { LockerGridMerge: "locker:grid-merge", LockerGridSplit: "locker:grid-split", LockerGridArrange: "locker:grid-arrange", + LockerItemAttach: "locker:item-attach", + LockerItemDetach: "locker:item-detach", + LockerItemSkin: "locker:item-skin", + LockerItemSkinClear: "locker:item-skin-clear", + LockerItemRename: "locker:item-rename", // Loadouts LoadoutCreate: "loadout:create", LoadoutUpdate: "loadout:update", LoadoutDelete: "loadout:delete", LoadoutEquip: "loadout:equip", + LoadoutSetActive: "loadout:set-active", // Shipping ShipmentCreate: "shipment:create",