1
0
Fork 0
polaris-task-force/src/app/(frontend)/locker/actions.ts

735 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;
}