1
0
Fork 0
polaris-task-force/src/app/(frontend)/logistics/structures/actions.ts
Z8MB1E eef1b11bb1 feat(rbac): migrate server actions and service layers to RBAC
Replace hasRoles calls with hasPermission in all server actions and page
components. Update qualification checks (logistics, intelligence) to use
permission-based checks instead of role name matching. Update staff lookup
in tickets/staff.ts to query roles collection. Use enlistmentDate field
on profile page instead of createdAt.
2026-08-19 19:47:57 -04:00

1319 lines
45 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 { GameStructure, Resource, Structure } from "@/payload-types";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
interface ActionResult {
success: boolean;
error?: string;
data?: GameStructure;
}
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 };
}
async function getStructure(payload: Awaited<ReturnType<typeof getPayload>>, id: number) {
const structure = await payload
.findByID({
collection: "game-structures",
id,
depth: 2,
})
.catch(() => null);
if (!structure) {
throw new Error("Structure not found");
}
return structure as unknown as GameStructure & { type: Structure };
}
function getStoredAmount(
storedResources: GameStructure["storedResources"],
resourceId: number,
): number {
if (!storedResources) return 0;
const entry = storedResources.find((e) => {
const res = e.resource;
return typeof res === "object" && res !== null && "id" in res
? res.id === resourceId
: res === resourceId;
});
return entry?.amount ?? 0;
}
function calculateMass(storedResources: GameStructure["storedResources"]): number {
if (!storedResources) return 0;
return storedResources.reduce((total, entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return total;
return total + (resource.massPerUnit ?? 0) * entry.amount;
}, 0);
}
export async function addResource(
structureId: number,
resourceId: number,
amount: number,
): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) {
return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
}
if (amount <= 0) {
return { success: false, error: "Amount must be greater than zero." };
}
const structure = await getStructure(payload, structureId);
const structureType = structure.type;
// Fetch the resource to check rules + mass
const resource = await payload
.findByID({
collection: "resources",
id: resourceId,
depth: 0,
})
.catch(() => null);
if (!resource) {
return { success: false, error: "Resource not found." };
}
// Check storage rules (prohibited → whitelist → per-item cap)
const currentStored =
getStoredAmount(structure.storedResources, resourceId) +
getStoredAmount(structure.voidStorage ?? [], resourceId);
const ruleCheck = checkStorageDeposit(structureType, resourceId, amount, currentStored);
if (!ruleCheck.allowed) {
return {
success: false,
error: storageViolationMessage(resource.name, ruleCheck),
};
}
// Check capacity
const currentMass = calculateMass(structure.storedResources);
const addedMass = (resource.massPerUnit ?? 0) * amount;
const maxMass = structureType.maxCapacityMass;
if (maxMass && currentMass + addedMass > maxMass) {
return {
success: false,
error: `Exceeds capacity. Available: ${(maxMass - currentMass).toLocaleString()} kg, needed: ${addedMass.toLocaleString()} kg.`,
};
}
// Auto-place on grid if structure has grid dimensions
const existing = [...(structure.storedResources ?? [])];
const gridW = structureType.xSize ?? 0;
const gridH = structureType.ySize ?? 0;
const hasGrid = gridW > 0 && gridH > 0;
if (hasGrid) {
const resW = resource.gridWidth ?? 1;
const resH = resource.gridHeight ?? 1;
const ew = resW;
const eh = resH;
const maxStack = resource.stackable === false ? 1 : (resource.maxStackSize ?? Infinity);
// 1. Fill existing stacks of the same resource that have space
let remaining = amount;
const sameType = existing.filter((e) => {
const r = e.resource;
return typeof r === "object" && r !== null && "id" in r
? r.id === resourceId
: r === resourceId;
});
for (const entry of sameType) {
if (remaining <= 0) break;
const space =
maxStack === Infinity ? remaining : Math.min(maxStack - entry.amount, remaining);
if (space <= 0) continue;
const idx = existing.findIndex((e) => e.id === entry.id);
if (idx >= 0) {
existing[idx] = { ...entry, amount: entry.amount + space };
remaining -= space;
}
}
// 2. Create new entries for remaining amount
while (remaining > 0) {
let spot: { x: number; y: number } | null = null;
for (let y = 0; y <= gridH - eh && !spot; y++) {
for (let x = 0; x <= gridW - ew && !spot; x++) {
const cells = new Set<string>();
for (let dx = 0; dx < ew; dx++) {
for (let dy = 0; dy < eh; dy++) {
cells.add(`${x + dx},${y + dy}`);
}
}
let overlaps = false;
for (const other of existing) {
if (other.id == null) continue;
const otherRes = other.resource as Resource;
if (!otherRes || typeof otherRes === "number") continue;
const otherRotated = other.rotated ?? false;
const ow = otherRotated ? (otherRes.gridHeight ?? 1) : (otherRes.gridWidth ?? 1);
const oh = otherRotated ? (otherRes.gridWidth ?? 1) : (otherRes.gridHeight ?? 1);
for (let dx = 0; dx < ow; dx++) {
for (let dy = 0; dy < oh; dy++) {
if (cells.has(`${other.gridX + dx},${other.gridY + dy}`)) {
overlaps = true;
break;
}
}
if (overlaps) break;
}
if (overlaps) break;
}
if (!overlaps) spot = { x, y };
}
}
if (!spot) {
return {
success: false,
error: `No space on grid for ${resource.name}. Need ${ew}×${eh} free cells.`,
};
}
const stackAmount = Math.min(remaining, maxStack === Infinity ? remaining : maxStack);
existing.push({
resource: resourceId,
amount: stackAmount,
gridX: spot.x,
gridY: spot.y,
rotated: false,
});
remaining -= stackAmount;
}
} else {
// No grid — flat-list behavior
const existingIndex = existing.findIndex((e) => {
const res = e.resource;
return typeof res === "object" && res !== null && "id" in res
? res.id === resourceId
: res === resourceId;
});
if (existingIndex >= 0) {
existing[existingIndex] = {
...existing[existingIndex],
amount: existing[existingIndex].amount + amount,
};
} else {
existing.push({ resource: resourceId, amount, gridX: 0, gridY: 0, rotated: false });
}
}
const updated = await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
await emitGameEvent(payload, {
type: EventTypes.StorageDeposit,
message: `Deposited ${amount.toLocaleString()}× ${resource.name} into "${structure.name}"`,
actor: user.id,
structure: structureId,
targetCollection: "game-structures",
targetId: structureId,
data: { resourceId, amount, resourceName: resource.name },
});
return { success: true, data: updated as unknown as GameStructure };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function removeResource(
structureId: number,
resourceId: number,
amount: number,
): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) {
return {
success: false,
error: "Insufficient permissions. Admin or Developer role required for withdrawal.",
};
}
if (amount <= 0) {
return { success: false, error: "Amount must be greater than zero." };
}
const structure = await getStructure(payload, structureId);
const storedAmount = getStoredAmount(structure.storedResources, resourceId);
const voidAmount = getStoredAmount((structure as any).voidStorage, resourceId);
const totalAmount = storedAmount + voidAmount;
if (totalAmount <= 0) {
return { success: false, error: "This resource is not stored here." };
}
if (amount > totalAmount) {
return {
success: false,
error: `Cannot remove more than stored. Available: ${totalAmount.toLocaleString()} (${storedAmount.toLocaleString()} in grid, ${voidAmount.toLocaleString()} in void).`,
};
}
let remainingToRemove = amount;
const storedResources = [...(structure.storedResources ?? [])];
const voidStorage = [...((structure as any).voidStorage ?? [])];
// Remove from void storage first — void should empty itself before touching the grid
const voidIdx = voidStorage.findIndex((e: any) => {
const res = e.resource;
return typeof res === "object" && res !== null && "id" in res
? (res as any).id === resourceId
: res === resourceId;
});
if (voidIdx >= 0) {
const entry = voidStorage[voidIdx];
if (entry.amount > remainingToRemove) {
voidStorage[voidIdx] = { ...entry, amount: entry.amount - remainingToRemove };
remainingToRemove = 0;
} else {
remainingToRemove -= entry.amount;
voidStorage.splice(voidIdx, 1);
}
}
// Remove remaining from grid storage
if (remainingToRemove > 0) {
const storedIdx = storedResources.findIndex((e) => {
const res = e.resource;
return typeof res === "object" && res !== null && "id" in res
? res.id === resourceId
: res === resourceId;
});
if (storedIdx >= 0) {
const entry = storedResources[storedIdx];
if (entry.amount > remainingToRemove) {
storedResources[storedIdx] = { ...entry, amount: entry.amount - remainingToRemove };
} else {
storedResources.splice(storedIdx, 1);
}
}
}
const updated = await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources, voidStorage },
});
const resName =
(
await payload
.findByID({ collection: "resources", id: resourceId, depth: 0 })
.catch(() => null)
)?.name ?? `resource #${resourceId}`;
await emitGameEvent(payload, {
type: EventTypes.StorageWithdraw,
message: `Withdrew ${amount.toLocaleString()}× ${resName} from "${structure.name}"`,
actor: user.id,
structure: structureId,
targetCollection: "game-structures",
targetId: structureId,
data: { resourceId, amount, resourceName: resName },
});
return { success: true, data: updated as unknown as GameStructure };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function transferResource(
fromStructureId: number,
toStructureId: number,
resourceId: number,
amount: number,
): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) {
return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required for transfer.",
};
}
if (amount <= 0) {
return { success: false, error: "Amount must be greater than zero." };
}
if (fromStructureId === toStructureId) {
return { success: false, error: "Source and destination cannot be the same." };
}
const fromStructure = await getStructure(payload, fromStructureId);
const toStructure = await getStructure(payload, toStructureId);
// Check proximity — if beyond threshold, require shipment
const fromCoords = fromStructure.coordinates as [number, number] | null;
const toCoords = toStructure.coordinates as [number, number] | null;
if (fromCoords && toCoords) {
const gameRules = await payload.findGlobal({ slug: "game-rules" });
const threshold = (gameRules.proximityThreshold as number) || 10;
const { calculateDistance } = await import("@/lib/distance");
const distance = calculateDistance(fromCoords, toCoords);
if (distance > threshold) {
return {
success: false,
error: `Structures are too far apart for instant transfer (${distance.toFixed(1)} units, max ${threshold}). Use the Shipments system to transport resources over long distances.`,
};
}
}
const sourceStored = getStoredAmount(fromStructure.storedResources, resourceId);
const sourceVoid = getStoredAmount((fromStructure as any).voidStorage, resourceId);
const sourceAmount = sourceStored + sourceVoid;
if (sourceAmount < amount) {
return {
success: false,
error: `Insufficient resources. Available: ${sourceAmount.toLocaleString()} (${sourceStored.toLocaleString()} in grid, ${sourceVoid.toLocaleString()} in void).`,
};
}
// Fetch the resource for rule + capacity checks
const resource = await payload
.findByID({
collection: "resources",
id: resourceId,
depth: 0,
})
.catch(() => null);
if (!resource) {
return { success: false, error: "Resource not found." };
}
// Check destination storage rules (prohibited → whitelist → per-item cap)
const destCurrentStored =
getStoredAmount(toStructure.storedResources, resourceId) +
getStoredAmount(toStructure.voidStorage ?? [], resourceId);
const destRuleCheck = checkStorageDeposit(
toStructure.type,
resourceId,
amount,
destCurrentStored,
);
if (!destRuleCheck.allowed) {
return {
success: false,
error: storageViolationMessage(resource.name, destRuleCheck),
};
}
// Check destination capacity
const destMass = calculateMass(toStructure.storedResources);
const addedMass = (resource.massPerUnit ?? 0) * amount;
const destMaxMass = toStructure.type.maxCapacityMass;
if (destMaxMass && destMass + addedMass > destMaxMass) {
return {
success: false,
error: `Destination exceeds capacity. Available: ${(destMaxMass - destMass).toLocaleString()} kg.`,
};
}
// Remove from source
const removeResult = await removeResource(fromStructureId, resourceId, amount);
if (!removeResult.success) {
return removeResult;
}
// Add to destination
const addResult = await addResource(toStructureId, resourceId, amount);
if (!addResult.success) {
// Rollback: re-add to source
await addResource(fromStructureId, resourceId, amount);
return { success: false, error: `Transfer failed at destination: ${addResult.error}` };
}
const resName = resource?.name ?? `resource #${resourceId}`;
await emitGameEvent(payload, {
type: EventTypes.StorageTransfer,
message: `Transferred ${amount.toLocaleString()}× ${resName} from "${fromStructure.name}" to "${toStructure.name}"`,
actor: user.id,
structure: toStructureId,
targetCollection: "game-structures",
targetId: toStructureId,
data: {
resourceId,
amount,
resourceName: resName,
fromStructureId,
toStructureId,
fromStructureName: fromStructure.name,
toStructureName: toStructure.name,
},
});
return { success: true, data: addResult.data };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
// --- Grid-specific actions ---
interface GridActionResult {
success: boolean;
error?: string;
}
export async function placeResourceOnGrid(
structureId: number,
resourceId: number,
amount: number,
gridX: number,
gridY: number,
rotated: boolean,
): Promise<GridActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) {
return {
success: false,
error: "Insufficient permissions. Admin or Developer role required for placement.",
};
}
if (amount <= 0) return { success: false, error: "Amount must be greater than zero." };
const structure = await getStructure(payload, structureId);
const structureType = structure.type;
const resource = await payload
.findByID({ collection: "resources", id: resourceId, depth: 0 })
.catch(() => null);
if (!resource) return { success: false, error: "Resource not found." };
// Check storage rules (prohibited → whitelist → per-item cap)
const currentStored =
getStoredAmount(structure.storedResources, resourceId) +
getStoredAmount(structure.voidStorage ?? [], resourceId);
const ruleCheck = checkStorageDeposit(structureType, resourceId, amount, currentStored);
if (!ruleCheck.allowed) {
return { success: false, error: storageViolationMessage(resource.name, ruleCheck) };
}
if (resource.stackable && resource.maxStackSize && amount > resource.maxStackSize) {
return { success: false, error: `Cannot stack more than ${resource.maxStackSize} units.` };
}
const currentMass = calculateMass(structure.storedResources);
const addedMass = (resource.massPerUnit ?? 0) * amount;
const maxMass = structureType.maxCapacityMass;
if (maxMass && currentMass + addedMass > maxMass) {
return {
success: false,
error: `Exceeds mass capacity. Available: ${(maxMass - currentMass).toLocaleString()} kg.`,
};
}
const gridW = structureType.xSize ?? 0;
const gridH = structureType.ySize ?? 0;
if (!gridW || !gridH) {
return { success: false, error: "This structure does not have grid dimensions configured." };
}
const resW = resource.gridWidth ?? 1;
const resH = resource.gridHeight ?? 1;
const effectiveW = rotated ? resH : resW;
const effectiveH = rotated ? resW : resH;
if (gridX < 0 || gridY < 0 || gridX + effectiveW > gridW || gridY + effectiveH > gridH) {
return { success: false, error: "Placement is outside grid bounds." };
}
const existing = [...(structure.storedResources ?? [])];
const targetCells = new Set<string>();
for (let dx = 0; dx < effectiveW; dx++) {
for (let dy = 0; dy < effectiveH; dy++) {
targetCells.add(`${gridX + dx},${gridY + dy}`);
}
}
for (const entry of existing) {
if (entry.id == null) continue;
const entryRes = entry.resource as Resource;
if (!entryRes || typeof entryRes === "number") continue;
const entryRotated = entry.rotated ?? false;
const ew = entryRotated ? (entryRes.gridHeight ?? 1) : (entryRes.gridWidth ?? 1);
const eh = entryRotated ? (entryRes.gridWidth ?? 1) : (entryRes.gridHeight ?? 1);
for (let dx = 0; dx < ew; dx++) {
for (let dy = 0; dy < eh; dy++) {
if (targetCells.has(`${entry.gridX + dx},${entry.gridY + dy}`)) {
return { success: false, error: "Placement overlaps with an existing item." };
}
}
}
}
existing.push({ resource: resourceId, amount, gridX, gridY, rotated });
await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
await emitGameEvent(payload, {
type: EventTypes.GridPlace,
message: `Placed ${amount.toLocaleString()}× ${resource.name} at (${gridX}, ${gridY}) in "${structure.name}"`,
actor: user.id,
structure: structureId,
targetCollection: "game-structures",
targetId: structureId,
data: { resourceId, amount, resourceName: resource.name, gridX, gridY, rotated },
});
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 moveGridItem(
structureId: number,
entryId: string,
gridX: number,
gridY: number,
rotated?: boolean,
): Promise<GridActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) {
return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
}
const structure = await getStructure(payload, structureId);
const existing = [...(structure.storedResources ?? [])];
const idx = existing.findIndex((e) => e.id === entryId);
if (idx < 0) return { success: false, error: "Item not found in grid." };
const entry = existing[idx];
const res = entry.resource as Resource;
if (!res || typeof res === "number") return { success: false, error: "Invalid resource." };
const effectiveRotated = rotated ?? entry.rotated ?? false;
const ew = effectiveRotated ? (res.gridHeight ?? 1) : (res.gridWidth ?? 1);
const eh = effectiveRotated ? (res.gridWidth ?? 1) : (res.gridHeight ?? 1);
const gridW = structure.type.xSize ?? 0;
const gridH = structure.type.ySize ?? 0;
if (gridX < 0 || gridY < 0 || gridX + ew > gridW || gridY + eh > gridH) {
return { success: false, error: "Placement is outside grid bounds." };
}
const targetCells = new Set<string>();
for (let dx = 0; dx < ew; dx++) {
for (let dy = 0; dy < eh; dy++) {
targetCells.add(`${gridX + dx},${gridY + dy}`);
}
}
for (const other of existing) {
if (other.id === entryId || other.id == null) continue;
const otherRes = other.resource as Resource;
if (!otherRes || typeof otherRes === "number") continue;
const otherRotated = other.rotated ?? false;
const ow = otherRotated ? (otherRes.gridHeight ?? 1) : (otherRes.gridWidth ?? 1);
const oh = otherRotated ? (otherRes.gridWidth ?? 1) : (otherRes.gridHeight ?? 1);
for (let dx = 0; dx < ow; dx++) {
for (let dy = 0; dy < oh; dy++) {
if (targetCells.has(`${other.gridX + dx},${other.gridY + dy}`)) {
return { success: false, error: "Cannot move here — overlaps with another item." };
}
}
}
}
existing[idx] =
rotated !== undefined ? { ...entry, gridX, gridY, rotated } : { ...entry, gridX, gridY };
await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
await emitGameEvent(payload, {
type: EventTypes.GridMove,
message: `Moved ${res.name} to (${gridX}, ${gridY}) in "${structure.name}"`,
actor: user.id,
structure: structureId,
targetCollection: "game-structures",
targetId: structureId,
data: {
entryId,
resourceId: typeof res === "object" ? res.id : res,
resourceName: res.name,
gridX,
gridY,
rotated,
},
});
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 mergeGridStacks(
structureId: number,
sourceId: string,
targetId: string,
): Promise<GridActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) {
return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
}
const structure = await getStructure(payload, structureId);
const existing = [...(structure.storedResources ?? [])];
const srcIdx = existing.findIndex((e) => e.id === sourceId);
const tgtIdx = existing.findIndex((e) => e.id === targetId);
if (srcIdx < 0 || tgtIdx < 0) return { success: false, error: "Item not found." };
const src = existing[srcIdx];
const tgt = existing[tgtIdx];
const srcRes = src.resource as Resource;
const tgtRes = tgt.resource as Resource;
if (!srcRes || typeof srcRes === "number" || !tgtRes || typeof tgtRes === "number") {
return { success: false, error: "Invalid resource." };
}
const srcResId = typeof srcRes === "object" ? srcRes.id : srcRes;
const tgtResId = typeof tgtRes === "object" ? tgtRes.id : tgtRes;
if (srcResId !== tgtResId)
return { success: false, error: "Cannot merge different resource types." };
if (tgtRes.stackable === false)
return { success: false, error: "This resource cannot be stacked." };
const maxStack = tgtRes.maxStackSize;
const spaceInTarget = maxStack ? maxStack - tgt.amount : Infinity;
if (spaceInTarget <= 0) return { success: false, error: "Target stack is full." };
const transferAmount = Math.min(src.amount, spaceInTarget);
existing[tgtIdx] = { ...tgt, amount: tgt.amount + transferAmount };
if (transferAmount >= src.amount) {
existing.splice(srcIdx, 1);
} else {
existing[srcIdx] = { ...src, amount: src.amount - transferAmount };
}
await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
const mergeResName = typeof srcRes === "object" ? srcRes.name : `resource #${srcResId}`;
await emitGameEvent(payload, {
type: EventTypes.GridMerge,
message: `Merged ${transferAmount}× ${mergeResName} stacks in "${structure.name}"`,
actor: user.id,
structure: structureId,
targetCollection: "game-structures",
targetId: structureId,
data: {
sourceId,
targetId,
resourceId: srcResId,
resourceName: mergeResName,
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 rotateGridItem(
structureId: number,
entryId: string,
): Promise<GridActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) {
return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
}
const structure = await getStructure(payload, structureId);
const existing = [...(structure.storedResources ?? [])];
const idx = existing.findIndex((e) => e.id === entryId);
if (idx < 0) return { success: false, error: "Item not found in grid." };
const entry = existing[idx];
const res = entry.resource as Resource;
if (!res || typeof res === "number") return { success: false, error: "Invalid resource." };
if (res.rotatable === false)
return { success: false, error: "This resource cannot be rotated." };
const newRotated = !(entry.rotated ?? false);
const ew = newRotated ? (res.gridHeight ?? 1) : (res.gridWidth ?? 1);
const eh = newRotated ? (res.gridWidth ?? 1) : (res.gridHeight ?? 1);
const gridW = structure.type.xSize ?? 0;
const gridH = structure.type.ySize ?? 0;
if (entry.gridX + ew > gridW || entry.gridY + eh > gridH) {
return { success: false, error: "Rotation would push the item out of bounds." };
}
const targetCells = new Set<string>();
for (let dx = 0; dx < ew; dx++) {
for (let dy = 0; dy < eh; dy++) {
targetCells.add(`${entry.gridX + dx},${entry.gridY + dy}`);
}
}
for (const other of existing) {
if (other.id === entryId || other.id == null) continue;
const otherRes = other.resource as Resource;
if (!otherRes || typeof otherRes === "number") continue;
const otherRotated = other.rotated ?? false;
const ow = otherRotated ? (otherRes.gridHeight ?? 1) : (otherRes.gridWidth ?? 1);
const oh = otherRotated ? (otherRes.gridWidth ?? 1) : (otherRes.gridHeight ?? 1);
for (let dx = 0; dx < ow; dx++) {
for (let dy = 0; dy < oh; dy++) {
if (targetCells.has(`${other.gridX + dx},${other.gridY + dy}`)) {
return { success: false, error: "Rotation would overlap with another item." };
}
}
}
}
existing[idx] = { ...entry, rotated: newRotated };
await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
await emitGameEvent(payload, {
type: EventTypes.GridRotate,
message: `Rotated ${res.name} in "${structure.name}"`,
actor: user.id,
structure: structureId,
targetCollection: "game-structures",
targetId: structureId,
data: {
entryId,
resourceId: typeof res === "object" ? res.id : res,
resourceName: res.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 removeGridItem(
structureId: number,
entryId: string,
): Promise<GridActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) {
return {
success: false,
error: "Insufficient permissions. Admin or Developer role required for removal.",
};
}
const structure = await getStructure(payload, structureId);
const existing = [...(structure.storedResources ?? [])];
const idx = existing.findIndex((e) => e.id === entryId);
if (idx < 0) return { success: false, error: "Item not found in grid." };
const removedEntry = existing[idx];
const removedRes = removedEntry.resource as Resource;
existing.splice(idx, 1);
await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
const removedName =
removedRes?.name ??
`resource #${typeof removedRes === "object" ? removedRes.id : removedRes}`;
await emitGameEvent(payload, {
type: EventTypes.StorageWithdraw,
message: `Removed ${removedEntry.amount}× ${removedName} from "${structure.name}"`,
actor: user.id,
structure: structureId,
targetCollection: "game-structures",
targetId: structureId,
data: {
entryId,
resourceId: typeof removedRes === "object" ? removedRes.id : removedRes,
resourceName: removedName,
amount: removedEntry.amount,
},
});
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 splitGridStack(
structureId: number,
entryId: string,
splitAmount: number,
): Promise<GridActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) {
return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
}
if (splitAmount <= 0) return { success: false, error: "Split amount must be positive." };
const structure = await getStructure(payload, structureId);
const existing = [...(structure.storedResources ?? [])];
const idx = existing.findIndex((e) => e.id === entryId);
if (idx < 0) return { success: false, error: "Item not found in grid." };
const entry = existing[idx];
if (splitAmount >= entry.amount)
return { success: false, error: "Split amount must be less than the stack size." };
const res = entry.resource as Resource;
if (!res || typeof res === "number") return { success: false, error: "Invalid resource." };
const rotated = entry.rotated ?? false;
const ew = rotated ? (res.gridHeight ?? 1) : (res.gridWidth ?? 1);
const eh = rotated ? (res.gridWidth ?? 1) : (res.gridHeight ?? 1);
const gridW = structure.type.xSize ?? 0;
const gridH = structure.type.ySize ?? 0;
let placed = false;
for (let y = 0; y <= gridH - eh && !placed; y++) {
for (let x = 0; x <= gridW - ew && !placed; x++) {
const targetCells = new Set<string>();
for (let dx = 0; dx < ew; dx++) {
for (let dy = 0; dy < eh; dy++) {
targetCells.add(`${x + dx},${y + dy}`);
}
}
let overlaps = false;
for (const other of existing) {
if (other.id == null) continue;
const otherRes = other.resource as Resource;
if (!otherRes || typeof otherRes === "number") continue;
const otherRotated = other.rotated ?? false;
const ow = otherRotated ? (otherRes.gridHeight ?? 1) : (otherRes.gridWidth ?? 1);
const oh = otherRotated ? (otherRes.gridWidth ?? 1) : (otherRes.gridHeight ?? 1);
for (let dx = 0; dx < ow; dx++) {
for (let dy = 0; dy < oh; dy++) {
if (targetCells.has(`${other.gridX + dx},${other.gridY + dy}`)) {
overlaps = true;
break;
}
}
if (overlaps) break;
}
if (overlaps) break;
}
if (!overlaps) {
existing[idx] = { ...entry, amount: entry.amount - splitAmount };
existing.push({
resource: entry.resource,
amount: splitAmount,
gridX: x,
gridY: y,
rotated,
});
placed = true;
}
}
}
if (!placed) return { success: false, error: "No empty spot available for the split stack." };
await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
const splitResName = typeof res === "object" ? res.name : `resource #${res}`;
await emitGameEvent(payload, {
type: EventTypes.GridSplit,
message: `Split ${splitAmount}× ${splitResName} from stack in "${structure.name}"`,
actor: user.id,
structure: structureId,
targetCollection: "game-structures",
targetId: structureId,
data: {
entryId,
resourceId: typeof res === "object" ? res.id : res,
resourceName: splitResName,
splitAmount,
remainingAmount: entry.amount - 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 retrieveFromVoid(structureId: number): Promise<GridActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) {
return { success: false, error: "Insufficient permissions." };
}
const structure = await getStructure(payload, structureId);
const gridW = structure.type.xSize ?? 0;
const gridH = structure.type.ySize ?? 0;
if (!gridW || !gridH) return { success: false, error: "Structure has no grid dimensions." };
const storedResources = [...(structure.storedResources ?? [])];
const voidStorage = [...((structure as any).voidStorage ?? [])];
const remainingVoid: typeof voidStorage = [];
for (const entry of voidStorage) {
const res = entry.resource as Resource;
if (!res || typeof res === "number") {
remainingVoid.push(entry);
continue;
}
const rotated = entry.rotated ?? false;
const ew = rotated ? (res.gridHeight ?? 1) : (res.gridWidth ?? 1);
const eh = rotated ? (res.gridWidth ?? 1) : (res.gridHeight ?? 1);
let spot: { x: number; y: number } | null = null;
for (let y = 0; y <= gridH - eh && !spot; y++) {
for (let x = 0; x <= gridW - ew && !spot; x++) {
const targetCells = new Set<string>();
for (let dx = 0; dx < ew; dx++) {
for (let dy = 0; dy < eh; dy++) {
targetCells.add(`${x + dx},${y + dy}`);
}
}
let overlaps = false;
for (const p of storedResources) {
const pRes = p.resource as Resource;
if (!pRes || typeof pRes === "number") continue;
const pRotated = p.rotated ?? false;
const pw = pRotated ? (pRes.gridHeight ?? 1) : (pRes.gridWidth ?? 1);
const ph = pRotated ? (pRes.gridWidth ?? 1) : (pRes.gridHeight ?? 1);
for (let dx = 0; dx < pw; dx++) {
for (let dy = 0; dy < ph; dy++) {
if (targetCells.has(`${p.gridX + dx},${p.gridY + dy}`)) {
overlaps = true;
break;
}
}
if (overlaps) break;
}
if (overlaps) break;
}
if (!overlaps) spot = { x, y };
}
}
if (spot) {
storedResources.push({ ...entry, gridX: spot.x, gridY: spot.y });
} else {
remainingVoid.push(entry);
}
}
await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources, voidStorage: remainingVoid },
});
const recovered = voidStorage.length - remainingVoid.length;
await emitGameEvent(payload, {
type: EventTypes.StorageRecover,
message:
recovered > 0
? `Retrieved ${recovered} item(s) from void in "${structure.name}"`
: `No void items could be recovered in "${structure.name}" — grid full`,
actor: user.id,
structure: structureId,
targetCollection: "game-structures",
targetId: structureId,
data: { recovered, remaining: remainingVoid.length, totalVoid: voidStorage.length },
});
return {
success: true,
error:
recovered > 0
? `${recovered} item(s) retrieved from void storage.`
: "No items could be retrieved — grid is full.",
};
} 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 autoArrangeGrid(structureId: number): Promise<GridActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) {
return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
}
const structure = await getStructure(payload, structureId);
const gridW = structure.type.xSize ?? 0;
const gridH = structure.type.ySize ?? 0;
if (!gridW || !gridH) return { success: false, error: "Structure has no grid dimensions." };
// Phase 1 — repack grid items
const gridItems = [...(structure.storedResources ?? [])].map((e) => ({
...e,
_sortKey: e.gridY * 1000 + e.gridX,
}));
gridItems.sort((a, b) => (a._sortKey ?? 0) - (b._sortKey ?? 0));
const placed: typeof gridItems = [];
const overflow: typeof gridItems = [];
for (const item of gridItems) {
const res = item.resource as Resource;
if (!res || typeof res === "number") continue;
const rotated = item.rotated ?? false;
const ew = rotated ? (res.gridHeight ?? 1) : (res.gridWidth ?? 1);
const eh = rotated ? (res.gridWidth ?? 1) : (res.gridHeight ?? 1);
let spotFound = false;
for (let y = 0; y <= gridH - eh && !spotFound; y++) {
for (let x = 0; x <= gridW - ew && !spotFound; x++) {
const targetCells = new Set<string>();
for (let dx = 0; dx < ew; dx++) {
for (let dy = 0; dy < eh; dy++) {
targetCells.add(`${x + dx},${y + dy}`);
}
}
let overlaps = false;
for (const p of placed) {
const pRes = p.resource as Resource;
if (!pRes || typeof pRes === "number") continue;
const pRotated = p.rotated ?? false;
const pw = pRotated ? (pRes.gridHeight ?? 1) : (pRes.gridWidth ?? 1);
const ph = pRotated ? (pRes.gridWidth ?? 1) : (pRes.gridHeight ?? 1);
for (let dx = 0; dx < pw; dx++) {
for (let dy = 0; dy < ph; dy++) {
if (targetCells.has(`${p.gridX + dx},${p.gridY + dy}`)) {
overlaps = true;
break;
}
}
if (overlaps) break;
}
if (overlaps) break;
}
if (!overlaps) {
placed.push({ ...item, gridX: x, gridY: y });
spotFound = true;
}
}
}
if (!spotFound) {
overflow.push(item);
}
}
// Phase 2 — try to recover void items into any remaining grid space
const voidStorage = [...((structure as any).voidStorage ?? [])];
const remainingVoid: typeof placed = [];
for (const entry of voidStorage) {
const res = entry.resource as Resource;
if (!res || typeof res === "number") {
remainingVoid.push(entry);
continue;
}
const rotated = entry.rotated ?? false;
const ew = rotated ? (res.gridHeight ?? 1) : (res.gridWidth ?? 1);
const eh = rotated ? (res.gridWidth ?? 1) : (res.gridHeight ?? 1);
let spot: { x: number; y: number } | null = null;
for (let y = 0; y <= gridH - eh && !spot; y++) {
for (let x = 0; x <= gridW - ew && !spot; x++) {
const targetCells = new Set<string>();
for (let dx = 0; dx < ew; dx++) {
for (let dy = 0; dy < eh; dy++) {
targetCells.add(`${x + dx},${y + dy}`);
}
}
let overlaps = false;
for (const p of placed) {
const pRes = p.resource as Resource;
if (!pRes || typeof pRes === "number") continue;
const pRotated = p.rotated ?? false;
const pw = pRotated ? (pRes.gridHeight ?? 1) : (pRes.gridWidth ?? 1);
const ph = pRotated ? (pRes.gridWidth ?? 1) : (pRes.gridHeight ?? 1);
for (let dx = 0; dx < pw; dx++) {
for (let dy = 0; dy < ph; dy++) {
if (targetCells.has(`${p.gridX + dx},${p.gridY + dy}`)) {
overlaps = true;
break;
}
}
if (overlaps) break;
}
if (overlaps) break;
}
if (!overlaps) spot = { x, y };
}
}
if (spot) {
placed.push({ ...entry, gridX: spot.x, gridY: spot.y });
} else {
remainingVoid.push(entry);
}
}
const updated = placed.map(({ _sortKey, ...rest }) => rest);
const overflowItems = overflow.map(({ _sortKey, ...rest }) => rest);
const voidCount = voidStorage.length;
const recoveredCount = voidCount - remainingVoid.length;
await payload.update({
collection: "game-structures",
id: structureId,
data: {
storedResources: updated,
voidStorage: [...remainingVoid, ...overflowItems],
},
});
const messages: string[] = [];
if (recoveredCount > 0) messages.push(`${recoveredCount} item(s) recovered from void.`);
if (overflow.length > 0) messages.push(`${overflow.length} item(s) moved to void — grid full.`);
await emitGameEvent(payload, {
type: EventTypes.GridArrange,
message: `Arranged grid in "${structure.name}"${messages.length > 0 ? ` — ${messages.join("; ")}` : ""}`,
actor: user.id,
structure: structureId,
targetCollection: "game-structures",
targetId: structureId,
data: { recoveredCount, overflowCount: overflow.length, totalVoid: voidStorage.length },
});
return { success: true, error: messages.length > 0 ? messages.join(" ") : undefined };
} 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 };
}
}