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>
131 lines
4.4 KiB
TypeScript
131 lines
4.4 KiB
TypeScript
"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>
|
|
);
|
|
}
|