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 { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
|
||||
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
|
||||
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
|
||||
import {
|
||||
calculateStoredMass,
|
||||
calculateUtilization,
|
||||
|
|
@ -248,6 +249,30 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
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 (
|
||||
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
||||
<header
|
||||
|
|
@ -411,6 +436,14 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
|
||||
<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
|
||||
structure={structureStaffing}
|
||||
staff={staff}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQu
|
|||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||
import { maybeStartConstruction } from "@/lib/map/construction";
|
||||
|
||||
interface ActionResult {
|
||||
success: boolean;
|
||||
|
|
@ -278,6 +279,13 @@ async function addResourceInternal(
|
|||
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 };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
|
@ -445,14 +453,14 @@ export async function transferResource(
|
|||
|
||||
if (fromCoords && toCoords) {
|
||||
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 distance = calculateDistance(fromCoords, toCoords);
|
||||
|
||||
if (distance > threshold) {
|
||||
return {
|
||||
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",
|
||||
required: true,
|
||||
label: "Build Time (in game ticks)",
|
||||
label: "Build Time (minutes)",
|
||||
min: 1,
|
||||
admin: {
|
||||
components: {
|
||||
Description: {
|
||||
path: "@/components/static/TickDurationDescription",
|
||||
clientProps: {
|
||||
task: "complete construction of this structure",
|
||||
},
|
||||
},
|
||||
},
|
||||
description:
|
||||
"How many real minutes it takes to complete construction of this structure after materials are delivered and building starts.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
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 type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
|
||||
import { processShipmentTick } from "./processShipmentTick";
|
||||
import { processConstructionTick } from "@/lib/map/construction";
|
||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
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, {
|
||||
type: EventTypes.GameTick,
|
||||
message: `Game tick processed ${processedCount} of ${activeShipments.docs.length} active shipments`,
|
||||
data: {
|
||||
processedCount,
|
||||
totalActive: activeShipments.docs.length,
|
||||
construction,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { emitGameEvent } from "@/utils/event-log/emit";
|
|||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
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";
|
||||
|
||||
type ShipmentDoc = Shipment & {
|
||||
|
|
@ -75,7 +77,7 @@ export async function processShipmentTick(
|
|||
const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost);
|
||||
|
||||
if (now >= estimatedArrival) {
|
||||
await completeShipment(payload, shipment, newFuelConsumed, log);
|
||||
await completeShipment(payload, shipment, newFuelConsumed, now, log);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +95,7 @@ async function completeShipment(
|
|||
payload: Payload,
|
||||
shipment: ShipmentDoc,
|
||||
finalFuelConsumed: number,
|
||||
now: Date,
|
||||
log: Pick<BinLogger, "error">,
|
||||
): Promise<void> {
|
||||
const vehicle = shipment.transportVehicle;
|
||||
|
|
@ -273,6 +276,15 @@ async function completeShipment(
|
|||
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).
|
||||
const creatorId =
|
||||
typeof shipment.createdBy === "object" && shipment.createdBy
|
||||
|
|
@ -314,6 +326,10 @@ async function createAutoReturnShipment(
|
|||
vehicleType.maxSpeedOnRoad ?? vehicleType.maxSpeedOffRoad ?? vehicleType.maxAirspeed ?? 50;
|
||||
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 returnDurationMs = (originalShipment.distance / speed) * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
|
|
@ -346,6 +362,11 @@ async function createAutoReturnShipment(
|
|||
name: `Return: ${originalShipment.name ?? `SHP-${originalShipment.id}`}`,
|
||||
origin: destinationId,
|
||||
destination: originId,
|
||||
map:
|
||||
typeof originalShipment.map === "object"
|
||||
? (originalShipment.map?.id ?? null)
|
||||
: (originalShipment.map ?? null),
|
||||
routePath: originalRoutePath,
|
||||
transportVehicle: vehicle.id,
|
||||
distance: originalShipment.distance,
|
||||
cargo: [],
|
||||
|
|
|
|||
Loading…
Reference in a new issue