1
0
Fork 0
polaris-task-force/src/app/(frontend)/logistics/structures/actions.ts
Z8MB1E dcb4893b10 feat(storage): split storage permissions and rework manage-storage UI
Deposits/withdrawals of stock (economy-mutating) are now superuser-only, while moving items around (transfer/place/collect) stays open to logistics-qualified users; seed roles updated to match. ManageStorageDialog reworked alongside its toolbar/button/view wrappers, and the structure page wires up the new permission split.
2026-09-07 05:07:53 -04:00

1373 lines
47 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 { isSuperuser } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
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;
}
type PayloadClient = Awaited<ReturnType<typeof getPayload>>;
type AuthedUser = Awaited<ReturnType<typeof authenticate>>["user"];
// SECURITY: creates/destroys stored items, overriding economy/production — superusers only
async function canManageStorage(payload: PayloadClient, user: AuthedUser): Promise<boolean> {
return isSuperuser(payload, user);
}
// SECURITY: conserves quantities (moves items around) — logistics personnel + superusers
async function canHandleStorage(payload: PayloadClient, user: AuthedUser): Promise<boolean> {
return (await isSuperuser(payload, user)) || (await hasLogisticsQualification(payload, user));
}
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 canManageStorage(payload, user))) {
return {
success: false,
error: "Only superusers may deposit resources into structures.",
};
}
return await addResourceInternal(payload, user, structureId, resourceId, amount);
} 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 };
}
}
async function addResourceInternal(
payload: PayloadClient,
user: AuthedUser,
structureId: number,
resourceId: number,
amount: number,
): Promise<ActionResult> {
try {
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 canManageStorage(payload, user))) {
return {
success: false,
error: "Only superusers may withdraw resources from structures.",
};
}
return await removeResourceInternal(payload, user, structureId, resourceId, amount);
} 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 };
}
}
async function removeResourceInternal(
payload: PayloadClient,
user: AuthedUser,
structureId: number,
resourceId: number,
amount: number,
): Promise<ActionResult> {
try {
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 canHandleStorage(payload, user))) {
return {
success: false,
error: "Logistics personnel or superuser role required for transfers.",
};
}
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 (unguarded internal — gate already applied above)
const removeResult = await removeResourceInternal(
payload,
user,
fromStructureId,
resourceId,
amount,
);
if (!removeResult.success) {
return removeResult;
}
// Add to destination
const addResult = await addResourceInternal(payload, user, toStructureId, resourceId, amount);
if (!addResult.success) {
// Rollback: re-add to source
await addResourceInternal(payload, user, 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 canManageStorage(payload, user))) {
return {
success: false,
error: "Only superusers may place resources on the storage grid.",
};
}
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 canHandleStorage(payload, user))) {
return {
success: false,
error: "Logistics personnel or superuser 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 canHandleStorage(payload, user))) {
return {
success: false,
error: "Logistics personnel or superuser 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 canHandleStorage(payload, user))) {
return {
success: false,
error: "Logistics personnel or superuser 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 canManageStorage(payload, user))) {
return {
success: false,
error: "Only superusers may remove items from the storage grid.",
};
}
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 canHandleStorage(payload, user))) {
return {
success: false,
error: "Logistics personnel or superuser 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 canHandleStorage(payload, user))) {
return { success: false, error: "Logistics personnel or superuser 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." };
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: the 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 canHandleStorage(payload, user))) {
return {
success: false,
error: "Logistics personnel or superuser 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 };
}
}