From 5be390012a6de085036c690c5c29f010d03cd764 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Wed, 16 Sep 2026 23:14:07 -0400 Subject: [PATCH] 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 --- .../logistics/structures/[id]/detail-data.ts | 297 ++++++++++++++++++ .../logistics/structures/[id]/detail.ts | 42 +++ .../logistics/structures/[id]/page.tsx | 33 ++ .../logistics/structures/actions.ts | 12 +- src/collections/logistics/Structures.ts | 14 +- .../baseManagement/ConstructionEta.tsx | 33 ++ .../baseManagement/ConstructionPanel.tsx | 131 ++++++++ src/lib/map/construction.ts | 202 ++++++++++++ src/scripts/gameTick.ts | 4 + src/scripts/processShipmentTick.ts | 23 +- 10 files changed, 778 insertions(+), 13 deletions(-) create mode 100644 src/app/(frontend)/logistics/structures/[id]/detail-data.ts create mode 100644 src/app/(frontend)/logistics/structures/[id]/detail.ts create mode 100644 src/components/frontend/baseManagement/ConstructionEta.tsx create mode 100644 src/components/frontend/baseManagement/ConstructionPanel.tsx create mode 100644 src/lib/map/construction.ts diff --git a/src/app/(frontend)/logistics/structures/[id]/detail-data.ts b/src/app/(frontend)/logistics/structures/[id]/detail-data.ts new file mode 100644 index 0000000..450a070 --- /dev/null +++ b/src/app/(frontend)/logistics/structures/[id]/detail-data.ts @@ -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; +} + +export type DetailStructure = GameStructure & { + type: Structure; + faction?: Faction | null; +}; + +/** + * Everything the structure detail page renders, as one serializable object. + * Mirrors the queries in logistics/structures/[id]/page.tsx (kept separate on + * purpose: the repo duplicates helpers per file rather than sharing them). + */ +export async function fetchStructureDetail( + payload: Payload, + structureId: number, + user: { id: number | string } | null | undefined, +): Promise { + const structureRes = await payload + .findByID({ + collection: "game-structures", + id: structureId, + depth: 2, + }) + .catch(() => null); + + if (!structureRes) return null; + + const structure = structureRes as unknown as DetailStructure; + const structureType = structure.type; + const storedResources = structure.storedResources ?? []; + const currentMass = calculateStoredMass(storedResources); + const utilization = calculateUtilization(structureType, storedResources); + const gridSlots = getGridSlots(structureType); + const description = structureType.description + ? convertLexicalToPlaintext({ data: structureType.description }) + : null; + + const image = + typeof structureType.image === "object" && structureType.image !== null + ? structureType.image + : null; + const imageUrl = image?.url ?? null; + + const upgradeTarget = structureType.upgradesInto; + const upgradeTargetId = + typeof upgradeTarget === "object" && upgradeTarget !== null ? upgradeTarget.id : upgradeTarget; + let upgrade: StructureDetailData["upgrade"] = null; + + if (typeof upgradeTargetId === "number") { + const targetRes = await payload + .findByID({ collection: "structures", id: upgradeTargetId, depth: 1 }) + .catch(() => null); + + if (targetRes) { + const target = targetRes as unknown as Structure; + const resourceIds = target.materials + .map((material) => + typeof material.resource === "object" && material.resource !== null + ? material.resource.id + : material.resource, + ) + .filter((resourceId): resourceId is number => typeof resourceId === "number"); + const resourceRes = + resourceIds.length > 0 + ? await payload.find({ + collection: "resources", + where: { id: { in: resourceIds } }, + limit: resourceIds.length, + depth: 0, + }) + : null; + const resourceNames = new Map( + (resourceRes?.docs ?? []).map((resource) => [resource.id, resource.name]), + ); + const inventory = [...storedResources, ...(structure.voidStorage ?? [])]; + + upgrade = { + targetName: target.name, + materials: target.materials.map((material) => { + const resourceId = + typeof material.resource === "object" && material.resource !== null + ? material.resource.id + : material.resource; + const resourceName = + typeof material.resource === "object" && material.resource !== null + ? material.resource.name + : (resourceNames.get(resourceId) ?? `Resource #${resourceId}`); + const have = inventory.reduce((total, entry) => { + const entryResourceId = + typeof entry.resource === "object" && entry.resource !== null + ? entry.resource.id + : entry.resource; + return entryResourceId === resourceId ? total + entry.amount : total; + }, 0); + + return { + name: resourceName, + needed: Math.ceil((material.amount ?? 0) * (structureType.upgradeCostMultiplier ?? 1)), + have, + }; + }), + }; + } + } + + const staffRes = await payload.find({ + collection: "npc-staffing", + where: { + and: [{ structure: { equals: structure.id } }, { status: { equals: "active" } }], + }, + limit: 50, + depth: 1, + }); + const staff = staffRes.docs.map((entry) => { + const npc = entry.npc; + const npcName = + typeof npc === "object" && npc !== null + ? npc.name + : typeof npc === "number" + ? `NPC #${npc}` + : null; + const laborClassificationId = + typeof entry.laborClassification === "object" && entry.laborClassification !== null + ? entry.laborClassification.id + : typeof entry.laborClassification === "number" + ? entry.laborClassification + : null; + + return { + id: entry.id, + npcName, + positionType: entry.positionType, + positionTitle: entry.positionTitle ?? "", + salary: entry.salary ?? 0, + headcount: entry.headcount ?? 1, + laborClassificationId, + hiredAt: new Date(entry.hiredAt ?? entry.createdAt).toISOString(), + }; + }); + + const canManage = await hasLogisticsQualification(payload, user); + const canManageStorage = await isSuperuser(payload, user); + const canTransferStorage = canManageStorage || canManage; + + const classificationsRes = await payload.find({ + collection: "labor-classifications", + limit: 100, + depth: 0, + }); + const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({ + id: doc.id, + name: doc.name, + key: doc.key, + defaultUnitSalary: doc.defaultUnitSalary ?? null, + availableHeadcount: doc.availableHeadcount ?? 0, + })); + + const rules = await payload.findGlobal({ + slug: "game-rules", + depth: 1, + overrideAccess: true, + }); + const currencyConfig = currencyConfigFromGameRules(rules); + + const structureStaffing: StructureStaffingOption = { + id: structure.id, + name: structure.name, + maintenanceCost: structure.maintenanceCost ?? 0, + laborRequirements: (structure.laborRequirements ?? []).map((requirement) => { + const classificationId = + typeof requirement.classification === "object" + ? requirement.classification.id + : requirement.classification; + return { + classificationId, + requiredHeadcount: requirement.requiredHeadcount, + maxHeadcount: requirement.maxHeadcount ?? 0, + enforcedSalary: requirement.enforcedSalary ?? null, + currentHeadcount: staff + .filter((member) => member.laborClassificationId === classificationId) + .reduce((total, member) => total + member.headcount, 0), + }; + }), + staffPositions: (structure.staffPositions ?? []).map((position) => ({ + positionType: position.positionType, + max: position.max ?? null, + enforcedSalary: position.enforcedSalary ?? null, + currentHeadcount: staff.filter( + (member) => member.npcName !== null && member.positionType === position.positionType, + ).length, + })), + }; + + const npcsRes = await payload.find({ + collection: "game-npcs", + limit: 200, + depth: 0, + }); + + const totalValue = storedResources.reduce((sum, entry) => { + const resource = entry.resource as { baseValue?: number | null } | number | null; + if (!resource || typeof resource === "number") return sum; + return sum + (resource.baseValue ?? 0) * entry.amount; + }, 0); + + const inventory = [...storedResources, ...(structure.voidStorage ?? [])]; + const constructionMaterials = structureType.materials.map((material) => { + const resourceId = + typeof material.resource === "object" && material.resource !== null + ? material.resource.id + : material.resource; + const resourceName = + typeof material.resource === "object" && material.resource !== null + ? material.resource.name + : `Resource #${resourceId}`; + const delivered = inventory.reduce((total, entry) => { + const entryResourceId = + typeof entry.resource === "object" && entry.resource !== null + ? entry.resource.id + : entry.resource; + return entryResourceId === resourceId ? total + entry.amount : total; + }, 0); + return { + name: resourceName, + required: Math.ceil(material.amount ?? 0), + delivered, + }; + }); + + return { + structure, + blueprint: structureType, + description, + imageUrl, + derived: { currentMass, utilization, gridSlots, totalValue, constructionMaterials }, + upgrade, + staffing: { + structureStaffing, + staff, + classifications, + npcs: npcsRes.docs.map((npc) => ({ id: npc.id, name: npc.name })), + }, + hiring: { + enabled: Boolean(rules.staffHiringEnabled), + disabledMessage: rules.staffHiringDisabledMessage ?? undefined, + }, + gates: { canManage, canManageStorage, canTransferStorage }, + currencyConfig, + }; +} diff --git a/src/app/(frontend)/logistics/structures/[id]/detail.ts b/src/app/(frontend)/logistics/structures/[id]/detail.ts new file mode 100644 index 0000000..ec0effa --- /dev/null +++ b/src/app/(frontend)/logistics/structures/[id]/detail.ts @@ -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 { + 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> { + 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 }; + } +} diff --git a/src/app/(frontend)/logistics/structures/[id]/page.tsx b/src/app/(frontend)/logistics/structures/[id]/page.tsx index b61a839..f16d213 100644 --- a/src/app/(frontend)/logistics/structures/[id]/page.tsx +++ b/src/app/(frontend)/logistics/structures/[id]/page.tsx @@ -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 (
+ + 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.`, }; } } diff --git a/src/collections/logistics/Structures.ts b/src/collections/logistics/Structures.ts index c1c4ad3..ce42178 100644 --- a/src/collections/logistics/Structures.ts +++ b/src/collections/logistics/Structures.ts @@ -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.", }, }, { diff --git a/src/components/frontend/baseManagement/ConstructionEta.tsx b/src/components/frontend/baseManagement/ConstructionEta.tsx new file mode 100644 index 0000000..5e1f66b --- /dev/null +++ b/src/components/frontend/baseManagement/ConstructionEta.tsx @@ -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 ETA {remaining <= 0 ? "any moment" : formatDuration(remaining)}; +} diff --git a/src/components/frontend/baseManagement/ConstructionPanel.tsx b/src/components/frontend/baseManagement/ConstructionPanel.tsx new file mode 100644 index 0000000..946e60f --- /dev/null +++ b/src/components/frontend/baseManagement/ConstructionPanel.tsx @@ -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 = { + 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 ( +
+
+

Construction

+ + {statusInfo.label} + +
+ + {status === "building" && ( +
+
+ + Build time {durationMinutes != null ? `${durationMinutes.toLocaleString()} min` : ""} + + {pct}% +
+
+
+
+
+ +
+
+ )} + + {status === "awaiting_materials" && ( +

+ Ship the required materials to this site. Construction starts automatically once + everything has arrived. +

+ )} + +
+ {materials.map((material) => { + const met = material.delivered >= material.required; + return ( +
+ {material.name} + + {material.delivered.toLocaleString()} / {material.required.toLocaleString()} + +
+ ); + })} + {materials.length === 0 && ( +

No materials required by this blueprint.

+ )} +
+ + {status === "awaiting_materials" && !allDelivered && ( +

+ {materials.filter((material) => material.delivered < material.required).length} material + type(s) still missing. +

+ )} +
+ ); +} diff --git a/src/lib/map/construction.ts b/src/lib/map/construction.ts new file mode 100644 index 0000000..07923da --- /dev/null +++ b/src/lib/map/construction.ts @@ -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 | null; + voidStorage: NonNullable | null; + type: Structure | number | null; +} + +async function blueprintOf(payload: Payload, site: ConstructionSite): Promise { + 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 { + 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 { + 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; +} diff --git a/src/scripts/gameTick.ts b/src/scripts/gameTick.ts index dde266d..454c728 100644 --- a/src/scripts/gameTick.ts +++ b/src/scripts/gameTick.ts @@ -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, }, }); diff --git a/src/scripts/processShipmentTick.ts b/src/scripts/processShipmentTick.ts index d904680..c1d5375 100644 --- a/src/scripts/processShipmentTick.ts +++ b/src/scripts/processShipmentTick.ts @@ -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, ): Promise { 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: [],