1
0
Fork 0
polaris-task-force/src/app/(frontend)/logistics/structures/[id]/detail-data.ts
Z8MB1E 5be390012a feat(base): timestamp-based construction lifecycle with progress panel
Blueprints declare constructionDurationMinutes instead of tick counts. Delivering the last material starts construction at delivery time via maybeStartConstruction, setting constructionStartedAt/CompletesAt on the site; the game tick only sweeps completed sites. Structure detail page extracts server data helpers and shows a ConstructionPanel with a literal ETA countdown.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:14:07 -04:00

297 lines
10 KiB
TypeScript

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<typeof currencyConfigFromGameRules>;
}
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<StructureDetailData | null> {
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,
};
}