From 0fc898d2275d2d003c576e304e686d2193357787 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 2 Aug 2026 14:32:45 -0400 Subject: [PATCH] feat(locker): add locker grid and loadout helpers --- src/lib/locker/index.ts | 320 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 src/lib/locker/index.ts diff --git a/src/lib/locker/index.ts b/src/lib/locker/index.ts new file mode 100644 index 0000000..ce1a810 --- /dev/null +++ b/src/lib/locker/index.ts @@ -0,0 +1,320 @@ +import type { Payload } from "payload"; +import type { Asset, LockerStorage } from "@/payload-types"; +import type { GridDimensions } from "@/lib/logistics"; + +/** + * A single entry in a user's locker grid. Mirrors the shape of the + * `items` array on the locker-storages collection, but with the asset + * populated so the frontend can render footprints, thumbnails, etc. + */ +export type LockerGridItem = { + id: string; + assetId: number; + quantity: number; + gridX: number; + gridY: number; + rotated: boolean; + asset: Asset; +}; + +/** Equipment slot definitions for user loadouts. */ +export type LoadoutSlotConfig = { + name: string; + label: string; + assetTypes: string[]; + description: string; +}; + +export const LOADOUT_SLOTS: LoadoutSlotConfig[] = [ + { name: "primary", label: "Primary", assetTypes: ["weapon"], description: "Primary weapon" }, + { name: "secondary", label: "Secondary", assetTypes: ["weapon"], description: "Secondary weapon" }, + { name: "launcher", label: "Launcher", assetTypes: ["weapon"], description: "Launcher / heavy weapon" }, + { name: "uniform", label: "Uniform", assetTypes: ["uniform"], description: "Uniform" }, + { name: "vest", label: "Vest", assetTypes: ["vest"], description: "Carrier / plate carrier" }, + { name: "backpack", label: "Backpack", assetTypes: ["backpack"], description: "Backpack / rucksack" }, + { name: "helmet", label: "Helmet", assetTypes: ["helmet"], description: "Helmet" }, + { name: "facewear", label: "Facewear", assetTypes: ["facewear"], description: "Facewear / eyewear" }, + { name: "nvg", label: "NVG", assetTypes: ["nvg"], description: "Night vision device" }, + { name: "radio", label: "Radio", assetTypes: ["radio"], description: "Radio / comms" }, + { name: "optic", label: "Optic", assetTypes: ["optic"], description: "Optic attachment" }, + { name: "muzzle", label: "Muzzle", assetTypes: ["muzzle"], description: "Muzzle attachment" }, + { name: "bipod", label: "Bipod", assetTypes: ["bipod"], description: "Bipod attachment" }, +]; + +export type LoadoutSlotName = (typeof LOADOUT_SLOTS)[number]["name"]; +export type LoadoutSlotValue = number | Asset | null; + +export type LoadoutSlots = Partial>; + +export function getLoadoutSlot(name: string): LoadoutSlotConfig | null { + return LOADOUT_SLOTS.find((s) => s.name === name) ?? null; +} + +export function getAssetTypeLabel(assetType: Asset["assetType"]): string { + switch (assetType) { + case "weapon": + return "Weapon"; + case "magazine": + return "Magazine"; + case "optic": + return "Optic"; + case "muzzle": + return "Muzzle"; + case "bipod": + return "Bipod"; + case "uniform": + return "Uniform"; + case "vest": + return "Vest"; + case "backpack": + return "Backpack"; + case "helmet": + return "Helmet"; + case "facewear": + return "Facewear"; + case "nvg": + return "NVG"; + case "radio": + return "Radio"; + case "medical": + return "Medical"; + case "tool": + return "Tool"; + case "explosive": + return "Explosive"; + case "miscellaneous": + return "Miscellaneous"; + default: + return "Item"; + } +} + +const ASSET_TYPE_COLORS: Record = { + weapon: "oklch(0.45 0.1 40)", + magazine: "oklch(0.45 0.12 90)", + optic: "oklch(0.45 0.1 250)", + muzzle: "oklch(0.45 0.1 200)", + bipod: "oklch(0.45 0.1 160)", + uniform: "oklch(0.4 0.09 130)", + vest: "oklch(0.42 0.09 110)", + backpack: "oklch(0.42 0.09 75)", + helmet: "oklch(0.45 0.08 250)", + facewear: "oklch(0.45 0.1 300)", + nvg: "oklch(0.45 0.12 290)", + radio: "oklch(0.45 0.1 30)", + medical: "oklch(0.45 0.14 20)", + tool: "oklch(0.45 0.1 80)", + explosive: "oklch(0.45 0.16 55)", + miscellaneous: "oklch(0.42 0 0)", +}; + +export function getAssetTypeColor(assetType: Asset["assetType"]): string { + return ASSET_TYPE_COLORS[assetType] ?? "oklch(0.42 0 0)"; +} + +type PayloadType = Awaited>; + +/** Resolve the locker grid dimensions from Game Rules. */ +export async function getLockerGridDimensions( + payload: PayloadType, +): Promise<{ width: number; height: number }> { + try { + const gameRules = await payload.findGlobal({ + slug: "game-rules", + depth: 0, + overrideAccess: true, + }); + const rules = gameRules as unknown as { lockerGridWidth?: number; lockerGridHeight?: number }; + return { + width: Math.max(4, rules.lockerGridWidth ?? 10), + height: Math.max(4, rules.lockerGridHeight ?? 6), + }; + } catch { + return { width: 10, height: 6 }; + } +} + +/** Find-or-create the personal locker for a user. */ +export async function ensureLockerStorage( + payload: PayloadType, + userId: number, +): Promise { + const existing = (await payload.find({ + collection: "locker-storages", + where: { ownerUser: { equals: userId } }, + limit: 1, + depth: 0, + overrideAccess: true, + })) as unknown as { docs: LockerStorage[] }; + + if (existing.docs.length > 0) return existing.docs[0]; + + const user = (await payload + .findByID({ collection: "users", id: userId, depth: 0, overrideAccess: true }) + .catch(() => null)) as unknown as { username?: string; displayName?: string } | null; + const label = user?.displayName || user?.username || `User ${userId}`; + + return (await payload.create({ + collection: "locker-storages", + data: { + name: `${label}'s Locker`, + ownerUser: userId, + items: [], + }, + overrideAccess: true, + depth: 0, + })) as unknown as LockerStorage; +} + +// --- Grid geometry helpers (asset-based equivalents of src/lib/logistics.ts) --- + +/** Effective footprint of an asset, accounting for rotation. */ +export function getLockerItemFootprint(asset: Asset, rotated: boolean): GridDimensions { + const storage = asset.storageDimensions; + const w = storage?.gridWidth ?? 1; + const h = storage?.gridHeight ?? 1; + return rotated ? { width: h, height: w } : { width: w, height: h }; +} + +export function lockerIsStackable(asset: Asset): boolean { + return asset.storageDimensions?.stackable !== false; +} + +export function lockerMaxStack(asset: Asset): number { + const max = asset.storageDimensions?.maxStackSize; + return typeof max === "number" && max > 0 ? max : Infinity; +} + +export function lockerIsRotatable(asset: Asset): boolean { + return asset.storageDimensions?.rotatable !== false; +} + +/** Cells occupied by an item as "x,y" strings. */ +export function getLockerOccupiedCells( + gridX: number, + gridY: number, + width: number, + height: number, +): string[] { + const cells: string[] = []; + for (let dx = 0; dx < width; dx++) { + for (let dy = 0; dy < height; dy++) { + cells.push(`${gridX + dx},${gridY + dy}`); + } + } + return cells; +} + +/** True if placing at (targetX, targetY) is in-bounds and non-overlapping. */ +export function isLockerPlacementValid( + items: LockerGridItem[], + excludeId: string | null, + targetX: number, + targetY: number, + width: number, + height: number, + gridWidth: number, + gridHeight: number, +): boolean { + if (targetX < 0 || targetY < 0) return false; + if (targetX + width > gridWidth || targetY + height > gridHeight) return false; + + const targetCells = new Set(getLockerOccupiedCells(targetX, targetY, width, height)); + + for (const item of items) { + if (item.id === excludeId) continue; + const fp = getLockerItemFootprint(item.asset, item.rotated); + const occupied = getLockerOccupiedCells(item.gridX, item.gridY, fp.width, fp.height); + for (const cell of occupied) { + if (targetCells.has(cell)) return false; + } + } + + return true; +} + +/** First free spot scanning left-to-right, top-to-bottom, or null. */ +export function findLockerEmptySpot( + items: LockerGridItem[], + width: number, + height: number, + gridWidth: number, + gridHeight: number, +): { x: number; y: number } | null { + for (let y = 0; y <= gridHeight - height; y++) { + for (let x = 0; x <= gridWidth - width; x++) { + if (isLockerPlacementValid(items, null, x, y, width, height, gridWidth, gridHeight)) { + return { x, y }; + } + } + } + return null; +} + +/** Repack all items tightly into the grid. Returns the re-arranged array. */ +export function autoArrangeLockerItems( + items: LockerGridItem[], + gridWidth: number, + gridHeight: number, +): LockerGridItem[] { + const result: LockerGridItem[] = []; + + for (const item of items) { + const fp = getLockerItemFootprint(item.asset, item.rotated); + const spot = findLockerEmptySpot(result, fp.width, fp.height, gridWidth, gridHeight); + if (spot) { + result.push({ ...item, gridX: spot.x, gridY: spot.y }); + } else { + result.push({ ...item, gridX: 0, gridY: 0 }); + } + } + + return result; +} + +/** Number of occupied cells vs total. */ +export function getLockerGridOccupancy( + items: LockerGridItem[], + gridWidth: number, + gridHeight: number, +): { used: number; total: number } { + const occupied = new Set(); + for (const item of items) { + const fp = getLockerItemFootprint(item.asset, item.rotated); + for (const c of getLockerOccupiedCells(item.gridX, item.gridY, fp.width, fp.height)) { + occupied.add(c); + } + } + return { used: occupied.size, total: gridWidth * gridHeight }; +} + +/** Find the stack of the same asset overlapping the drop target, if any. */ +export function findLockerMergeTarget( + items: LockerGridItem[], + dragged: LockerGridItem, + targetX: number, + targetY: number, + rotated?: boolean, +): LockerGridItem | null { + const effective = rotated ?? dragged.rotated; + const fp = getLockerItemFootprint(dragged.asset, effective); + const targetCells = new Set(); + for (let dx = 0; dx < fp.width; dx++) { + for (let dy = 0; dy < fp.height; dy++) { + targetCells.add(`${targetX + dx},${targetY + dy}`); + } + } + for (const other of items) { + if (other.id === dragged.id) continue; + if (other.assetId !== dragged.assetId) continue; + const otherFp = getLockerItemFootprint(other.asset, other.rotated); + for (let dx = 0; dx < otherFp.width; dx++) { + for (let dy = 0; dy < otherFp.height; dy++) { + if (targetCells.has(`${other.gridX + dx},${other.gridY + dy}`)) { + return other; + } + } + } + } + return null; +}