import { getPayload, type Payload } from "payload"; import type { Faction, GameStructure, Structure } from "@/payload-types"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { calculateStoredMass, calculateUtilization, getGridSlots, } from "@/lib/logistics"; import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base"; import { currencyConfigFromGameRules } from "@/lib/banking"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { isSuperuser } from "@/utils/access-control/hasPermission"; export interface StructureDetailData { structure: DetailStructure; blueprint: Structure; description: string | null; imageUrl: string | null; derived: { currentMass: number; utilization: number | null; gridSlots: number | null; totalValue: number; constructionMaterials: { name: string; required: number; delivered: number }[]; }; upgrade: { targetName: string; materials: { name: string; needed: number; have: number }[]; } | null; staffing: { structureStaffing: StructureStaffingOption; staff: { id: number; npcName: string | null; positionType: string; positionTitle: string; salary: number; headcount: number; laborClassificationId: number | null; hiredAt: string; }[]; classifications: LaborClassificationOption[]; npcs: { id: number; name: string }[]; }; hiring: { enabled: boolean; disabledMessage?: string }; gates: { canManage: boolean; canManageStorage: boolean; canTransferStorage: boolean }; currencyConfig: ReturnType; } export type DetailStructure = GameStructure & { type: Structure; faction?: Faction | null; }; /** * Everything the structure detail page renders, as one serializable object. * Mirrors the queries in logistics/structures/[id]/page.tsx (kept separate on * purpose: the repo duplicates helpers per file rather than sharing them). */ export async function fetchStructureDetail( payload: Payload, structureId: number, user: { id: number | string } | null | undefined, ): Promise { const structureRes = await payload .findByID({ collection: "game-structures", id: structureId, depth: 2, }) .catch(() => null); if (!structureRes) return null; const structure = structureRes as unknown as DetailStructure; const structureType = structure.type; const storedResources = structure.storedResources ?? []; const currentMass = calculateStoredMass(storedResources); const utilization = calculateUtilization(structureType, storedResources); const gridSlots = getGridSlots(structureType); const description = structureType.description ? convertLexicalToPlaintext({ data: structureType.description }) : null; const image = typeof structureType.image === "object" && structureType.image !== null ? structureType.image : null; const imageUrl = image?.url ?? null; const upgradeTarget = structureType.upgradesInto; const upgradeTargetId = typeof upgradeTarget === "object" && upgradeTarget !== null ? upgradeTarget.id : upgradeTarget; let upgrade: StructureDetailData["upgrade"] = null; if (typeof upgradeTargetId === "number") { const targetRes = await payload .findByID({ collection: "structures", id: upgradeTargetId, depth: 1 }) .catch(() => null); if (targetRes) { const target = targetRes as unknown as Structure; const resourceIds = target.materials .map((material) => typeof material.resource === "object" && material.resource !== null ? material.resource.id : material.resource, ) .filter((resourceId): resourceId is number => typeof resourceId === "number"); const resourceRes = resourceIds.length > 0 ? await payload.find({ collection: "resources", where: { id: { in: resourceIds } }, limit: resourceIds.length, depth: 0, }) : null; const resourceNames = new Map( (resourceRes?.docs ?? []).map((resource) => [resource.id, resource.name]), ); const inventory = [...storedResources, ...(structure.voidStorage ?? [])]; upgrade = { targetName: target.name, materials: target.materials.map((material) => { const resourceId = typeof material.resource === "object" && material.resource !== null ? material.resource.id : material.resource; const resourceName = typeof material.resource === "object" && material.resource !== null ? material.resource.name : (resourceNames.get(resourceId) ?? `Resource #${resourceId}`); const have = inventory.reduce((total, entry) => { const entryResourceId = typeof entry.resource === "object" && entry.resource !== null ? entry.resource.id : entry.resource; return entryResourceId === resourceId ? total + entry.amount : total; }, 0); return { name: resourceName, needed: Math.ceil((material.amount ?? 0) * (structureType.upgradeCostMultiplier ?? 1)), have, }; }), }; } } const staffRes = await payload.find({ collection: "npc-staffing", where: { and: [{ structure: { equals: structure.id } }, { status: { equals: "active" } }], }, limit: 50, depth: 1, }); const staff = staffRes.docs.map((entry) => { const npc = entry.npc; const npcName = typeof npc === "object" && npc !== null ? npc.name : typeof npc === "number" ? `NPC #${npc}` : null; const laborClassificationId = typeof entry.laborClassification === "object" && entry.laborClassification !== null ? entry.laborClassification.id : typeof entry.laborClassification === "number" ? entry.laborClassification : null; return { id: entry.id, npcName, positionType: entry.positionType, positionTitle: entry.positionTitle ?? "", salary: entry.salary ?? 0, headcount: entry.headcount ?? 1, laborClassificationId, hiredAt: new Date(entry.hiredAt ?? entry.createdAt).toISOString(), }; }); const canManage = await hasLogisticsQualification(payload, user); const canManageStorage = await isSuperuser(payload, user); const canTransferStorage = canManageStorage || canManage; const classificationsRes = await payload.find({ collection: "labor-classifications", limit: 100, depth: 0, }); const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({ id: doc.id, name: doc.name, key: doc.key, defaultUnitSalary: doc.defaultUnitSalary ?? null, availableHeadcount: doc.availableHeadcount ?? 0, })); const rules = await payload.findGlobal({ slug: "game-rules", depth: 1, overrideAccess: true, }); const currencyConfig = currencyConfigFromGameRules(rules); const structureStaffing: StructureStaffingOption = { id: structure.id, name: structure.name, maintenanceCost: structure.maintenanceCost ?? 0, laborRequirements: (structure.laborRequirements ?? []).map((requirement) => { const classificationId = typeof requirement.classification === "object" ? requirement.classification.id : requirement.classification; return { classificationId, requiredHeadcount: requirement.requiredHeadcount, maxHeadcount: requirement.maxHeadcount ?? 0, enforcedSalary: requirement.enforcedSalary ?? null, currentHeadcount: staff .filter((member) => member.laborClassificationId === classificationId) .reduce((total, member) => total + member.headcount, 0), }; }), staffPositions: (structure.staffPositions ?? []).map((position) => ({ positionType: position.positionType, max: position.max ?? null, enforcedSalary: position.enforcedSalary ?? null, currentHeadcount: staff.filter( (member) => member.npcName !== null && member.positionType === position.positionType, ).length, })), }; const npcsRes = await payload.find({ collection: "game-npcs", limit: 200, depth: 0, }); const totalValue = storedResources.reduce((sum, entry) => { const resource = entry.resource as { baseValue?: number | null } | number | null; if (!resource || typeof resource === "number") return sum; return sum + (resource.baseValue ?? 0) * entry.amount; }, 0); const inventory = [...storedResources, ...(structure.voidStorage ?? [])]; const constructionMaterials = structureType.materials.map((material) => { const resourceId = typeof material.resource === "object" && material.resource !== null ? material.resource.id : material.resource; const resourceName = typeof material.resource === "object" && material.resource !== null ? material.resource.name : `Resource #${resourceId}`; const delivered = inventory.reduce((total, entry) => { const entryResourceId = typeof entry.resource === "object" && entry.resource !== null ? entry.resource.id : entry.resource; return entryResourceId === resourceId ? total + entry.amount : total; }, 0); return { name: resourceName, required: Math.ceil(material.amount ?? 0), delivered, }; }); return { structure, blueprint: structureType, description, imageUrl, derived: { currentMass, utilization, gridSlots, totalValue, constructionMaterials }, upgrade, staffing: { structureStaffing, staff, classifications, npcs: npcsRes.docs.map((npc) => ({ id: npc.id, name: npc.name })), }, hiring: { enabled: Boolean(rules.staffHiringEnabled), disabledMessage: rules.staffHiringDisabledMessage ?? undefined, }, gates: { canManage, canManageStorage, canTransferStorage }, currencyConfig, }; }