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>
This commit is contained in:
parent
c64c9da0da
commit
5be390012a
10 changed files with 778 additions and 13 deletions
297
src/app/(frontend)/logistics/structures/[id]/detail-data.ts
Normal file
297
src/app/(frontend)/logistics/structures/[id]/detail-data.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
42
src/app/(frontend)/logistics/structures/[id]/detail.ts
Normal file
42
src/app/(frontend)/logistics/structures/[id]/detail.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getPayload, type Payload } from "payload";
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { fetchStructureDetail, type StructureDetailData } from "./detail-data";
|
||||||
|
|
||||||
|
interface ActionResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(): Promise<{ payload: Payload; user: { id: number | string } | null }> {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const hdrs = await headers();
|
||||||
|
const { user } = await payload.auth({
|
||||||
|
headers: hdrs,
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStructureDetail(
|
||||||
|
structureId: number,
|
||||||
|
): Promise<ActionResult<StructureDetailData>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
const data = await fetchStructureDetail(payload, structureId, user);
|
||||||
|
if (!data) {
|
||||||
|
return { success: false, error: "Structure not found." };
|
||||||
|
}
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { StorageRules } from "@/components/frontend/storage/StorageRules";
|
||||||
import { StructureStorageView } from "@/components/frontend/storage/StructureStorageView";
|
import { StructureStorageView } from "@/components/frontend/storage/StructureStorageView";
|
||||||
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
|
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
|
||||||
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
|
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
|
||||||
|
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
|
||||||
import {
|
import {
|
||||||
calculateStoredMass,
|
calculateStoredMass,
|
||||||
calculateUtilization,
|
calculateUtilization,
|
||||||
|
|
@ -248,6 +249,30 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
return sum + (resource.baseValue ?? 0) * entry.amount;
|
return sum + (resource.baseValue ?? 0) * entry.amount;
|
||||||
}, 0);
|
}, 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 (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
||||||
<header
|
<header
|
||||||
|
|
@ -411,6 +436,14 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
|
|
||||||
<UpgradeCard gameStructureId={structure.id} upgrade={upgrade} />
|
<UpgradeCard gameStructureId={structure.id} upgrade={upgrade} />
|
||||||
|
|
||||||
|
<ConstructionPanel
|
||||||
|
status={structure.constructionStatus ?? "complete"}
|
||||||
|
startedAt={structure.constructionStartedAt ?? null}
|
||||||
|
completesAt={structure.constructionCompletesAt ?? null}
|
||||||
|
durationMinutes={structureType.constructionDurationMinutes ?? null}
|
||||||
|
materials={constructionMaterials}
|
||||||
|
/>
|
||||||
|
|
||||||
<StaffRoster
|
<StaffRoster
|
||||||
structure={structureStaffing}
|
structure={structureStaffing}
|
||||||
staff={staff}
|
staff={staff}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQu
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||||
|
import { maybeStartConstruction } from "@/lib/map/construction";
|
||||||
|
|
||||||
interface ActionResult {
|
interface ActionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -278,6 +279,13 @@ async function addResourceInternal(
|
||||||
data: { resourceId, amount, resourceName: resource.name },
|
data: { resourceId, amount, resourceName: resource.name },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delivery-time construction start (fire-and-forget: the deposit already succeeded).
|
||||||
|
try {
|
||||||
|
await maybeStartConstruction(payload, structureId);
|
||||||
|
} catch {
|
||||||
|
// The site retries on the next deposit.
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, data: updated as unknown as GameStructure };
|
return { success: true, data: updated as unknown as GameStructure };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
|
@ -445,14 +453,14 @@ export async function transferResource(
|
||||||
|
|
||||||
if (fromCoords && toCoords) {
|
if (fromCoords && toCoords) {
|
||||||
const gameRules = await payload.findGlobal({ slug: "game-rules" });
|
const gameRules = await payload.findGlobal({ slug: "game-rules" });
|
||||||
const threshold = (gameRules.proximityThreshold as number) || 10;
|
const threshold = (gameRules.proximityThreshold as number) || 1000;
|
||||||
const { calculateDistance } = await import("@/lib/distance");
|
const { calculateDistance } = await import("@/lib/distance");
|
||||||
const distance = calculateDistance(fromCoords, toCoords);
|
const distance = calculateDistance(fromCoords, toCoords);
|
||||||
|
|
||||||
if (distance > threshold) {
|
if (distance > threshold) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Structures are too far apart for instant transfer (${distance.toFixed(1)} units, max ${threshold}). Use the Shipments system to transport resources over long distances.`,
|
error: `Structures are too far apart for instant transfer (${Math.round(distance)} m apart, max ${threshold} m). Use the Shipments system to transport resources over long distances.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -349,20 +349,14 @@ export const Structures: CollectionConfig = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "constructionTime",
|
name: "constructionDurationMinutes",
|
||||||
type: "number",
|
type: "number",
|
||||||
required: true,
|
required: true,
|
||||||
label: "Build Time (in game ticks)",
|
label: "Build Time (minutes)",
|
||||||
min: 1,
|
min: 1,
|
||||||
admin: {
|
admin: {
|
||||||
components: {
|
description:
|
||||||
Description: {
|
"How many real minutes it takes to complete construction of this structure after materials are delivered and building starts.",
|
||||||
path: "@/components/static/TickDurationDescription",
|
|
||||||
clientProps: {
|
|
||||||
task: "complete construction of this structure",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
33
src/components/frontend/baseManagement/ConstructionEta.tsx
Normal file
33
src/components/frontend/baseManagement/ConstructionEta.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface ConstructionEtaProps {
|
||||||
|
completesAt: string | Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live countdown to the site's exact constructionCompletesAt deadline.
|
||||||
|
*/
|
||||||
|
export function ConstructionEta({ completesAt }: ConstructionEtaProps) {
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNowMs(Date.now()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deadline = completesAt ? new Date(completesAt).getTime() : NaN;
|
||||||
|
const remaining = Number.isFinite(deadline) ? deadline - nowMs : 0;
|
||||||
|
|
||||||
|
return <span>ETA {remaining <= 0 ? "any moment" : formatDuration(remaining)}</span>;
|
||||||
|
}
|
||||||
131
src/components/frontend/baseManagement/ConstructionPanel.tsx
Normal file
131
src/components/frontend/baseManagement/ConstructionPanel.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ConstructionEta } from "@/components/frontend/baseManagement/ConstructionEta";
|
||||||
|
|
||||||
|
interface ConstructionMaterial {
|
||||||
|
name: string;
|
||||||
|
required: number;
|
||||||
|
delivered: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConstructionPanelProps {
|
||||||
|
status: "awaiting_materials" | "building" | "complete" | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
completesAt: string | null;
|
||||||
|
durationMinutes: number | null;
|
||||||
|
materials: ConstructionMaterial[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
|
||||||
|
awaiting_materials: {
|
||||||
|
label: "Awaiting Materials",
|
||||||
|
className: "border-amber-500/40 bg-amber-500/10 text-amber-300",
|
||||||
|
},
|
||||||
|
building: {
|
||||||
|
label: "Building",
|
||||||
|
className: "border-blue-500/40 bg-blue-500/10 text-blue-300",
|
||||||
|
},
|
||||||
|
complete: {
|
||||||
|
label: "Operational",
|
||||||
|
className: "border-emerald-500/40 bg-emerald-500/10 text-emerald-300",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function progressPct(startedAt: string | null, completesAt: string | null, nowMs: number): number {
|
||||||
|
if (!startedAt || !completesAt) return 0;
|
||||||
|
const start = new Date(startedAt).getTime();
|
||||||
|
const end = new Date(completesAt).getTime();
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0;
|
||||||
|
const ratio = (nowMs - start) / (end - start);
|
||||||
|
if (!Number.isFinite(ratio)) return 0;
|
||||||
|
return Math.min(100, Math.max(0, Math.round(ratio * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConstructionPanel({
|
||||||
|
status,
|
||||||
|
startedAt,
|
||||||
|
completesAt,
|
||||||
|
durationMinutes,
|
||||||
|
materials,
|
||||||
|
}: ConstructionPanelProps) {
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNowMs(Date.now()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!status || status === "complete") return null;
|
||||||
|
|
||||||
|
const statusInfo = STATUS_LABELS[status] ?? STATUS_LABELS.awaiting_materials;
|
||||||
|
const allDelivered = materials.every((material) => material.delivered >= material.required);
|
||||||
|
const pct = status === "building" ? progressPct(startedAt, completesAt, nowMs) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border p-5 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="text-sm font-semibold">Construction</h2>
|
||||||
|
<span
|
||||||
|
className={`rounded-sm border px-2 py-0.5 text-xs uppercase tracking-wide ${statusInfo.className}`}
|
||||||
|
>
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "building" && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Build time {durationMinutes != null ? `${durationMinutes.toLocaleString()} min` : ""}
|
||||||
|
</span>
|
||||||
|
<span>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-blue-400"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end text-xs tabular-nums text-muted-foreground">
|
||||||
|
<ConstructionEta completesAt={completesAt} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "awaiting_materials" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Ship the required materials to this site. Construction starts automatically once
|
||||||
|
everything has arrived.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{materials.map((material) => {
|
||||||
|
const met = material.delivered >= material.required;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={material.name}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md bg-muted/40 px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<span>{material.name}</span>
|
||||||
|
<span className={`tabular-nums ${met ? "text-emerald-400" : "text-amber-300"}`}>
|
||||||
|
{material.delivered.toLocaleString()} / {material.required.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{materials.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">No materials required by this blueprint.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "awaiting_materials" && !allDelivered && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{materials.filter((material) => material.delivered < material.required).length} material
|
||||||
|
type(s) still missing.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
src/lib/map/construction.ts
Normal file
202
src/lib/map/construction.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import type { GameStructure, Structure } from "@/payload-types";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import { consumeStorage, relationshipId, storedAmount } from "@/lib/base/storage";
|
||||||
|
|
||||||
|
export interface MaybeStartConstructionResult {
|
||||||
|
started: boolean;
|
||||||
|
/** Why the site did not start (undefined when started). */
|
||||||
|
reason?: "not_awaiting" | "missing_materials" | "no_blueprint";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConstructionSweepResult {
|
||||||
|
/** Sites whose deadline passed and were flipped to complete this sweep. */
|
||||||
|
completed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConstructionLog {
|
||||||
|
info?: (message: string) => void;
|
||||||
|
error: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConstructionSite {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
constructionStatus: "awaiting_materials" | "building" | "complete" | null;
|
||||||
|
constructionStartedAt: string | Date | null;
|
||||||
|
constructionCompletesAt: string | Date | null;
|
||||||
|
storedResources: NonNullable<GameStructure["storedResources"]> | null;
|
||||||
|
voidStorage: NonNullable<GameStructure["voidStorage"]> | null;
|
||||||
|
type: Structure | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blueprintOf(payload: Payload, site: ConstructionSite): Promise<Structure | null> {
|
||||||
|
if (!site.type) return null;
|
||||||
|
if (typeof site.type === "object") return site.type;
|
||||||
|
return payload.findByID({
|
||||||
|
collection: "structures",
|
||||||
|
id: site.type,
|
||||||
|
depth: 2,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function materialShortfalls(blueprint: Structure, site: ConstructionSite): string[] {
|
||||||
|
const stored = site.storedResources ?? [];
|
||||||
|
const voidStorage = site.voidStorage ?? [];
|
||||||
|
const shortages: string[] = [];
|
||||||
|
|
||||||
|
for (const material of blueprint.materials ?? []) {
|
||||||
|
const resourceId = relationshipId(material.resource);
|
||||||
|
const needed = Math.ceil(material.amount ?? 0);
|
||||||
|
if (resourceId === null || needed <= 0) continue;
|
||||||
|
const available = storedAmount(stored, resourceId) + storedAmount(voidStorage, resourceId);
|
||||||
|
if (available < needed) {
|
||||||
|
shortages.push(`resource #${resourceId} (have ${available}, need ${needed})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shortages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeMaterials(
|
||||||
|
blueprint: Structure,
|
||||||
|
site: ConstructionSite,
|
||||||
|
): { storedResources: ConstructionSite["storedResources"]; voidStorage: ConstructionSite["voidStorage"] } {
|
||||||
|
let stored = [...(site.storedResources ?? [])];
|
||||||
|
let voidStorage = [...(site.voidStorage ?? [])];
|
||||||
|
|
||||||
|
for (const material of blueprint.materials ?? []) {
|
||||||
|
const resourceId = relationshipId(material.resource);
|
||||||
|
const needed = Math.ceil(material.amount ?? 0);
|
||||||
|
if (resourceId === null || needed <= 0) continue;
|
||||||
|
const fromStored = consumeStorage(stored, resourceId, needed);
|
||||||
|
stored = fromStored.entries;
|
||||||
|
if (fromStored.remaining > 0) {
|
||||||
|
voidStorage = consumeStorage(voidStorage, resourceId, fromStored.remaining).entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { storedResources: stored, voidStorage };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive construction start (settled decision: construction runs on
|
||||||
|
* timestamps, not tick counts). Called at delivery time: after a shipment
|
||||||
|
* completes at the site, after a successful storage deposit, or immediately
|
||||||
|
* after placement for blueprints that need no materials.
|
||||||
|
*
|
||||||
|
* If the site is awaiting_materials and every blueprint material is present
|
||||||
|
* in the site's storage (grid + void), consumes them and flips the site to
|
||||||
|
* building with constructionStartedAt = now and constructionCompletesAt =
|
||||||
|
* now + the blueprint's constructionDurationMinutes. Otherwise it is a no-op.
|
||||||
|
*/
|
||||||
|
export async function maybeStartConstruction(
|
||||||
|
payload: Payload,
|
||||||
|
siteId: number,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): Promise<MaybeStartConstructionResult> {
|
||||||
|
const doc = await payload.findByID({
|
||||||
|
collection: "game-structures",
|
||||||
|
id: siteId,
|
||||||
|
depth: 2,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const site = doc as unknown as ConstructionSite;
|
||||||
|
|
||||||
|
if (site.constructionStatus !== "awaiting_materials") {
|
||||||
|
return { started: false, reason: "not_awaiting" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blueprint = await blueprintOf(payload, site);
|
||||||
|
if (!blueprint) {
|
||||||
|
return { started: false, reason: "no_blueprint" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (materialShortfalls(blueprint, site).length > 0) {
|
||||||
|
return { started: false, reason: "missing_materials" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storedResources, voidStorage } = consumeMaterials(blueprint, site);
|
||||||
|
const durationMinutes = Math.max(0, blueprint.constructionDurationMinutes ?? 0);
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: "game-structures",
|
||||||
|
id: site.id,
|
||||||
|
data: {
|
||||||
|
storedResources,
|
||||||
|
voidStorage,
|
||||||
|
constructionStatus: "building",
|
||||||
|
constructionStartedAt: now.toISOString(),
|
||||||
|
constructionCompletesAt: new Date(now.getTime() + durationMinutes * 60 * 1000).toISOString(),
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { started: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweeps finished construction sites (settled decision: the game tick no
|
||||||
|
* longer starts construction or advances progress; it only completes sites
|
||||||
|
* whose constructionCompletesAt deadline has passed and emits the event).
|
||||||
|
* Awaiting-to-building transitions happen exclusively in
|
||||||
|
* maybeStartConstruction at delivery time.
|
||||||
|
*/
|
||||||
|
export async function processConstructionTick(
|
||||||
|
payload: Payload,
|
||||||
|
log?: ConstructionLog,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): Promise<ConstructionSweepResult> {
|
||||||
|
const result: ConstructionSweepResult = { completed: 0 };
|
||||||
|
|
||||||
|
const sites = await payload.find({
|
||||||
|
collection: "game-structures",
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ constructionStatus: { equals: "building" } },
|
||||||
|
{ constructionCompletesAt: { less_than_equal: now.toISOString() } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
depth: 2,
|
||||||
|
limit: 1000,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const doc of sites.docs) {
|
||||||
|
const site = doc as unknown as ConstructionSite;
|
||||||
|
try {
|
||||||
|
const blueprint = await blueprintOf(payload, site);
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: "game-structures",
|
||||||
|
id: site.id,
|
||||||
|
data: { constructionStatus: "complete" },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
result.completed++;
|
||||||
|
|
||||||
|
await emitGameEvent(payload, {
|
||||||
|
type: EventTypes.StructureConstructionComplete,
|
||||||
|
message: `Construction of "${site.name}" completed`,
|
||||||
|
structure: site.id,
|
||||||
|
targetCollection: "game-structures",
|
||||||
|
targetId: site.id,
|
||||||
|
data: {
|
||||||
|
blueprintId: blueprint?.id ?? null,
|
||||||
|
blueprintName: blueprint?.name ?? null,
|
||||||
|
startedAt: site.constructionStartedAt,
|
||||||
|
completesAt: site.constructionCompletesAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log?.error?.(
|
||||||
|
`Construction sweep failed for site "${site.name}" (#${site.id}): ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import type { SanitizedConfig } from "payload";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
|
import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
|
||||||
import { processShipmentTick } from "./processShipmentTick";
|
import { processShipmentTick } from "./processShipmentTick";
|
||||||
|
import { processConstructionTick } from "@/lib/map/construction";
|
||||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
|
@ -40,12 +41,15 @@ export const script = async (config: SanitizedConfig) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const construction = await processConstructionTick(payload, log);
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.GameTick,
|
type: EventTypes.GameTick,
|
||||||
message: `Game tick processed ${processedCount} of ${activeShipments.docs.length} active shipments`,
|
message: `Game tick processed ${processedCount} of ${activeShipments.docs.length} active shipments`,
|
||||||
data: {
|
data: {
|
||||||
processedCount,
|
processedCount,
|
||||||
totalActive: activeShipments.docs.length,
|
totalActive: activeShipments.docs.length,
|
||||||
|
construction,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import { notifyUser } from "@/lib/notifications";
|
import { notifyUser } from "@/lib/notifications";
|
||||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||||
|
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
||||||
|
import { maybeStartConstruction } from "@/lib/map/construction";
|
||||||
import { errorMessage, type BinLogger } from "./lib/binFileLogger";
|
import { errorMessage, type BinLogger } from "./lib/binFileLogger";
|
||||||
|
|
||||||
type ShipmentDoc = Shipment & {
|
type ShipmentDoc = Shipment & {
|
||||||
|
|
@ -75,7 +77,7 @@ export async function processShipmentTick(
|
||||||
const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost);
|
const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost);
|
||||||
|
|
||||||
if (now >= estimatedArrival) {
|
if (now >= estimatedArrival) {
|
||||||
await completeShipment(payload, shipment, newFuelConsumed, log);
|
await completeShipment(payload, shipment, newFuelConsumed, now, log);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,6 +95,7 @@ async function completeShipment(
|
||||||
payload: Payload,
|
payload: Payload,
|
||||||
shipment: ShipmentDoc,
|
shipment: ShipmentDoc,
|
||||||
finalFuelConsumed: number,
|
finalFuelConsumed: number,
|
||||||
|
now: Date,
|
||||||
log: Pick<BinLogger, "error">,
|
log: Pick<BinLogger, "error">,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const vehicle = shipment.transportVehicle;
|
const vehicle = shipment.transportVehicle;
|
||||||
|
|
@ -273,6 +276,15 @@ async function completeShipment(
|
||||||
data: { vehicleId: vehicle.id, originId, destinationId },
|
data: { vehicleId: vehicle.id, originId, destinationId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fire-and-forget: a failed construction start must not fail the completed shipment.
|
||||||
|
try {
|
||||||
|
await maybeStartConstruction(payload, destinationId, now);
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
`Construction start failed at destination "${destinationId}": ${errorMessage(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Notify the user who created the shipment (the closest thing to a recipient/owner).
|
// Notify the user who created the shipment (the closest thing to a recipient/owner).
|
||||||
const creatorId =
|
const creatorId =
|
||||||
typeof shipment.createdBy === "object" && shipment.createdBy
|
typeof shipment.createdBy === "object" && shipment.createdBy
|
||||||
|
|
@ -314,6 +326,10 @@ async function createAutoReturnShipment(
|
||||||
vehicleType.maxSpeedOnRoad ?? vehicleType.maxSpeedOffRoad ?? vehicleType.maxAirspeed ?? 50;
|
vehicleType.maxSpeedOnRoad ?? vehicleType.maxSpeedOffRoad ?? vehicleType.maxAirspeed ?? 50;
|
||||||
const rate = vehicleType.fuel?.fuelConsumptionRate ?? 1;
|
const rate = vehicleType.fuel?.fuelConsumptionRate ?? 1;
|
||||||
|
|
||||||
|
const originalRoutePath = isPointArray(originalShipment.routePath)
|
||||||
|
? ([...originalShipment.routePath] as MapPoint[]).reverse()
|
||||||
|
: null;
|
||||||
|
|
||||||
const returnFuelCost = Math.ceil(originalShipment.distance * rate);
|
const returnFuelCost = Math.ceil(originalShipment.distance * rate);
|
||||||
const returnDurationMs = (originalShipment.distance / speed) * 60 * 60 * 1000;
|
const returnDurationMs = (originalShipment.distance / speed) * 60 * 60 * 1000;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -346,6 +362,11 @@ async function createAutoReturnShipment(
|
||||||
name: `Return: ${originalShipment.name ?? `SHP-${originalShipment.id}`}`,
|
name: `Return: ${originalShipment.name ?? `SHP-${originalShipment.id}`}`,
|
||||||
origin: destinationId,
|
origin: destinationId,
|
||||||
destination: originId,
|
destination: originId,
|
||||||
|
map:
|
||||||
|
typeof originalShipment.map === "object"
|
||||||
|
? (originalShipment.map?.id ?? null)
|
||||||
|
: (originalShipment.map ?? null),
|
||||||
|
routePath: originalRoutePath,
|
||||||
transportVehicle: vehicle.id,
|
transportVehicle: vehicle.id,
|
||||||
distance: originalShipment.distance,
|
distance: originalShipment.distance,
|
||||||
cargo: [],
|
cargo: [],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue