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

1331 lines
42 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 {
ATTACHMENT_CATEGORIES,
autoArrangeLockerItems,
ensureLockerStorage,
findLockerEmptySpot,
getLoadoutSlot,
getLockerGridDimensions,
getLockerItemFootprint,
isAttachmentCompatible,
isLockerPlacementValid,
isSkinAsset,
lockerIsRotatable,
lockerIsStackable,
lockerMaxStack,
resolveAssetId,
skinAppliesTo,
toLockerGridItems,
} from "@/lib/locker";
export 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);
}
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 = 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<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 = 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<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 = 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<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 = 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<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(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<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;
}
// --- Wardrobe / equipped loadout ---
export async function equipLoadout(loadoutId: number, equipped = true): Promise<ActionResult> {
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<ActionResult> {
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<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 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<ActionResult> {
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<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 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<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 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<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 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 };
}
}