"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 { autoArrangeLockerItems, ensureLockerStorage, findLockerEmptySpot, getLockerGridDimensions, getLockerItemFootprint, getLoadoutSlot, isLockerPlacementValid, lockerIsRotatable, lockerIsStackable, lockerMaxStack, type LockerGridItem, } from "@/lib/locker"; 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); } 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, ): 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 = toGridItems(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 = toGridItems(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 = toGridItems(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 = toGridItems(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(toGridItems(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; }