1
0
Fork 0

feat(grid): add grid-aware server actions

addResource now auto-places deposited resources onto the structure
grid (first-fit scan, fills existing stacks first, falls back to
flat-list behavior for non-grid structures).

New server actions:
- placeResourceOnGrid — explicit placement at x,y,rotated
- moveGridItem — move item to new position with optional rotation
- mergeGridStacks — merge two stacks of the same resource
- rotateGridItem — toggle rotation in-place
- removeGridItem — delete a grid entry
- splitGridStack — split a stack into two at an empty spot
- autoArrangeGrid — re-pack all items into grid
This commit is contained in:
Jason Fraley 2026-07-28 14:03:00 -04:00
parent 7bcbd534e4
commit d2222e8701

View file

@ -110,8 +110,90 @@ export async function addResource(
}; };
} }
// Update stored resources // Auto-place on grid if structure has grid dimensions
const existing = [...(structure.storedResources ?? [])]; 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 existingIndex = existing.findIndex((e) => {
const res = e.resource; const res = e.resource;
return typeof res === "object" && res !== null && "id" in res return typeof res === "object" && res !== null && "id" in res
@ -125,7 +207,8 @@ export async function addResource(
amount: existing[existingIndex].amount + amount, amount: existing[existingIndex].amount + amount,
}; };
} else { } else {
existing.push({ resource: resourceId, amount }); existing.push({ resource: resourceId, amount, gridX: 0, gridY: 0, rotated: false });
}
} }
const updated = await payload.update({ const updated = await payload.update({
@ -287,3 +370,460 @@ export async function transferResource(
return { success: false, error: message }; 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 } = await authenticate();
if (amount <= 0) return { success: false, error: "Amount must be greater than zero." };
const structure = await getStructure(payload, structureId);
const structureType = structure.type;
const isProhibited = structureType.prohibitedStorage?.some((rule) => {
if (!rule.item) return false;
const item = rule.item;
if (typeof item === "object" && "id" in item) {
return item.relationTo === "resources" && item.value === resourceId;
}
return false;
});
if (isProhibited) {
return { success: false, error: "This resource is prohibited from storage here." };
}
const resource = await payload.findByID({ collection: "resources", id: resourceId, depth: 0 }).catch(() => null);
if (!resource) return { success: false, error: "Resource not found." };
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 } });
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 } = await authenticate();
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 } });
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 } = await authenticate();
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 } });
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 } = await authenticate();
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 } });
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 } = await authenticate();
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." };
existing.splice(idx, 1);
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } });
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 } = await authenticate();
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 === 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}`)) {
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 } });
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 autoArrangeGrid(structureId: number): Promise<GridActionResult> {
try {
const { payload } = await authenticate();
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 items = [...(structure.storedResources ?? [])].map((e) => ({
...e,
_sortKey: e.gridY * 1000 + e.gridX,
}));
items.sort((a, b) => (a._sortKey ?? 0) - (b._sortKey ?? 0));
const placed: typeof items = [];
for (const item of items) {
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) {
placed.push({ ...item, gridX: 0, gridY: 0 });
}
}
const updated = placed.map(({ _sortKey, ...rest }) => rest);
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: updated } });
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 };
}
}