"use server"; import config from "@payload-config"; import { getPayload } from "payload"; import type { Asset, Loadout, LockerStorage, User } from "@/payload-types"; 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, getLoadoutSlot, getLockerGridDimensions, getLockerItemFootprint, isAttachmentCompatible, isLockerPlacementValid, isSkinAsset, lockerIsRotatable, lockerIsStackable, lockerMaxStack, resolveAssetId, skinAppliesTo, toLockerGridItems, } from "@/lib/locker"; export interface ActionResult { success: boolean; error?: string; data?: T; } async function authenticate() { const payloadConfig = await config; const payload = await getPayload({ config: payloadConfig }); const { headers } = await import("next/headers"); const hdrs = await headers(); const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false }); if (!user) { throw new Error("Unauthorized"); } return { payload, user }; } function isLockerManager(user: User): boolean { return hasRoles(["admin", "developer"], user); } async function getLockerWithItems( payload: Awaited>, userId: number, ): Promise { const locker = await ensureLockerStorage(payload, userId); return (await payload.findByID({ collection: "locker-storages", id: locker.id, depth: 2, overrideAccess: true, })) as unknown as LockerStorage; } async function getAssetOrThrow( payload: Awaited>, assetId: number, ): Promise { const asset = (await payload .findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true }) .catch(() => null)) as unknown as Asset | null; if (!asset) throw new Error("Asset not found."); return asset; } export async function ensureLocker(): Promise> { try { const { payload, user } = await authenticate(); const locker = await ensureLockerStorage(payload, user.id as number); const existing = await payload.find({ collection: "game-event-logs", where: { and: [ { type: { equals: EventTypes.LockerCreate } }, { targetCollection: { equals: "locker-storages" } }, { targetId: { equals: locker.id } }, ], }, limit: 1, depth: 0, overrideAccess: true, }); if (existing.docs.length === 0) { await emitGameEvent(payload, { type: EventTypes.LockerCreate, message: `Personal locker provisioned`, actor: user.id, targetCollection: "locker-storages", targetId: locker.id, }); } return { success: true, data: locker.id }; } 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 addItemToLocker( assetId: number, quantity: number, ): Promise> { try { const { payload, user } = await authenticate(); if (!isLockerManager(user)) { return { success: false, error: "Insufficient permissions. Admin or Developer role required to add items.", }; } if (!Number.isInteger(quantity) || quantity <= 0) { return { success: false, error: "Quantity must be a positive integer." }; } const asset = await getAssetOrThrow(payload, assetId); const locker = await getLockerWithItems(payload, user.id as number); const grid = await getLockerGridDimensions(payload); const existing = [...(locker.items ?? [])]; const gridItems = toLockerGridItems(existing); const stackable = lockerIsStackable(asset); const maxStack = lockerMaxStack(asset); const fp = getLockerItemFootprint(asset, false); let remaining = quantity; if (stackable) { const sameType = gridItems.filter((e) => e.assetId === assetId); for (const entry of sameType) { if (remaining <= 0) break; const space = maxStack === Infinity ? remaining : Math.max(0, Math.min(maxStack - entry.quantity, remaining)); if (space <= 0) continue; const idx = existing.findIndex((e) => e.id === entry.id); if (idx >= 0) { existing[idx] = { ...existing[idx], quantity: existing[idx].quantity + space }; remaining -= space; } } } while (remaining > 0) { const stackAmount = Math.min(remaining, maxStack === Infinity ? remaining : maxStack); const spot = findLockerEmptySpot(gridItems, fp.width, fp.height, grid.width, grid.height); if (!spot) { return { success: false, error: `No space in locker for ${asset.name}. Need ${fp.width}×${fp.height} free cells.`, }; } existing.push({ asset: assetId, quantity: stackAmount, gridX: spot.x, gridY: spot.y, rotated: false, }); gridItems.push({ id: `new-${existing.length}`, assetId, quantity: stackAmount, gridX: spot.x, gridY: spot.y, rotated: false, asset, }); remaining -= stackAmount; } const added = quantity - remaining; const updated = (await payload.update({ collection: "locker-storages", id: locker.id, data: { items: existing }, overrideAccess: true, depth: 0, })) as unknown as LockerStorage; await emitGameEvent(payload, { type: EventTypes.LockerAddItem, message: `Added ${added.toLocaleString()}× ${asset.name} to locker`, actor: user.id, targetCollection: "locker-storages", targetId: locker.id, data: { assetId, assetName: asset.name, quantity: added }, }); return { success: true, data: updated }; } 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 removeLockerItem(entryId: 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 removed = items[idx]; const asset = removed.asset as Asset; const removedName = typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(removed.asset)}`; items.splice(idx, 1); await payload.update({ collection: "locker-storages", id: locker.id, data: { items }, overrideAccess: true, depth: 0, }); await emitGameEvent(payload, { type: EventTypes.LockerRemoveItem, message: `Removed ${removed.quantity}× ${removedName} from locker`, actor: user.id, targetCollection: "locker-storages", targetId: locker.id, data: { entryId, assetId: resolveAssetId(removed.asset), assetName: removedName, quantity: removed.quantity, }, }); 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 moveLockerItem( entryId: string, gridX: number, gridY: number, rotated?: boolean, ): 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 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 effectiveRotated = rotated ?? entry.rotated ?? false; const fp = getLockerItemFootprint(asset, effectiveRotated); if ( gridX < 0 || gridY < 0 || gridX + fp.width > grid.width || gridY + fp.height > grid.height ) { return { success: false, error: "Placement is outside grid bounds." }; } 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." }; } items[idx] = rotated !== undefined ? { ...entry, gridX, gridY, rotated } : { ...entry, gridX, gridY }; await payload.update({ collection: "locker-storages", id: locker.id, data: { items }, overrideAccess: true, depth: 0, }); await emitGameEvent(payload, { type: EventTypes.LockerGridMove, message: `Moved ${asset.name} to (${gridX}, ${gridY}) in locker`, actor: user.id, targetCollection: "locker-storages", targetId: locker.id, data: { entryId, assetId: asset.id, assetName: asset.name, gridX, gridY, rotated: effectiveRotated, }, }); 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 rotateLockerItem(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 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." }; if (!lockerIsRotatable(asset)) return { success: false, error: "This asset cannot be rotated." }; const newRotated = !(entry.rotated ?? false); const fp = getLockerItemFootprint(asset, newRotated); if (entry.gridX + fp.width > grid.width || entry.gridY + fp.height > grid.height) { return { success: false, error: "Rotation would push the item out of bounds." }; } 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." }; } items[idx] = { ...entry, rotated: newRotated }; await payload.update({ collection: "locker-storages", id: locker.id, data: { items }, overrideAccess: true, depth: 0, }); await emitGameEvent(payload, { type: EventTypes.LockerGridRotate, message: `Rotated ${asset.name} in locker`, actor: user.id, targetCollection: "locker-storages", targetId: locker.id, data: { entryId, assetId: asset.id, assetName: asset.name, rotated: newRotated }, }); 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 mergeLockerStacks(sourceId: string, targetId: string): Promise { try { const { payload, user } = await authenticate(); const locker = await getLockerWithItems(payload, user.id as number); const items = [...(locker.items ?? [])]; const srcIdx = items.findIndex((e) => e.id === sourceId); const tgtIdx = items.findIndex((e) => e.id === targetId); if (srcIdx < 0 || tgtIdx < 0) return { success: false, error: "Item not found." }; const src = items[srcIdx]; const tgt = items[tgtIdx]; const srcAsset = src.asset as Asset; const tgtAsset = tgt.asset as Asset; if (!srcAsset || typeof srcAsset === "number" || !tgtAsset || typeof tgtAsset === "number") { return { success: false, error: "Invalid asset." }; } if (srcAsset.id !== tgtAsset.id) { return { success: false, error: "Cannot merge different asset types." }; } if (!lockerIsStackable(tgtAsset)) return { success: false, error: "This asset cannot be stacked." }; const maxStack = lockerMaxStack(tgtAsset); const spaceInTarget = maxStack === Infinity ? Infinity : Math.max(0, maxStack - tgt.quantity); if (spaceInTarget <= 0) return { success: false, error: "Target stack is full." }; const transferAmount = Math.min(src.quantity, spaceInTarget); items[tgtIdx] = { ...tgt, quantity: tgt.quantity + transferAmount }; if (transferAmount >= src.quantity) { items.splice(srcIdx, 1); } else { items[srcIdx] = { ...src, quantity: src.quantity - transferAmount }; } await payload.update({ collection: "locker-storages", id: locker.id, data: { items }, overrideAccess: true, depth: 0, }); await emitGameEvent(payload, { type: EventTypes.LockerGridMerge, message: `Merged ${transferAmount}× ${tgtAsset.name} stacks in locker`, actor: user.id, targetCollection: "locker-storages", targetId: locker.id, data: { sourceId, targetId, assetId: srcAsset.id, assetName: tgtAsset.name, transferAmount }, }); 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 splitLockerStack( entryId: string, splitAmount: number, ): Promise { try { const { payload, user } = await authenticate(); if (!Number.isInteger(splitAmount) || splitAmount <= 0) { return { success: false, error: "Split amount must be a positive integer." }; } const locker = await getLockerWithItems(payload, user.id as number); const grid = await getLockerGridDimensions(payload); 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]; if (splitAmount >= entry.quantity) { return { success: false, error: "Split amount must be less than the stack size." }; } const asset = entry.asset as Asset; 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 = 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." }; items[idx] = { ...entry, quantity: entry.quantity - splitAmount }; items.push({ asset: entry.asset, quantity: splitAmount, gridX: spot.x, gridY: spot.y, rotated: entry.rotated ?? false, }); await payload.update({ collection: "locker-storages", id: locker.id, data: { items }, overrideAccess: true, depth: 0, }); await emitGameEvent(payload, { type: EventTypes.LockerGridSplit, message: `Split ${splitAmount}× ${asset.name} from stack in locker`, actor: user.id, targetCollection: "locker-storages", targetId: locker.id, data: { entryId, assetId: asset.id, assetName: asset.name, splitAmount, remainingAmount: entry.quantity - splitAmount, }, }); 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 autoArrangeLocker(): 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 rearranged = autoArrangeLockerItems(toLockerGridItems(items), grid.width, grid.height); const updatedItems = items.map((entry) => { const match = rearranged.find((r) => r.id === entry.id); return match ? { ...entry, gridX: match.gridX, gridY: match.gridY } : entry; }); await payload.update({ collection: "locker-storages", id: locker.id, data: { items: updatedItems }, overrideAccess: true, depth: 0, }); await emitGameEvent(payload, { type: EventTypes.LockerGridArrange, message: "Arranged locker grid", actor: user.id, targetCollection: "locker-storages", targetId: locker.id, data: { itemCount: updatedItems.length }, }); 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 }; } } // --- Loadouts --- export async function createLoadout(input: { name: string; description?: string; }): Promise> { try { const { payload, user } = await authenticate(); if (!input.name?.trim()) { return { success: false, error: "A loadout name is required." }; } const loadout = (await payload.create({ collection: "loadouts", data: { name: input.name.trim(), description: input.description?.trim() || null, ownerUser: user.id as number, slots: {}, }, overrideAccess: true, depth: 0, })) as unknown as Loadout; await emitGameEvent(payload, { type: EventTypes.LoadoutCreate, message: `Created loadout "${loadout.name}"`, actor: user.id, targetCollection: "loadouts", targetId: loadout.id, data: { loadoutName: loadout.name }, }); return { success: true, data: loadout.id }; } 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 updateLoadout( id: number, input: { name?: string; description?: string }, ): Promise { try { const { payload, user } = await authenticate(); const loadout = await getOwnedLoadout(payload, user, id); if (!loadout) return { success: false, error: "Loadout not found." }; const data: { name?: string; description?: string | null } = {}; if (input.name?.trim()) data.name = input.name.trim(); if (input.description !== undefined) data.description = input.description?.trim() || null; const updated = (await payload.update({ collection: "loadouts", id, data, overrideAccess: true, depth: 0, })) as unknown as Loadout; await emitGameEvent(payload, { type: EventTypes.LoadoutUpdate, message: `Updated loadout "${updated.name}"`, actor: user.id, targetCollection: "loadouts", targetId: id, data: { loadoutName: updated.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 deleteLoadout(id: number): Promise { try { const { payload, user } = await authenticate(); const loadout = await getOwnedLoadout(payload, user, id); if (!loadout) return { success: false, error: "Loadout not found." }; await payload.delete({ collection: "loadouts", id, overrideAccess: true }); await emitGameEvent(payload, { type: EventTypes.LoadoutDelete, message: `Deleted loadout "${loadout.name}"`, actor: user.id, targetCollection: "loadouts", targetId: id, data: { loadoutName: loadout.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 setLoadoutSlot( loadoutId: number, slot: string, assetId: number | null, ): Promise { try { const { payload, user } = await authenticate(); const slotConfig = getLoadoutSlot(slot); if (!slotConfig) return { success: false, error: "Unknown loadout slot." }; const loadout = await getOwnedLoadout(payload, user, loadoutId, 1); if (!loadout) return { success: false, error: "Loadout not found." }; if (assetId !== null) { const asset = await getAssetOrThrow(payload, assetId); const allowed = slotConfig.assetTypes.includes(asset.assetType); if (!allowed) { return { success: false, error: `"${asset.name}" cannot be equipped in the ${slotConfig.label} slot.`, }; } const locker = await getLockerWithItems(payload, user.id as number); const owned = (locker.items ?? []).some((e) => resolveAssetId(e.asset) === assetId); if (!owned) { return { success: false, error: "You can only equip assets from your own locker." }; } } const currentSlots = (loadout.slots as Record | null) ?? {}; const updatedSlots = { ...currentSlots, [slot]: assetId ?? null }; await payload.update({ collection: "loadouts", id: loadoutId, data: { slots: updatedSlots }, overrideAccess: true, depth: 0, }); const equippedName = assetId ? ((await getAssetOrThrow(payload, assetId).catch(() => null))?.name ?? `asset #${assetId}`) : null; await emitGameEvent(payload, { type: EventTypes.LoadoutEquip, message: equippedName ? `Equipped ${equippedName} to "${loadout.name}" ${slotConfig.label} slot` : `Cleared ${slotConfig.label} slot on "${loadout.name}"`, actor: user.id, targetCollection: "loadouts", targetId: loadoutId, data: { loadoutId, slot, assetId, assetName: equippedName }, }); 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 }; } } async function getOwnedLoadout( payload: Awaited>, user: User, id: number, depth = 0, ): Promise { const loadout = (await payload .findByID({ collection: "loadouts", id, depth, overrideAccess: true }) .catch(() => null)) as unknown as Loadout | null; if (!loadout) return null; const ownerId = typeof loadout.ownerUser === "object" && loadout.ownerUser ? (loadout.ownerUser as { id: number }).id : (loadout.ownerUser as number); 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 }; } }