1
0
Fork 0

feat(locker): add locker and loadout server actions

This commit is contained in:
Jason Fraley 2026-08-02 14:32:47 -04:00
parent 0fc898d227
commit d32f36a39c

View file

@ -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<T = undefined> {
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<LockerStorage["items"]>[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<ReturnType<typeof getPayload>>,
userId: number,
): Promise<LockerStorage> {
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<ReturnType<typeof getPayload>>,
assetId: number,
): Promise<Asset> {
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<ActionResult<number>> {
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<ActionResult<LockerStorage>> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult<number>> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<string, unknown> | 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<ReturnType<typeof getPayload>>,
user: User,
id: number,
depth = 0,
): Promise<Loadout | null> {
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;
}