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.
1373 lines
47 KiB
TypeScript
1373 lines
47 KiB
TypeScript
"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 };
|
||
}
|
||
}
|