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>
297 lines
10 KiB
TypeScript
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,
|
|
};
|
|
}
|