1
0
Fork 0

feat(locker): add locker grid and loadout helpers

This commit is contained in:
Jason Fraley 2026-08-02 14:32:45 -04:00
parent 9a565986e9
commit 0fc898d227

320
src/lib/locker/index.ts Normal file
View file

@ -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<Record<LoadoutSlotName, LoadoutSlotValue>>;
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<Asset["assetType"], string> = {
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<ReturnType<typeof import("payload").getPayload>>;
/** 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<LockerStorage> {
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<string>();
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<string>();
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;
}