1
0
Fork 0

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:
Jason Fraley 2026-09-16 23:14:07 -04:00
parent c64c9da0da
commit 5be390012a
10 changed files with 778 additions and 13 deletions

View 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,
};
}

View 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 };
}
}

View file

@ -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}

View file

@ -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.`,
};
}
}

View file

@ -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.",
},
},
{

View 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>;
}

View 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
View 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;
}

View file

@ -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,
},
});

View file

@ -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: [],