diff --git a/src/app/(frontend)/locker/actions.ts b/src/app/(frontend)/locker/actions.ts new file mode 100644 index 0000000..a6817d3 --- /dev/null +++ b/src/app/(frontend)/locker/actions.ts @@ -0,0 +1,735 @@ +"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; +}